Files
minstrel/web/src/routes/settings/settings.test.ts
T
bvandeusen fb4431207d
test-go / test (push) Successful in 31s
test-web / test (push) Successful in 37s
test-go / integration (push) Successful in 4m37s
feat(recommendation): For You exploration attribution — taste vs fresh picks
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
2026-07-02 20:39:41 -04:00

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());
});
});