import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; import type { FingerprintSettings } from '#lib/api/admin.js'; import { ERROR_COPY } from '#lib/api/error-copy.js'; vi.mock('#lib/api/admin.js', () => ({ getFingerprintSettings: vi.fn(), updateFingerprintSettings: vi.fn() })); vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() })); import FingerprintSettingsCard from './FingerprintSettingsCard.svelte'; import { getFingerprintSettings, updateFingerprintSettings } from '#lib/api/admin.js'; import { pushToast } from '#lib/stores/toast.svelte.js'; const base: FingerprintSettings = { enabled: true, chromaprint_length_sec: 120, acoustic_max_bit_error_rate: 0.15, backfill_concurrency: 2, sweep_interval_hours: 1, auto_resolve: true }; afterEach(() => vi.clearAllMocks()); async function renderCard( over: Partial = {}, props: { libraryTotal?: number; onSaved?: () => void } = {} ) { vi.mocked(getFingerprintSettings).mockResolvedValue({ ...base, ...over }); const r = render(FingerprintSettingsCard, { props }); await screen.findByRole('spinbutton', { name: /seconds of audio/i }); return r; } const saveButton = () => screen.getByRole('button', { name: /save/i }); describe('FingerprintSettingsCard', () => { test('save is disabled until something changes', async () => { await renderCard(); expect(saveButton()).toHaveProperty('disabled', true); await fireEvent.input(screen.getByRole('spinbutton', { name: /hours between sweeps/i }), { target: { value: '6' } }); await waitFor(() => expect(saveButton()).toHaveProperty('disabled', false)); }); // The matcher works in bit-error rates; the report shows match percentages. // A card that showed 0.15 beside a report saying "96% match" would leave the // operator converting in their head. test('the threshold reads as a match percentage and saves as a bit-error rate', async () => { vi.mocked(updateFingerprintSettings).mockResolvedValue({ ...base, acoustic_max_bit_error_rate: 0.1 }); await renderCard(); const match = screen.getByRole('spinbutton', { name: /minimum match/i }) as HTMLInputElement; expect(match.value).toBe('85'); await fireEvent.input(match, { target: { value: '90' } }); await fireEvent.click(saveButton()); await waitFor(() => expect(updateFingerprintSettings).toHaveBeenCalledWith( expect.objectContaining({ acoustic_max_bit_error_rate: 0.1 }) ) ); }); // A new length re-fingerprints the whole library. Nothing else on the page // would say so before the operator commits to it. test('changing the length warns that every track is re-fingerprinted', async () => { await renderCard({}, { libraryTotal: 18026 }); expect(screen.queryByTestId('length-warning')).toBeNull(); await fireEvent.input(screen.getByRole('spinbutton', { name: /seconds of audio/i }), { target: { value: '60' } }); const warning = await screen.findByTestId('length-warning'); expect(warning.textContent).toMatch(/all\s+18,026\s+tracks/); }); test('other changes carry no re-fingerprinting warning', async () => { await renderCard({}, { libraryTotal: 18026 }); await fireEvent.input(screen.getByRole('spinbutton', { name: /files fingerprinted at once/i }), { target: { value: '4' } }); await waitFor(() => expect(saveButton()).toHaveProperty('disabled', false)); expect(screen.queryByTestId('length-warning')).toBeNull(); }); test('a value out of range blocks saving and says which', async () => { await renderCard(); await fireEvent.input(screen.getByRole('spinbutton', { name: /files fingerprinted at once/i }), { target: { value: '12' } }); const problems = await screen.findByTestId('settings-problems'); expect(problems.textContent).toMatch(/files fingerprinted at once must be from 1 to 8/i); expect(saveButton()).toHaveProperty('disabled', true); }); test('a rejected save surfaces the field the server names', async () => { const message = 'fingerprint setting out of range: sweep_interval_hours must be 1-168'; vi.mocked(updateFingerprintSettings).mockRejectedValue({ code: 'invalid_setting', message, status: 400 }); await renderCard(); await fireEvent.input(screen.getByRole('spinbutton', { name: /hours between sweeps/i }), { target: { value: '6' } }); await fireEvent.click(saveButton()); await waitFor(() => expect(pushToast).toHaveBeenCalledWith(`${ERROR_COPY.invalid_setting} ${message}`, 'error') ); }); // M498, rule 25: the resolver acting on its own is the operator's to turn off. test('auto-resolve can be turned off and is saved with the rest', async () => { vi.mocked(updateFingerprintSettings).mockResolvedValue({ ...base, auto_resolve: false }); await renderCard(); const toggle = screen.getByRole('checkbox', { name: /resolve duplicates automatically/i }) as HTMLInputElement; expect(toggle.checked).toBe(true); await fireEvent.click(toggle); await fireEvent.click(saveButton()); await waitFor(() => expect(updateFingerprintSettings).toHaveBeenCalledWith({ ...base, auto_resolve: false }) ); }); // Switching fingerprinting off changes what the page's counts mean. test('a save tells the page, so its counts refresh', async () => { const onSaved = vi.fn(); vi.mocked(updateFingerprintSettings).mockResolvedValue({ ...base, enabled: false }); await renderCard({}, { onSaved }); await fireEvent.click(screen.getByRole('checkbox', { name: /fingerprint tracks/i })); await fireEvent.click(saveButton()); await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1)); expect(updateFingerprintSettings).toHaveBeenCalledWith( expect.objectContaining({ enabled: false }) ); }); test('a failed load offers a retry rather than an empty card', async () => { vi.mocked(getFingerprintSettings).mockRejectedValue(new Error('nope')); render(FingerprintSettingsCard); await waitFor(() => expect(screen.getByText(/couldn't load fingerprinting settings/i)).toBeTruthy() ); expect(screen.getByRole('button', { name: /try again/i })).toBeTruthy(); }); });