feat: Reports, Duplicates and Suspect sources live under Collection, with their counts on its rail and a "To look at" line naming the ones waiting; notices link there (M504 #5625)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m31s
release / android (push) Successful in 6m5s
release / Build signed APK (releases and dev) (push) Successful in 6m21s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 32s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m31s
release / android (push) Successful in 6m5s
release / Build signed APK (releases and dev) (push) Successful in 6m21s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 32s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
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']
|
||||
})
|
||||
}));
|
||||
|
||||
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<AdminDuplicatesResponse['groups'][number]['members'][number]> = {}) {
|
||||
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> = {}): 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<T>(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<typeof createDuplicatesQuery>
|
||||
);
|
||||
vi.mocked(createResolvedDuplicatesQuery).mockReturnValue(
|
||||
infinite([resolvedPage(resolved)]) as unknown as ReturnType<typeof createResolvedDuplicatesQuery>
|
||||
);
|
||||
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('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 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 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();
|
||||
});
|
||||
});
|
||||
|
||||
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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user