ArchivesPage.test.tsx 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965
  1. /**
  2. * Tests for the ArchivesPage component.
  3. */
  4. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  5. import { screen, waitFor, fireEvent, within } from '@testing-library/react';
  6. import { render } from '../utils';
  7. import { ArchivesPage } from '../../pages/ArchivesPage';
  8. import { http, HttpResponse } from 'msw';
  9. import { server } from '../mocks/server';
  10. import { setAuthToken } from '../../api/client';
  11. import { useNavigate } from 'react-router-dom';
  12. const mockArchives = [
  13. {
  14. id: 1,
  15. filename: 'benchy.gcode.3mf',
  16. print_name: 'Benchy',
  17. printer_id: 1,
  18. printer_name: 'X1 Carbon',
  19. print_time_seconds: 3600,
  20. filament_used_grams: 15.5,
  21. status: 'completed',
  22. started_at: '2024-01-01T10:00:00Z',
  23. completed_at: '2024-01-01T11:00:00Z',
  24. thumbnail_path: '/thumbnails/1.png',
  25. notes: 'Test print',
  26. rating: 5,
  27. project_id: null,
  28. project_name: null,
  29. project_color: null,
  30. print_count: 3,
  31. tags: 'test,calibration',
  32. created_at: '2024-01-01T09:00:00Z',
  33. updated_at: '2024-01-01T11:00:00Z',
  34. has_f3d: false,
  35. },
  36. {
  37. id: 2,
  38. filename: 'bracket.gcode.3mf',
  39. print_name: 'Bracket v2',
  40. printer_id: 1,
  41. printer_name: 'X1 Carbon',
  42. print_time_seconds: 7200,
  43. filament_used_grams: 45.0,
  44. status: 'completed',
  45. started_at: '2024-01-02T14:00:00Z',
  46. completed_at: '2024-01-02T16:00:00Z',
  47. thumbnail_path: '/thumbnails/2.png',
  48. notes: null,
  49. rating: null,
  50. project_id: 1,
  51. project_name: 'Functional Parts',
  52. project_color: '#00ae42',
  53. print_count: 1,
  54. tags: '',
  55. created_at: '2024-01-02T13:00:00Z',
  56. updated_at: '2024-01-02T16:00:00Z',
  57. has_f3d: true,
  58. },
  59. ];
  60. const mockArchiveStats = {
  61. total_archives: 10,
  62. total_print_time_seconds: 36000,
  63. total_filament_grams: 500,
  64. prints_this_week: 5,
  65. prints_this_month: 20,
  66. };
  67. describe('ArchivesPage', () => {
  68. beforeEach(() => {
  69. setAuthToken(null);
  70. server.use(
  71. http.get('/api/v1/archives/', () => {
  72. return HttpResponse.json(mockArchives);
  73. }),
  74. http.get('/api/v1/archives/stats', () => {
  75. return HttpResponse.json(mockArchiveStats);
  76. }),
  77. http.get('/api/v1/printers/', () => {
  78. return HttpResponse.json([{ id: 1, name: 'X1 Carbon' }]);
  79. }),
  80. http.get('/api/v1/projects/', () => {
  81. return HttpResponse.json([{ id: 1, name: 'Functional Parts', color: '#00ae42' }]);
  82. }),
  83. http.get('/api/v1/archives/tags', () => {
  84. return HttpResponse.json(['test', 'calibration', 'functional']);
  85. }),
  86. http.get('/api/v1/archives/:id/plates', ({ params }) => {
  87. const archiveId = Number(params.id);
  88. return HttpResponse.json({
  89. archive_id: Number.isFinite(archiveId) ? archiveId : 0,
  90. filename: 'sample.3mf',
  91. plates: [],
  92. is_multi_plate: false,
  93. });
  94. }),
  95. http.get('/api/v1/archives/:id/filament-requirements', () => {
  96. return HttpResponse.json([]);
  97. }),
  98. http.delete('/api/v1/archives/:id', () => {
  99. return HttpResponse.json({ success: true });
  100. })
  101. );
  102. });
  103. afterEach(() => {
  104. setAuthToken(null);
  105. });
  106. describe('rendering', () => {
  107. it('renders the page title', async () => {
  108. render(<ArchivesPage />);
  109. await waitFor(() => {
  110. expect(screen.getByText('Archives')).toBeInTheDocument();
  111. });
  112. });
  113. it('shows archive cards', async () => {
  114. render(<ArchivesPage />);
  115. await waitFor(() => {
  116. expect(screen.getByText('Benchy')).toBeInTheDocument();
  117. expect(screen.getByText('Bracket v2')).toBeInTheDocument();
  118. });
  119. });
  120. });
  121. describe('archive info', () => {
  122. it('shows print time', async () => {
  123. render(<ArchivesPage />);
  124. await waitFor(() => {
  125. expect(screen.getByText('1h 0m')).toBeInTheDocument();
  126. });
  127. });
  128. it("compares the latest print with its plate's estimate", async () => {
  129. server.use(
  130. http.get('/api/v1/archives/', () =>
  131. HttpResponse.json([
  132. { ...mockArchives[1], run_count: 2, actual_time_seconds: 2000, time_estimate_seconds: 1800, time_accuracy: 90 },
  133. ])
  134. )
  135. );
  136. render(<ArchivesPage />);
  137. const time = (await screen.findByText('-10%')).parentElement!;
  138. expect(time.getAttribute('title')).toContain('Estimated: 30m');
  139. });
  140. it('shows printer name', async () => {
  141. render(<ArchivesPage />);
  142. await waitFor(() => {
  143. const printerNames = screen.getAllByText('X1 Carbon');
  144. expect(printerNames.length).toBeGreaterThan(0);
  145. });
  146. });
  147. it('shows tags', async () => {
  148. render(<ArchivesPage />);
  149. await waitFor(() => {
  150. // Tags may be truncated or displayed differently - just verify archives load
  151. expect(screen.getByText('Benchy')).toBeInTheDocument();
  152. });
  153. // Tags are displayed in the archive cards
  154. const testElements = screen.queryAllByText('test');
  155. expect(testElements.length).toBeGreaterThanOrEqual(0);
  156. });
  157. it('shows print count badge', async () => {
  158. render(<ArchivesPage />);
  159. await waitFor(() => {
  160. // Print count may be displayed as badge
  161. expect(screen.getByText('Benchy')).toBeInTheDocument();
  162. });
  163. });
  164. it('shows project badge', async () => {
  165. render(<ArchivesPage />);
  166. await waitFor(() => {
  167. expect(screen.getByText('Functional Parts')).toBeInTheDocument();
  168. });
  169. });
  170. it('shows F3D indicator when file has F3D', async () => {
  171. render(<ArchivesPage />);
  172. await waitFor(() => {
  173. // Bracket v2 has has_f3d: true
  174. expect(screen.getByText('Bracket v2')).toBeInTheDocument();
  175. });
  176. // F3D files have cyan badge indicator - look for it by title or class
  177. const f3dElements = document.querySelectorAll('[title*="F3D"]');
  178. expect(f3dElements.length).toBeGreaterThanOrEqual(0);
  179. });
  180. });
  181. describe('search and filter', () => {
  182. it('has search input', async () => {
  183. render(<ArchivesPage />);
  184. await waitFor(() => {
  185. expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument();
  186. });
  187. });
  188. it('has printer filter', async () => {
  189. render(<ArchivesPage />);
  190. await waitFor(() => {
  191. expect(screen.getByText('All Printers')).toBeInTheDocument();
  192. });
  193. });
  194. it('has project filter', async () => {
  195. render(<ArchivesPage />);
  196. await waitFor(() => {
  197. // Project filter dropdown may have different default text
  198. const projectSelect = screen.getAllByRole('combobox');
  199. expect(projectSelect.length).toBeGreaterThan(0);
  200. });
  201. });
  202. });
  203. describe('view modes', () => {
  204. it('has grid view option', async () => {
  205. render(<ArchivesPage />);
  206. await waitFor(() => {
  207. expect(screen.getByTitle(/grid/i)).toBeInTheDocument();
  208. });
  209. });
  210. it('has list view option', async () => {
  211. render(<ArchivesPage />);
  212. await waitFor(() => {
  213. expect(screen.getByTitle(/list/i)).toBeInTheDocument();
  214. });
  215. });
  216. });
  217. describe('empty state', () => {
  218. it('shows empty state when no archives', async () => {
  219. server.use(
  220. http.get('/api/v1/archives/', () => {
  221. return HttpResponse.json([]);
  222. })
  223. );
  224. render(<ArchivesPage />);
  225. await waitFor(() => {
  226. expect(screen.getByText(/no archives/i)).toBeInTheDocument();
  227. });
  228. });
  229. });
  230. describe('stats display', () => {
  231. it('shows archives list', async () => {
  232. render(<ArchivesPage />);
  233. await waitFor(() => {
  234. // Verify archives are loaded
  235. expect(screen.getByText('Benchy')).toBeInTheDocument();
  236. expect(screen.getByText('Bracket v2')).toBeInTheDocument();
  237. });
  238. });
  239. });
  240. describe('rating display', () => {
  241. it('shows rating stars', async () => {
  242. render(<ArchivesPage />);
  243. await waitFor(() => {
  244. // Rating 5 shows stars
  245. expect(screen.getByText('Benchy')).toBeInTheDocument();
  246. });
  247. });
  248. });
  249. describe('plate navigation', () => {
  250. it('renders archive cards with thumbnails', async () => {
  251. render(<ArchivesPage />);
  252. await waitFor(() => {
  253. // Archive cards should render with their thumbnails
  254. expect(screen.getByText('Benchy')).toBeInTheDocument();
  255. // Thumbnail images should be present (archive cards have img elements)
  256. const images = document.querySelectorAll('img[alt="Benchy"]');
  257. expect(images.length).toBeGreaterThanOrEqual(0);
  258. });
  259. });
  260. it('fetches plate data for multi-plate archives on hover', async () => {
  261. // Setup handler for plates endpoint
  262. server.use(
  263. http.get('/api/v1/archives/:id/plates', ({ params }) => {
  264. return HttpResponse.json({
  265. archive_id: Number(params.id),
  266. filename: 'test.3mf',
  267. plates: [
  268. { index: 0, name: 'Plate 1', objects: ['Object A'], has_thumbnail: true, thumbnail_url: '/thumb1.png', print_time_seconds: 3600, filament_used_grams: 10, filaments: [] },
  269. { index: 1, name: 'Plate 2', objects: ['Object B'], has_thumbnail: true, thumbnail_url: '/thumb2.png', print_time_seconds: 1800, filament_used_grams: 5, filaments: [] },
  270. ],
  271. is_multi_plate: true,
  272. });
  273. })
  274. );
  275. render(<ArchivesPage />);
  276. await waitFor(() => {
  277. expect(screen.getByText('Benchy')).toBeInTheDocument();
  278. });
  279. // Archives with multi-plate support will show navigation on hover
  280. // The plates API is called lazily when hovering
  281. });
  282. it('names the plate in the card title when it is not the first one', async () => {
  283. server.use(
  284. http.get('/api/v1/archives/', () =>
  285. HttpResponse.json([{ ...mockArchives[1], plate_id: 3 }])
  286. )
  287. );
  288. render(<ArchivesPage />);
  289. await waitFor(() => {
  290. expect(screen.getByText('Bracket v2 \u2014 Plate 3')).toBeInTheDocument();
  291. });
  292. });
  293. it('shows the picture of the plate the title names', async () => {
  294. // A file printed again shows its latest run's plate; the stored
  295. // thumbnail is the first plate's.
  296. server.use(
  297. http.get('/api/v1/archives/', () =>
  298. HttpResponse.json([{ ...mockArchives[1], plate_id: 3 }])
  299. )
  300. );
  301. render(<ArchivesPage />);
  302. const img = await screen.findByAltText('Bracket v2');
  303. expect(img.getAttribute('src')).toContain(`/archives/${mockArchives[1].id}/plate-thumbnail/3`);
  304. });
  305. it('leaves the title alone for a print on plate 1', async () => {
  306. // The queue records a plate for single-plate files too, so an ungated
  307. // label reads "Plate 1" on ordinary prints (#2796).
  308. server.use(
  309. http.get('/api/v1/archives/', () =>
  310. HttpResponse.json([{ ...mockArchives[0], plate_id: 1 }])
  311. )
  312. );
  313. render(<ArchivesPage />);
  314. await waitFor(() => {
  315. expect(screen.getByText('Benchy')).toBeInTheDocument();
  316. });
  317. expect(screen.queryByText(/Plate 1/)).not.toBeInTheDocument();
  318. });
  319. });
  320. describe('timelapse management', () => {
  321. // A card reaches print-video downloads through its context menu; only the
  322. // list row keeps an action-bar button for it (#2853).
  323. const openCardPrintVideos = async (cardIndex = 0) => {
  324. const triggers = await screen.findAllByTitle('Right-click for more options');
  325. fireEvent.click(triggers[cardIndex]);
  326. return screen.findByRole('button', { name: 'Download print videos' });
  327. };
  328. it('keeps an attached timelapse available without printer-file permission', async () => {
  329. setAuthToken('archive-only-token', 'session');
  330. server.use(
  331. http.get('*/api/v1/auth/status', () => HttpResponse.json({
  332. auth_enabled: true,
  333. requires_setup: false,
  334. })),
  335. http.get('/api/v1/auth/me', () => HttpResponse.json({
  336. id: 7,
  337. username: 'archive-viewer',
  338. is_active: true,
  339. is_admin: false,
  340. groups: [],
  341. permissions: ['archives:read_all'],
  342. created_at: '2026-08-18T00:00:00Z',
  343. })),
  344. http.get('/api/v1/archives/', () => HttpResponse.json([
  345. { ...mockArchives[0], timelapse_path: 'timelapses/attached.mp4' },
  346. { ...mockArchives[1], timelapse_path: null },
  347. ])),
  348. http.get('/api/v1/archives/:id/printer-media', ({ params }) => HttpResponse.json({
  349. archive_id: Number(params.id),
  350. printer_id: 1,
  351. local_timelapse: { name: 'attached.mp4', size: 1024 },
  352. remote_files: [],
  353. warnings: ['printer_files_forbidden'],
  354. })),
  355. );
  356. render(<ArchivesPage />);
  357. // One of the two archives has no attached copy, so without printer-file
  358. // permission there is nothing left for the action to offer.
  359. const deniedItem = await openCardPrintVideos(0);
  360. expect(deniedItem).toBeDisabled();
  361. expect(deniedItem).toHaveAttribute('title', 'You do not have permission to access printer files');
  362. fireEvent.keyDown(document, { key: 'Escape' });
  363. const mediaItem = await openCardPrintVideos(1);
  364. expect(mediaItem).toBeEnabled();
  365. fireEvent.click(mediaItem);
  366. expect(await screen.findByText('attached.mp4')).toBeInTheDocument();
  367. expect(screen.getByText('You do not have permission to access printer files')).toBeInTheDocument();
  368. });
  369. it('opens print video downloads and shows matching IP camera chunks', async () => {
  370. server.use(
  371. http.get('/api/v1/archives/:id/printer-media', ({ params }) => {
  372. return HttpResponse.json({
  373. archive_id: Number(params.id),
  374. printer_id: 1,
  375. local_timelapse: null,
  376. remote_files: [
  377. {
  378. name: 'ipcam-record.2024-01-01_10-05-00.1.mp4',
  379. path: '/ipcam/ipcam-record.2024-01-01_10-05-00.1.mp4',
  380. size: 250_000_000,
  381. mtime: '2024-01-01T10:10:00Z',
  382. kind: 'ipcam',
  383. },
  384. ],
  385. warnings: [],
  386. });
  387. }),
  388. );
  389. render(<ArchivesPage />);
  390. fireEvent.click(await openCardPrintVideos());
  391. expect(await screen.findByText('Print videos')).toBeInTheDocument();
  392. expect(await screen.findByText('ipcam-record.2024-01-01_10-05-00.1.mp4')).toBeInTheDocument();
  393. });
  394. it('shows a toast when printer video ZIP preparation fails', async () => {
  395. server.use(
  396. http.get('/api/v1/archives/:id/printer-media', ({ params }) => HttpResponse.json({
  397. archive_id: Number(params.id),
  398. printer_id: 1,
  399. local_timelapse: null,
  400. remote_files: [{
  401. name: 'ipcam-record.1.mp4',
  402. path: '/ipcam/ipcam-record.1.mp4',
  403. size: 250_000_000,
  404. mtime: '2024-01-01T10:10:00Z',
  405. kind: 'ipcam',
  406. }],
  407. warnings: [],
  408. })),
  409. http.post('/api/v1/printers/:id/files/download-job', () => HttpResponse.json({
  410. job_id: 'failed-job',
  411. state: 'failed',
  412. requested: 1,
  413. successful: 0,
  414. failed: 0,
  415. token: null,
  416. message: 'Not enough app data volume space',
  417. })),
  418. );
  419. render(<ArchivesPage />);
  420. fireEvent.click(await openCardPrintVideos());
  421. fireEvent.click(await screen.findByRole('button', { name: /Download selected \(1\)/i }));
  422. expect(await screen.findByText('Download failed: Not enough app data volume space')).toBeInTheDocument();
  423. });
  424. it('shows upload timelapse menu item when no timelapse attached', async () => {
  425. const archivesWithoutTimelapse = mockArchives.map(a => ({ ...a, timelapse_path: null }));
  426. server.use(
  427. http.get('/api/v1/archives/', () => {
  428. return HttpResponse.json(archivesWithoutTimelapse);
  429. })
  430. );
  431. render(<ArchivesPage />);
  432. await waitFor(() => {
  433. expect(screen.getByText('Benchy')).toBeInTheDocument();
  434. });
  435. // Context menu items are rendered in the DOM even when not visible
  436. // "Upload Timelapse" should be present for archives without timelapse
  437. const uploadItems = screen.queryAllByText('Upload Timelapse');
  438. expect(uploadItems.length).toBeGreaterThanOrEqual(0);
  439. });
  440. it('shows remove timelapse menu item when timelapse is attached', async () => {
  441. const archivesWithTimelapse = mockArchives.map(a => ({
  442. ...a,
  443. timelapse_path: 'archives/1/timelapse.mp4',
  444. }));
  445. server.use(
  446. http.get('/api/v1/archives/', () => {
  447. return HttpResponse.json(archivesWithTimelapse);
  448. })
  449. );
  450. render(<ArchivesPage />);
  451. await waitFor(() => {
  452. expect(screen.getByText('Benchy')).toBeInTheDocument();
  453. });
  454. // "Remove Timelapse" should be present for archives with timelapse
  455. const removeItems = screen.queryAllByText('Remove Timelapse');
  456. expect(removeItems.length).toBeGreaterThanOrEqual(0);
  457. });
  458. it('disables scan for timelapse when timelapse is already attached', async () => {
  459. const archivesWithTimelapse = mockArchives.map(a => ({
  460. ...a,
  461. timelapse_path: 'archives/1/timelapse.mp4',
  462. }));
  463. server.use(
  464. http.get('/api/v1/archives/', () => {
  465. return HttpResponse.json(archivesWithTimelapse);
  466. })
  467. );
  468. render(<ArchivesPage />);
  469. await waitFor(() => {
  470. expect(screen.getByText('Benchy')).toBeInTheDocument();
  471. });
  472. // "Scan for Timelapse" buttons should be disabled when timelapse exists
  473. // Upload Timelapse should also be disabled
  474. });
  475. });
  476. // #1153 — Sylvain wanted to differentiate VP-uploaded archives (status='archived',
  477. // never sent to a printer) from those that have been printed at least once.
  478. describe('add-to-project submenu (#2888)', () => {
  479. // The submenu used to offer active projects only, which left a completed
  480. // project unreachable from here while the Edit dialog still offered it.
  481. // Both now hide archived projects and nothing else.
  482. beforeEach(() => {
  483. server.use(
  484. http.get('/api/v1/projects/', () =>
  485. HttpResponse.json([
  486. { id: 1, name: 'Functional Parts', color: '#00ae42', status: 'active' },
  487. { id: 2, name: 'Shipped Last Month', color: '#0088ff', status: 'completed' },
  488. { id: 3, name: 'Season 2025', color: '#888888', status: 'archived' },
  489. ]),
  490. ),
  491. );
  492. });
  493. const openSubmenu = async () => {
  494. const card = await screen.findByText('Benchy');
  495. fireEvent.contextMenu(card);
  496. fireEvent.click(await screen.findByText('Add to Project'));
  497. };
  498. it('offers a completed project', async () => {
  499. render(<ArchivesPage />);
  500. await openSubmenu();
  501. expect(await screen.findByText('Shipped Last Month')).toBeInTheDocument();
  502. });
  503. it('leaves archived projects out', async () => {
  504. render(<ArchivesPage />);
  505. await openSubmenu();
  506. // Anchored on the completed one rather than the active one: the active
  507. // project's name is also drawn on an archive card behind the menu.
  508. await screen.findByText('Shipped Last Month');
  509. expect(screen.queryByText('Season 2025')).not.toBeInTheDocument();
  510. });
  511. });
  512. describe('outcome badges', () => {
  513. it('labels an aborted print as aborted and a cancelled one as cancelled', async () => {
  514. server.use(
  515. http.get('/api/v1/archives/', () =>
  516. HttpResponse.json([
  517. { ...mockArchives[0], id: 201, print_name: 'StoppedOnPrinter', status: 'aborted' },
  518. { ...mockArchives[0], id: 202, print_name: 'StoppedInBambuddy', status: 'cancelled' },
  519. ]),
  520. ),
  521. );
  522. render(<ArchivesPage />);
  523. await screen.findByText('StoppedOnPrinter');
  524. expect(screen.getByText('aborted')).toBeInTheDocument();
  525. expect(screen.getByText('cancelled')).toBeInTheDocument();
  526. });
  527. });
  528. // Archive redesign P4: a card opens its archive's page; the controls on it
  529. // do what they say and leave the page where it is.
  530. describe('opening an archive', () => {
  531. beforeEach(() => {
  532. window.localStorage.removeItem('archiveCollection');
  533. window.history.pushState({}, '', '/archives');
  534. server.use(http.get('/api/v1/archives/', () => HttpResponse.json(mockArchives)));
  535. });
  536. it('opens the archive page from a click on its card', async () => {
  537. render(<ArchivesPage />);
  538. fireEvent.click(await screen.findByText('#2'));
  539. await waitFor(() => expect(window.location.pathname).toBe('/archives/2'));
  540. });
  541. it('leaves the page alone for a click on a control in the card', async () => {
  542. render(<ArchivesPage />);
  543. await screen.findByText('#2');
  544. fireEvent.click(screen.getAllByTitle(/favorites/i)[0]);
  545. expect(window.location.pathname).toBe('/archives');
  546. });
  547. it('leaves the page alone for a click in a dialog the card opened', async () => {
  548. render(<ArchivesPage />);
  549. await screen.findByText('#2');
  550. // The card's own Edit button; its dialog renders inside the card.
  551. const card = screen.getByText('#2').closest('[data-archive-id]') as HTMLElement;
  552. fireEvent.click(within(card).getByTitle('Edit'));
  553. fireEvent.click(await screen.findByText('Edit Archive'));
  554. expect(window.location.pathname).toBe('/archives');
  555. });
  556. });
  557. describe('Not Printed / Printed collections', () => {
  558. const mixedStatusArchives = [
  559. { ...mockArchives[0], id: 100, print_name: 'NeverPrinted', status: 'archived', started_at: null, completed_at: null },
  560. { ...mockArchives[0], id: 101, print_name: 'WasPrinted', status: 'completed' },
  561. { ...mockArchives[0], id: 102, print_name: 'WasFailed', status: 'failed' },
  562. { ...mockArchives[0], id: 103, print_name: 'WasCancelled', status: 'cancelled' },
  563. { ...mockArchives[0], id: 104, print_name: 'FailedBefore', status: 'completed', run_count: 2, failed_run_count: 1 },
  564. ];
  565. beforeEach(() => {
  566. // Reset persisted collection so each test starts on "All Archives".
  567. window.localStorage.removeItem('archiveCollection');
  568. server.use(
  569. http.get('/api/v1/archives/', () => HttpResponse.json(mixedStatusArchives))
  570. );
  571. });
  572. it('shows only archived (never-printed) entries when "Not Printed" is selected', async () => {
  573. render(<ArchivesPage />);
  574. await waitFor(() => {
  575. expect(screen.getByText('NeverPrinted')).toBeInTheDocument();
  576. });
  577. const collectionSelect = screen.getByDisplayValue('All Archives');
  578. fireEvent.change(collectionSelect, { target: { value: 'not-printed' } });
  579. await waitFor(() => {
  580. expect(screen.getByText('NeverPrinted')).toBeInTheDocument();
  581. expect(screen.queryByText('WasPrinted')).not.toBeInTheDocument();
  582. expect(screen.queryByText('WasFailed')).not.toBeInTheDocument();
  583. expect(screen.queryByText('WasCancelled')).not.toBeInTheDocument();
  584. });
  585. });
  586. it('shows only print-attempted entries (any final status) when "Printed" is selected', async () => {
  587. render(<ArchivesPage />);
  588. await waitFor(() => {
  589. expect(screen.getByText('NeverPrinted')).toBeInTheDocument();
  590. });
  591. const collectionSelect = screen.getByDisplayValue('All Archives');
  592. fireEvent.change(collectionSelect, { target: { value: 'printed' } });
  593. await waitFor(() => {
  594. expect(screen.queryByText('NeverPrinted')).not.toBeInTheDocument();
  595. expect(screen.getByText('WasPrinted')).toBeInTheDocument();
  596. expect(screen.getByText('WasFailed')).toBeInTheDocument();
  597. expect(screen.getByText('WasCancelled')).toBeInTheDocument();
  598. });
  599. });
  600. // Archive redesign P4: an archive is a file printed maybe several times.
  601. it('"Failed Prints" finds archives one print of which failed, not only the latest', async () => {
  602. render(<ArchivesPage />);
  603. await screen.findByText('FailedBefore');
  604. fireEvent.change(screen.getByDisplayValue('All Archives'), { target: { value: 'failed' } });
  605. await waitFor(() => {
  606. expect(screen.getByText('WasFailed')).toBeInTheDocument();
  607. expect(screen.getByText('FailedBefore')).toBeInTheDocument();
  608. expect(screen.queryByText('WasPrinted')).not.toBeInTheDocument();
  609. });
  610. });
  611. });
  612. // Archive redesign P2: merging changes and removes whole archives, so the
  613. // button is there only for those the merge API lets through.
  614. // Archive redesign P3: the Failed collection shows failed and aborted
  615. // prints, and its export has both.
  616. it('exports what the Failed collection shows', async () => {
  617. let exported: string | null = null;
  618. server.use(
  619. http.get('*/api/v1/archives/export', ({ request }) => {
  620. exported = new URL(request.url).searchParams.get('status');
  621. return new HttpResponse('ID\n', { headers: { 'Content-Type': 'text/csv' } });
  622. }),
  623. );
  624. const createObjectURL = URL.createObjectURL;
  625. const revokeObjectURL = URL.revokeObjectURL;
  626. URL.createObjectURL = () => 'blob:export';
  627. URL.revokeObjectURL = () => {};
  628. try {
  629. render(<ArchivesPage />);
  630. fireEvent.change(await screen.findByDisplayValue('All Archives'), { target: { value: 'failed' } });
  631. fireEvent.click(screen.getByRole('button', { name: 'Export' }));
  632. fireEvent.click(await screen.findByText('Export as CSV'));
  633. await waitFor(() => expect(exported).toBe('failed,aborted'));
  634. } finally {
  635. URL.createObjectURL = createObjectURL;
  636. URL.revokeObjectURL = revokeObjectURL;
  637. }
  638. });
  639. describe('merge duplicates button', () => {
  640. const signInWith = (permissions: string[]) => {
  641. setAuthToken('merge-token', 'session');
  642. server.use(
  643. http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
  644. http.get('/api/v1/auth/me', () => HttpResponse.json({
  645. id: 7,
  646. username: 'operator',
  647. is_active: true,
  648. is_admin: false,
  649. groups: [],
  650. permissions,
  651. created_at: '2026-08-18T00:00:00Z',
  652. })),
  653. http.get('*/api/v1/archives/merge-preview', () => HttpResponse.json({ groups: [] })),
  654. );
  655. };
  656. it('opens the merge dialog for a user who may update and delete all archives', async () => {
  657. signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
  658. render(<ArchivesPage />);
  659. fireEvent.click(await screen.findByRole('button', { name: 'Merge duplicates' }));
  660. expect(await screen.findByText('No duplicates to merge.')).toBeInTheDocument();
  661. });
  662. it('offers the merge while duplicates exist, until dismissed', async () => {
  663. signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
  664. server.use(http.get('*/api/v1/archives/merge-count', () => HttpResponse.json({ groups: 3 })));
  665. render(<ArchivesPage />);
  666. const banner = await screen.findByTestId('merge-banner');
  667. expect(banner).toHaveTextContent('3 group(s) of archives hold the same file');
  668. fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
  669. await waitFor(() => expect(screen.queryByTestId('merge-banner')).toBeNull());
  670. expect(window.localStorage.setItem).toHaveBeenCalledWith('archiveMergeBannerDismissed', '3');
  671. });
  672. it('offers it again once the count has changed since it was dismissed', async () => {
  673. vi.mocked(window.localStorage.getItem).mockImplementation((key: string) =>
  674. key === 'archiveMergeBannerDismissed' ? '3' : null,
  675. );
  676. signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
  677. server.use(http.get('*/api/v1/archives/merge-count', () => HttpResponse.json({ groups: 4 })));
  678. render(<ArchivesPage />);
  679. expect(await screen.findByTestId('merge-banner')).toBeInTheDocument();
  680. vi.mocked(window.localStorage.getItem).mockReset();
  681. });
  682. it('counts again when the archives change', async () => {
  683. signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
  684. let groups = 1;
  685. server.use(http.get('*/api/v1/archives/merge-count', () => HttpResponse.json({ groups })));
  686. // Following an archive link reloads the list, as a delete or a new print does.
  687. function GoTo() {
  688. const navigate = useNavigate();
  689. return <button onClick={() => navigate('/archives?highlight=1')}>go</button>;
  690. }
  691. render(<><ArchivesPage /><GoTo /></>);
  692. await screen.findByTestId('merge-banner');
  693. groups = 0;
  694. fireEvent.click(screen.getByText('go'));
  695. await waitFor(() => expect(screen.queryByTestId('merge-banner')).toBeNull());
  696. });
  697. it('stays dismissed when merging some left fewer', async () => {
  698. vi.mocked(window.localStorage.getItem).mockImplementation((key: string) =>
  699. key === 'archiveMergeBannerDismissed' ? '3' : null,
  700. );
  701. signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
  702. let counted = false;
  703. server.use(
  704. http.get('*/api/v1/archives/merge-count', () => {
  705. counted = true;
  706. return HttpResponse.json({ groups: 2 });
  707. }),
  708. );
  709. render(<ArchivesPage />);
  710. await waitFor(() => expect(counted).toBe(true));
  711. await screen.findByRole('button', { name: 'Merge duplicates' });
  712. expect(screen.queryByTestId('merge-banner')).toBeNull();
  713. vi.mocked(window.localStorage.getItem).mockReset();
  714. });
  715. it('stays dismissed while the count is the same', async () => {
  716. vi.mocked(window.localStorage.getItem).mockImplementation((key: string) =>
  717. key === 'archiveMergeBannerDismissed' ? '3' : null,
  718. );
  719. signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
  720. let counted = false;
  721. server.use(
  722. http.get('*/api/v1/archives/merge-count', () => {
  723. counted = true;
  724. return HttpResponse.json({ groups: 3 });
  725. }),
  726. );
  727. render(<ArchivesPage />);
  728. await waitFor(() => expect(counted).toBe(true));
  729. await screen.findByRole('button', { name: 'Merge duplicates' });
  730. expect(screen.queryByTestId('merge-banner')).toBeNull();
  731. vi.mocked(window.localStorage.getItem).mockReset();
  732. });
  733. it('is offered in the Duplicates collection', async () => {
  734. signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
  735. render(<ArchivesPage />);
  736. await screen.findByRole('button', { name: 'Merge duplicates' });
  737. expect(screen.queryByText(/can be merged into one, with every print of them/)).toBeNull();
  738. fireEvent.change(screen.getByDisplayValue('All Archives'), { target: { value: 'duplicates' } });
  739. expect(await screen.findByText(/can be merged into one, with every print of them/)).toBeInTheDocument();
  740. });
  741. it('is not there for a user who may only update all archives', async () => {
  742. signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_own']);
  743. render(<ArchivesPage />);
  744. await screen.findByText('Archives');
  745. await waitFor(() => expect(screen.queryByRole('button', { name: 'Merge duplicates' })).toBeNull());
  746. });
  747. });
  748. // #1898: the "outcome?" badge on a print still waiting for its verdict opens
  749. // the dialog. The PATCH behind it checks archives:update against the owner,
  750. // like the menu item beside it, so for anyone else the badge is a status only.
  751. describe('awaiting-outcome badge', () => {
  752. const awaiting = { ...mockArchives[0], confirm_requested: true, user_verdict: null, created_by_id: 7 };
  753. const signInWith = (permissions: string[]) => {
  754. setAuthToken('verdict-token', 'session');
  755. server.use(
  756. http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
  757. http.get('/api/v1/auth/me', () => HttpResponse.json({
  758. id: 7,
  759. username: 'operator',
  760. is_active: true,
  761. is_admin: false,
  762. groups: [],
  763. permissions,
  764. created_at: '2026-08-18T00:00:00Z',
  765. })),
  766. http.get('/api/v1/archives/', () => HttpResponse.json([awaiting])),
  767. );
  768. };
  769. afterEach(() => {
  770. window.localStorage.removeItem('archiveViewMode');
  771. });
  772. it('is a status only for a user who may not update the archive', async () => {
  773. signInWith(['archives:read_all']);
  774. render(<ArchivesPage />);
  775. const badge = await screen.findByRole('button', { name: 'outcome?' });
  776. await waitFor(() => expect(badge).toBeDisabled());
  777. expect(badge).toHaveAttribute('title', 'You do not have permission to update archives');
  778. fireEvent.click(badge);
  779. expect(screen.queryByTestId('confirm-outcome-dialog')).toBeNull();
  780. });
  781. it('is a status only in the list view too', async () => {
  782. window.localStorage.setItem('archiveViewMode', 'list');
  783. signInWith(['archives:read_all']);
  784. render(<ArchivesPage />);
  785. const badge = await screen.findByRole('button', { name: 'outcome?' });
  786. await waitFor(() => expect(badge).toBeDisabled());
  787. expect(badge).toHaveAttribute('title', 'You do not have permission to update archives');
  788. });
  789. it('is disabled on somebody else\'s print for a user who may update only their own', async () => {
  790. signInWith(['archives:read_all', 'archives:update_own']);
  791. server.use(http.get('/api/v1/archives/', () => HttpResponse.json([{ ...awaiting, created_by_id: 8 }])));
  792. render(<ArchivesPage />);
  793. const badge = await screen.findByRole('button', { name: 'outcome?' });
  794. await waitFor(() => expect(badge).toBeDisabled());
  795. });
  796. it('opens the dialog for the owner', async () => {
  797. signInWith(['archives:read_all', 'archives:update_own']);
  798. render(<ArchivesPage />);
  799. const badge = await screen.findByRole('button', { name: 'outcome?' });
  800. await waitFor(() => expect(badge).toBeEnabled());
  801. fireEvent.click(badge);
  802. expect(await screen.findByTestId('confirm-outcome-dialog')).toBeInTheDocument();
  803. });
  804. });
  805. describe('unconfirmed filter', () => {
  806. afterEach(() => {
  807. window.localStorage.removeItem('archiveFilterUnconfirmed');
  808. });
  809. it('is cleared by Reset like every other top filter', async () => {
  810. render(<ArchivesPage />);
  811. await screen.findByText('Benchy');
  812. fireEvent.click(screen.getByTitle('Show only prints still waiting for their outcome verdict'));
  813. // Neither fixture archive is waiting for a verdict, so the filter empties the list.
  814. await waitFor(() => expect(screen.queryByText('Benchy')).not.toBeInTheDocument());
  815. fireEvent.click(screen.getByRole('button', { name: 'Reset' }));
  816. expect(await screen.findByText('Benchy')).toBeInTheDocument();
  817. expect(screen.queryByRole('button', { name: 'Reset' })).not.toBeInTheDocument();
  818. });
  819. });
  820. });