ArchivesPage.test.tsx 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023
  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("counts every print's video and opens the archive to play them", async () => {
  542. // Plate 2's video is the archive's; plate 1's is on its print.
  543. server.use(
  544. http.get('/api/v1/archives/', () =>
  545. HttpResponse.json([{ ...mockArchives[0], timelapse_path: 'archives/1/b.mp4', timelapse_count: 2 }]),
  546. ),
  547. );
  548. render(<ArchivesPage />);
  549. const button = await screen.findByTestId('archive-card-timelapse');
  550. expect(button).toHaveAttribute('title', 'View 2 timelapses');
  551. expect(within(button).getByText('2')).toBeInTheDocument();
  552. fireEvent.click(button);
  553. await waitFor(() => expect(window.location.pathname).toBe('/archives/1'));
  554. });
  555. it('plays a single video in place', async () => {
  556. server.use(
  557. http.get('/api/v1/archives/', () =>
  558. HttpResponse.json([{ ...mockArchives[0], timelapse_path: 'archives/1/a.mp4', timelapse_count: 1 }]),
  559. ),
  560. );
  561. render(<ArchivesPage />);
  562. const button = await screen.findByTestId('archive-card-timelapse');
  563. expect(button).toHaveAttribute('title', 'View timelapse');
  564. fireEvent.click(button);
  565. expect(window.location.pathname).toBe('/archives');
  566. });
  567. it("opens the archive for a video only an earlier print has", async () => {
  568. server.use(
  569. http.get('/api/v1/archives/', () =>
  570. HttpResponse.json([{ ...mockArchives[0], timelapse_path: null, timelapse_count: 1 }]),
  571. ),
  572. );
  573. render(<ArchivesPage />);
  574. fireEvent.click(await screen.findByTestId('archive-card-timelapse'));
  575. await waitFor(() => expect(window.location.pathname).toBe('/archives/1'));
  576. });
  577. it("says in the list how many videos an archive's prints have", async () => {
  578. server.use(
  579. http.get('/api/v1/archives/', () =>
  580. HttpResponse.json([{ ...mockArchives[0], timelapse_path: null, timelapse_count: 2 }]),
  581. ),
  582. );
  583. render(<ArchivesPage />);
  584. await screen.findByTestId('archive-card-timelapse');
  585. fireEvent.click(screen.getByTitle(/list/i));
  586. await waitFor(() => expect(screen.queryByTestId('archive-card-timelapse')).not.toBeInTheDocument());
  587. expect(screen.getByTitle('View 2 timelapses')).toBeInTheDocument();
  588. });
  589. it('leaves the page alone for a click on a control in the card', async () => {
  590. render(<ArchivesPage />);
  591. await screen.findByText('#2');
  592. fireEvent.click(screen.getAllByTitle(/favorites/i)[0]);
  593. expect(window.location.pathname).toBe('/archives');
  594. });
  595. it('leaves the page alone for a click in a dialog the card opened', async () => {
  596. render(<ArchivesPage />);
  597. await screen.findByText('#2');
  598. // The card's own Edit button; its dialog renders inside the card.
  599. const card = screen.getByText('#2').closest('[data-archive-id]') as HTMLElement;
  600. fireEvent.click(within(card).getByTitle('Edit'));
  601. fireEvent.click(await screen.findByText('Edit Archive'));
  602. expect(window.location.pathname).toBe('/archives');
  603. });
  604. });
  605. describe('Not Printed / Printed collections', () => {
  606. const mixedStatusArchives = [
  607. { ...mockArchives[0], id: 100, print_name: 'NeverPrinted', status: 'archived', started_at: null, completed_at: null },
  608. { ...mockArchives[0], id: 101, print_name: 'WasPrinted', status: 'completed' },
  609. { ...mockArchives[0], id: 102, print_name: 'WasFailed', status: 'failed' },
  610. { ...mockArchives[0], id: 103, print_name: 'WasCancelled', status: 'cancelled' },
  611. { ...mockArchives[0], id: 104, print_name: 'FailedBefore', status: 'completed', run_count: 2, failed_run_count: 1 },
  612. ];
  613. beforeEach(() => {
  614. // Reset persisted collection so each test starts on "All Archives".
  615. window.localStorage.removeItem('archiveCollection');
  616. server.use(
  617. http.get('/api/v1/archives/', () => HttpResponse.json(mixedStatusArchives))
  618. );
  619. });
  620. it('shows only archived (never-printed) entries when "Not Printed" is selected', async () => {
  621. render(<ArchivesPage />);
  622. await waitFor(() => {
  623. expect(screen.getByText('NeverPrinted')).toBeInTheDocument();
  624. });
  625. const collectionSelect = screen.getByDisplayValue('All Archives');
  626. fireEvent.change(collectionSelect, { target: { value: 'not-printed' } });
  627. await waitFor(() => {
  628. expect(screen.getByText('NeverPrinted')).toBeInTheDocument();
  629. expect(screen.queryByText('WasPrinted')).not.toBeInTheDocument();
  630. expect(screen.queryByText('WasFailed')).not.toBeInTheDocument();
  631. expect(screen.queryByText('WasCancelled')).not.toBeInTheDocument();
  632. });
  633. });
  634. it('shows only print-attempted entries (any final status) when "Printed" is selected', async () => {
  635. render(<ArchivesPage />);
  636. await waitFor(() => {
  637. expect(screen.getByText('NeverPrinted')).toBeInTheDocument();
  638. });
  639. const collectionSelect = screen.getByDisplayValue('All Archives');
  640. fireEvent.change(collectionSelect, { target: { value: 'printed' } });
  641. await waitFor(() => {
  642. expect(screen.queryByText('NeverPrinted')).not.toBeInTheDocument();
  643. expect(screen.getByText('WasPrinted')).toBeInTheDocument();
  644. expect(screen.getByText('WasFailed')).toBeInTheDocument();
  645. expect(screen.getByText('WasCancelled')).toBeInTheDocument();
  646. });
  647. });
  648. // Archive redesign P4: an archive is a file printed maybe several times.
  649. it('"Failed Prints" finds archives one print of which failed, not only the latest', async () => {
  650. render(<ArchivesPage />);
  651. await screen.findByText('FailedBefore');
  652. fireEvent.change(screen.getByDisplayValue('All Archives'), { target: { value: 'failed' } });
  653. await waitFor(() => {
  654. expect(screen.getByText('WasFailed')).toBeInTheDocument();
  655. expect(screen.getByText('FailedBefore')).toBeInTheDocument();
  656. expect(screen.queryByText('WasPrinted')).not.toBeInTheDocument();
  657. });
  658. });
  659. });
  660. // Archive redesign P2: merging changes and removes whole archives, so the
  661. // button is there only for those the merge API lets through.
  662. // Archive redesign P3: the Failed collection shows failed and aborted
  663. // prints, and its export has both.
  664. it('exports what the Failed collection shows', async () => {
  665. let exported: string | null = null;
  666. server.use(
  667. http.get('*/api/v1/archives/export', ({ request }) => {
  668. exported = new URL(request.url).searchParams.get('status');
  669. return new HttpResponse('ID\n', { headers: { 'Content-Type': 'text/csv' } });
  670. }),
  671. );
  672. const createObjectURL = URL.createObjectURL;
  673. const revokeObjectURL = URL.revokeObjectURL;
  674. URL.createObjectURL = () => 'blob:export';
  675. URL.revokeObjectURL = () => {};
  676. try {
  677. render(<ArchivesPage />);
  678. fireEvent.change(await screen.findByDisplayValue('All Archives'), { target: { value: 'failed' } });
  679. fireEvent.click(screen.getByRole('button', { name: 'Export' }));
  680. fireEvent.click(await screen.findByText('Export as CSV'));
  681. await waitFor(() => expect(exported).toBe('failed,aborted'));
  682. } finally {
  683. URL.createObjectURL = createObjectURL;
  684. URL.revokeObjectURL = revokeObjectURL;
  685. }
  686. });
  687. describe('merge duplicates button', () => {
  688. const signInWith = (permissions: string[]) => {
  689. setAuthToken('merge-token', 'session');
  690. server.use(
  691. http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
  692. http.get('/api/v1/auth/me', () => HttpResponse.json({
  693. id: 7,
  694. username: 'operator',
  695. is_active: true,
  696. is_admin: false,
  697. groups: [],
  698. permissions,
  699. created_at: '2026-08-18T00:00:00Z',
  700. })),
  701. http.get('*/api/v1/archives/merge-preview', () => HttpResponse.json({ groups: [] })),
  702. );
  703. };
  704. it('opens the merge dialog for a user who may update and delete all archives', async () => {
  705. signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
  706. render(<ArchivesPage />);
  707. fireEvent.click(await screen.findByRole('button', { name: 'Merge duplicates' }));
  708. expect(await screen.findByText('No duplicates to merge.')).toBeInTheDocument();
  709. });
  710. it('offers the merge while duplicates exist, until dismissed', async () => {
  711. signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
  712. server.use(http.get('*/api/v1/archives/merge-count', () => HttpResponse.json({ groups: 3 })));
  713. render(<ArchivesPage />);
  714. const banner = await screen.findByTestId('merge-banner');
  715. expect(banner).toHaveTextContent('3 group(s) of archives hold the same file');
  716. fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
  717. await waitFor(() => expect(screen.queryByTestId('merge-banner')).toBeNull());
  718. expect(window.localStorage.setItem).toHaveBeenCalledWith('archiveMergeBannerDismissed', '3');
  719. });
  720. it('offers it again once the count has changed since it was dismissed', async () => {
  721. vi.mocked(window.localStorage.getItem).mockImplementation((key: string) =>
  722. key === 'archiveMergeBannerDismissed' ? '3' : null,
  723. );
  724. signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
  725. server.use(http.get('*/api/v1/archives/merge-count', () => HttpResponse.json({ groups: 4 })));
  726. render(<ArchivesPage />);
  727. expect(await screen.findByTestId('merge-banner')).toBeInTheDocument();
  728. vi.mocked(window.localStorage.getItem).mockReset();
  729. });
  730. it('counts again when the archives change', async () => {
  731. signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
  732. let groups = 1;
  733. server.use(http.get('*/api/v1/archives/merge-count', () => HttpResponse.json({ groups })));
  734. // Following an archive link reloads the list, as a delete or a new print does.
  735. function GoTo() {
  736. const navigate = useNavigate();
  737. return <button onClick={() => navigate('/archives?highlight=1')}>go</button>;
  738. }
  739. render(<><ArchivesPage /><GoTo /></>);
  740. await screen.findByTestId('merge-banner');
  741. groups = 0;
  742. fireEvent.click(screen.getByText('go'));
  743. await waitFor(() => expect(screen.queryByTestId('merge-banner')).toBeNull());
  744. });
  745. it('stays dismissed when merging some left fewer', async () => {
  746. vi.mocked(window.localStorage.getItem).mockImplementation((key: string) =>
  747. key === 'archiveMergeBannerDismissed' ? '3' : null,
  748. );
  749. signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
  750. let counted = false;
  751. server.use(
  752. http.get('*/api/v1/archives/merge-count', () => {
  753. counted = true;
  754. return HttpResponse.json({ groups: 2 });
  755. }),
  756. );
  757. render(<ArchivesPage />);
  758. await waitFor(() => expect(counted).toBe(true));
  759. await screen.findByRole('button', { name: 'Merge duplicates' });
  760. expect(screen.queryByTestId('merge-banner')).toBeNull();
  761. vi.mocked(window.localStorage.getItem).mockReset();
  762. });
  763. it('stays dismissed while the count is the same', async () => {
  764. vi.mocked(window.localStorage.getItem).mockImplementation((key: string) =>
  765. key === 'archiveMergeBannerDismissed' ? '3' : null,
  766. );
  767. signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
  768. let counted = false;
  769. server.use(
  770. http.get('*/api/v1/archives/merge-count', () => {
  771. counted = true;
  772. return HttpResponse.json({ groups: 3 });
  773. }),
  774. );
  775. render(<ArchivesPage />);
  776. await waitFor(() => expect(counted).toBe(true));
  777. await screen.findByRole('button', { name: 'Merge duplicates' });
  778. expect(screen.queryByTestId('merge-banner')).toBeNull();
  779. vi.mocked(window.localStorage.getItem).mockReset();
  780. });
  781. it('is offered in the Duplicates collection', async () => {
  782. signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
  783. render(<ArchivesPage />);
  784. await screen.findByRole('button', { name: 'Merge duplicates' });
  785. expect(screen.queryByText(/can be merged into one, with every print of them/)).toBeNull();
  786. fireEvent.change(screen.getByDisplayValue('All Archives'), { target: { value: 'duplicates' } });
  787. expect(await screen.findByText(/can be merged into one, with every print of them/)).toBeInTheDocument();
  788. });
  789. it('is not there for a user who may only update all archives', async () => {
  790. signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_own']);
  791. render(<ArchivesPage />);
  792. await screen.findByText('Archives');
  793. await waitFor(() => expect(screen.queryByRole('button', { name: 'Merge duplicates' })).toBeNull());
  794. });
  795. });
  796. // #1898: the "outcome?" badge on a print still waiting for its verdict opens
  797. // the dialog. The PATCH behind it checks archives:update against the owner,
  798. // like the menu item beside it, so for anyone else the badge is a status only.
  799. describe('awaiting-outcome badge', () => {
  800. const awaiting = { ...mockArchives[0], confirm_requested: true, user_verdict: null, created_by_id: 7 };
  801. const signInWith = (permissions: string[]) => {
  802. setAuthToken('verdict-token', 'session');
  803. server.use(
  804. http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
  805. http.get('/api/v1/auth/me', () => HttpResponse.json({
  806. id: 7,
  807. username: 'operator',
  808. is_active: true,
  809. is_admin: false,
  810. groups: [],
  811. permissions,
  812. created_at: '2026-08-18T00:00:00Z',
  813. })),
  814. http.get('/api/v1/archives/', () => HttpResponse.json([awaiting])),
  815. );
  816. };
  817. afterEach(() => {
  818. window.localStorage.removeItem('archiveViewMode');
  819. });
  820. it('is a status only for a user who may not update the archive', async () => {
  821. signInWith(['archives:read_all']);
  822. render(<ArchivesPage />);
  823. const badge = await screen.findByRole('button', { name: 'outcome?' });
  824. await waitFor(() => expect(badge).toBeDisabled());
  825. expect(badge).toHaveAttribute('title', 'You do not have permission to update archives');
  826. fireEvent.click(badge);
  827. expect(screen.queryByTestId('confirm-outcome-dialog')).toBeNull();
  828. });
  829. it('is a status only in the list view too', async () => {
  830. window.localStorage.setItem('archiveViewMode', 'list');
  831. signInWith(['archives:read_all']);
  832. render(<ArchivesPage />);
  833. const badge = await screen.findByRole('button', { name: 'outcome?' });
  834. await waitFor(() => expect(badge).toBeDisabled());
  835. expect(badge).toHaveAttribute('title', 'You do not have permission to update archives');
  836. });
  837. it('is disabled on somebody else\'s print for a user who may update only their own', async () => {
  838. signInWith(['archives:read_all', 'archives:update_own']);
  839. server.use(http.get('/api/v1/archives/', () => HttpResponse.json([{ ...awaiting, created_by_id: 8 }])));
  840. render(<ArchivesPage />);
  841. const badge = await screen.findByRole('button', { name: 'outcome?' });
  842. await waitFor(() => expect(badge).toBeDisabled());
  843. });
  844. it('opens the dialog for the owner', async () => {
  845. signInWith(['archives:read_all', 'archives:update_own']);
  846. render(<ArchivesPage />);
  847. const badge = await screen.findByRole('button', { name: 'outcome?' });
  848. await waitFor(() => expect(badge).toBeEnabled());
  849. fireEvent.click(badge);
  850. expect(await screen.findByTestId('confirm-outcome-dialog')).toBeInTheDocument();
  851. });
  852. });
  853. describe('unconfirmed filter', () => {
  854. afterEach(() => {
  855. window.localStorage.removeItem('archiveFilterUnconfirmed');
  856. });
  857. it('is cleared by Reset like every other top filter', async () => {
  858. render(<ArchivesPage />);
  859. await screen.findByText('Benchy');
  860. fireEvent.click(screen.getByTitle('Show only prints still waiting for their outcome verdict'));
  861. // Neither fixture archive is waiting for a verdict, so the filter empties the list.
  862. await waitFor(() => expect(screen.queryByText('Benchy')).not.toBeInTheDocument());
  863. fireEvent.click(screen.getByRole('button', { name: 'Reset' }));
  864. expect(await screen.findByText('Benchy')).toBeInTheDocument();
  865. expect(screen.queryByRole('button', { name: 'Reset' })).not.toBeInTheDocument();
  866. });
  867. });
  868. });