release / govulncheck (push) Successful in 36s
release / web (push) Successful in 1m39s
release / go (push) Successful in 1m45s
release / android (push) Successful in 5m9s
release / integration (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m21s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m23s
release / Verify release artifacts (tag releases only) (push) Skipped
The duplicates review offered only Merge for a wrong-slot group. A merge keeps
one copy's title whether or not it is the audio's, and neither sets the release
aside nor wants the missing song back.
For class mismatch the page now asks which song the recording is. "Get it
again" (the operator can't tell) removes every copy; "Keep <title>" keeps that
copy and removes the ones under other titles. Either way it runs the resolver's
own path: the release the files came from is set aside, the removed copies'
tracks stay marked missing and emptied, and the album is wanted again.
- library.SettleWrongSlot, POST /api/admin/library/duplicates/{id}/refetch
- ListDuplicateGroupsForResolve takes an optional group_id
- removeWrongSlots takes the acting operator (manual: not automatic, actor in
the audit row) and removes files through removeTrackFileOnDisk, so a
read-only library answers library_not_writable
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
520 lines
21 KiB
TypeScript
520 lines
21 KiB
TypeScript
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<AdminDuplicatesResponse['groups'][number]['members'][number]> = {}) {
|
|
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> = {}): 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']);
|
|
});
|
|
|
|
// 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' });
|
|
});
|
|
});
|
|
});
|