import { describe, expect, test, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; import type { TuningSnapshot, TrendsResponse } from '$lib/api/tuning'; vi.mock('$lib/api/tuning', async (orig) => { const actual = (await orig()) as Record; return { ...actual, getTuning: vi.fn(), patchTuning: vi.fn(), resetTuning: vi.fn(), getTrends: vi.fn() }; }); const pushToast = vi.fn(); vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: (...a: unknown[]) => pushToast(...a) })); import TuningPage from './+page.svelte'; import { getTuning, patchTuning, resetTuning, getTrends } from '$lib/api/tuning'; const weights = (over: Partial> = {}) => ({ base_weight: 1, like_boost: 2, recency_weight: 1, skip_penalty: 2, jitter_magnitude: 0.1, context_weight: 0.5, similarity_weight: 1.5, taste_weight: 1.5, context_time_weight: 1, ...over }); const taste = (over: Partial> = {}) => ({ half_life_days: 75, engagement_hard_skip: 0.05, engagement_neutral: 0.3, engagement_full: 0.9, enriched_tag_scale: 0.5, era_scale: 0.5, mood_scale: 0.5, ...over }); const discover = (over: Partial> = {}) => ({ tag_overlap_weight: 1, snooze_days: 90, ...over }); // NOTE the `as TuningSnapshot` cast below: it silences the type error when the // server snapshot grows a field, so a missing key here surfaces as every test // on this page throwing inside fillForm rather than as a compile failure. Add // new scopes to BOTH this fixture and `shipped`. function snapshot(over: Partial = {}): TuningSnapshot { return { profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() }, taste: taste(), discover: discover(), shipped: { profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() }, taste: taste(), discover: discover() }, ...over } as TuningSnapshot; } const emptyTrends: TrendsResponse = { weeks: 12, series: [], markers: [] }; beforeEach(() => { vi.clearAllMocks(); (getTrends as ReturnType).mockResolvedValue(emptyTrends); }); describe('Admin tuning page', () => { test('renders both profiles and the taste card with current values', async () => { (getTuning as ReturnType).mockResolvedValue(snapshot()); render(TuningPage); await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument()); expect(screen.getByText('Daily mixes')).toBeInTheDocument(); expect(screen.getByText('Taste profile build')).toBeInTheDocument(); const radioTaste = screen.getByLabelText(/taste weight/i, { selector: '#radio-taste_weight' }) as HTMLInputElement; expect(radioTaste.value).toBe('1'); const halfLife = document.getElementById('taste-half_life_days') as HTMLInputElement; expect(halfLife.value).toBe('75'); }); test('save sends only the changed fields for the scope', async () => { (getTuning as ReturnType).mockResolvedValue(snapshot()); (patchTuning as ReturnType).mockResolvedValue(snapshot()); render(TuningPage); await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument()); const input = document.getElementById('radio-taste_weight') as HTMLInputElement; await fireEvent.input(input, { target: { value: '2.5' } }); await fireEvent.click(screen.getByRole('button', { name: /save radio/i })); await waitFor(() => expect(patchTuning).toHaveBeenCalledWith('radio', { taste_weight: 2.5 }) ); }); test('save with no changes calls nothing and says so', async () => { (getTuning as ReturnType).mockResolvedValue(snapshot()); render(TuningPage); await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument()); await fireEvent.click(screen.getByRole('button', { name: /save radio/i })); expect(patchTuning).not.toHaveBeenCalled(); await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Nothing changed.')); }); // Targets the button by its accessible name, not its position. This used to // click the LAST reset button and assume that meant taste — which silently // became the wrong scope the moment a card was added below it. test('reset calls resetTuning for the scope', async () => { (getTuning as ReturnType).mockResolvedValue(snapshot()); (resetTuning as ReturnType).mockResolvedValue(snapshot()); render(TuningPage); await waitFor(() => expect(screen.getByText('Taste profile build')).toBeInTheDocument()); await fireEvent.click( screen.getByRole('button', { name: /reset taste profile build to defaults/i }) ); await waitFor(() => expect(resetTuning).toHaveBeenCalledWith('taste')); }); test('marks knobs that deviate from shipped defaults', async () => { const snap = snapshot(); snap.profiles.daily_mix = weights({ similarity_weight: 4 }); (getTuning as ReturnType).mockResolvedValue(snap); render(TuningPage); await waitFor(() => expect(screen.getByText('Daily mixes')).toBeInTheDocument()); expect( screen.getByTitle(/deviates from the shipped default \(1\.5\)/i) ).toBeInTheDocument(); }); test('renders weekly trend rows with sparklines and knob-turn markers (#1251)', async () => { (getTuning as ReturnType).mockResolvedValue(snapshot()); (getTrends as ReturnType).mockResolvedValue({ weeks: 12, series: [ { key: 'radio', label: 'Radio', intent: 'go_to', plays: 40, points: [ { week_start: '2026-06-22', plays: 25, skips: 5, skip_rate: 0.2, avg_completion: 0.8, taste_hit_rate: 0.6 }, { week_start: '2026-06-29', plays: 15, skips: 6, skip_rate: 0.4, avg_completion: 0.7, taste_hit_rate: 0.5 } ] }, { key: 'discover', label: 'Discover', intent: 'discovery', plays: 5, points: [ { week_start: '2026-06-29', plays: 5, skips: 3, skip_rate: 0.6, avg_completion: 0.4, taste_hit_rate: 0.2 } ] } ], markers: [ { changed_at: '2026-06-30T10:00:00Z', scope: 'radio', action: 'update', changes: [{ field: 'taste_weight', old: 1, new: 2 }] } ] } satisfies TrendsResponse); render(TuningPage); await waitFor(() => expect(screen.getByText('Weekly trends')).toBeInTheDocument()); expect(screen.getByTestId('sparkline-radio')).toBeInTheDocument(); expect(screen.getByTestId('sparkline-discover')).toBeInTheDocument(); // The skip column is the LAST WEEK's rate, now carrying that week's play // count so a rate off a handful of plays reads as what it is (#2495). // Radio's latest week: 40% skip over 15 plays; Discover's: 60% over 5. expect(screen.getByText('/15')).toBeInTheDocument(); expect(screen.getByText('/5')).toBeInTheDocument(); // Completion columns are unchanged and still bare percentages — radio 70%. expect(screen.getByText('70%')).toBeInTheDocument(); // '40%' is now genuinely ambiguous: radio's latest SKIP rate and discover's // latest COMPLETION are both 40%. testing-library matches an element's own // direct text nodes, so the skip cell still matches despite its trailing // play-count span. Assert the count rather than pretending it's unique. expect(screen.getAllByText('40%')).toHaveLength(2); // The window/last-week distinction has to be visible in the headers, or the // Plays total reads as the denominator of the skip rate. That misreading is // what #2495 was filed over. // // Queried as column headers rather than by text: the caption below also // mentions "Plays", and matching on the word finds the prose too. // Exact accessible names: /^Skip/ also matches the "Skip rate by week" // sparkline column, and /Plays/ matched the caption prose before that. expect(screen.getByRole('columnheader', { name: 'Plays (window)' })).toBeInTheDocument(); expect(screen.getByRole('columnheader', { name: 'Skip (last wk)' })).toBeInTheDocument(); expect(screen.getByRole('columnheader', { name: 'Completion (last wk)' })).toBeInTheDocument(); // The knob turn is listed under the chart AND tooltipped on each // sparkline's marker tick, hence getAllBy. expect( screen.getAllByText(/2026-06-30 — radio: taste_weight 1→2/).length ).toBeGreaterThan(0); // Low-volume series (5 plays) is dimmed as anecdote. expect(screen.getByTitle(/fewer than 20 plays in the window/i)).toBeInTheDocument(); }); test('empty trends show the accumulation hint', async () => { (getTuning as ReturnType).mockResolvedValue(snapshot()); render(TuningPage); await waitFor(() => expect(screen.getByText(/trends appear once listening accumulates/i)).toBeInTheDocument() ); }); // --- Discover scope (#2377) --- test('renders the Discover card with its two knobs', async () => { (getTuning as ReturnType).mockResolvedValue(snapshot()); render(TuningPage); await waitFor(() => expect(screen.getByText('Discover requests')).toBeInTheDocument()); expect(screen.getByLabelText(/taste-tag weight/i)).toBeInTheDocument(); expect(screen.getByLabelText(/snooze length/i)).toBeInTheDocument(); }); test('saving Discover patches only the changed field under the discover scope', async () => { (getTuning as ReturnType).mockResolvedValue(snapshot()); (patchTuning as ReturnType).mockResolvedValue(snapshot()); render(TuningPage); await waitFor(() => expect(screen.getByText('Discover requests')).toBeInTheDocument()); await fireEvent.input(screen.getByLabelText(/taste-tag weight/i), { target: { value: '2.5' } }); await fireEvent.click(screen.getByRole('button', { name: /save discover/i })); await waitFor(() => expect(patchTuning).toHaveBeenCalledWith('discover', { tag_overlap_weight: 2.5 }) ); }); // 0 is the operator's off switch for the whole tag term, so the form must be // able to send it — a falsy-value bug here would silently make the feature // impossible to disable. test('a Discover weight of 0 is sent, not dropped as falsy', async () => { (getTuning as ReturnType).mockResolvedValue(snapshot()); (patchTuning as ReturnType).mockResolvedValue(snapshot()); render(TuningPage); await waitFor(() => expect(screen.getByText('Discover requests')).toBeInTheDocument()); await fireEvent.input(screen.getByLabelText(/taste-tag weight/i), { target: { value: '0' } }); await fireEvent.click(screen.getByRole('button', { name: /save discover/i })); await waitFor(() => expect(patchTuning).toHaveBeenCalledWith('discover', { tag_overlap_weight: 0 }) ); }); test('reset targets the discover scope', async () => { (getTuning as ReturnType).mockResolvedValue(snapshot()); (resetTuning as ReturnType).mockResolvedValue(snapshot()); render(TuningPage); await waitFor(() => expect(screen.getByText('Discover requests')).toBeInTheDocument()); await fireEvent.click( screen.getByRole('button', { name: /reset discover requests to defaults/i }) ); await waitFor(() => expect(resetTuning).toHaveBeenCalledWith('discover')); }); test('a deviating Discover knob is dotted', async () => { const snap = snapshot(); snap.discover = discover({ tag_overlap_weight: 3 }); (getTuning as ReturnType).mockResolvedValue(snap); render(TuningPage); await waitFor(() => expect(screen.getByText('Discover requests')).toBeInTheDocument()); expect(screen.getByTitle(/deviates from the shipped default \(1\)/i)).toBeInTheDocument(); }); });