PrinterQueueWidgetClearPlate.test.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616
  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" />);
  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" />);
  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" plateCleared={true} />);
  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" plateCleared={true} />);
  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. });
  107. describe('clear plate button shows queue info', () => {
  108. it('shows next item name in clear plate mode', async () => {
  109. render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
  110. await waitFor(() => {
  111. expect(screen.getByText('First Print')).toBeInTheDocument();
  112. });
  113. });
  114. it('shows additional items badge in clear plate mode', async () => {
  115. render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
  116. await waitFor(() => {
  117. expect(screen.getByText('+1')).toBeInTheDocument();
  118. });
  119. });
  120. });
  121. describe('clear plate action', () => {
  122. it('shows confirmation state after clicking clear plate', async () => {
  123. const user = userEvent.setup();
  124. render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
  125. await waitFor(() => {
  126. expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
  127. });
  128. await user.click(screen.getByText('Clear Plate & Start Next'));
  129. await waitFor(() => {
  130. // Both the widget confirmation and the toast show this text
  131. const elements = screen.getAllByText('Plate cleared — ready for next print');
  132. expect(elements.length).toBeGreaterThanOrEqual(1);
  133. });
  134. });
  135. it('shows error toast on API failure', async () => {
  136. server.use(
  137. http.post('/api/v1/printers/:id/clear-plate', () => {
  138. return HttpResponse.json(
  139. { detail: 'Printer not connected' },
  140. { status: 400 }
  141. );
  142. })
  143. );
  144. const user = userEvent.setup();
  145. render(<PrinterQueueWidget printerId={1} printerState="FAILED" />);
  146. await waitFor(() => {
  147. expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
  148. });
  149. await user.click(screen.getByText('Clear Plate & Start Next'));
  150. // Button should remain visible (not transition to success state)
  151. await waitFor(() => {
  152. expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
  153. });
  154. });
  155. });
  156. describe('empty queue', () => {
  157. it('renders nothing in FINISH state with no queue items', async () => {
  158. const { container } = render(<PrinterQueueWidget printerId={999} printerState="FINISH" />);
  159. await waitFor(() => {
  160. expect(container.querySelector('button')).not.toBeInTheDocument();
  161. });
  162. });
  163. });
  164. describe('filament compatibility filtering', () => {
  165. const petgQueueItems = [
  166. {
  167. id: 10,
  168. printer_id: 1,
  169. archive_id: 10,
  170. position: 1,
  171. status: 'pending',
  172. archive_name: 'PETG Print',
  173. printer_name: 'H2S',
  174. print_time_seconds: 3600,
  175. scheduled_time: null,
  176. required_filament_types: ['PETG'],
  177. },
  178. ];
  179. it('hides widget when queue item requires filament not loaded on printer', async () => {
  180. server.use(
  181. http.get('/api/v1/queue/', () => HttpResponse.json(petgQueueItems))
  182. );
  183. const { container } = render(
  184. <PrinterQueueWidget
  185. printerId={1}
  186. printerState="FINISH"
  187. loadedFilamentTypes={new Set(['PLA'])}
  188. />
  189. );
  190. // Wait for query to settle, then confirm widget is not rendered
  191. await waitFor(() => {
  192. expect(container.querySelector('button')).not.toBeInTheDocument();
  193. });
  194. expect(screen.queryByText('PETG Print')).not.toBeInTheDocument();
  195. });
  196. it('shows widget when queue item required filaments match loaded', async () => {
  197. server.use(
  198. http.get('/api/v1/queue/', () => HttpResponse.json(petgQueueItems))
  199. );
  200. render(
  201. <PrinterQueueWidget
  202. printerId={1}
  203. printerState="FINISH"
  204. loadedFilamentTypes={new Set(['PLA', 'PETG'])}
  205. />
  206. );
  207. await waitFor(() => {
  208. expect(screen.getByText('PETG Print')).toBeInTheDocument();
  209. expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
  210. });
  211. });
  212. it('shows widget when queue item has no required_filament_types', async () => {
  213. // Default mockQueueItems have no required_filament_types
  214. render(
  215. <PrinterQueueWidget
  216. printerId={1}
  217. printerState="FINISH"
  218. loadedFilamentTypes={new Set(['PLA'])}
  219. />
  220. );
  221. await waitFor(() => {
  222. expect(screen.getByText('First Print')).toBeInTheDocument();
  223. expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
  224. });
  225. });
  226. it('shows widget when loadedFilamentTypes prop is not provided', async () => {
  227. server.use(
  228. http.get('/api/v1/queue/', () => HttpResponse.json(petgQueueItems))
  229. );
  230. render(
  231. <PrinterQueueWidget printerId={1} printerState="FINISH" />
  232. );
  233. await waitFor(() => {
  234. expect(screen.getByText('PETG Print')).toBeInTheDocument();
  235. expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
  236. });
  237. });
  238. it('skips incompatible first item and shows compatible second item', async () => {
  239. const mixedQueue = [
  240. {
  241. id: 10,
  242. printer_id: 1,
  243. archive_id: 10,
  244. position: 1,
  245. status: 'pending',
  246. archive_name: 'PETG Print',
  247. printer_name: 'H2S',
  248. print_time_seconds: 3600,
  249. scheduled_time: null,
  250. required_filament_types: ['PETG'],
  251. },
  252. {
  253. id: 11,
  254. printer_id: 1,
  255. archive_id: 11,
  256. position: 2,
  257. status: 'pending',
  258. archive_name: 'PLA Print',
  259. printer_name: 'H2S',
  260. print_time_seconds: 1800,
  261. scheduled_time: null,
  262. required_filament_types: ['PLA'],
  263. },
  264. ];
  265. server.use(
  266. http.get('/api/v1/queue/', () => HttpResponse.json(mixedQueue))
  267. );
  268. render(
  269. <PrinterQueueWidget
  270. printerId={1}
  271. printerState="FINISH"
  272. loadedFilamentTypes={new Set(['PLA'])}
  273. />
  274. );
  275. await waitFor(() => {
  276. expect(screen.getByText('PLA Print')).toBeInTheDocument();
  277. });
  278. expect(screen.queryByText('PETG Print')).not.toBeInTheDocument();
  279. });
  280. it('matches filament types case-insensitively', async () => {
  281. const lowercaseQueue = [
  282. {
  283. id: 10,
  284. printer_id: 1,
  285. archive_id: 10,
  286. position: 1,
  287. status: 'pending',
  288. archive_name: 'Petg Print',
  289. printer_name: 'H2S',
  290. print_time_seconds: 3600,
  291. scheduled_time: null,
  292. required_filament_types: ['petg'],
  293. },
  294. ];
  295. server.use(
  296. http.get('/api/v1/queue/', () => HttpResponse.json(lowercaseQueue))
  297. );
  298. render(
  299. <PrinterQueueWidget
  300. printerId={1}
  301. printerState="FINISH"
  302. loadedFilamentTypes={new Set(['PETG'])}
  303. />
  304. );
  305. await waitFor(() => {
  306. expect(screen.getByText('Petg Print')).toBeInTheDocument();
  307. expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
  308. });
  309. });
  310. });
  311. describe('filament override color filtering', () => {
  312. const whitePetgOverrideItem = [
  313. {
  314. id: 20,
  315. printer_id: null,
  316. archive_id: 20,
  317. position: 1,
  318. status: 'pending',
  319. archive_name: 'White PETG Print',
  320. printer_name: null,
  321. print_time_seconds: 3600,
  322. scheduled_time: null,
  323. required_filament_types: ['PETG'],
  324. filament_overrides: [{ slot_id: 1, type: 'PETG', color: '#FFFFFF' }],
  325. },
  326. ];
  327. it('hides widget when override color does not match loaded filaments', async () => {
  328. server.use(
  329. http.get('/api/v1/queue/', () => HttpResponse.json(whitePetgOverrideItem))
  330. );
  331. const { container } = render(
  332. <PrinterQueueWidget
  333. printerId={1}
  334. printerState="FINISH"
  335. loadedFilamentTypes={new Set(['PETG'])}
  336. loadedFilaments={new Set(['PETG:0000ff'])}
  337. />
  338. );
  339. await waitFor(() => {
  340. expect(container.querySelector('button')).not.toBeInTheDocument();
  341. });
  342. expect(screen.queryByText('White PETG Print')).not.toBeInTheDocument();
  343. });
  344. it('shows widget when override color matches loaded filaments', async () => {
  345. server.use(
  346. http.get('/api/v1/queue/', () => HttpResponse.json(whitePetgOverrideItem))
  347. );
  348. render(
  349. <PrinterQueueWidget
  350. printerId={1}
  351. printerState="FINISH"
  352. loadedFilamentTypes={new Set(['PETG'])}
  353. loadedFilaments={new Set(['PETG:ffffff'])}
  354. />
  355. );
  356. await waitFor(() => {
  357. expect(screen.getByText('White PETG Print')).toBeInTheDocument();
  358. expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
  359. });
  360. });
  361. it('normalizes override color format (strips # and lowercases)', async () => {
  362. const upperCaseColorItem = [
  363. {
  364. id: 21,
  365. printer_id: null,
  366. archive_id: 21,
  367. position: 1,
  368. status: 'pending',
  369. archive_name: 'Red PLA Print',
  370. printer_name: null,
  371. print_time_seconds: 3600,
  372. scheduled_time: null,
  373. required_filament_types: ['PLA'],
  374. filament_overrides: [{ slot_id: 1, type: 'PLA', color: '#FF0000' }],
  375. },
  376. ];
  377. server.use(
  378. http.get('/api/v1/queue/', () => HttpResponse.json(upperCaseColorItem))
  379. );
  380. render(
  381. <PrinterQueueWidget
  382. printerId={1}
  383. printerState="FINISH"
  384. loadedFilamentTypes={new Set(['PLA'])}
  385. loadedFilaments={new Set(['PLA:ff0000'])}
  386. />
  387. );
  388. await waitFor(() => {
  389. expect(screen.getByText('Red PLA Print')).toBeInTheDocument();
  390. });
  391. });
  392. it('shows widget when no loadedFilaments prop is provided (no color filtering)', async () => {
  393. server.use(
  394. http.get('/api/v1/queue/', () => HttpResponse.json(whitePetgOverrideItem))
  395. );
  396. render(
  397. <PrinterQueueWidget
  398. printerId={1}
  399. printerState="FINISH"
  400. loadedFilamentTypes={new Set(['PETG'])}
  401. />
  402. );
  403. await waitFor(() => {
  404. expect(screen.getByText('White PETG Print')).toBeInTheDocument();
  405. });
  406. });
  407. it('shows widget when queue item has no filament overrides', async () => {
  408. // Default mockQueueItems have no filament_overrides
  409. render(
  410. <PrinterQueueWidget
  411. printerId={1}
  412. printerState="FINISH"
  413. loadedFilaments={new Set(['PLA:000000'])}
  414. />
  415. );
  416. await waitFor(() => {
  417. expect(screen.getByText('First Print')).toBeInTheDocument();
  418. });
  419. });
  420. it('matches any override when multiple overrides exist', async () => {
  421. const multiOverrideItem = [
  422. {
  423. id: 22,
  424. printer_id: null,
  425. archive_id: 22,
  426. position: 1,
  427. status: 'pending',
  428. archive_name: 'Multi Color Print',
  429. printer_name: null,
  430. print_time_seconds: 3600,
  431. scheduled_time: null,
  432. required_filament_types: ['PLA'],
  433. filament_overrides: [
  434. { slot_id: 1, type: 'PLA', color: '#FF0000' },
  435. { slot_id: 2, type: 'PLA', color: '#00FF00' },
  436. ],
  437. },
  438. ];
  439. server.use(
  440. http.get('/api/v1/queue/', () => HttpResponse.json(multiOverrideItem))
  441. );
  442. // Printer has green PLA but not red — should still match (at least one override)
  443. render(
  444. <PrinterQueueWidget
  445. printerId={1}
  446. printerState="FINISH"
  447. loadedFilamentTypes={new Set(['PLA'])}
  448. loadedFilaments={new Set(['PLA:00ff00'])}
  449. />
  450. );
  451. await waitFor(() => {
  452. expect(screen.getByText('Multi Color Print')).toBeInTheDocument();
  453. });
  454. });
  455. });
  456. describe('requirePlateClear setting', () => {
  457. it('shows passive link when requirePlateClear is false even in FINISH state', async () => {
  458. render(<PrinterQueueWidget printerId={1} printerState="FINISH" requirePlateClear={false} />);
  459. await waitFor(() => {
  460. const link = screen.getByRole('link');
  461. expect(link).toHaveAttribute('href', '/queue');
  462. });
  463. expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument();
  464. });
  465. it('shows passive link when requirePlateClear is false even in FAILED state', async () => {
  466. render(<PrinterQueueWidget printerId={1} printerState="FAILED" requirePlateClear={false} />);
  467. await waitFor(() => {
  468. const link = screen.getByRole('link');
  469. expect(link).toHaveAttribute('href', '/queue');
  470. });
  471. expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument();
  472. });
  473. it('shows clear plate button when requirePlateClear is true (explicit)', async () => {
  474. render(<PrinterQueueWidget printerId={1} printerState="FINISH" requirePlateClear={true} />);
  475. await waitFor(() => {
  476. expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
  477. });
  478. });
  479. it('shows clear plate button when requirePlateClear is not provided (defaults to true)', async () => {
  480. render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
  481. await waitFor(() => {
  482. expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
  483. });
  484. });
  485. it('still shows next item info in passive link when requirePlateClear is false', async () => {
  486. render(<PrinterQueueWidget printerId={1} printerState="FINISH" requirePlateClear={false} />);
  487. await waitFor(() => {
  488. expect(screen.getByText('First Print')).toBeInTheDocument();
  489. });
  490. });
  491. });
  492. describe('staged (manual_start) items', () => {
  493. const stagedItems = [
  494. { id: 10, printer_id: 1, archive_id: 1, position: 1, status: 'pending', archive_name: 'Staged Print 1', manual_start: true, scheduled_time: null },
  495. { id: 11, printer_id: 1, archive_id: 2, position: 2, status: 'pending', archive_name: 'Staged Print 2', manual_start: true, scheduled_time: null },
  496. ];
  497. it('does not show clear plate button when all items are staged', async () => {
  498. server.use(
  499. http.get('/api/v1/queue/', () => HttpResponse.json(stagedItems)),
  500. );
  501. render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
  502. // Should show the passive link (not the clear plate button)
  503. await waitFor(() => {
  504. expect(screen.getByText('Staged Print 1')).toBeInTheDocument();
  505. });
  506. expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument();
  507. });
  508. it('shows clear plate button when mix of staged and auto-dispatch items', async () => {
  509. const mixedItems = [
  510. { id: 10, printer_id: 1, archive_id: 1, position: 1, status: 'pending', archive_name: 'Staged Print', manual_start: true, scheduled_time: null },
  511. { id: 11, printer_id: 1, archive_id: 2, position: 2, status: 'pending', archive_name: 'Auto Print', manual_start: false, scheduled_time: null },
  512. ];
  513. server.use(
  514. http.get('/api/v1/queue/', () => HttpResponse.json(mixedItems)),
  515. );
  516. render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
  517. await waitFor(() => {
  518. expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
  519. });
  520. });
  521. });
  522. });