feat(web): admin page for files the library has lost — #2527
test-web / test (push) Failing after 32s
test-web / test (push) Failing after 32s
Renders GET /api/admin/library/missing under Admin -> Missing files. Folder-grouped, because that is the unit an operator decides about: the case behind #2523 was three reorganised albums, and forty individual rows hides that it is really three decisions. Each row leads with the fact that settles whether a missing file is worth chasing -- "last played 2d ago" against "never played". The group header carries how many tracks and how long they have been gone. Read-only. No remove button anywhere: the row, its play history and its likes survive a file going missing, and the scanner clears the mark by itself when the file returns (or adopts the row if it returns renamed, #2528). The page says so in its own copy rather than leaving the operator to infer it. Empty state explains the feature instead of the emptiness -- what puts a row here (moved outside Minstrel, deleted, a drive that didn't mount) and that rows leave on their own. Someone who has never seen this page should not have to guess. Paging follows the house pattern -- plain offset into the factory, wrapped in $derived so a page change re-creates the query with a new key. Passing a getter instead would capture the key once and paging would silently not refetch. The pager only renders when it can do something.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../../test-utils/query';
|
||||
import type { AdminMissingResponse } from '$lib/api/types';
|
||||
|
||||
vi.mock('$lib/api/admin', () => ({
|
||||
createMissingFilesQuery: vi.fn()
|
||||
}));
|
||||
|
||||
import AdminMissingFilesPage from './+page.svelte';
|
||||
import { createMissingFilesQuery } from '$lib/api/admin';
|
||||
|
||||
const DAY = 24 * 3_600_000;
|
||||
|
||||
function track(title: string, opts: { lastPlayed?: string | null } = {}) {
|
||||
return {
|
||||
track_id: `t-${title}`,
|
||||
title,
|
||||
artist_id: 'ar-1',
|
||||
artist_name: 'Linkin Park',
|
||||
album_id: 'al-1',
|
||||
album_title: 'Minutes to Midnight',
|
||||
file_path: `/music/Linkin Park/Minutes to Midnight/${title}.flac`,
|
||||
duration_sec: 185,
|
||||
missing_since: new Date(Date.now() - 3 * DAY).toISOString(),
|
||||
last_played_at: opts.lastPlayed === undefined ? null : opts.lastPlayed
|
||||
};
|
||||
}
|
||||
|
||||
const response: AdminMissingResponse = {
|
||||
total: 3,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
groups: [
|
||||
{
|
||||
directory: '/music/Linkin Park/Minutes to Midnight',
|
||||
missing_since: new Date(Date.now() - 3 * DAY).toISOString(),
|
||||
tracks: [
|
||||
track('Given Up', { lastPlayed: new Date(Date.now() - 2 * DAY).toISOString() }),
|
||||
track('Bleed It Out')
|
||||
]
|
||||
},
|
||||
{
|
||||
directory: '/music/Boards of Canada/Geogaddi',
|
||||
missing_since: new Date(Date.now() - 9 * DAY).toISOString(),
|
||||
tracks: [track('1969')]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
function renderWith(data: AdminMissingResponse | undefined, extra = {}) {
|
||||
vi.mocked(createMissingFilesQuery).mockReturnValue(
|
||||
mockQuery({ data, ...extra }) as ReturnType<typeof createMissingFilesQuery>
|
||||
);
|
||||
return render(AdminMissingFilesPage);
|
||||
}
|
||||
|
||||
describe('admin missing files', () => {
|
||||
test('groups rows under their directory', () => {
|
||||
renderWith(response);
|
||||
expect(screen.getByText('/music/Linkin Park/Minutes to Midnight')).toBeTruthy();
|
||||
expect(screen.getByText('/music/Boards of Canada/Geogaddi')).toBeTruthy();
|
||||
expect(screen.getAllByTestId('missing-track-row')).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('the count pill reports the server total, not the page size', () => {
|
||||
renderWith(response);
|
||||
expect(screen.getByTestId('missing-count-pill').textContent?.trim()).toBe('3');
|
||||
});
|
||||
|
||||
test('a group states how many tracks and how long gone', () => {
|
||||
renderWith(response);
|
||||
expect(screen.getByText(/2 tracks · gone 3d ago/)).toBeTruthy();
|
||||
expect(screen.getByText(/1 track · gone 9d ago/)).toBeTruthy();
|
||||
});
|
||||
|
||||
// The deciding fact for whether a missing file is worth chasing.
|
||||
test('distinguishes a played track from one never played', () => {
|
||||
renderWith(response);
|
||||
expect(screen.getByText(/last played 2d ago/)).toBeTruthy();
|
||||
expect(screen.getAllByText('never played')).toHaveLength(2);
|
||||
});
|
||||
|
||||
// An operator who has never seen this page should not have to guess what
|
||||
// would put a row here.
|
||||
test('the empty state explains what missing means', () => {
|
||||
renderWith({ total: 0, limit: 50, offset: 0, groups: [] });
|
||||
expect(screen.getByText(/every track's file is where it should be/i)).toBeTruthy();
|
||||
expect(screen.getByText(/can't find its file/i)).toBeTruthy();
|
||||
expect(screen.queryByTestId('missing-count-pill')).toBeNull();
|
||||
});
|
||||
|
||||
test('shows a loading line while pending', () => {
|
||||
renderWith(undefined, { isPending: true });
|
||||
expect(screen.getByText(/checking what's missing/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('shows an error line when the query fails', () => {
|
||||
renderWith(undefined, { isError: true });
|
||||
expect(screen.getByText(/couldn't load the missing-files list/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Paging only appears when it can do something: a single page of results
|
||||
// should not render dead Previous/Next buttons.
|
||||
test('no pager when everything fits on one page', () => {
|
||||
renderWith(response);
|
||||
expect(screen.queryByLabelText('Missing files pages')).toBeNull();
|
||||
});
|
||||
|
||||
test('pager appears when the server reports more than this page holds', () => {
|
||||
renderWith({ ...response, total: 120 });
|
||||
expect(screen.getByLabelText('Missing files pages')).toBeTruthy();
|
||||
expect(screen.getByText('1–3 of 120')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'Previous' })).toHaveProperty('disabled', true);
|
||||
expect(screen.getByRole('button', { name: 'Next' })).toHaveProperty('disabled', false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user