PrinterQueueWidgetClearPlate.test.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647
  1. /**
  2. * Tests for the PrinterQueueWidget clear plate behavior.
  3. *
  4. * When the printer is in FINISH or FAILED state and has pending queue items,
  5. * the widget shows a "Clear Plate & Start Next" button instead of the
  6. * passive queue link. After clicking, it shows a confirmation state.
  7. */
  8. import { describe, it, expect, beforeEach } from 'vitest';
  9. import { screen, waitFor } from '@testing-library/react';
  10. import userEvent from '@testing-library/user-event';
  11. import { render } from '../utils';
  12. import { PrinterQueueWidget } from '../../components/PrinterQueueWidget';
  13. import { http, HttpResponse } from 'msw';
  14. import { server } from '../mocks/server';
  15. const mockQueueItems = [
  16. {
  17. id: 1,
  18. printer_id: 1,
  19. archive_id: 1,
  20. position: 1,
  21. status: 'pending',
  22. archive_name: 'First Print',
  23. printer_name: 'X1 Carbon',
  24. print_time_seconds: 3600,
  25. scheduled_time: null,
  26. },
  27. {
  28. id: 2,
  29. printer_id: 1,
  30. archive_id: 2,
  31. position: 2,
  32. status: 'pending',
  33. archive_name: 'Second Print',
  34. printer_name: 'X1 Carbon',
  35. print_time_seconds: 7200,
  36. scheduled_time: null,
  37. },
  38. ];
  39. describe('PrinterQueueWidget - Clear Plate', () => {
  40. beforeEach(() => {
  41. server.use(
  42. http.get('/api/v1/queue/', ({ request }) => {
  43. const url = new URL(request.url);
  44. const printerId = url.searchParams.get('printer_id');
  45. if (printerId === '1') {
  46. return HttpResponse.json(mockQueueItems);
  47. }
  48. return HttpResponse.json([]);
  49. }),
  50. http.post('/api/v1/printers/:id/clear-plate', () => {
  51. return HttpResponse.json({ success: true, message: 'Plate cleared' });
  52. })
  53. );
  54. });
  55. describe('clear plate button visibility', () => {
  56. it('shows clear plate button when printer state is FINISH', async () => {
  57. render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} />);
  58. await waitFor(() => {
  59. expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
  60. });
  61. });
  62. it('shows clear plate button when printer state is FAILED', async () => {
  63. render(<PrinterQueueWidget printerId={1} printerState="FAILED" awaitingPlateClear={true} />);
  64. await waitFor(() => {
  65. expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
  66. });
  67. });
  68. it('shows passive link when printer state is IDLE', async () => {
  69. render(<PrinterQueueWidget printerId={1} printerState="IDLE" />);
  70. await waitFor(() => {
  71. const link = screen.getByRole('link');
  72. expect(link).toHaveAttribute('href', '/queue');
  73. });
  74. expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument();
  75. });
  76. it('shows passive link when printer state is RUNNING', async () => {
  77. render(<PrinterQueueWidget printerId={1} printerState="RUNNING" />);
  78. await waitFor(() => {
  79. const link = screen.getByRole('link');
  80. expect(link).toHaveAttribute('href', '/queue');
  81. });
  82. });
  83. it('shows passive link when printerState is not provided', async () => {
  84. render(<PrinterQueueWidget printerId={1} />);
  85. await waitFor(() => {
  86. const link = screen.getByRole('link');
  87. expect(link).toHaveAttribute('href', '/queue');
  88. });
  89. });
  90. it('shows passive link when FINISH but plateCleared is true', async () => {
  91. render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={false} />);
  92. await waitFor(() => {
  93. const link = screen.getByRole('link');
  94. expect(link).toHaveAttribute('href', '/queue');
  95. });
  96. expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument();
  97. });
  98. it('shows passive link when FAILED but plateCleared is true', async () => {
  99. render(<PrinterQueueWidget printerId={1} printerState="FAILED" awaitingPlateClear={false} />);
  100. await waitFor(() => {
  101. const link = screen.getByRole('link');
  102. expect(link).toHaveAttribute('href', '/queue');
  103. });
  104. expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument();
  105. });
  106. // Regression for #961: after Auto Off cycles the printer it boots into IDLE while
  107. // still awaiting plate-clear ack. The prompt must still show — the ack state, not
  108. // the reported printer state, is the authoritative signal.
  109. it('shows clear plate button in IDLE state when awaitingPlateClear is true (#961)', async () => {
  110. render(<PrinterQueueWidget printerId={1} printerState="IDLE" awaitingPlateClear={true} />);
  111. await waitFor(() => {
  112. expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
  113. });
  114. });
  115. it('shows clear plate button with no printerState when awaitingPlateClear is true', async () => {
  116. // State may be null briefly after a reconnect; the widget must still gate on the flag.
  117. render(<PrinterQueueWidget printerId={1} awaitingPlateClear={true} />);
  118. await waitFor(() => {
  119. expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
  120. });
  121. });
  122. });
  123. describe('clear plate button shows queue info', () => {
  124. it('shows next item name in clear plate mode', async () => {
  125. render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} />);
  126. await waitFor(() => {
  127. expect(screen.getByText('First Print')).toBeInTheDocument();
  128. });
  129. });
  130. it('shows additional items badge in clear plate mode', async () => {
  131. render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} />);
  132. await waitFor(() => {
  133. expect(screen.getByText('+1')).toBeInTheDocument();
  134. });
  135. });
  136. });
  137. describe('clear plate action', () => {
  138. it('shows confirmation state after clicking clear plate', async () => {
  139. const user = userEvent.setup();
  140. render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} />);
  141. await waitFor(() => {
  142. expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
  143. });
  144. await user.click(screen.getByText('Clear Plate & Start Next'));
  145. await waitFor(() => {
  146. // Both the widget confirmation and the toast show this text
  147. const elements = screen.getAllByText('Plate cleared — ready for next print');
  148. expect(elements.length).toBeGreaterThanOrEqual(1);
  149. });
  150. });
  151. it('shows error toast on API failure', async () => {
  152. server.use(
  153. http.post('/api/v1/printers/:id/clear-plate', () => {
  154. return HttpResponse.json(
  155. { detail: 'Printer not connected' },
  156. { status: 400 }
  157. );
  158. })
  159. );
  160. const user = userEvent.setup();
  161. render(<PrinterQueueWidget printerId={1} printerState="FAILED" awaitingPlateClear={true} />);
  162. await waitFor(() => {
  163. expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
  164. });
  165. await user.click(screen.getByText('Clear Plate & Start Next'));
  166. // Button should remain visible (not transition to success state)
  167. await waitFor(() => {
  168. expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
  169. });
  170. });
  171. });
  172. describe('empty queue', () => {
  173. it('renders nothing in FINISH state with no queue items', async () => {
  174. const { container } = render(<PrinterQueueWidget printerId={999} printerState="FINISH" awaitingPlateClear={true} />);
  175. await waitFor(() => {
  176. expect(container.querySelector('button')).not.toBeInTheDocument();
  177. });
  178. });
  179. });
  180. describe('filament compatibility filtering', () => {
  181. const petgQueueItems = [
  182. {
  183. id: 10,
  184. printer_id: 1,
  185. archive_id: 10,
  186. position: 1,
  187. status: 'pending',
  188. archive_name: 'PETG Print',
  189. printer_name: 'H2S',
  190. print_time_seconds: 3600,
  191. scheduled_time: null,
  192. required_filament_types: ['PETG'],
  193. },
  194. ];
  195. it('hides widget when queue item requires filament not loaded on printer', async () => {
  196. server.use(
  197. http.get('/api/v1/queue/', () => HttpResponse.json(petgQueueItems))
  198. );
  199. const { container } = render(
  200. <PrinterQueueWidget
  201. printerId={1}
  202. printerState="FINISH"
  203. awaitingPlateClear={true}
  204. loadedFilamentTypes={new Set(['PLA'])}
  205. />
  206. );
  207. // Wait for query to settle, then confirm widget is not rendered
  208. await waitFor(() => {
  209. expect(container.querySelector('button')).not.toBeInTheDocument();
  210. });
  211. expect(screen.queryByText('PETG Print')).not.toBeInTheDocument();
  212. });
  213. it('shows widget when queue item required filaments match loaded', async () => {
  214. server.use(
  215. http.get('/api/v1/queue/', () => HttpResponse.json(petgQueueItems))
  216. );
  217. render(
  218. <PrinterQueueWidget
  219. printerId={1}
  220. printerState="FINISH"
  221. awaitingPlateClear={true}
  222. loadedFilamentTypes={new Set(['PLA', 'PETG'])}
  223. />
  224. );
  225. await waitFor(() => {
  226. expect(screen.getByText('PETG Print')).toBeInTheDocument();
  227. expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
  228. });
  229. });
  230. it('shows widget when queue item has no required_filament_types', async () => {
  231. // Default mockQueueItems have no required_filament_types
  232. render(
  233. <PrinterQueueWidget
  234. printerId={1}
  235. printerState="FINISH"
  236. awaitingPlateClear={true}
  237. loadedFilamentTypes={new Set(['PLA'])}
  238. />
  239. );
  240. await waitFor(() => {
  241. expect(screen.getByText('First Print')).toBeInTheDocument();
  242. expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
  243. });
  244. });
  245. it('shows widget when loadedFilamentTypes prop is not provided', async () => {
  246. server.use(
  247. http.get('/api/v1/queue/', () => HttpResponse.json(petgQueueItems))
  248. );
  249. render(
  250. <PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} />
  251. );
  252. await waitFor(() => {
  253. expect(screen.getByText('PETG Print')).toBeInTheDocument();
  254. expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
  255. });
  256. });
  257. it('skips incompatible first item and shows compatible second item', async () => {
  258. const mixedQueue = [
  259. {
  260. id: 10,
  261. printer_id: 1,
  262. archive_id: 10,
  263. position: 1,
  264. status: 'pending',
  265. archive_name: 'PETG Print',
  266. printer_name: 'H2S',
  267. print_time_seconds: 3600,
  268. scheduled_time: null,
  269. required_filament_types: ['PETG'],
  270. },
  271. {
  272. id: 11,
  273. printer_id: 1,
  274. archive_id: 11,
  275. position: 2,
  276. status: 'pending',
  277. archive_name: 'PLA Print',
  278. printer_name: 'H2S',
  279. print_time_seconds: 1800,
  280. scheduled_time: null,
  281. required_filament_types: ['PLA'],
  282. },
  283. ];
  284. server.use(
  285. http.get('/api/v1/queue/', () => HttpResponse.json(mixedQueue))
  286. );
  287. render(
  288. <PrinterQueueWidget
  289. printerId={1}
  290. printerState="FINISH"
  291. awaitingPlateClear={true}
  292. loadedFilamentTypes={new Set(['PLA'])}
  293. />
  294. );
  295. await waitFor(() => {
  296. expect(screen.getByText('PLA Print')).toBeInTheDocument();
  297. });
  298. expect(screen.queryByText('PETG Print')).not.toBeInTheDocument();
  299. });
  300. it('matches filament types case-insensitively', async () => {
  301. const lowercaseQueue = [
  302. {
  303. id: 10,
  304. printer_id: 1,
  305. archive_id: 10,
  306. position: 1,
  307. status: 'pending',
  308. archive_name: 'Petg Print',
  309. printer_name: 'H2S',
  310. print_time_seconds: 3600,
  311. scheduled_time: null,
  312. required_filament_types: ['petg'],
  313. },
  314. ];
  315. server.use(
  316. http.get('/api/v1/queue/', () => HttpResponse.json(lowercaseQueue))
  317. );
  318. render(
  319. <PrinterQueueWidget
  320. printerId={1}
  321. printerState="FINISH"
  322. awaitingPlateClear={true}
  323. loadedFilamentTypes={new Set(['PETG'])}
  324. />
  325. );
  326. await waitFor(() => {
  327. expect(screen.getByText('Petg Print')).toBeInTheDocument();
  328. expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
  329. });
  330. });
  331. });
  332. describe('filament override color filtering', () => {
  333. const whitePetgOverrideItem = [
  334. {
  335. id: 20,
  336. printer_id: null,
  337. archive_id: 20,
  338. position: 1,
  339. status: 'pending',
  340. archive_name: 'White PETG Print',
  341. printer_name: null,
  342. print_time_seconds: 3600,
  343. scheduled_time: null,
  344. required_filament_types: ['PETG'],
  345. filament_overrides: [{ slot_id: 1, type: 'PETG', color: '#FFFFFF' }],
  346. },
  347. ];
  348. it('hides widget when override color does not match loaded filaments', async () => {
  349. server.use(
  350. http.get('/api/v1/queue/', () => HttpResponse.json(whitePetgOverrideItem))
  351. );
  352. const { container } = render(
  353. <PrinterQueueWidget
  354. printerId={1}
  355. printerState="FINISH"
  356. awaitingPlateClear={true}
  357. loadedFilamentTypes={new Set(['PETG'])}
  358. loadedFilaments={new Set(['PETG:0000ff'])}
  359. />
  360. );
  361. await waitFor(() => {
  362. expect(container.querySelector('button')).not.toBeInTheDocument();
  363. });
  364. expect(screen.queryByText('White PETG Print')).not.toBeInTheDocument();
  365. });
  366. it('shows widget when override color matches loaded filaments', async () => {
  367. server.use(
  368. http.get('/api/v1/queue/', () => HttpResponse.json(whitePetgOverrideItem))
  369. );
  370. render(
  371. <PrinterQueueWidget
  372. printerId={1}
  373. printerState="FINISH"
  374. awaitingPlateClear={true}
  375. loadedFilamentTypes={new Set(['PETG'])}
  376. loadedFilaments={new Set(['PETG:ffffff'])}
  377. />
  378. );
  379. await waitFor(() => {
  380. expect(screen.getByText('White PETG Print')).toBeInTheDocument();
  381. expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
  382. });
  383. });
  384. it('normalizes override color format (strips # and lowercases)', async () => {
  385. const upperCaseColorItem = [
  386. {
  387. id: 21,
  388. printer_id: null,
  389. archive_id: 21,
  390. position: 1,
  391. status: 'pending',
  392. archive_name: 'Red PLA Print',
  393. printer_name: null,
  394. print_time_seconds: 3600,
  395. scheduled_time: null,
  396. required_filament_types: ['PLA'],
  397. filament_overrides: [{ slot_id: 1, type: 'PLA', color: '#FF0000' }],
  398. },
  399. ];
  400. server.use(
  401. http.get('/api/v1/queue/', () => HttpResponse.json(upperCaseColorItem))
  402. );
  403. render(
  404. <PrinterQueueWidget
  405. printerId={1}
  406. printerState="FINISH"
  407. awaitingPlateClear={true}
  408. loadedFilamentTypes={new Set(['PLA'])}
  409. loadedFilaments={new Set(['PLA:ff0000'])}
  410. />
  411. );
  412. await waitFor(() => {
  413. expect(screen.getByText('Red PLA Print')).toBeInTheDocument();
  414. });
  415. });
  416. it('shows widget when no loadedFilaments prop is provided (no color filtering)', async () => {
  417. server.use(
  418. http.get('/api/v1/queue/', () => HttpResponse.json(whitePetgOverrideItem))
  419. );
  420. render(
  421. <PrinterQueueWidget
  422. printerId={1}
  423. printerState="FINISH"
  424. awaitingPlateClear={true}
  425. loadedFilamentTypes={new Set(['PETG'])}
  426. />
  427. );
  428. await waitFor(() => {
  429. expect(screen.getByText('White PETG Print')).toBeInTheDocument();
  430. });
  431. });
  432. it('shows widget when queue item has no filament overrides', async () => {
  433. // Default mockQueueItems have no filament_overrides
  434. render(
  435. <PrinterQueueWidget
  436. printerId={1}
  437. printerState="FINISH"
  438. awaitingPlateClear={true}
  439. loadedFilaments={new Set(['PLA:000000'])}
  440. />
  441. );
  442. await waitFor(() => {
  443. expect(screen.getByText('First Print')).toBeInTheDocument();
  444. });
  445. });
  446. it('matches any override when multiple overrides exist', async () => {
  447. const multiOverrideItem = [
  448. {
  449. id: 22,
  450. printer_id: null,
  451. archive_id: 22,
  452. position: 1,
  453. status: 'pending',
  454. archive_name: 'Multi Color Print',
  455. printer_name: null,
  456. print_time_seconds: 3600,
  457. scheduled_time: null,
  458. required_filament_types: ['PLA'],
  459. filament_overrides: [
  460. { slot_id: 1, type: 'PLA', color: '#FF0000' },
  461. { slot_id: 2, type: 'PLA', color: '#00FF00' },
  462. ],
  463. },
  464. ];
  465. server.use(
  466. http.get('/api/v1/queue/', () => HttpResponse.json(multiOverrideItem))
  467. );
  468. // Printer has green PLA but not red — should still match (at least one override)
  469. render(
  470. <PrinterQueueWidget
  471. printerId={1}
  472. printerState="FINISH"
  473. awaitingPlateClear={true}
  474. loadedFilamentTypes={new Set(['PLA'])}
  475. loadedFilaments={new Set(['PLA:00ff00'])}
  476. />
  477. );
  478. await waitFor(() => {
  479. expect(screen.getByText('Multi Color Print')).toBeInTheDocument();
  480. });
  481. });
  482. });
  483. describe('requirePlateClear setting', () => {
  484. it('shows passive link when requirePlateClear is false even in FINISH state', async () => {
  485. render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} requirePlateClear={false} />);
  486. await waitFor(() => {
  487. const link = screen.getByRole('link');
  488. expect(link).toHaveAttribute('href', '/queue');
  489. });
  490. expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument();
  491. });
  492. it('shows passive link when requirePlateClear is false even in FAILED state', async () => {
  493. render(<PrinterQueueWidget printerId={1} printerState="FAILED" awaitingPlateClear={true} requirePlateClear={false} />);
  494. await waitFor(() => {
  495. const link = screen.getByRole('link');
  496. expect(link).toHaveAttribute('href', '/queue');
  497. });
  498. expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument();
  499. });
  500. it('shows clear plate button when requirePlateClear is true (explicit)', async () => {
  501. render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} requirePlateClear={true} />);
  502. await waitFor(() => {
  503. expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
  504. });
  505. });
  506. it('shows clear plate button when requirePlateClear is not provided (defaults to true)', async () => {
  507. render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} />);
  508. await waitFor(() => {
  509. expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
  510. });
  511. });
  512. it('still shows next item info in passive link when requirePlateClear is false', async () => {
  513. render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} requirePlateClear={false} />);
  514. await waitFor(() => {
  515. expect(screen.getByText('First Print')).toBeInTheDocument();
  516. });
  517. });
  518. });
  519. describe('staged (manual_start) items', () => {
  520. const stagedItems = [
  521. { id: 10, printer_id: 1, archive_id: 1, position: 1, status: 'pending', archive_name: 'Staged Print 1', manual_start: true, scheduled_time: null },
  522. { id: 11, printer_id: 1, archive_id: 2, position: 2, status: 'pending', archive_name: 'Staged Print 2', manual_start: true, scheduled_time: null },
  523. ];
  524. it('does not show clear plate button when all items are staged', async () => {
  525. server.use(
  526. http.get('/api/v1/queue/', () => HttpResponse.json(stagedItems)),
  527. );
  528. render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} />);
  529. // Should show the passive link (not the clear plate button)
  530. await waitFor(() => {
  531. expect(screen.getByText('Staged Print 1')).toBeInTheDocument();
  532. });
  533. expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument();
  534. });
  535. it('shows clear plate button when mix of staged and auto-dispatch items', async () => {
  536. const mixedItems = [
  537. { id: 10, printer_id: 1, archive_id: 1, position: 1, status: 'pending', archive_name: 'Staged Print', manual_start: true, scheduled_time: null },
  538. { id: 11, printer_id: 1, archive_id: 2, position: 2, status: 'pending', archive_name: 'Auto Print', manual_start: false, scheduled_time: null },
  539. ];
  540. server.use(
  541. http.get('/api/v1/queue/', () => HttpResponse.json(mixedItems)),
  542. );
  543. render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} />);
  544. await waitFor(() => {
  545. expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
  546. });
  547. });
  548. });
  549. });