/**
* Tests for the ArchivesPage component.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { screen, waitFor, fireEvent, within } from '@testing-library/react';
import { render } from '../utils';
import { ArchivesPage } from '../../pages/ArchivesPage';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
import { setAuthToken } from '../../api/client';
import { useNavigate } from 'react-router-dom';
const mockArchives = [
{
id: 1,
filename: 'benchy.gcode.3mf',
print_name: 'Benchy',
printer_id: 1,
printer_name: 'X1 Carbon',
print_time_seconds: 3600,
filament_used_grams: 15.5,
status: 'completed',
started_at: '2024-01-01T10:00:00Z',
completed_at: '2024-01-01T11:00:00Z',
thumbnail_path: '/thumbnails/1.png',
notes: 'Test print',
rating: 5,
project_id: null,
project_name: null,
project_color: null,
print_count: 3,
tags: 'test,calibration',
created_at: '2024-01-01T09:00:00Z',
updated_at: '2024-01-01T11:00:00Z',
has_f3d: false,
},
{
id: 2,
filename: 'bracket.gcode.3mf',
print_name: 'Bracket v2',
printer_id: 1,
printer_name: 'X1 Carbon',
print_time_seconds: 7200,
filament_used_grams: 45.0,
status: 'completed',
started_at: '2024-01-02T14:00:00Z',
completed_at: '2024-01-02T16:00:00Z',
thumbnail_path: '/thumbnails/2.png',
notes: null,
rating: null,
project_id: 1,
project_name: 'Functional Parts',
project_color: '#00ae42',
print_count: 1,
tags: '',
created_at: '2024-01-02T13:00:00Z',
updated_at: '2024-01-02T16:00:00Z',
has_f3d: true,
},
];
const mockArchiveStats = {
total_archives: 10,
total_print_time_seconds: 36000,
total_filament_grams: 500,
prints_this_week: 5,
prints_this_month: 20,
};
describe('ArchivesPage', () => {
beforeEach(() => {
setAuthToken(null);
server.use(
http.get('/api/v1/archives/', () => {
return HttpResponse.json(mockArchives);
}),
http.get('/api/v1/archives/stats', () => {
return HttpResponse.json(mockArchiveStats);
}),
http.get('/api/v1/printers/', () => {
return HttpResponse.json([{ id: 1, name: 'X1 Carbon' }]);
}),
http.get('/api/v1/projects/', () => {
return HttpResponse.json([{ id: 1, name: 'Functional Parts', color: '#00ae42' }]);
}),
http.get('/api/v1/archives/tags', () => {
return HttpResponse.json(['test', 'calibration', 'functional']);
}),
http.get('/api/v1/archives/:id/plates', ({ params }) => {
const archiveId = Number(params.id);
return HttpResponse.json({
archive_id: Number.isFinite(archiveId) ? archiveId : 0,
filename: 'sample.3mf',
plates: [],
is_multi_plate: false,
});
}),
http.get('/api/v1/archives/:id/filament-requirements', () => {
return HttpResponse.json([]);
}),
http.delete('/api/v1/archives/:id', () => {
return HttpResponse.json({ success: true });
})
);
});
afterEach(() => {
setAuthToken(null);
});
describe('rendering', () => {
it('renders the page title', async () => {
render();
await waitFor(() => {
expect(screen.getByText('Archives')).toBeInTheDocument();
});
});
it('shows archive cards', async () => {
render();
await waitFor(() => {
expect(screen.getByText('Benchy')).toBeInTheDocument();
expect(screen.getByText('Bracket v2')).toBeInTheDocument();
});
});
});
describe('archive info', () => {
it('shows print time', async () => {
render();
await waitFor(() => {
expect(screen.getByText('1h 0m')).toBeInTheDocument();
});
});
it("compares the latest print with its plate's estimate", async () => {
server.use(
http.get('/api/v1/archives/', () =>
HttpResponse.json([
{ ...mockArchives[1], run_count: 2, actual_time_seconds: 2000, time_estimate_seconds: 1800, time_accuracy: 90 },
])
)
);
render();
const time = (await screen.findByText('-10%')).parentElement!;
expect(time.getAttribute('title')).toContain('Estimated: 30m');
});
it('shows printer name', async () => {
render();
await waitFor(() => {
const printerNames = screen.getAllByText('X1 Carbon');
expect(printerNames.length).toBeGreaterThan(0);
});
});
it('shows tags', async () => {
render();
await waitFor(() => {
// Tags may be truncated or displayed differently - just verify archives load
expect(screen.getByText('Benchy')).toBeInTheDocument();
});
// Tags are displayed in the archive cards
const testElements = screen.queryAllByText('test');
expect(testElements.length).toBeGreaterThanOrEqual(0);
});
it('shows print count badge', async () => {
render();
await waitFor(() => {
// Print count may be displayed as badge
expect(screen.getByText('Benchy')).toBeInTheDocument();
});
});
it('shows project badge', async () => {
render();
await waitFor(() => {
expect(screen.getByText('Functional Parts')).toBeInTheDocument();
});
});
it('shows F3D indicator when file has F3D', async () => {
render();
await waitFor(() => {
// Bracket v2 has has_f3d: true
expect(screen.getByText('Bracket v2')).toBeInTheDocument();
});
// F3D files have cyan badge indicator - look for it by title or class
const f3dElements = document.querySelectorAll('[title*="F3D"]');
expect(f3dElements.length).toBeGreaterThanOrEqual(0);
});
});
describe('search and filter', () => {
it('has search input', async () => {
render();
await waitFor(() => {
expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument();
});
});
it('has printer filter', async () => {
render();
await waitFor(() => {
expect(screen.getByText('All Printers')).toBeInTheDocument();
});
});
it('has project filter', async () => {
render();
await waitFor(() => {
// Project filter dropdown may have different default text
const projectSelect = screen.getAllByRole('combobox');
expect(projectSelect.length).toBeGreaterThan(0);
});
});
});
describe('view modes', () => {
it('has grid view option', async () => {
render();
await waitFor(() => {
expect(screen.getByTitle(/grid/i)).toBeInTheDocument();
});
});
it('has list view option', async () => {
render();
await waitFor(() => {
expect(screen.getByTitle(/list/i)).toBeInTheDocument();
});
});
});
describe('empty state', () => {
it('shows empty state when no archives', async () => {
server.use(
http.get('/api/v1/archives/', () => {
return HttpResponse.json([]);
})
);
render();
await waitFor(() => {
expect(screen.getByText(/no archives/i)).toBeInTheDocument();
});
});
});
describe('stats display', () => {
it('shows archives list', async () => {
render();
await waitFor(() => {
// Verify archives are loaded
expect(screen.getByText('Benchy')).toBeInTheDocument();
expect(screen.getByText('Bracket v2')).toBeInTheDocument();
});
});
});
describe('rating display', () => {
it('shows rating stars', async () => {
render();
await waitFor(() => {
// Rating 5 shows stars
expect(screen.getByText('Benchy')).toBeInTheDocument();
});
});
});
describe('plate navigation', () => {
it('renders archive cards with thumbnails', async () => {
render();
await waitFor(() => {
// Archive cards should render with their thumbnails
expect(screen.getByText('Benchy')).toBeInTheDocument();
// Thumbnail images should be present (archive cards have img elements)
const images = document.querySelectorAll('img[alt="Benchy"]');
expect(images.length).toBeGreaterThanOrEqual(0);
});
});
it('fetches plate data for multi-plate archives on hover', async () => {
// Setup handler for plates endpoint
server.use(
http.get('/api/v1/archives/:id/plates', ({ params }) => {
return HttpResponse.json({
archive_id: Number(params.id),
filename: 'test.3mf',
plates: [
{ index: 0, name: 'Plate 1', objects: ['Object A'], has_thumbnail: true, thumbnail_url: '/thumb1.png', print_time_seconds: 3600, filament_used_grams: 10, filaments: [] },
{ index: 1, name: 'Plate 2', objects: ['Object B'], has_thumbnail: true, thumbnail_url: '/thumb2.png', print_time_seconds: 1800, filament_used_grams: 5, filaments: [] },
],
is_multi_plate: true,
});
})
);
render();
await waitFor(() => {
expect(screen.getByText('Benchy')).toBeInTheDocument();
});
// Archives with multi-plate support will show navigation on hover
// The plates API is called lazily when hovering
});
it('names the plate in the card title when it is not the first one', async () => {
server.use(
http.get('/api/v1/archives/', () =>
HttpResponse.json([{ ...mockArchives[1], plate_id: 3 }])
)
);
render();
await waitFor(() => {
expect(screen.getByText('Bracket v2 \u2014 Plate 3')).toBeInTheDocument();
});
});
it('shows the picture of the plate the title names', async () => {
// A file printed again shows its latest run's plate; the stored
// thumbnail is the first plate's.
server.use(
http.get('/api/v1/archives/', () =>
HttpResponse.json([{ ...mockArchives[1], plate_id: 3 }])
)
);
render();
const img = await screen.findByAltText('Bracket v2');
expect(img.getAttribute('src')).toContain(`/archives/${mockArchives[1].id}/plate-thumbnail/3`);
});
it('leaves the title alone for a print on plate 1', async () => {
// The queue records a plate for single-plate files too, so an ungated
// label reads "Plate 1" on ordinary prints (#2796).
server.use(
http.get('/api/v1/archives/', () =>
HttpResponse.json([{ ...mockArchives[0], plate_id: 1 }])
)
);
render();
await waitFor(() => {
expect(screen.getByText('Benchy')).toBeInTheDocument();
});
expect(screen.queryByText(/Plate 1/)).not.toBeInTheDocument();
});
});
describe('timelapse management', () => {
// A card reaches print-video downloads through its context menu; only the
// list row keeps an action-bar button for it (#2853).
const openCardPrintVideos = async (cardIndex = 0) => {
const triggers = await screen.findAllByTitle('Right-click for more options');
fireEvent.click(triggers[cardIndex]);
return screen.findByRole('button', { name: 'Download print videos' });
};
it('keeps an attached timelapse available without printer-file permission', async () => {
setAuthToken('archive-only-token', 'session');
server.use(
http.get('*/api/v1/auth/status', () => HttpResponse.json({
auth_enabled: true,
requires_setup: false,
})),
http.get('/api/v1/auth/me', () => HttpResponse.json({
id: 7,
username: 'archive-viewer',
is_active: true,
is_admin: false,
groups: [],
permissions: ['archives:read_all'],
created_at: '2026-08-18T00:00:00Z',
})),
http.get('/api/v1/archives/', () => HttpResponse.json([
{ ...mockArchives[0], timelapse_path: 'timelapses/attached.mp4' },
{ ...mockArchives[1], timelapse_path: null },
])),
http.get('/api/v1/archives/:id/printer-media', ({ params }) => HttpResponse.json({
archive_id: Number(params.id),
printer_id: 1,
local_timelapse: { name: 'attached.mp4', size: 1024 },
remote_files: [],
warnings: ['printer_files_forbidden'],
})),
);
render();
// One of the two archives has no attached copy, so without printer-file
// permission there is nothing left for the action to offer.
const deniedItem = await openCardPrintVideos(0);
expect(deniedItem).toBeDisabled();
expect(deniedItem).toHaveAttribute('title', 'You do not have permission to access printer files');
fireEvent.keyDown(document, { key: 'Escape' });
const mediaItem = await openCardPrintVideos(1);
expect(mediaItem).toBeEnabled();
fireEvent.click(mediaItem);
expect(await screen.findByText('attached.mp4')).toBeInTheDocument();
expect(screen.getByText('You do not have permission to access printer files')).toBeInTheDocument();
});
it('opens print video downloads and shows matching IP camera chunks', async () => {
server.use(
http.get('/api/v1/archives/:id/printer-media', ({ params }) => {
return HttpResponse.json({
archive_id: Number(params.id),
printer_id: 1,
local_timelapse: null,
remote_files: [
{
name: 'ipcam-record.2024-01-01_10-05-00.1.mp4',
path: '/ipcam/ipcam-record.2024-01-01_10-05-00.1.mp4',
size: 250_000_000,
mtime: '2024-01-01T10:10:00Z',
kind: 'ipcam',
},
],
warnings: [],
});
}),
);
render();
fireEvent.click(await openCardPrintVideos());
expect(await screen.findByText('Print videos')).toBeInTheDocument();
expect(await screen.findByText('ipcam-record.2024-01-01_10-05-00.1.mp4')).toBeInTheDocument();
});
it('shows a toast when printer video ZIP preparation fails', async () => {
server.use(
http.get('/api/v1/archives/:id/printer-media', ({ params }) => HttpResponse.json({
archive_id: Number(params.id),
printer_id: 1,
local_timelapse: null,
remote_files: [{
name: 'ipcam-record.1.mp4',
path: '/ipcam/ipcam-record.1.mp4',
size: 250_000_000,
mtime: '2024-01-01T10:10:00Z',
kind: 'ipcam',
}],
warnings: [],
})),
http.post('/api/v1/printers/:id/files/download-job', () => HttpResponse.json({
job_id: 'failed-job',
state: 'failed',
requested: 1,
successful: 0,
failed: 0,
token: null,
message: 'Not enough app data volume space',
})),
);
render();
fireEvent.click(await openCardPrintVideos());
fireEvent.click(await screen.findByRole('button', { name: /Download selected \(1\)/i }));
expect(await screen.findByText('Download failed: Not enough app data volume space')).toBeInTheDocument();
});
it('shows upload timelapse menu item when no timelapse attached', async () => {
const archivesWithoutTimelapse = mockArchives.map(a => ({ ...a, timelapse_path: null }));
server.use(
http.get('/api/v1/archives/', () => {
return HttpResponse.json(archivesWithoutTimelapse);
})
);
render();
await waitFor(() => {
expect(screen.getByText('Benchy')).toBeInTheDocument();
});
// Context menu items are rendered in the DOM even when not visible
// "Upload Timelapse" should be present for archives without timelapse
const uploadItems = screen.queryAllByText('Upload Timelapse');
expect(uploadItems.length).toBeGreaterThanOrEqual(0);
});
it('shows remove timelapse menu item when timelapse is attached', async () => {
const archivesWithTimelapse = mockArchives.map(a => ({
...a,
timelapse_path: 'archives/1/timelapse.mp4',
}));
server.use(
http.get('/api/v1/archives/', () => {
return HttpResponse.json(archivesWithTimelapse);
})
);
render();
await waitFor(() => {
expect(screen.getByText('Benchy')).toBeInTheDocument();
});
// "Remove Timelapse" should be present for archives with timelapse
const removeItems = screen.queryAllByText('Remove Timelapse');
expect(removeItems.length).toBeGreaterThanOrEqual(0);
});
it('disables scan for timelapse when timelapse is already attached', async () => {
const archivesWithTimelapse = mockArchives.map(a => ({
...a,
timelapse_path: 'archives/1/timelapse.mp4',
}));
server.use(
http.get('/api/v1/archives/', () => {
return HttpResponse.json(archivesWithTimelapse);
})
);
render();
await waitFor(() => {
expect(screen.getByText('Benchy')).toBeInTheDocument();
});
// "Scan for Timelapse" buttons should be disabled when timelapse exists
// Upload Timelapse should also be disabled
});
});
// #1153 — Sylvain wanted to differentiate VP-uploaded archives (status='archived',
// never sent to a printer) from those that have been printed at least once.
describe('add-to-project submenu (#2888)', () => {
// The submenu used to offer active projects only, which left a completed
// project unreachable from here while the Edit dialog still offered it.
// Both now hide archived projects and nothing else.
beforeEach(() => {
server.use(
http.get('/api/v1/projects/', () =>
HttpResponse.json([
{ id: 1, name: 'Functional Parts', color: '#00ae42', status: 'active' },
{ id: 2, name: 'Shipped Last Month', color: '#0088ff', status: 'completed' },
{ id: 3, name: 'Season 2025', color: '#888888', status: 'archived' },
]),
),
);
});
const openSubmenu = async () => {
const card = await screen.findByText('Benchy');
fireEvent.contextMenu(card);
fireEvent.click(await screen.findByText('Add to Project'));
};
it('offers a completed project', async () => {
render();
await openSubmenu();
expect(await screen.findByText('Shipped Last Month')).toBeInTheDocument();
});
it('leaves archived projects out', async () => {
render();
await openSubmenu();
// Anchored on the completed one rather than the active one: the active
// project's name is also drawn on an archive card behind the menu.
await screen.findByText('Shipped Last Month');
expect(screen.queryByText('Season 2025')).not.toBeInTheDocument();
});
});
describe('outcome badges', () => {
it('labels an aborted print as aborted and a cancelled one as cancelled', async () => {
server.use(
http.get('/api/v1/archives/', () =>
HttpResponse.json([
{ ...mockArchives[0], id: 201, print_name: 'StoppedOnPrinter', status: 'aborted' },
{ ...mockArchives[0], id: 202, print_name: 'StoppedInBambuddy', status: 'cancelled' },
]),
),
);
render();
await screen.findByText('StoppedOnPrinter');
expect(screen.getByText('aborted')).toBeInTheDocument();
expect(screen.getByText('cancelled')).toBeInTheDocument();
});
});
// Archive redesign P4: a card opens its archive's page; the controls on it
// do what they say and leave the page where it is.
describe('opening an archive', () => {
beforeEach(() => {
window.localStorage.removeItem('archiveCollection');
window.history.pushState({}, '', '/archives');
server.use(http.get('/api/v1/archives/', () => HttpResponse.json(mockArchives)));
});
it('opens the archive page from a click on its card', async () => {
render();
fireEvent.click(await screen.findByText('#2'));
await waitFor(() => expect(window.location.pathname).toBe('/archives/2'));
});
it("counts every print's video and opens the archive to play them", async () => {
// Plate 2's video is the archive's; plate 1's is on its print.
server.use(
http.get('/api/v1/archives/', () =>
HttpResponse.json([{ ...mockArchives[0], timelapse_path: 'archives/1/b.mp4', timelapse_count: 2 }]),
),
);
render();
const button = await screen.findByTestId('archive-card-timelapse');
expect(button).toHaveAttribute('title', 'View 2 timelapses');
expect(within(button).getByText('2')).toBeInTheDocument();
fireEvent.click(button);
await waitFor(() => expect(window.location.pathname).toBe('/archives/1'));
});
it('plays a single video in place', async () => {
server.use(
http.get('/api/v1/archives/', () =>
HttpResponse.json([{ ...mockArchives[0], timelapse_path: 'archives/1/a.mp4', timelapse_count: 1 }]),
),
);
render();
const button = await screen.findByTestId('archive-card-timelapse');
expect(button).toHaveAttribute('title', 'View timelapse');
fireEvent.click(button);
expect(window.location.pathname).toBe('/archives');
});
it("opens the archive for a video only an earlier print has", async () => {
server.use(
http.get('/api/v1/archives/', () =>
HttpResponse.json([{ ...mockArchives[0], timelapse_path: null, timelapse_count: 1 }]),
),
);
render();
fireEvent.click(await screen.findByTestId('archive-card-timelapse'));
await waitFor(() => expect(window.location.pathname).toBe('/archives/1'));
});
it("says in the list how many videos an archive's prints have", async () => {
server.use(
http.get('/api/v1/archives/', () =>
HttpResponse.json([{ ...mockArchives[0], timelapse_path: null, timelapse_count: 2 }]),
),
);
render();
await screen.findByTestId('archive-card-timelapse');
fireEvent.click(screen.getByTitle(/list/i));
await waitFor(() => expect(screen.queryByTestId('archive-card-timelapse')).not.toBeInTheDocument());
expect(screen.getByTitle('View 2 timelapses')).toBeInTheDocument();
});
it('leaves the page alone for a click on a control in the card', async () => {
render();
await screen.findByText('#2');
fireEvent.click(screen.getAllByTitle(/favorites/i)[0]);
expect(window.location.pathname).toBe('/archives');
});
it('leaves the page alone for a click in a dialog the card opened', async () => {
render();
await screen.findByText('#2');
// The card's own Edit button; its dialog renders inside the card.
const card = screen.getByText('#2').closest('[data-archive-id]') as HTMLElement;
fireEvent.click(within(card).getByTitle('Edit'));
fireEvent.click(await screen.findByText('Edit Archive'));
expect(window.location.pathname).toBe('/archives');
});
});
describe('Not Printed / Printed collections', () => {
const mixedStatusArchives = [
{ ...mockArchives[0], id: 100, print_name: 'NeverPrinted', status: 'archived', started_at: null, completed_at: null },
{ ...mockArchives[0], id: 101, print_name: 'WasPrinted', status: 'completed' },
{ ...mockArchives[0], id: 102, print_name: 'WasFailed', status: 'failed' },
{ ...mockArchives[0], id: 103, print_name: 'WasCancelled', status: 'cancelled' },
{ ...mockArchives[0], id: 104, print_name: 'FailedBefore', status: 'completed', run_count: 2, failed_run_count: 1 },
];
beforeEach(() => {
// Reset persisted collection so each test starts on "All Archives".
window.localStorage.removeItem('archiveCollection');
server.use(
http.get('/api/v1/archives/', () => HttpResponse.json(mixedStatusArchives))
);
});
it('shows only archived (never-printed) entries when "Not Printed" is selected', async () => {
render();
await waitFor(() => {
expect(screen.getByText('NeverPrinted')).toBeInTheDocument();
});
const collectionSelect = screen.getByDisplayValue('All Archives');
fireEvent.change(collectionSelect, { target: { value: 'not-printed' } });
await waitFor(() => {
expect(screen.getByText('NeverPrinted')).toBeInTheDocument();
expect(screen.queryByText('WasPrinted')).not.toBeInTheDocument();
expect(screen.queryByText('WasFailed')).not.toBeInTheDocument();
expect(screen.queryByText('WasCancelled')).not.toBeInTheDocument();
});
});
it('shows only print-attempted entries (any final status) when "Printed" is selected', async () => {
render();
await waitFor(() => {
expect(screen.getByText('NeverPrinted')).toBeInTheDocument();
});
const collectionSelect = screen.getByDisplayValue('All Archives');
fireEvent.change(collectionSelect, { target: { value: 'printed' } });
await waitFor(() => {
expect(screen.queryByText('NeverPrinted')).not.toBeInTheDocument();
expect(screen.getByText('WasPrinted')).toBeInTheDocument();
expect(screen.getByText('WasFailed')).toBeInTheDocument();
expect(screen.getByText('WasCancelled')).toBeInTheDocument();
});
});
// Archive redesign P4: an archive is a file printed maybe several times.
it('"Failed Prints" finds archives one print of which failed, not only the latest', async () => {
render();
await screen.findByText('FailedBefore');
fireEvent.change(screen.getByDisplayValue('All Archives'), { target: { value: 'failed' } });
await waitFor(() => {
expect(screen.getByText('WasFailed')).toBeInTheDocument();
expect(screen.getByText('FailedBefore')).toBeInTheDocument();
expect(screen.queryByText('WasPrinted')).not.toBeInTheDocument();
});
});
});
// Archive redesign P2: merging changes and removes whole archives, so the
// button is there only for those the merge API lets through.
// Archive redesign P3: the Failed collection shows failed and aborted
// prints, and its export has both.
it('exports what the Failed collection shows', async () => {
let exported: string | null = null;
server.use(
http.get('*/api/v1/archives/export', ({ request }) => {
exported = new URL(request.url).searchParams.get('status');
return new HttpResponse('ID\n', { headers: { 'Content-Type': 'text/csv' } });
}),
);
const createObjectURL = URL.createObjectURL;
const revokeObjectURL = URL.revokeObjectURL;
URL.createObjectURL = () => 'blob:export';
URL.revokeObjectURL = () => {};
try {
render();
fireEvent.change(await screen.findByDisplayValue('All Archives'), { target: { value: 'failed' } });
fireEvent.click(screen.getByRole('button', { name: 'Export' }));
fireEvent.click(await screen.findByText('Export as CSV'));
await waitFor(() => expect(exported).toBe('failed,aborted'));
} finally {
URL.createObjectURL = createObjectURL;
URL.revokeObjectURL = revokeObjectURL;
}
});
describe('merge duplicates button', () => {
const signInWith = (permissions: string[]) => {
setAuthToken('merge-token', 'session');
server.use(
http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
http.get('/api/v1/auth/me', () => HttpResponse.json({
id: 7,
username: 'operator',
is_active: true,
is_admin: false,
groups: [],
permissions,
created_at: '2026-08-18T00:00:00Z',
})),
http.get('*/api/v1/archives/merge-preview', () => HttpResponse.json({ groups: [] })),
);
};
it('opens the merge dialog for a user who may update and delete all archives', async () => {
signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
render();
fireEvent.click(await screen.findByRole('button', { name: 'Merge duplicates' }));
expect(await screen.findByText('No duplicates to merge.')).toBeInTheDocument();
});
it('offers the merge while duplicates exist, until dismissed', async () => {
signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
server.use(http.get('*/api/v1/archives/merge-count', () => HttpResponse.json({ groups: 3 })));
render();
const banner = await screen.findByTestId('merge-banner');
expect(banner).toHaveTextContent('3 group(s) of archives hold the same file');
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
await waitFor(() => expect(screen.queryByTestId('merge-banner')).toBeNull());
expect(window.localStorage.setItem).toHaveBeenCalledWith('archiveMergeBannerDismissed', '3');
});
it('offers it again once the count has changed since it was dismissed', async () => {
vi.mocked(window.localStorage.getItem).mockImplementation((key: string) =>
key === 'archiveMergeBannerDismissed' ? '3' : null,
);
signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
server.use(http.get('*/api/v1/archives/merge-count', () => HttpResponse.json({ groups: 4 })));
render();
expect(await screen.findByTestId('merge-banner')).toBeInTheDocument();
vi.mocked(window.localStorage.getItem).mockReset();
});
it('counts again when the archives change', async () => {
signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
let groups = 1;
server.use(http.get('*/api/v1/archives/merge-count', () => HttpResponse.json({ groups })));
// Following an archive link reloads the list, as a delete or a new print does.
function GoTo() {
const navigate = useNavigate();
return ;
}
render(<>>);
await screen.findByTestId('merge-banner');
groups = 0;
fireEvent.click(screen.getByText('go'));
await waitFor(() => expect(screen.queryByTestId('merge-banner')).toBeNull());
});
it('stays dismissed when merging some left fewer', async () => {
vi.mocked(window.localStorage.getItem).mockImplementation((key: string) =>
key === 'archiveMergeBannerDismissed' ? '3' : null,
);
signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
let counted = false;
server.use(
http.get('*/api/v1/archives/merge-count', () => {
counted = true;
return HttpResponse.json({ groups: 2 });
}),
);
render();
await waitFor(() => expect(counted).toBe(true));
await screen.findByRole('button', { name: 'Merge duplicates' });
expect(screen.queryByTestId('merge-banner')).toBeNull();
vi.mocked(window.localStorage.getItem).mockReset();
});
it('stays dismissed while the count is the same', async () => {
vi.mocked(window.localStorage.getItem).mockImplementation((key: string) =>
key === 'archiveMergeBannerDismissed' ? '3' : null,
);
signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
let counted = false;
server.use(
http.get('*/api/v1/archives/merge-count', () => {
counted = true;
return HttpResponse.json({ groups: 3 });
}),
);
render();
await waitFor(() => expect(counted).toBe(true));
await screen.findByRole('button', { name: 'Merge duplicates' });
expect(screen.queryByTestId('merge-banner')).toBeNull();
vi.mocked(window.localStorage.getItem).mockReset();
});
it('is offered in the Duplicates collection', async () => {
signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
render();
await screen.findByRole('button', { name: 'Merge duplicates' });
expect(screen.queryByText(/can be merged into one, with every print of them/)).toBeNull();
fireEvent.change(screen.getByDisplayValue('All Archives'), { target: { value: 'duplicates' } });
expect(await screen.findByText(/can be merged into one, with every print of them/)).toBeInTheDocument();
});
it('is not there for a user who may only update all archives', async () => {
signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_own']);
render();
await screen.findByText('Archives');
await waitFor(() => expect(screen.queryByRole('button', { name: 'Merge duplicates' })).toBeNull());
});
});
// #1898: the "outcome?" badge on a print still waiting for its verdict opens
// the dialog. The PATCH behind it checks archives:update against the owner,
// like the menu item beside it, so for anyone else the badge is a status only.
describe('awaiting-outcome badge', () => {
const awaiting = { ...mockArchives[0], confirm_requested: true, user_verdict: null, created_by_id: 7 };
const signInWith = (permissions: string[]) => {
setAuthToken('verdict-token', 'session');
server.use(
http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
http.get('/api/v1/auth/me', () => HttpResponse.json({
id: 7,
username: 'operator',
is_active: true,
is_admin: false,
groups: [],
permissions,
created_at: '2026-08-18T00:00:00Z',
})),
http.get('/api/v1/archives/', () => HttpResponse.json([awaiting])),
);
};
afterEach(() => {
window.localStorage.removeItem('archiveViewMode');
});
it('is a status only for a user who may not update the archive', async () => {
signInWith(['archives:read_all']);
render();
const badge = await screen.findByRole('button', { name: 'outcome?' });
await waitFor(() => expect(badge).toBeDisabled());
expect(badge).toHaveAttribute('title', 'You do not have permission to update archives');
fireEvent.click(badge);
expect(screen.queryByTestId('confirm-outcome-dialog')).toBeNull();
});
it('is a status only in the list view too', async () => {
window.localStorage.setItem('archiveViewMode', 'list');
signInWith(['archives:read_all']);
render();
const badge = await screen.findByRole('button', { name: 'outcome?' });
await waitFor(() => expect(badge).toBeDisabled());
expect(badge).toHaveAttribute('title', 'You do not have permission to update archives');
});
it('is disabled on somebody else\'s print for a user who may update only their own', async () => {
signInWith(['archives:read_all', 'archives:update_own']);
server.use(http.get('/api/v1/archives/', () => HttpResponse.json([{ ...awaiting, created_by_id: 8 }])));
render();
const badge = await screen.findByRole('button', { name: 'outcome?' });
await waitFor(() => expect(badge).toBeDisabled());
});
it('opens the dialog for the owner', async () => {
signInWith(['archives:read_all', 'archives:update_own']);
render();
const badge = await screen.findByRole('button', { name: 'outcome?' });
await waitFor(() => expect(badge).toBeEnabled());
fireEvent.click(badge);
expect(await screen.findByTestId('confirm-outcome-dialog')).toBeInTheDocument();
});
});
describe('unconfirmed filter', () => {
afterEach(() => {
window.localStorage.removeItem('archiveFilterUnconfirmed');
});
it('is cleared by Reset like every other top filter', async () => {
render();
await screen.findByText('Benchy');
fireEvent.click(screen.getByTitle('Show only prints still waiting for their outcome verdict'));
// Neither fixture archive is waiting for a verdict, so the filter empties the list.
await waitFor(() => expect(screen.queryByText('Benchy')).not.toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Reset' }));
expect(await screen.findByText('Benchy')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Reset' })).not.toBeInTheDocument();
});
});
});