import { afterEach, describe, expect, test, vi } from 'vitest'; import { fireEvent, render, screen } from '@testing-library/svelte'; import { readable } from 'svelte/store'; import type { AdminDuplicatesResponse, ResolvedDuplicateActivity, ResolvedDuplicatesResponse } from '#lib/api/types.js'; vi.mock('#lib/api/admin.js', () => ({ createDuplicatesQuery: vi.fn(), createResolvedDuplicatesQuery: vi.fn(), runDuplicateSweep: vi.fn().mockResolvedValue({ started: true }), dismissDuplicateGroup: vi.fn().mockResolvedValue(undefined), mergeDuplicateGroup: vi.fn().mockResolvedValue({ survivor_track_id: 'www-01', removed_paths: ['/music/Moe Shop/WWW (2020)/www-02.mp3'] }), // The page embeds FingerprintSettingsCard, which loads its own settings from // this module; the card has its own suite. getFingerprintSettings: vi.fn().mockResolvedValue({ enabled: true, chromaprint_length_sec: 120, acoustic_max_bit_error_rate: 0.15, backfill_concurrency: 2, sweep_interval_hours: 1, auto_resolve: true }), updateFingerprintSettings: vi.fn() })); import AdminDuplicatesPage from './+page.svelte'; import { createDuplicatesQuery, createResolvedDuplicatesQuery, dismissDuplicateGroup, mergeDuplicateGroup, runDuplicateSweep } from '#lib/api/admin.js'; const HOUR = 3_600_000; const ago = (ms: number) => new Date(Date.now() - ms).toISOString(); const finishedSweep = { state: 'finished' as const, started_at: ago(2 * HOUR), finished_at: ago(HOUR), candidates: 1200, groups_found: 1, oversize_clusters: 0, error_message: null }; const allFingerprinted = { total: 1200, fingerprinted: 1200, rejected: 0, pending: 0, enabled: true }; function member(id: string, extra: Partial = {}) { return { track_id: id, title: 'WWW', artist_name: 'Moe Shop', album_id: 'al-1', album_title: 'WWW', file_path: `/music/Moe Shop/WWW (2020)/${id}.mp3`, file_format: 'mp3', file_size: 6_900_000, duration_sec: 215, added_at: ago(500 * HOUR), like_count: 0, play_count: 0, disc_number: null, track_number: null, ...extra }; } function response(over: Partial = {}): AdminDuplicatesResponse { return { sweep: finishedSweep, fingerprints: allFingerprinted, view: 'review', counts: { review: 1, cross_release: 4, resolved: 2 }, total: 1, limit: 25, offset: 0, groups: [ { id: 'g-1', tier: 'exact', worst_bit_error_rate: null, detected_at: ago(HOUR), survivor_track_id: 'www-01', survivor_reason: 'in the library longest', class: 'review', resolve_note: null, members: [member('www-01', { like_count: 2, play_count: 14 }), member('www-02')] } ], ...over }; } // The page reads only these fields of an infinite query. function infinite(pages: T[]) { return readable({ data: { pages }, isPending: false, isError: false, hasNextPage: false, isFetchingNextPage: false, isFetchNextPageError: false, fetchNextPage: () => {}, refetch: () => {} }); } function resolvedPage(items: ResolvedDuplicateActivity[]): ResolvedDuplicatesResponse { return { total: items.length, limit: 25, offset: 0, items }; } function renderWith(data: AdminDuplicatesResponse, resolved: ResolvedDuplicateActivity[] = []) { vi.mocked(createDuplicatesQuery).mockReturnValue( infinite([data]) as unknown as ReturnType ); vi.mocked(createResolvedDuplicatesQuery).mockReturnValue( infinite([resolvedPage(resolved)]) as unknown as ReturnType ); return render(AdminDuplicatesPage); } function text(el: HTMLElement): string { return (el.textContent ?? '').replace(/\s+/g, ' ').trim(); } afterEach(() => vi.clearAllMocks()); describe('admin duplicates', () => { test('shows each group with its members, what they carry, and the copy to keep', () => { renderWith(response()); expect(screen.getAllByTestId('duplicate-group')).toHaveLength(1); expect(screen.getAllByTestId('duplicate-member')).toHaveLength(2); expect(text(screen.getByTestId('duplicate-tier'))).toBe('Identical audio'); expect(text(screen.getByTestId('survivor-badge'))).toBe('Keep · in the library longest'); const history = screen.getAllByTestId('member-history').map(text); expect(history).toEqual(['2 likes · 14 plays', 'no likes or plays']); }); // The badge must sit on the proposed survivor, not merely appear somewhere. test('the keep badge is on the survivor row', () => { renderWith(response()); const rows = screen.getAllByTestId('duplicate-member'); expect(rows[0].querySelector('[data-testid="survivor-badge"]')).not.toBeNull(); expect(rows[1].querySelector('[data-testid="survivor-badge"]')).toBeNull(); }); test('an acoustic group reads as a match percentage', () => { const r = response(); r.groups[0] = { ...r.groups[0], tier: 'acoustic', worst_bit_error_rate: 0.04 }; renderWith(r); expect(text(screen.getByTestId('duplicate-tier'))).toBe('Same recording · 96% match'); }); // #3885: identical audio titled "WWW" and "WWW (instrumental)". One file // claimed to be the vocal and wasn't, and nothing said the vocal was missing. describe('identical audio under different titles', () => { function www() { const r = response(); r.groups[0] = { ...r.groups[0], members: [member('www-01'), member('www-02', { title: 'WWW (instrumental)' })] }; return r; } test('says the copies are titled differently and names both titles', () => { renderWith(www()); const notice = text(screen.getByTestId('title-mismatch')); expect(notice).toContain('titled “WWW” and “WWW (instrumental)”'); expect(notice).toContain("isn't in your library"); }); test('three different titles are all named', () => { const r = www(); r.groups[0].members.push(member('www-03', { title: 'WWW (Radio Edit)' })); renderWith(r); expect(text(screen.getByTestId('title-mismatch'))).toContain( '“WWW”, “WWW (instrumental)” and “WWW (Radio Edit)”' ); }); test('titles differing only in case or spacing are the same title', () => { const r = response(); r.groups[0] = { ...r.groups[0], members: [ member('www-01', { title: 'WWW (Instrumental)' }), member('www-02', { title: ' www (instrumental) ' }) ] }; renderWith(r); expect(screen.queryByTestId('title-mismatch')).toBeNull(); }); test('matching titles raise nothing', () => { renderWith(response()); expect(screen.queryByTestId('title-mismatch')).toBeNull(); }); // Across encodings a "Remastered" suffix is routine, not a mislabel. test('an acoustic group with different titles raises nothing', () => { const r = www(); r.groups[0] = { ...r.groups[0], tier: 'acoustic', worst_bit_error_rate: 0.04 }; renderWith(r); expect(screen.queryByTestId('title-mismatch')).toBeNull(); }); }); // Three states all show zero groups. Each must say which it is. test('empty while still fingerprinting says so', () => { renderWith( response({ groups: [], total: 0, fingerprints: { total: 1200, fingerprinted: 0, rejected: 0, pending: 1200, enabled: true } }) ); expect(text(screen.getByTestId('empty-state'))).toContain('still being fingerprinted'); }); // Switched off, the backlog never shrinks. "Still being fingerprinted" would // promise work nothing is doing (#3913). test('with fingerprinting off the page says so instead of promising progress', () => { renderWith( response({ groups: [], total: 0, fingerprints: { total: 1200, fingerprinted: 0, rejected: 0, pending: 1200, enabled: false } }) ); const empty = text(screen.getByTestId('empty-state')); expect(empty).toContain('Fingerprinting is off'); expect(empty).not.toContain('still being fingerprinted'); expect(text(screen.getByTestId('sweep-status'))).toContain('Fingerprinting is off'); }); test('the fingerprinting settings are on this page', async () => { renderWith(response()); expect(await screen.findByRole('spinbutton', { name: /seconds of audio/i })).toBeTruthy(); }); test('empty before any sweep says the sweep has not run', () => { renderWith( response({ groups: [], total: 0, sweep: { ...finishedSweep, state: 'never', started_at: null, finished_at: null, candidates: null } }) ); expect(text(screen.getByTestId('empty-state'))).toContain("hasn't run yet"); }); test('empty after a sweep says nothing needs review', () => { renderWith(response({ groups: [], total: 0 })); expect(text(screen.getByTestId('empty-state'))).toContain('Nothing needs review'); }); test('Not duplicates dismisses that group', async () => { renderWith(response()); await fireEvent.click(screen.getByRole('button', { name: 'Not duplicates' })); expect(dismissDuplicateGroup).toHaveBeenCalledWith('g-1'); }); test('Sweep now starts a sweep', async () => { renderWith(response()); await fireEvent.click(screen.getByRole('button', { name: 'Sweep now' })); expect(runDuplicateSweep).toHaveBeenCalledTimes(1); }); // A merge deletes files. The first click must only arm it. test('Merge needs a second click, and keeps the proposed copy by default', async () => { renderWith(response()); await fireEvent.click(screen.getByRole('button', { name: 'Merge…' })); expect(mergeDuplicateGroup).not.toHaveBeenCalled(); expect(text(screen.getByTestId('merge-confirm'))).toContain('The other 1 file will be deleted from disk'); await fireEvent.click(screen.getByRole('button', { name: 'Remove 1 file and merge' })); expect(mergeDuplicateGroup).toHaveBeenCalledWith('g-1', { survivor_track_id: 'www-01', unmonitor: false }); }); test('choosing another copy to keep sends that copy', async () => { renderWith(response()); const radios = screen.getAllByRole('radio'); expect((radios[0] as HTMLInputElement).checked).toBe(true); await fireEvent.click(radios[1]); await fireEvent.click(screen.getByRole('button', { name: 'Merge…' })); await fireEvent.click(screen.getByRole('button', { name: 'Remove 1 file and merge' })); expect(mergeDuplicateGroup).toHaveBeenCalledWith('g-1', { survivor_track_id: 'www-02', unmonitor: false }); }); test('Cancel disarms the merge', async () => { renderWith(response()); await fireEvent.click(screen.getByRole('button', { name: 'Merge…' })); await fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); expect(screen.queryByTestId('merge-confirm')).toBeNull(); expect(screen.getByRole('button', { name: 'Merge…' })).toBeTruthy(); }); // M498: the resolver's three outcomes are three tabs, each with its size. describe('tabs', () => { test('each tab shows its count, and Across releases asks for that view', async () => { renderWith(response()); expect(screen.getByRole('tab', { name: /needs review/i })).toHaveAttribute('aria-selected', 'true'); expect(screen.getAllByTestId('tab-count').map(text)).toEqual(['1', '4', '2']); expect(createDuplicatesQuery).toHaveBeenLastCalledWith('review'); await fireEvent.click(screen.getByRole('tab', { name: /across releases/i })); expect(createDuplicatesQuery).toHaveBeenLastCalledWith('cross_release'); }); // Copies across releases are linked as one song; dismissing one says they // are not, which is what the button names. test('Across releases dismisses as Not the same song', async () => { renderWith(response()); await fireEvent.click(screen.getByRole('tab', { name: /across releases/i })); await fireEvent.click(screen.getByRole('button', { name: 'Not the same song' })); expect(dismissDuplicateGroup).toHaveBeenCalledWith('g-1'); }); // Release changes stopped with Lidarr (M502), but the ones made before stay // in the history. test('Resolved automatically lists merges and past release changes', async () => { renderWith(response(), [ { id: 'a-1', action: 'duplicate_merge', created_at: ago(HOUR), details: { survivor_path: '/music/Gorillaz/Humanz (2017)/03 - Saturnz Barz.mp3', survivor_reason: 'the copy that is not a video rip', removed: [{ track_id: 't-2', file_path: '/music/Gorillaz/Humanz (2017)/03 - Saturnz Barz (Official Video).mp3' }] } }, { id: 'a-2', action: 'lidarr_release_change', created_at: ago(2 * HOUR), details: { album_title: 'Humanz', artist_name: 'Gorillaz', from_release: '14x12" Vinyl, Digital Media (68 tracks)', to_release: 'Digital Media (40 tracks)' } } ]); await fireEvent.click(screen.getByRole('tab', { name: /resolved automatically/i })); const items = screen.getAllByTestId('resolved-item').map(text); expect(items[0]).toContain('Kept 03 - Saturnz Barz.mp3 (the copy that is not a video rip) and removed 1 file.'); expect(items[1]).toContain('Lidarr now monitors Digital Media (40 tracks) of Humanz by Gorillaz'); expect(screen.queryByTestId('duplicate-group')).toBeNull(); }); }); describe("the resolver's note", () => { test('shows above the group it explains', () => { const r = response(); r.groups[0] = { ...r.groups[0], resolve_note: 'Identical audio filed under different titles.' }; renderWith(r); expect(text(screen.getByTestId('resolve-note'))).toBe('Identical audio filed under different titles.'); }); test('a merge confirms and sends the copy chosen to keep', async () => { renderWith(response()); await fireEvent.click(screen.getAllByRole('radio')[1]); await fireEvent.click(screen.getByRole('button', { name: 'Merge…' })); await fireEvent.click(screen.getByRole('button', { name: 'Remove 1 file and merge' })); expect(mergeDuplicateGroup).toHaveBeenCalledWith('g-1', { survivor_track_id: 'www-02', unmonitor: false }); }); }); });