ArchivesPageWiring.test.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585
  1. /**
  2. * Wiring tests for ArchivesPage: pin the exact mutation requests behind
  3. * archive delete (card, list row, bulk), file removals (timelapse, source
  4. * 3MF, F3D), project assignment, bulk favourite and the print-log
  5. * edit / delete / clear actions -- and that cancelling each destructive
  6. * confirm sends nothing.
  7. */
  8. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
  9. import { screen, waitFor, within, fireEvent } from '@testing-library/react';
  10. import userEvent from '@testing-library/user-event';
  11. import { http, HttpResponse } from 'msw';
  12. import { render } from '../utils';
  13. import { server } from '../mocks/server';
  14. import { ArchivesPage } from '../../pages/ArchivesPage';
  15. import { setAuthToken } from '../../api/client';
  16. import { Route, Routes } from 'react-router-dom';
  17. const archive = (over: Record<string, unknown>) => ({
  18. id: 1,
  19. filename: 'a.gcode.3mf',
  20. print_name: 'Archive',
  21. printer_id: 1,
  22. printer_name: 'X1 Carbon',
  23. print_time_seconds: 3600,
  24. filament_used_grams: 10,
  25. status: 'completed',
  26. started_at: '2024-01-01T10:00:00Z',
  27. completed_at: '2024-01-01T11:00:00Z',
  28. thumbnail_path: null,
  29. notes: null,
  30. rating: null,
  31. project_id: null,
  32. project_name: null,
  33. project_color: null,
  34. print_count: 1,
  35. tags: '',
  36. created_at: '2024-01-01T09:00:00Z',
  37. updated_at: '2024-01-01T11:00:00Z',
  38. has_f3d: false,
  39. is_favorite: false,
  40. ...over,
  41. });
  42. const ARCHIVES = [
  43. archive({ id: 1, print_name: 'Benchy', created_at: '2024-01-03T09:00:00Z' }),
  44. archive({
  45. id: 2,
  46. print_name: 'Bracket',
  47. created_at: '2024-01-02T09:00:00Z',
  48. timelapse_path: 'timelapse/2.mp4',
  49. source_3mf_path: 'source/2.3mf',
  50. f3d_path: 'f3d/2.f3d',
  51. }),
  52. archive({ id: 3, print_name: 'Clip', created_at: '2024-01-01T09:00:00Z' }),
  53. ];
  54. const LOG_ENTRIES = [
  55. {
  56. id: 501,
  57. archive_id: 1,
  58. print_name: 'Logged Print',
  59. printer_name: 'X1 Carbon',
  60. printer_id: 1,
  61. status: 'completed',
  62. started_at: '2024-01-01T10:00:00Z',
  63. completed_at: '2024-01-01T11:00:00Z',
  64. duration_seconds: 3600,
  65. filament_type: 'PLA',
  66. filament_color: '#000000',
  67. filament_used_grams: 10,
  68. cost: 0.4,
  69. energy_kwh: null,
  70. energy_cost: null,
  71. failure_reason: null,
  72. thumbnail_path: null,
  73. created_by_id: null,
  74. created_by_username: null,
  75. created_at: '2024-01-01T10:00:00Z',
  76. },
  77. ];
  78. interface Captured {
  79. method: string;
  80. path: string;
  81. search: string;
  82. body?: unknown;
  83. }
  84. let calls: Captured[];
  85. let viewMode: string | null;
  86. async function capture(request: Request) {
  87. const url = new URL(request.url);
  88. let body: unknown;
  89. if (request.method === 'PATCH' || request.method === 'POST' || request.method === 'PUT') {
  90. const text = await request.text();
  91. body = text ? JSON.parse(text) : undefined;
  92. }
  93. calls.push({ method: request.method, path: url.pathname, search: url.search, body });
  94. }
  95. const mutations = () => calls.filter((c) => c.method !== 'GET');
  96. beforeEach(() => {
  97. calls = [];
  98. viewMode = null;
  99. setAuthToken(null);
  100. vi.mocked(localStorage.getItem).mockImplementation((key: string) =>
  101. key === 'archiveViewMode' ? viewMode : null,
  102. );
  103. server.use(
  104. http.get('*/api/v1/archives/', () => HttpResponse.json(ARCHIVES)),
  105. http.get('*/api/v1/archives/stats', () =>
  106. HttpResponse.json({
  107. total_archives: 3,
  108. total_print_time_seconds: 0,
  109. total_filament_grams: 0,
  110. prints_this_week: 0,
  111. prints_this_month: 0,
  112. }),
  113. ),
  114. http.get('*/api/v1/archives/tags', () => HttpResponse.json([])),
  115. http.get('*/api/v1/printers/', () => HttpResponse.json([{ id: 1, name: 'X1 Carbon' }])),
  116. http.get('*/api/v1/projects/', () =>
  117. HttpResponse.json([
  118. { id: 4, name: 'Gizmos', color: '#ff0000', status: 'active' },
  119. { id: 8, name: 'Shelved', color: '#00ff00', status: 'archived' },
  120. ]),
  121. ),
  122. http.get('*/api/v1/archives/:id/delete-impact', () =>
  123. HttpResponse.json({ related_queue_items: 0, currently_printing: 0 }),
  124. ),
  125. http.get('*/api/v1/print-log/', () =>
  126. HttpResponse.json({ items: LOG_ENTRIES, total: LOG_ENTRIES.length }),
  127. ),
  128. http.delete('*/api/v1/archives/:id', async ({ request }) => {
  129. await capture(request);
  130. return new HttpResponse(null, { status: 204 });
  131. }),
  132. http.delete('*/api/v1/archives/:id/timelapse', async ({ request }) => {
  133. await capture(request);
  134. return HttpResponse.json({ status: 'ok' });
  135. }),
  136. http.delete('*/api/v1/archives/:id/source', async ({ request }) => {
  137. await capture(request);
  138. return HttpResponse.json({ status: 'ok' });
  139. }),
  140. http.delete('*/api/v1/archives/:id/f3d', async ({ request }) => {
  141. await capture(request);
  142. return HttpResponse.json({ status: 'ok' });
  143. }),
  144. http.patch('*/api/v1/archives/:id', async ({ request }) => {
  145. await capture(request);
  146. return HttpResponse.json(ARCHIVES[0]);
  147. }),
  148. http.post('*/api/v1/archives/:id/favorite', async ({ request }) => {
  149. await capture(request);
  150. return HttpResponse.json(ARCHIVES[0]);
  151. }),
  152. http.delete('*/api/v1/print-log/', async ({ request }) => {
  153. await capture(request);
  154. return HttpResponse.json({ deleted: 1 });
  155. }),
  156. http.delete('*/api/v1/print-log/:id', async ({ request }) => {
  157. await capture(request);
  158. return HttpResponse.json({ status: 'ok', id: 501 });
  159. }),
  160. http.patch('*/api/v1/print-log/:id', async ({ request }) => {
  161. await capture(request);
  162. return HttpResponse.json(LOG_ENTRIES[0]);
  163. }),
  164. );
  165. });
  166. afterEach(() => {
  167. setAuthToken(null);
  168. });
  169. async function cardFor(id: number): Promise<HTMLElement> {
  170. await screen.findByText('Benchy');
  171. const el = document.querySelector<HTMLElement>(`[data-archive-id="${id}"]`);
  172. expect(el).not.toBeNull();
  173. return el!;
  174. }
  175. /** The fixed overlay of the ConfirmModal whose title is ``title``. */
  176. async function confirmDialog(title: string): Promise<HTMLElement> {
  177. const heading = await screen.findByRole('heading', { name: title });
  178. return heading.closest('div.fixed') as HTMLElement;
  179. }
  180. async function openMenu(id: number) {
  181. fireEvent.contextMenu(await cardFor(id));
  182. }
  183. describe('ArchivesPage wiring: single archive delete', () => {
  184. it('card Delete -> confirm sends DELETE /archives/{id} for that archive only', async () => {
  185. const user = userEvent.setup();
  186. render(<ArchivesPage />);
  187. const card = await cardFor(2);
  188. await user.click(within(card).getByTitle('Delete'));
  189. const dialog = await confirmDialog('Delete Archive');
  190. await user.click(within(dialog).getByRole('button', { name: 'Delete' }));
  191. await waitFor(() => expect(mutations()).toHaveLength(1));
  192. expect(mutations()[0]).toMatchObject({ method: 'DELETE', path: '/api/v1/archives/2', search: '' });
  193. });
  194. it('card Delete with "also remove from Quick Stats" sends purge_stats=true', async () => {
  195. const user = userEvent.setup();
  196. render(<ArchivesPage />);
  197. const card = await cardFor(3);
  198. await user.click(within(card).getByTitle('Delete'));
  199. const dialog = await confirmDialog('Delete Archive');
  200. await user.click(within(dialog).getByRole('checkbox'));
  201. await user.click(within(dialog).getByRole('button', { name: 'Delete' }));
  202. await waitFor(() => expect(mutations()).toHaveLength(1));
  203. expect(mutations()[0]).toMatchObject({
  204. method: 'DELETE',
  205. path: '/api/v1/archives/3',
  206. search: '?purge_stats=true',
  207. });
  208. });
  209. it('card Delete -> cancel sends nothing', async () => {
  210. const user = userEvent.setup();
  211. render(<ArchivesPage />);
  212. const card = await cardFor(2);
  213. await user.click(within(card).getByTitle('Delete'));
  214. const dialog = await confirmDialog('Delete Archive');
  215. await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
  216. await waitFor(() => expect(screen.queryByRole('heading', { name: 'Delete Archive' })).toBeNull());
  217. expect(mutations()).toHaveLength(0);
  218. });
  219. it('list row Delete -> confirm sends DELETE for that row', async () => {
  220. viewMode = 'list';
  221. const user = userEvent.setup();
  222. render(<ArchivesPage />);
  223. const row = await cardFor(3);
  224. await user.click(within(row).getByTitle('Delete'));
  225. const dialog = await confirmDialog('Delete Archive');
  226. await user.click(within(dialog).getByRole('button', { name: 'Delete' }));
  227. await waitFor(() => expect(mutations()).toHaveLength(1));
  228. expect(mutations()[0]).toMatchObject({ method: 'DELETE', path: '/api/v1/archives/3' });
  229. });
  230. it('list row Delete -> cancel sends nothing', async () => {
  231. viewMode = 'list';
  232. const user = userEvent.setup();
  233. render(<ArchivesPage />);
  234. const row = await cardFor(3);
  235. await user.click(within(row).getByTitle('Delete'));
  236. const dialog = await confirmDialog('Delete Archive');
  237. await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
  238. await waitFor(() => expect(screen.queryByRole('heading', { name: 'Delete Archive' })).toBeNull());
  239. expect(mutations()).toHaveLength(0);
  240. });
  241. });
  242. describe('ArchivesPage wiring: file removals from the context menu', () => {
  243. const cases: Array<[string, string, string]> = [
  244. ['Remove Timelapse', 'Remove Timelapse', '/api/v1/archives/2/timelapse'],
  245. ['Remove Source 3MF', 'Remove Source 3MF', '/api/v1/archives/2/source'],
  246. ['Remove F3D', 'Remove F3D', '/api/v1/archives/2/f3d'],
  247. ];
  248. for (const [menuLabel, dialogTitle, path] of cases) {
  249. it(`${menuLabel} -> confirm sends DELETE ${path}`, async () => {
  250. const user = userEvent.setup();
  251. render(<ArchivesPage />);
  252. await openMenu(2);
  253. await user.click(await screen.findByRole('button', { name: menuLabel }));
  254. const dialog = await confirmDialog(dialogTitle);
  255. await user.click(within(dialog).getByRole('button', { name: 'Remove' }));
  256. await waitFor(() => expect(mutations()).toHaveLength(1));
  257. expect(mutations()[0]).toMatchObject({ method: 'DELETE', path });
  258. });
  259. it(`${menuLabel} -> cancel sends nothing`, async () => {
  260. const user = userEvent.setup();
  261. render(<ArchivesPage />);
  262. await openMenu(2);
  263. await user.click(await screen.findByRole('button', { name: menuLabel }));
  264. const dialog = await confirmDialog(dialogTitle);
  265. await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
  266. await waitFor(() => expect(screen.queryByRole('heading', { name: dialogTitle })).toBeNull());
  267. expect(mutations()).toHaveLength(0);
  268. });
  269. }
  270. it('list row: Remove Timelapse -> confirm sends DELETE for that row', async () => {
  271. viewMode = 'list';
  272. const user = userEvent.setup();
  273. render(<ArchivesPage />);
  274. await openMenu(2);
  275. await user.click(await screen.findByRole('button', { name: 'Remove Timelapse' }));
  276. const dialog = await confirmDialog('Remove Timelapse');
  277. await user.click(within(dialog).getByRole('button', { name: 'Remove' }));
  278. await waitFor(() => expect(mutations()).toHaveLength(1));
  279. expect(mutations()[0]).toMatchObject({ method: 'DELETE', path: '/api/v1/archives/2/timelapse' });
  280. });
  281. });
  282. describe('ArchivesPage wiring: project and favourite', () => {
  283. it('Add to Project -> project sends PATCH /archives/{id} with project_id', async () => {
  284. const user = userEvent.setup();
  285. render(<ArchivesPage />);
  286. await openMenu(3);
  287. // Click (not hover) toggles the submenu open.
  288. fireEvent.click(await screen.findByRole('button', { name: 'Add to Project' }));
  289. const gizmos = await screen.findByRole('button', { name: 'Gizmos' });
  290. // Archived projects are not offered.
  291. expect(screen.queryByRole('button', { name: 'Shelved' })).toBeNull();
  292. await user.click(gizmos);
  293. await waitFor(() => expect(mutations()).toHaveLength(1));
  294. expect(mutations()[0]).toEqual({
  295. method: 'PATCH',
  296. path: '/api/v1/archives/3',
  297. search: '',
  298. body: { project_id: 4 },
  299. });
  300. });
  301. it('Remove from Project sends PATCH with project_id null', async () => {
  302. server.use(
  303. http.get('*/api/v1/archives/', () =>
  304. HttpResponse.json([
  305. ARCHIVES[0],
  306. archive({ id: 9, print_name: 'Filed', project_id: 4, project_name: 'Gizmos' }),
  307. ]),
  308. ),
  309. );
  310. const user = userEvent.setup();
  311. render(<ArchivesPage />);
  312. await openMenu(9);
  313. fireEvent.click(await screen.findByRole('button', { name: 'Add to Project' }));
  314. await user.click(await screen.findByRole('button', { name: 'Remove from Project' }));
  315. await waitFor(() => expect(mutations()).toHaveLength(1));
  316. expect(mutations()[0]).toMatchObject({ method: 'PATCH', path: '/api/v1/archives/9', body: { project_id: null } });
  317. });
  318. it('context menu Add to Favorites posts /archives/{id}/favorite', async () => {
  319. const user = userEvent.setup();
  320. render(<ArchivesPage />);
  321. await openMenu(1);
  322. await user.click(await screen.findByRole('button', { name: 'Add to Favorites' }));
  323. await waitFor(() => expect(mutations()).toHaveLength(1));
  324. expect(mutations()[0]).toMatchObject({ method: 'POST', path: '/api/v1/archives/1/favorite' });
  325. });
  326. });
  327. describe('ArchivesPage wiring: bulk selection', () => {
  328. /** Select archives 1 and 3 via the context menu + selection-mode clicks. */
  329. async function selectOneAndThree(user: ReturnType<typeof userEvent.setup>) {
  330. render(<ArchivesPage />);
  331. await openMenu(1);
  332. const menu = (await screen.findByRole('button', { name: 'Add to Project' })).closest(
  333. 'div.fixed',
  334. ) as HTMLElement;
  335. await user.click(within(menu).getByRole('button', { name: 'Select' }));
  336. await screen.findByText('1 selected');
  337. fireEvent.click(await cardFor(3));
  338. return (await screen.findByText('2 selected')).closest('div.fixed') as HTMLElement;
  339. }
  340. it('bulk Delete -> confirm sends DELETE for exactly the selected ids', async () => {
  341. const user = userEvent.setup();
  342. const toolbar = await selectOneAndThree(user);
  343. await user.click(within(toolbar).getByRole('button', { name: 'Delete' }));
  344. const dialog = await confirmDialog('Delete Archives');
  345. await user.click(within(dialog).getByRole('button', { name: 'Delete 2' }));
  346. await waitFor(() => expect(mutations()).toHaveLength(2));
  347. expect(mutations().map((c) => `${c.method} ${c.path}`).sort()).toEqual([
  348. 'DELETE /api/v1/archives/1',
  349. 'DELETE /api/v1/archives/3',
  350. ]);
  351. });
  352. it('bulk Delete -> cancel sends nothing', async () => {
  353. const user = userEvent.setup();
  354. const toolbar = await selectOneAndThree(user);
  355. await user.click(within(toolbar).getByRole('button', { name: 'Delete' }));
  356. const dialog = await confirmDialog('Delete Archives');
  357. await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
  358. await waitFor(() => expect(screen.queryByRole('heading', { name: 'Delete Archives' })).toBeNull());
  359. expect(mutations()).toHaveLength(0);
  360. });
  361. it('bulk Favorite toggles each selected id once', async () => {
  362. const user = userEvent.setup();
  363. const toolbar = await selectOneAndThree(user);
  364. await user.click(within(toolbar).getByRole('button', { name: 'Favorite' }));
  365. await waitFor(() => expect(mutations()).toHaveLength(2));
  366. expect(mutations().map((c) => `${c.method} ${c.path}`).sort()).toEqual([
  367. 'POST /api/v1/archives/1/favorite',
  368. 'POST /api/v1/archives/3/favorite',
  369. ]);
  370. });
  371. });
  372. describe('ArchivesPage wiring: print log', () => {
  373. async function openLog() {
  374. render(<ArchivesPage />);
  375. fireEvent.click(await screen.findByTitle('Print Log'));
  376. await screen.findByText('Logged Print');
  377. }
  378. it('Clear Log -> confirm sends DELETE /print-log/', async () => {
  379. const user = userEvent.setup();
  380. await openLog();
  381. await user.click(screen.getByRole('button', { name: 'Clear Log' }));
  382. const dialog = await confirmDialog('Clear Print Log');
  383. await user.click(within(dialog).getByRole('button', { name: 'Clear All' }));
  384. await waitFor(() => expect(mutations()).toHaveLength(1));
  385. expect(mutations()[0]).toMatchObject({ method: 'DELETE', path: '/api/v1/print-log/' });
  386. });
  387. it('Clear Log -> cancel sends nothing', async () => {
  388. const user = userEvent.setup();
  389. await openLog();
  390. await user.click(screen.getByRole('button', { name: 'Clear Log' }));
  391. const dialog = await confirmDialog('Clear Print Log');
  392. await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
  393. await waitFor(() => expect(screen.queryByRole('heading', { name: 'Clear Print Log' })).toBeNull());
  394. expect(mutations()).toHaveLength(0);
  395. });
  396. it('row delete -> confirm sends DELETE /print-log/{id}', async () => {
  397. const user = userEvent.setup();
  398. await openLog();
  399. await user.click(screen.getByTitle('Delete print log entry'));
  400. const dialog = await confirmDialog('Delete print log entry');
  401. await user.click(within(dialog).getByRole('button', { name: 'Delete' }));
  402. await waitFor(() => expect(mutations()).toHaveLength(1));
  403. expect(mutations()[0]).toMatchObject({ method: 'DELETE', path: '/api/v1/print-log/501' });
  404. });
  405. it('row delete -> cancel sends nothing', async () => {
  406. const user = userEvent.setup();
  407. await openLog();
  408. await user.click(screen.getByTitle('Delete print log entry'));
  409. const dialog = await confirmDialog('Delete print log entry');
  410. await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
  411. await waitFor(() => expect(screen.queryByRole('heading', { name: 'Delete print log entry' })).toBeNull());
  412. expect(mutations()).toHaveLength(0);
  413. });
  414. it('row edit sends PATCH /print-log/{id} with only the changed fields', async () => {
  415. const user = userEvent.setup();
  416. await openLog();
  417. await user.click(screen.getByTitle('Edit print log entry'));
  418. const heading = await screen.findByRole('heading', { name: 'Edit print log entry' });
  419. const modal = heading.parentElement as HTMLElement;
  420. const [statusSelect] = within(modal).getAllByRole('combobox');
  421. await user.selectOptions(statusSelect, 'failed');
  422. await user.click(within(modal).getByRole('button', { name: 'Save' }));
  423. await waitFor(() => expect(mutations()).toHaveLength(1));
  424. expect(mutations()[0]).toEqual({
  425. method: 'PATCH',
  426. path: '/api/v1/print-log/501',
  427. search: '',
  428. body: { status: 'failed' },
  429. });
  430. });
  431. it('row edit with nothing changed sends nothing', async () => {
  432. const user = userEvent.setup();
  433. await openLog();
  434. await user.click(screen.getByTitle('Edit print log entry'));
  435. const heading = await screen.findByRole('heading', { name: 'Edit print log entry' });
  436. await user.click(within(heading.parentElement as HTMLElement).getByRole('button', { name: 'Save' }));
  437. await waitFor(() => expect(screen.queryByRole('heading', { name: 'Edit print log entry' })).toBeNull());
  438. expect(mutations()).toHaveLength(0);
  439. });
  440. });
  441. describe('ArchivesPage wiring: reprint opens on the archive plate (#3395)', () => {
  442. const plate = (index: number) => ({
  443. index, name: '', has_thumbnail: false, thumbnail_url: null, objects: [`Part ${index}`],
  444. filaments: [], print_time_seconds: 600, filament_used_grams: 5,
  445. });
  446. beforeEach(() => {
  447. server.use(
  448. http.get('*/api/v1/archives/', () =>
  449. HttpResponse.json(ARCHIVES.map((a) => ({ ...a, file_path: `archive/${a.id}.gcode.3mf`, plate_id: a.id === 2 ? 3 : null }))),
  450. ),
  451. http.get('*/api/v1/archives/:id/plates', () =>
  452. HttpResponse.json({ is_multi_plate: true, plates: [plate(1), plate(2), plate(3)] }),
  453. ),
  454. http.get('*/api/v1/archives/:id/filament-requirements', () => HttpResponse.json({ filaments: [] })),
  455. );
  456. });
  457. it.each([
  458. ['card', null, (el: HTMLElement) => within(el).getByRole('button', { name: /Print/ })],
  459. ['list row', 'list', (el: HTMLElement) => within(el).getByTitle('Print')],
  460. ])('%s Print preselects the plate the archive printed', async (_where, mode, button) => {
  461. viewMode = mode;
  462. const user = userEvent.setup();
  463. render(<ArchivesPage />);
  464. await user.click(button(await cardFor(2)));
  465. await waitFor(() => {
  466. expect(screen.getByTestId('selected-plates-summary')).toHaveTextContent(/^Plate 3$/);
  467. });
  468. });
  469. });
  470. describe('ArchivesPage wiring: an archive opens over the list', () => {
  471. it('a card click opens the archive at /archives/<id>; Escape closes it back to the list', async () => {
  472. server.use(
  473. http.get('*/api/v1/archives/:id', ({ params }) => HttpResponse.json(ARCHIVES.find((a) => a.id === Number(params.id)))),
  474. http.get('*/api/v1/archives/:id/runs', () => HttpResponse.json({ items: [], total: 0 })),
  475. http.get('*/api/v1/archives/:id/plates', () => HttpResponse.json({ archive_id: 1, filename: 'a.gcode.3mf', plates: [], is_multi_plate: false })),
  476. );
  477. window.history.pushState({}, '', '/archives');
  478. render(
  479. <Routes>
  480. <Route path="/archives" element={<ArchivesPage />} />
  481. <Route path="/archives/:id" element={<ArchivesPage />} />
  482. </Routes>,
  483. );
  484. fireEvent.click(await cardFor(1));
  485. expect(await screen.findByRole('dialog', { name: 'Benchy' })).toBeInTheDocument();
  486. expect(window.location.pathname).toBe('/archives/1');
  487. // The list's shortcuts stay off while the archive is open: no upload
  488. // dialog underneath it.
  489. fireEvent.keyDown(document, { key: 'u' });
  490. fireEvent.drop(screen.getByRole('dialog', { name: 'Benchy' }), {
  491. dataTransfer: { files: [new File(['x'], 'part.3mf')], types: ['Files'] },
  492. });
  493. expect(screen.queryByText('Upload 3MF Files')).toBeNull();
  494. fireEvent.keyDown(document, { key: 'Escape' });
  495. await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Benchy' })).toBeNull());
  496. expect(window.location.pathname).toBe('/archives');
  497. expect(screen.getByText('Bracket')).toBeInTheDocument();
  498. window.history.pushState({}, '', '/');
  499. });
  500. });