2b3be8311a
Promote the enriched-tag weight (#1490) from a taste.Config default into the DB-backed tuning lab so operators can dial how much folksonomy tags count vs raw ID3 genre (rule #25). - Migration 0044: taste_tuning.enriched_tag_scale (DEFAULT 0.5, backfills the existing row). - recsettings: TasteTuning gains the field; seeded/read/updated through reconcile + persistTaste; applyTastePatch validates it to [0,1] (generic non-half-life clamp) and diffTaste audits it; TasteConfig maps it into the profile build. - API: tasteTuningResp exposes enriched_tag_scale. - Web tuning card: a data-driven "Enriched tag weight" knob (0 = genre only). Tests: recsettings persist+range, web fixture field. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
199 lines
7.0 KiB
TypeScript
199 lines
7.0 KiB
TypeScript
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<string, unknown>;
|
|
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<Record<string, number>> = {}) => ({
|
|
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,
|
|
...over
|
|
});
|
|
|
|
const taste = (over: Partial<Record<string, number>> = {}) => ({
|
|
half_life_days: 75,
|
|
engagement_hard_skip: 0.05,
|
|
engagement_neutral: 0.3,
|
|
engagement_full: 0.9,
|
|
enriched_tag_scale: 0.5,
|
|
...over
|
|
});
|
|
|
|
function snapshot(over: Partial<TuningSnapshot> = {}): TuningSnapshot {
|
|
return {
|
|
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
|
|
taste: taste(),
|
|
shipped: {
|
|
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
|
|
taste: taste()
|
|
},
|
|
...over
|
|
} as TuningSnapshot;
|
|
}
|
|
|
|
const emptyTrends: TrendsResponse = { weeks: 12, series: [], markers: [] };
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
(getTrends as ReturnType<typeof vi.fn>).mockResolvedValue(emptyTrends);
|
|
});
|
|
|
|
describe('Admin tuning page', () => {
|
|
test('renders both profiles and the taste card with current values', async () => {
|
|
(getTuning as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(snapshot());
|
|
(patchTuning as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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.'));
|
|
});
|
|
|
|
test('reset calls resetTuning for the scope', async () => {
|
|
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
|
|
(resetTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
|
|
render(TuningPage);
|
|
await waitFor(() => expect(screen.getByText('Taste profile build')).toBeInTheDocument());
|
|
const resetButtons = screen.getAllByRole('button', { name: /reset to defaults/i });
|
|
await fireEvent.click(resetButtons[resetButtons.length - 1]);
|
|
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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(snapshot());
|
|
(getTrends as ReturnType<typeof vi.fn>).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();
|
|
// Latest skip rate column for radio = 40% (also discover's latest
|
|
// completion, hence getAllBy).
|
|
expect(screen.getAllByText('40%').length).toBeGreaterThan(0);
|
|
// 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<typeof vi.fn>).mockResolvedValue(snapshot());
|
|
render(TuningPage);
|
|
await waitFor(() =>
|
|
expect(screen.getByText(/trends appear once listening accumulates/i)).toBeInTheDocument()
|
|
);
|
|
});
|
|
});
|