QueuePage.test.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537
  1. /**
  2. * Tests for the QueuePage component.
  3. */
  4. import { describe, it, expect, beforeEach, vi } from 'vitest';
  5. import { screen, waitFor } from '@testing-library/react';
  6. import userEvent from '@testing-library/user-event';
  7. import { render } from '../utils';
  8. import { QueuePage } from '../../pages/QueuePage';
  9. import { http, HttpResponse } from 'msw';
  10. import { server } from '../mocks/server';
  11. // Mock queue data
  12. const mockQueueItems = [
  13. {
  14. id: 1,
  15. printer_id: 1,
  16. archive_id: 1,
  17. position: 1,
  18. status: 'pending',
  19. scheduled_time: null,
  20. require_previous_success: false,
  21. auto_off_after: false,
  22. manual_start: false,
  23. ams_mapping: null,
  24. plate_id: null,
  25. bed_levelling: true,
  26. flow_cali: false,
  27. vibration_cali: true,
  28. layer_inspect: false,
  29. timelapse: false,
  30. use_ams: true,
  31. started_at: null,
  32. completed_at: null,
  33. error_message: null,
  34. created_at: '2024-01-01T00:00:00Z',
  35. archive_name: 'Test Print 1',
  36. archive_thumbnail: '/thumb1.png',
  37. printer_name: 'Test Printer',
  38. print_time_seconds: 3600,
  39. },
  40. {
  41. id: 2,
  42. printer_id: 1,
  43. archive_id: 2,
  44. position: 2,
  45. status: 'printing',
  46. scheduled_time: null,
  47. require_previous_success: false,
  48. auto_off_after: true,
  49. manual_start: false,
  50. ams_mapping: null,
  51. plate_id: null,
  52. bed_levelling: true,
  53. flow_cali: false,
  54. vibration_cali: true,
  55. layer_inspect: false,
  56. timelapse: false,
  57. use_ams: true,
  58. started_at: '2024-01-01T10:00:00Z',
  59. completed_at: null,
  60. error_message: null,
  61. created_at: '2024-01-01T00:00:00Z',
  62. archive_name: 'Active Print',
  63. archive_thumbnail: '/thumb2.png',
  64. printer_name: 'Test Printer',
  65. print_time_seconds: 7200,
  66. },
  67. {
  68. id: 3,
  69. printer_id: 1,
  70. archive_id: 3,
  71. position: 3,
  72. status: 'completed',
  73. scheduled_time: null,
  74. require_previous_success: false,
  75. auto_off_after: false,
  76. manual_start: false,
  77. ams_mapping: null,
  78. plate_id: null,
  79. bed_levelling: true,
  80. flow_cali: false,
  81. vibration_cali: true,
  82. layer_inspect: false,
  83. timelapse: false,
  84. use_ams: true,
  85. started_at: '2024-01-01T08:00:00Z',
  86. completed_at: '2024-01-01T09:00:00Z',
  87. error_message: null,
  88. created_at: '2024-01-01T00:00:00Z',
  89. archive_name: 'Completed Print',
  90. archive_thumbnail: '/thumb3.png',
  91. printer_name: 'Test Printer',
  92. print_time_seconds: 1800,
  93. },
  94. ];
  95. const mockPrinters = [
  96. {
  97. id: 1,
  98. name: 'Test Printer',
  99. ip_address: '192.168.1.100',
  100. serial_number: 'TESTSERIAL0001',
  101. access_code: '12345678',
  102. model: 'X1C',
  103. enabled: true,
  104. created_at: '2024-01-01T00:00:00Z',
  105. },
  106. ];
  107. describe('QueuePage', () => {
  108. beforeEach(() => {
  109. // Mock localStorage.getItem to return expected defaults for queue page
  110. vi.mocked(localStorage.getItem).mockImplementation((key: string) => {
  111. if (key === 'queue.historyCollapsed') return 'false'; // expanded
  112. if (key === 'queue.viewMode') return 'list';
  113. return null;
  114. });
  115. // Setup MSW handlers for this test
  116. server.use(
  117. http.get('/api/v1/queue/', () => {
  118. return HttpResponse.json(mockQueueItems);
  119. }),
  120. http.get('/api/v1/printers/', () => {
  121. return HttpResponse.json(mockPrinters);
  122. }),
  123. http.delete('/api/v1/queue/:id', () => {
  124. return HttpResponse.json({ success: true });
  125. }),
  126. http.post('/api/v1/queue/:id/cancel', () => {
  127. return HttpResponse.json({ success: true });
  128. }),
  129. http.post('/api/v1/queue/:id/start', () => {
  130. return HttpResponse.json({ success: true });
  131. }),
  132. http.post('/api/v1/queue/:id/stop', () => {
  133. return HttpResponse.json({ success: true });
  134. }),
  135. http.post('/api/v1/queue/reorder', () => {
  136. return HttpResponse.json({ success: true });
  137. })
  138. );
  139. });
  140. describe('rendering', () => {
  141. it('renders the page title', async () => {
  142. render(<QueuePage />);
  143. await waitFor(() => {
  144. expect(screen.getByText('Print Queue')).toBeInTheDocument();
  145. });
  146. });
  147. it('renders the page description', async () => {
  148. render(<QueuePage />);
  149. await waitFor(() => {
  150. expect(screen.getByText('Schedule and manage your print jobs')).toBeInTheDocument();
  151. });
  152. });
  153. it('shows summary cards', async () => {
  154. render(<QueuePage />);
  155. await waitFor(() => {
  156. // Check for the page title (Print Queue is the h1)
  157. expect(screen.getByText('Print Queue')).toBeInTheDocument();
  158. });
  159. });
  160. it('shows filter dropdowns', async () => {
  161. render(<QueuePage />);
  162. await waitFor(() => {
  163. expect(screen.getByText('All Printers')).toBeInTheDocument();
  164. expect(screen.getByText('All Status')).toBeInTheDocument();
  165. });
  166. });
  167. });
  168. describe('queue items display', () => {
  169. it('shows pending queue items', async () => {
  170. render(<QueuePage />);
  171. await waitFor(() => {
  172. expect(screen.getByText('Test Print 1')).toBeInTheDocument();
  173. });
  174. });
  175. it('shows active printing items', async () => {
  176. render(<QueuePage />);
  177. await waitFor(() => {
  178. expect(screen.getByText('Active Print')).toBeInTheDocument();
  179. expect(screen.getByText('Currently Printing')).toBeInTheDocument();
  180. });
  181. });
  182. it('shows completed items in history', async () => {
  183. const user = userEvent.setup();
  184. render(<QueuePage />);
  185. // The History tab now owns the completed/cancelled/failed list.
  186. await user.click(await screen.findByRole('button', { name: /^History/ }));
  187. await waitFor(() => {
  188. expect(screen.getByText('Completed Print')).toBeInTheDocument();
  189. });
  190. });
  191. it('shows status badges', async () => {
  192. render(<QueuePage />);
  193. await waitFor(() => {
  194. // Queue items should be visible with status indicators
  195. expect(screen.getByText('Test Print 1')).toBeInTheDocument();
  196. });
  197. });
  198. it('shows printer names', async () => {
  199. render(<QueuePage />);
  200. await waitFor(() => {
  201. const printerElements = screen.getAllByText('Test Printer');
  202. expect(printerElements.length).toBeGreaterThan(0);
  203. });
  204. });
  205. it('renders queue items with plate_id correctly', async () => {
  206. // Override with queue items that have plate_id set
  207. server.use(
  208. http.get('/api/v1/queue/', () => {
  209. return HttpResponse.json([
  210. {
  211. ...mockQueueItems[0],
  212. plate_id: 2,
  213. archive_name: 'Multi-plate Print',
  214. },
  215. ]);
  216. })
  217. );
  218. render(<QueuePage />);
  219. await waitFor(() => {
  220. expect(screen.getByText('Multi-plate Print')).toBeInTheDocument();
  221. });
  222. });
  223. });
  224. describe('empty state', () => {
  225. it('shows empty state when no queue items', async () => {
  226. server.use(
  227. http.get('/api/v1/queue/', () => {
  228. return HttpResponse.json([]);
  229. })
  230. );
  231. render(<QueuePage />);
  232. await waitFor(() => {
  233. expect(screen.getByText('No prints scheduled')).toBeInTheDocument();
  234. });
  235. });
  236. });
  237. describe('filtering', () => {
  238. it('has printer filter options', async () => {
  239. const user = userEvent.setup();
  240. render(<QueuePage />);
  241. await waitFor(() => {
  242. expect(screen.getByText('All Printers')).toBeInTheDocument();
  243. });
  244. const printerSelect = screen.getByDisplayValue('All Printers');
  245. await user.click(printerSelect);
  246. expect(screen.getByText('Unassigned')).toBeInTheDocument();
  247. });
  248. it('has status filter options', async () => {
  249. const user = userEvent.setup();
  250. render(<QueuePage />);
  251. await waitFor(() => {
  252. expect(screen.getByText('All Status')).toBeInTheDocument();
  253. });
  254. const statusSelect = screen.getByDisplayValue('All Status');
  255. await user.click(statusSelect);
  256. expect(screen.getByRole('option', { name: 'Pending' })).toBeInTheDocument();
  257. expect(screen.getByRole('option', { name: 'Printing' })).toBeInTheDocument();
  258. expect(screen.getByRole('option', { name: 'Completed' })).toBeInTheDocument();
  259. });
  260. });
  261. describe('queue actions', () => {
  262. it('shows edit button for pending items', async () => {
  263. render(<QueuePage />);
  264. await waitFor(() => {
  265. expect(screen.getByText('Test Print 1')).toBeInTheDocument();
  266. });
  267. // Find the edit button (Pencil icon)
  268. const editButtons = screen.getAllByTitle('Edit');
  269. expect(editButtons.length).toBeGreaterThan(0);
  270. });
  271. it('shows cancel button for pending items', async () => {
  272. render(<QueuePage />);
  273. await waitFor(() => {
  274. expect(screen.getByText('Test Print 1')).toBeInTheDocument();
  275. });
  276. const cancelButtons = screen.getAllByTitle('Cancel');
  277. expect(cancelButtons.length).toBeGreaterThan(0);
  278. });
  279. it('shows stop button for printing items', async () => {
  280. render(<QueuePage />);
  281. await waitFor(() => {
  282. expect(screen.getByText('Active Print')).toBeInTheDocument();
  283. });
  284. const stopButtons = screen.getAllByTitle('Stop Print');
  285. expect(stopButtons.length).toBeGreaterThan(0);
  286. });
  287. it('shows re-queue button for history items', async () => {
  288. const user = userEvent.setup();
  289. render(<QueuePage />);
  290. await user.click(await screen.findByRole('button', { name: /^History/ }));
  291. await waitFor(() => {
  292. expect(screen.getByText('Completed Print')).toBeInTheDocument();
  293. });
  294. const requeueButtons = screen.getAllByTitle('Re-queue');
  295. expect(requeueButtons.length).toBeGreaterThan(0);
  296. });
  297. });
  298. describe('clear history', () => {
  299. it('shows clear history button when history exists', async () => {
  300. const user = userEvent.setup();
  301. render(<QueuePage />);
  302. // Clear History only renders inside the History tab now.
  303. await user.click(await screen.findByRole('button', { name: /^History/ }));
  304. await waitFor(() => {
  305. expect(screen.getByText('Clear History')).toBeInTheDocument();
  306. });
  307. });
  308. it('opens confirm modal when clicking clear history', async () => {
  309. const user = userEvent.setup();
  310. render(<QueuePage />);
  311. await user.click(await screen.findByRole('button', { name: /^History/ }));
  312. await waitFor(() => {
  313. expect(screen.getByText('Clear History')).toBeInTheDocument();
  314. });
  315. const clearButton = screen.getByRole('button', { name: /clear history/i });
  316. await user.click(clearButton);
  317. await waitFor(() => {
  318. expect(screen.getByText(/Are you sure you want to remove all/i)).toBeInTheDocument();
  319. });
  320. });
  321. });
  322. describe('staged items', () => {
  323. it('shows staged badge for manual_start items', async () => {
  324. server.use(
  325. http.get('/api/v1/queue/', () => {
  326. return HttpResponse.json([
  327. {
  328. ...mockQueueItems[0],
  329. manual_start: true,
  330. },
  331. ]);
  332. })
  333. );
  334. render(<QueuePage />);
  335. await waitFor(() => {
  336. expect(screen.getByText('Staged')).toBeInTheDocument();
  337. });
  338. });
  339. it('shows start button for staged items', async () => {
  340. server.use(
  341. http.get('/api/v1/queue/', () => {
  342. return HttpResponse.json([
  343. {
  344. ...mockQueueItems[0],
  345. manual_start: true,
  346. },
  347. ]);
  348. })
  349. );
  350. render(<QueuePage />);
  351. await waitFor(() => {
  352. expect(screen.getByTitle('Start Print')).toBeInTheDocument();
  353. });
  354. });
  355. });
  356. describe('auto power off badge', () => {
  357. it('shows power off badge when auto_off_after is true', async () => {
  358. render(<QueuePage />);
  359. await waitFor(() => {
  360. expect(screen.getByText('Auto power off')).toBeInTheDocument();
  361. });
  362. });
  363. });
  364. describe('gcode injection badge', () => {
  365. it('shows G-code badge when gcode_injection is true', async () => {
  366. const itemsWithGcode = mockQueueItems.map((item, i) =>
  367. i === 0 ? { ...item, gcode_injection: true } : item
  368. );
  369. server.use(
  370. http.get('/api/v1/queue/', () => HttpResponse.json(itemsWithGcode)),
  371. );
  372. render(<QueuePage />);
  373. await waitFor(() => {
  374. expect(screen.getByText('G-code')).toBeInTheDocument();
  375. });
  376. });
  377. it('does not show G-code badge when gcode_injection is false', async () => {
  378. render(<QueuePage />);
  379. await waitFor(() => {
  380. expect(screen.getByText('Test Print 1')).toBeInTheDocument();
  381. });
  382. expect(screen.queryByText('G-code')).not.toBeInTheDocument();
  383. });
  384. });
  385. describe('filament-short ▶ flow (#1496)', () => {
  386. /**
  387. * The dispatch pre-flight flags a queue item as filament_short. The user
  388. * clicks ▶, the backend re-checks live and either dispatches (no deficit
  389. * anymore — clear flag) or returns 409 with the per-slot deficit so the
  390. * frontend can render the "Print Anyway" confirm modal.
  391. */
  392. const shortItem = {
  393. ...mockQueueItems[0],
  394. manual_start: true,
  395. filament_short: true,
  396. };
  397. it('renders the filament-short badge on a flagged pending row', async () => {
  398. server.use(
  399. http.get('/api/v1/queue/', () => HttpResponse.json([shortItem])),
  400. );
  401. render(<QueuePage />);
  402. await waitFor(() => {
  403. expect(screen.getByText(/Insufficient filament for the assigned spool/i)).toBeInTheDocument();
  404. });
  405. });
  406. it('opens the Print Anyway modal when ▶ returns 409 and retries with skip_filament_check', async () => {
  407. let secondCallSkippedCheck: boolean | null = null;
  408. let attempts = 0;
  409. server.use(
  410. http.get('/api/v1/queue/', () => HttpResponse.json([shortItem])),
  411. http.post('/api/v1/queue/:id/start', ({ request }) => {
  412. attempts += 1;
  413. const url = new URL(request.url);
  414. const skip = url.searchParams.get('skip_filament_check') === 'true';
  415. if (attempts === 1) {
  416. return HttpResponse.json(
  417. {
  418. detail: {
  419. code: 'insufficient_filament',
  420. deficit: [
  421. {
  422. slot_id: 1,
  423. ams_id: 0,
  424. tray_id: 0,
  425. filament_type: 'PLA',
  426. required_grams: 270,
  427. remaining_grams: 200,
  428. },
  429. ],
  430. },
  431. },
  432. { status: 409 },
  433. );
  434. }
  435. secondCallSkippedCheck = skip;
  436. return HttpResponse.json({ ...shortItem, manual_start: false, filament_short: false });
  437. }),
  438. );
  439. render(<QueuePage />);
  440. const playButton = await screen.findByTitle(/Start Print|do not have permission to start prints/i);
  441. await userEvent.click(playButton);
  442. // Wait for the start endpoint to be hit (the 409 path returns to onError).
  443. await waitFor(() => expect(attempts).toBe(1));
  444. // Modal shows the deficit detail
  445. await screen.findByRole('button', { name: /Print Anyway/i });
  446. expect(
  447. screen.getByText(/Slot 1: needs 270 g, 200 g remaining/i),
  448. ).toBeInTheDocument();
  449. await userEvent.click(screen.getByRole('button', { name: /Print Anyway/i }));
  450. await waitFor(() => expect(secondCallSkippedCheck).toBe(true));
  451. expect(attempts).toBe(2);
  452. });
  453. });
  454. });