fb4431207d
Milestone #127 step 2 (#1249). For You deliberately blends two populations — a head of top-scored taste picks and a tail sampled from deeper ranking (the freshness injection) — but the metrics judged it as one blob, so its skip rate couldn't distinguish "the taste engine is missing" from "the freshness tax is too high". That number decides the exploration share before we tune it. - Migration 0038: nullable pick_kind ('taste'|'fresh') on both playlist_tracks (stamped at snapshot build) and play_events (frozen at play-ingestion — the snapshot rebuilds daily, so attribution cannot be reconstructed at read time). - Builder: pickHeadAndTail marks head=taste / tail=fresh; the small-pool fallback is all taste (top-N-by-score IS the taste mechanism). Other variants persist NULL. - Ingestion: for_you plays (live + offline replay) look the track up in the user's current snapshot; not found → unattributed, never guessed. - Metrics: For You's row gains a breakdown (taste / fresh / earlier unattributed plays), parent row stays the sum; web card renders the sub-rows indented with the same baseline deltas + low-data dimming. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
276 lines
11 KiB
TypeScript
276 lines
11 KiB
TypeScript
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
|
import { readable, writable } from 'svelte/store';
|
|
import type { LBStatus } from '$lib/api/listenbrainz';
|
|
|
|
vi.mock('$lib/api/listenbrainz', () => ({
|
|
createLBStatusQuery: vi.fn(),
|
|
createTokenMutation: vi.fn(),
|
|
createEnabledMutation: vi.fn(),
|
|
setListenBrainzToken: vi.fn(),
|
|
setListenBrainzEnabled: vi.fn()
|
|
}));
|
|
|
|
vi.mock('$lib/api/me', () => ({
|
|
updateProfile: vi.fn(),
|
|
changePassword: vi.fn(),
|
|
// Default to a resolved value so the page's $effect doesn't crash
|
|
// on `.then()` of undefined when individual tests don't override.
|
|
getAPIToken: vi.fn().mockResolvedValue({ api_token: '' }),
|
|
regenerateAPIToken: vi.fn()
|
|
}));
|
|
|
|
// Mutable holder so individual tests can inject populated metrics;
|
|
// vi.mock is hoisted, hence vi.hoisted for the shared reference.
|
|
const metricsMock = vi.hoisted(() => ({
|
|
data: { window_days: 30, baseline: null, groups: [] } as unknown
|
|
}));
|
|
|
|
vi.mock('$lib/api/metrics', () => ({
|
|
createRecommendationMetricsQuery: () => ({
|
|
subscribe: (run: (v: unknown) => void) => {
|
|
run({ isPending: false, isError: false, data: metricsMock.data });
|
|
return () => {};
|
|
}
|
|
})
|
|
}));
|
|
|
|
import SettingsPage from './+page.svelte';
|
|
import {
|
|
createLBStatusQuery,
|
|
createTokenMutation,
|
|
createEnabledMutation,
|
|
setListenBrainzToken,
|
|
setListenBrainzEnabled
|
|
} from '$lib/api/listenbrainz';
|
|
import {
|
|
updateProfile,
|
|
changePassword,
|
|
getAPIToken,
|
|
regenerateAPIToken
|
|
} from '$lib/api/me';
|
|
|
|
function mockStatusStore(data: LBStatus) {
|
|
return readable({ data, isPending: false, isError: false });
|
|
}
|
|
|
|
function mockMutationStore(mutateFn: (vars: unknown) => void = vi.fn()) {
|
|
return readable({ isPending: false, mutate: mutateFn, mutateAsync: vi.fn() });
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
metricsMock.data = { window_days: 30, baseline: null, groups: [] };
|
|
});
|
|
|
|
describe('Settings page — ListenBrainz', () => {
|
|
test('token-not-set state renders input + Save button', async () => {
|
|
(createLBStatusQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
|
mockStatusStore({ enabled: false, token_set: false, last_scrobbled_at: null })
|
|
);
|
|
(createTokenMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
|
(createEnabledMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
|
render(SettingsPage);
|
|
await waitFor(() =>
|
|
expect(screen.getByPlaceholderText(/paste your lb token/i)).toBeInTheDocument()
|
|
);
|
|
expect(screen.getByRole('button', { name: /^save$/i })).toBeInTheDocument();
|
|
});
|
|
|
|
test('token-set state renders masked + Clear button', async () => {
|
|
(createLBStatusQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
|
mockStatusStore({ enabled: false, token_set: true, last_scrobbled_at: null })
|
|
);
|
|
(createTokenMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
|
(createEnabledMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
|
render(SettingsPage);
|
|
await waitFor(() => expect(screen.getByText(/\(set\)/)).toBeInTheDocument());
|
|
expect(screen.getByText('clear')).toBeInTheDocument();
|
|
});
|
|
|
|
test('Save button calls setListenBrainzToken with typed value', async () => {
|
|
(createLBStatusQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
|
mockStatusStore({ enabled: false, token_set: false, last_scrobbled_at: null })
|
|
);
|
|
const mutateFn = vi.fn();
|
|
(createTokenMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore(mutateFn));
|
|
(createEnabledMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
|
render(SettingsPage);
|
|
const input = await screen.findByPlaceholderText(/paste your lb token/i);
|
|
await fireEvent.input(input, { target: { value: 'mytoken' } });
|
|
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
|
await waitFor(() => expect(mutateFn).toHaveBeenCalledWith('mytoken', expect.anything()));
|
|
});
|
|
|
|
test('Enabled checkbox is disabled when no token', async () => {
|
|
(createLBStatusQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
|
mockStatusStore({ enabled: false, token_set: false, last_scrobbled_at: null })
|
|
);
|
|
(createTokenMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
|
(createEnabledMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
|
render(SettingsPage);
|
|
const checkbox = await screen.findByRole('checkbox');
|
|
expect(checkbox).toBeDisabled();
|
|
});
|
|
|
|
test('Last-scrobbled-at renders when present', async () => {
|
|
(createLBStatusQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
|
mockStatusStore({ enabled: true, token_set: true, last_scrobbled_at: '2026-04-28T03:00:00Z' })
|
|
);
|
|
(createTokenMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
|
(createEnabledMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
|
render(SettingsPage);
|
|
await waitFor(() =>
|
|
expect(screen.getByText(/last scrobbled:/i)).toBeInTheDocument()
|
|
);
|
|
});
|
|
});
|
|
|
|
// Shared setup for cards that don't need LB state
|
|
function setupPage() {
|
|
(createLBStatusQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
|
mockStatusStore({ enabled: false, token_set: false, last_scrobbled_at: null })
|
|
);
|
|
(createTokenMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
|
(createEnabledMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
|
(getAPIToken as ReturnType<typeof vi.fn>).mockResolvedValue({ api_token: 'tok_abc123' });
|
|
}
|
|
|
|
describe('Settings page — Profile card', () => {
|
|
test('Save profile calls updateProfile with form values', async () => {
|
|
setupPage();
|
|
(updateProfile as ReturnType<typeof vi.fn>).mockResolvedValue({});
|
|
render(SettingsPage);
|
|
await fireEvent.input(screen.getByLabelText(/display name/i), { target: { value: 'Alice' } });
|
|
await fireEvent.input(screen.getByLabelText(/email/i), { target: { value: 'alice@example.com' } });
|
|
await fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
|
|
await waitFor(() =>
|
|
expect(updateProfile).toHaveBeenCalledWith(
|
|
expect.objectContaining({ display_name: 'Alice', email: 'alice@example.com' })
|
|
)
|
|
);
|
|
});
|
|
|
|
test('Save profile shows toast on success', async () => {
|
|
setupPage();
|
|
(updateProfile as ReturnType<typeof vi.fn>).mockResolvedValue({});
|
|
render(SettingsPage);
|
|
await fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
|
|
await waitFor(() => expect(screen.getByTestId('toast').textContent).toMatch(/profile saved/i));
|
|
});
|
|
});
|
|
|
|
describe('Settings page — Password card', () => {
|
|
test('mismatched passwords show toast and do not call changePassword', async () => {
|
|
setupPage();
|
|
render(SettingsPage);
|
|
await fireEvent.input(screen.getByLabelText(/current password/i), { target: { value: 'oldpw1234' } });
|
|
await fireEvent.input(screen.getByLabelText(/new password/i), { target: { value: 'newpw1234' } });
|
|
await fireEvent.input(screen.getByLabelText(/confirm/i), { target: { value: 'different1' } });
|
|
await fireEvent.click(screen.getByRole('button', { name: /change password/i }));
|
|
expect(changePassword).not.toHaveBeenCalled();
|
|
await waitFor(() => expect(screen.getByTestId('toast').textContent).toMatch(/do not match/i));
|
|
});
|
|
|
|
test('matched passwords call changePassword(current, new)', async () => {
|
|
setupPage();
|
|
(changePassword as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
|
render(SettingsPage);
|
|
await fireEvent.input(screen.getByLabelText(/current password/i), { target: { value: 'oldpw1234' } });
|
|
await fireEvent.input(screen.getByLabelText(/new password/i), { target: { value: 'newpw1234' } });
|
|
await fireEvent.input(screen.getByLabelText(/confirm/i), { target: { value: 'newpw1234' } });
|
|
await fireEvent.click(screen.getByRole('button', { name: /change password/i }));
|
|
await waitFor(() =>
|
|
expect(changePassword).toHaveBeenCalledWith('oldpw1234', 'newpw1234')
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Settings page — Recommendation metrics card', () => {
|
|
const metric = (key: string, label: string, over: Record<string, unknown> = {}) => ({
|
|
key,
|
|
label,
|
|
plays: 30,
|
|
skips: 3,
|
|
skip_rate: 0.1,
|
|
avg_completion: 0.9,
|
|
low_confidence: false,
|
|
...over
|
|
});
|
|
|
|
test('renders the For You taste/fresh breakdown rows (#1249)', async () => {
|
|
setupPage();
|
|
metricsMock.data = {
|
|
window_days: 30,
|
|
baseline: metric('manual', 'Manual library plays', { plays: 100 }),
|
|
groups: [
|
|
{
|
|
intent: 'go_to',
|
|
label: 'Go-to surfaces',
|
|
surfaces: [
|
|
metric('for_you', 'For You', {
|
|
plays: 45,
|
|
breakdown: [
|
|
metric('for_you_taste', 'Taste picks'),
|
|
metric('for_you_fresh', 'Fresh picks', {
|
|
plays: 10,
|
|
skip_rate: 0.4,
|
|
low_confidence: true
|
|
}),
|
|
metric('for_you_unattributed', 'Earlier plays', { plays: 5 })
|
|
]
|
|
})
|
|
]
|
|
}
|
|
]
|
|
};
|
|
render(SettingsPage);
|
|
await waitFor(() => expect(screen.getByText('For You')).toBeInTheDocument());
|
|
expect(screen.getByText(/Taste picks/)).toBeInTheDocument();
|
|
expect(screen.getByText(/Fresh picks/)).toBeInTheDocument();
|
|
expect(screen.getByText(/Earlier plays/)).toBeInTheDocument();
|
|
});
|
|
|
|
test('surfaces without a breakdown render no sub-rows', async () => {
|
|
setupPage();
|
|
metricsMock.data = {
|
|
window_days: 30,
|
|
baseline: null,
|
|
groups: [
|
|
{
|
|
intent: 'go_to',
|
|
label: 'Go-to surfaces',
|
|
surfaces: [metric('radio', 'Radio')]
|
|
}
|
|
]
|
|
};
|
|
render(SettingsPage);
|
|
await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument());
|
|
expect(screen.queryByText(/Taste picks/)).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Settings page — API Token card', () => {
|
|
test('first click on Regenerate shows "Click again to confirm"', async () => {
|
|
setupPage();
|
|
render(SettingsPage);
|
|
await fireEvent.click(screen.getByRole('button', { name: /regenerate/i }));
|
|
await waitFor(() =>
|
|
expect(screen.getByRole('button', { name: /click again to confirm/i })).toBeInTheDocument()
|
|
);
|
|
expect(regenerateAPIToken).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('second click within 5s calls regenerateAPIToken', async () => {
|
|
setupPage();
|
|
(regenerateAPIToken as ReturnType<typeof vi.fn>).mockResolvedValue({ api_token: 'new_tok_xyz' });
|
|
render(SettingsPage);
|
|
await fireEvent.click(screen.getByRole('button', { name: /regenerate/i }));
|
|
await waitFor(() =>
|
|
expect(screen.getByRole('button', { name: /click again to confirm/i })).toBeInTheDocument()
|
|
);
|
|
await fireEvent.click(screen.getByRole('button', { name: /click again to confirm/i }));
|
|
await waitFor(() => expect(regenerateAPIToken).toHaveBeenCalled());
|
|
});
|
|
});
|