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'] }), refetchDuplicateGroup: vi.fn().mockResolvedValue({ kept_track_id: null, removed_paths: ['/music/Moe Shop/WWW (2020)/www-01.mp3', '/music/Moe Shop/WWW (2020)/www-02.mp3'], blocked: ['Moe Shop - WWW [WEB]'] }) })); vi.mock('#lib/player/store.svelte.js', () => ({ playQueue: vi.fn() })); import AdminDuplicatesPage from './+page.svelte'; import { playQueue } from '#lib/player/store.svelte.js'; import { createDuplicatesQuery, createResolvedDuplicatesQuery, dismissDuplicateGroup, mergeDuplicateGroup, refetchDuplicateGroup, 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_id: 'ar-1', 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, stream_url: `/api/tracks/${id}/stream`, ...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']); }); // Size and length come from the shared formatters (M505). test('each copy shows its format, size and length', () => { const group = response().groups[0]; group.members = [member('www-01'), member('www-02', { file_size: 812_000, duration_sec: 3665 })]; renderWith(response({ groups: [group] })); expect(screen.getAllByTestId('member-file').map(text)).toEqual([ 'MP3 · 6.9 MB · 3:35', 'MP3 · 812 KB · 1:01:05' ]); }); // 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('picking another copy moves the keep badge to it', async () => { renderWith(response()); const rows = screen.getAllByTestId('duplicate-member'); await fireEvent.click(rows[1].querySelector('input[type="radio"]') as HTMLInputElement); const after = screen.getAllByTestId('duplicate-member'); expect(after[0].querySelector('[data-testid="survivor-badge"]')).toBeNull(); expect(text(after[1].querySelector('[data-testid="survivor-badge"]') as HTMLElement)).toBe('Keep · your pick'); }); 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 music · 96% match'); }); // The operator took the match for a guess from size and length (#6168). test('each group says what the match rests on', () => { const r = response(); r.groups.push({ ...r.groups[0], id: 'g-2', tier: 'acoustic', worst_bit_error_rate: 0.04 }); renderWith(r); const lines = screen.getAllByTestId('match-evidence').map(text); expect(lines[0]).toContain('the same audio data, byte for byte'); expect(lines[1]).toBe( 'Matched by listening, not by size or length: 96% of their audio fingerprints agree, so these files play the same music.' ); }); test('each copy can be played', async () => { renderWith(response()); const plays = screen.getAllByTestId('member-play'); expect(plays).toHaveLength(2); await fireEvent.click(plays[1]); expect(playQueue).toHaveBeenLastCalledWith( [expect.objectContaining({ id: 'www-02', artist_id: 'ar-1', stream_url: '/api/tracks/www-02/stream' })], 0 ); }); // #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(); }); // Unless the resolver found different songs on one album: Savant's Mortals // held one recording as 16 tracks, each cut to its own song's length (#6169). test('an acoustic group the resolver found to be different songs says so', () => { const r = www(); r.groups[0] = { ...r.groups[0], tier: 'acoustic', worst_bit_error_rate: 0, class: 'mismatch' }; renderWith(r); const notice = text(screen.getByTestId('title-mismatch')); expect(notice).toContain('The music is the same, but the copies are titled “WWW” and “WWW (instrumental)”'); expect(notice).toContain("If you can't tell, get the album again"); }); }); // One recording in several of an album's slots (#6184). A merge would keep // one title whether or not it is the audio's and fetch nothing, so it is not // offered: the operator says which song it is, or that they can't tell. describe('one recording in several slots', () => { function slots() { const r = response(); r.groups[0] = { ...r.groups[0], tier: 'acoustic', worst_bit_error_rate: 0.04, class: 'mismatch', members: [member('www-01'), member('www-02', { title: 'WWW (instrumental)' })] }; return r; } test('offers Get it again instead of Merge, with no copy proposed', () => { renderWith(slots()); expect(screen.queryByRole('button', { name: 'Merge…' })).toBeNull(); expect(screen.getByRole('button', { name: 'Get it again…' })).toBeTruthy(); expect(screen.getAllByRole('radio').some((r) => (r as HTMLInputElement).checked)).toBe(false); expect(screen.queryByTestId('survivor-badge')).toBeNull(); }); test("can't tell: every copy goes, after a second click", async () => { renderWith(slots()); await fireEvent.click(screen.getByRole('button', { name: 'Get it again…' })); expect(refetchDuplicateGroup).not.toHaveBeenCalled(); const confirm = text(screen.getByTestId('refetch-confirm')); expect(confirm).toContain('All 2 copies will be deleted from disk'); expect(confirm).toContain('the album is fetched again from another one'); await fireEvent.click(screen.getByRole('button', { name: 'Remove 2 files and get it again' })); expect(refetchDuplicateGroup).toHaveBeenCalledWith('g-1', { keep_track_id: '' }); }); test('naming the song keeps that copy and removes the others', async () => { renderWith(slots()); await fireEvent.click(screen.getByRole('radio', { name: /This is WWW \(instrumental\)/ })); expect(screen.getByTestId('song-badge')).toBeTruthy(); await fireEvent.click(screen.getByRole('button', { name: 'Keep “WWW (instrumental)”…' })); expect(text(screen.getByTestId('refetch-confirm'))).toContain( 'The copy titled “WWW (instrumental)” stays, and 1 copy under other titles will be deleted from disk' ); await fireEvent.click(screen.getByRole('button', { name: 'Remove 1 file and get it again' })); expect(refetchDuplicateGroup).toHaveBeenCalledWith('g-1', { keep_track_id: 'www-02' }); }); test("I can't tell takes the pick back", async () => { renderWith(slots()); await fireEvent.click(screen.getAllByRole('radio')[0]); await fireEvent.click(screen.getByRole('button', { name: "I can't tell which song it is" })); expect(screen.getByRole('button', { name: 'Get it again…' })).toBeTruthy(); expect(screen.queryByTestId('song-badge')).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 a link to Library, saying whether it is on', () => { renderWith(response()); const line = screen.getByTestId('fingerprint-settings-link'); expect(text(line)).toContain('Fingerprinting is on'); expect(line.querySelector('a')?.getAttribute('href')).toBe('/admin/library#fingerprints'); }); 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' }); }); 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' }); }); 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'); }); test('Resolved automatically lists the merges the resolver made', 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' }] } } ]); 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(screen.queryByTestId('duplicate-group')).toBeNull(); }); test('Resolved automatically says what was dismissed and which slots were emptied', async () => { renderWith(response(), [ { id: 'a-2', action: 'duplicate_dismiss', created_at: ago(HOUR), details: { artist: 'Moe Shop', titles: ['Lovesick', 'Lovesick (Instrumental)'] } }, { id: 'a-3', action: 'duplicate_wrong_file', created_at: ago(HOUR), details: { artist: 'Savant', kept_title: 'Void', removed: [{ track_id: 't-9', file_path: '/data/Music/Savant/Void/17 - Reach.m4a', title: 'Reach' }] } }, { id: 'a-4', action: 'duplicate_wrong_file', created_at: ago(HOUR), details: { artist: 'Shnabubula', removed: [ { track_id: 't-5', file_path: '/data/Music/Shnabubula/05 - Fading Light.mp3', title: 'Fading Light' }, { track_id: 't-10', file_path: '/data/Music/Shnabubula/10 - Looking for Answers.mp3', title: 'Looking for Answers' } ] } } ]); await fireEvent.click(screen.getByRole('tab', { name: /resolved automatically/i })); const items = screen.getAllByTestId('resolved-item').map(text); expect(items[0]).toContain('Moe Shop: Lovesick and Lovesick (Instrumental) are different versions, not duplicates. Both kept.'); expect(items[1]).toContain( 'Savant: the recording of Void was also filed as Reach. Removed from there; the album will be fetched again.' ); expect(items[1]).toContain('/data/Music/Savant/Void/17 - Reach.m4a'); expect(items[2]).toContain( 'Shnabubula: one recording was filed as Fading Light and Looking for Answers, and it is none of them. Removed; the album will be fetched again.' ); }); }); 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' }); }); }); });