PrintModal.test.tsx 46 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555
  1. /**
  2. * Tests for the unified PrintModal component.
  3. *
  4. * The PrintModal supports two modes:
  5. * - 'create': Create a print queue item
  6. * - 'edit-queue-item': Edit existing queue item (single printer)
  7. */
  8. import { describe, it, expect, vi, 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 { PrintModal } from '../../components/PrintModal';
  13. import { http, HttpResponse } from 'msw';
  14. import { server } from '../mocks/server';
  15. import type { PrintQueueItem } from '../../api/client';
  16. const mockPrinters = [
  17. { id: 1, name: 'X1 Carbon', model: 'X1C', ip_address: '192.168.1.100', enabled: true, is_active: true },
  18. { id: 2, name: 'P1S', model: 'P1S', ip_address: '192.168.1.101', enabled: true, is_active: true },
  19. { id: 3, name: 'A1 Mini', model: 'A1M', ip_address: '192.168.1.102', enabled: true, is_active: true },
  20. ];
  21. const createMockQueueItem = (overrides: Partial<PrintQueueItem> = {}): PrintQueueItem => ({
  22. id: 1,
  23. printer_id: 1,
  24. archive_id: 1,
  25. position: 1,
  26. scheduled_time: null,
  27. require_previous_success: false,
  28. auto_off_after: false,
  29. gcode_injection: false,
  30. manual_start: false,
  31. ams_mapping: null,
  32. plate_id: null,
  33. bed_levelling: true,
  34. flow_cali: false,
  35. vibration_cali: true,
  36. layer_inspect: false,
  37. timelapse: false,
  38. use_ams: true,
  39. status: 'pending',
  40. started_at: null,
  41. completed_at: null,
  42. error_message: null,
  43. created_at: '2024-01-01T00:00:00Z',
  44. archive_name: 'Test Print',
  45. archive_thumbnail: null,
  46. printer_name: 'Test Printer',
  47. print_time_seconds: 3600,
  48. batch_id: null,
  49. batch_name: null,
  50. ...overrides,
  51. });
  52. describe('PrintModal', () => {
  53. const mockOnClose = vi.fn();
  54. const mockOnSuccess = vi.fn();
  55. beforeEach(() => {
  56. vi.clearAllMocks();
  57. server.use(
  58. http.get('/api/v1/printers/', () => {
  59. return HttpResponse.json(mockPrinters);
  60. }),
  61. http.get('/api/v1/archives/:id/plates', () => {
  62. return HttpResponse.json({ is_multi_plate: false, plates: [] });
  63. }),
  64. http.get('/api/v1/archives/:id/filament-requirements', () => {
  65. return HttpResponse.json({ filaments: [] });
  66. }),
  67. http.get('/api/v1/printers/:id/status', () => {
  68. return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] });
  69. }),
  70. http.post('/api/v1/queue/', () => {
  71. return HttpResponse.json({ id: 1, status: 'pending' });
  72. }),
  73. http.patch('/api/v1/queue/:id', () => {
  74. return HttpResponse.json({ id: 1, status: 'pending' });
  75. })
  76. );
  77. });
  78. describe('create mode', () => {
  79. it('renders the modal title', () => {
  80. render(
  81. <PrintModal
  82. mode="create"
  83. archiveId={1}
  84. archiveName="Benchy"
  85. initialSelectedPrinterIds={[1]}
  86. onClose={mockOnClose}
  87. onSuccess={mockOnSuccess}
  88. />
  89. );
  90. expect(screen.getByRole('heading', { name: 'Print' })).toBeInTheDocument();
  91. });
  92. it('shows archive name', () => {
  93. render(
  94. <PrintModal
  95. mode="create"
  96. archiveId={1}
  97. archiveName="Benchy"
  98. onClose={mockOnClose}
  99. onSuccess={mockOnSuccess}
  100. />
  101. );
  102. expect(screen.getByText('Benchy')).toBeInTheDocument();
  103. });
  104. it('shows printer selection with checkboxes for multi-select', async () => {
  105. render(
  106. <PrintModal
  107. mode="create"
  108. archiveId={1}
  109. archiveName="Benchy"
  110. onClose={mockOnClose}
  111. onSuccess={mockOnSuccess}
  112. />
  113. );
  114. await waitFor(() => {
  115. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  116. expect(screen.getByText('P1S')).toBeInTheDocument();
  117. });
  118. });
  119. it('has print button', () => {
  120. render(
  121. <PrintModal
  122. mode="create"
  123. archiveId={1}
  124. archiveName="Benchy"
  125. onClose={mockOnClose}
  126. onSuccess={mockOnSuccess}
  127. />
  128. );
  129. // Get the submit button specifically (not printer selection buttons)
  130. const submitButton = screen.getByRole('button', { name: /^print$/i });
  131. expect(submitButton).toBeInTheDocument();
  132. });
  133. it('has cancel button', () => {
  134. render(
  135. <PrintModal
  136. mode="create"
  137. archiveId={1}
  138. archiveName="Benchy"
  139. onClose={mockOnClose}
  140. onSuccess={mockOnSuccess}
  141. />
  142. );
  143. expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
  144. });
  145. it('calls onClose when cancel is clicked', async () => {
  146. const user = userEvent.setup();
  147. render(
  148. <PrintModal
  149. mode="create"
  150. archiveId={1}
  151. archiveName="Benchy"
  152. onClose={mockOnClose}
  153. onSuccess={mockOnSuccess}
  154. />
  155. );
  156. await user.click(screen.getByRole('button', { name: /cancel/i }));
  157. expect(mockOnClose).toHaveBeenCalled();
  158. });
  159. it('print button is disabled until printer is selected', () => {
  160. render(
  161. <PrintModal
  162. mode="create"
  163. archiveId={1}
  164. archiveName="Benchy"
  165. onClose={mockOnClose}
  166. onSuccess={mockOnSuccess}
  167. />
  168. );
  169. // Get the submit button specifically (not printer selection buttons)
  170. const printButton = screen.getByRole('button', { name: /^print$/i });
  171. expect(printButton).toBeDisabled();
  172. });
  173. it('shows no printers message when none active', async () => {
  174. server.use(
  175. http.get('/api/v1/printers/', () => {
  176. return HttpResponse.json([]);
  177. })
  178. );
  179. render(
  180. <PrintModal
  181. mode="create"
  182. archiveId={1}
  183. archiveName="Benchy"
  184. onClose={mockOnClose}
  185. onSuccess={mockOnSuccess}
  186. />
  187. );
  188. await waitFor(() => {
  189. expect(screen.getByText('No active printers available')).toBeInTheDocument();
  190. });
  191. });
  192. it('shows print options toggle', async () => {
  193. render(
  194. <PrintModal
  195. mode="create"
  196. archiveId={1}
  197. archiveName="Benchy"
  198. initialSelectedPrinterIds={[1]}
  199. onClose={mockOnClose}
  200. onSuccess={mockOnSuccess}
  201. />
  202. );
  203. await waitFor(() => {
  204. expect(screen.getByText('Print Options')).toBeInTheDocument();
  205. });
  206. });
  207. });
  208. describe('create mode', () => {
  209. it('renders the modal title', () => {
  210. render(
  211. <PrintModal
  212. mode="create"
  213. archiveId={1}
  214. archiveName="Test Print"
  215. onClose={mockOnClose}
  216. />
  217. );
  218. expect(screen.getByRole('heading', { name: 'Print' })).toBeInTheDocument();
  219. });
  220. it('shows archive name', () => {
  221. render(
  222. <PrintModal
  223. mode="create"
  224. archiveId={1}
  225. archiveName="Test Print"
  226. onClose={mockOnClose}
  227. />
  228. );
  229. expect(screen.getByText('Test Print')).toBeInTheDocument();
  230. });
  231. it('shows add button', () => {
  232. render(
  233. <PrintModal
  234. mode="create"
  235. archiveId={1}
  236. archiveName="Test Print"
  237. onClose={mockOnClose}
  238. />
  239. );
  240. expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
  241. });
  242. it('shows cancel button', () => {
  243. render(
  244. <PrintModal
  245. mode="create"
  246. archiveId={1}
  247. archiveName="Test Print"
  248. onClose={mockOnClose}
  249. />
  250. );
  251. expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
  252. });
  253. it('shows Queue option', () => {
  254. render(
  255. <PrintModal
  256. mode="create"
  257. archiveId={1}
  258. archiveName="Test Print"
  259. onClose={mockOnClose}
  260. />
  261. );
  262. expect(screen.getByRole('button', { name: /queue/i })).toBeInTheDocument();
  263. });
  264. it('shows power off option', () => {
  265. render(
  266. <PrintModal
  267. mode="create"
  268. archiveId={1}
  269. archiveName="Test Print"
  270. onClose={mockOnClose}
  271. />
  272. );
  273. expect(screen.getByText(/power off/i)).toBeInTheDocument();
  274. });
  275. it('shows schedule options', () => {
  276. render(
  277. <PrintModal
  278. mode="create"
  279. archiveId={1}
  280. archiveName="Test Print"
  281. onClose={mockOnClose}
  282. />
  283. );
  284. expect(screen.getByRole('button', { name: /asap/i })).toBeInTheDocument();
  285. expect(screen.getByRole('button', { name: /queue/i })).toBeInTheDocument();
  286. expect(screen.getByRole('button', { name: /schedule/i })).toBeInTheDocument();
  287. });
  288. it('orders schedule options by time', () => {
  289. render(
  290. <PrintModal
  291. mode="create"
  292. archiveId={1}
  293. archiveName="Test Print"
  294. onClose={mockOnClose}
  295. />
  296. );
  297. const options = screen.getAllByRole('button', { name: /^(asap|queue|schedule)$/i });
  298. expect(options.map(button => button.textContent?.trim())).toEqual(['ASAP', 'Queue', 'Schedule']);
  299. });
  300. it('calls onClose when cancel is clicked', async () => {
  301. const user = userEvent.setup();
  302. render(
  303. <PrintModal
  304. mode="create"
  305. archiveId={1}
  306. archiveName="Test Print"
  307. onClose={mockOnClose}
  308. />
  309. );
  310. await user.click(screen.getByRole('button', { name: /cancel/i }));
  311. expect(mockOnClose).toHaveBeenCalled();
  312. });
  313. });
  314. describe('edit-queue-item mode', () => {
  315. it('renders the modal title', () => {
  316. const item = createMockQueueItem();
  317. render(
  318. <PrintModal
  319. mode="edit-queue-item"
  320. archiveId={1}
  321. archiveName="Test Print"
  322. queueItem={item}
  323. onClose={mockOnClose}
  324. />
  325. );
  326. expect(screen.getByText('Edit Queue Item')).toBeInTheDocument();
  327. });
  328. it('shows save button', () => {
  329. const item = createMockQueueItem();
  330. render(
  331. <PrintModal
  332. mode="edit-queue-item"
  333. archiveId={1}
  334. archiveName="Test Print"
  335. queueItem={item}
  336. onClose={mockOnClose}
  337. />
  338. );
  339. expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
  340. });
  341. it('shows cancel button', () => {
  342. const item = createMockQueueItem();
  343. render(
  344. <PrintModal
  345. mode="edit-queue-item"
  346. archiveId={1}
  347. archiveName="Test Print"
  348. queueItem={item}
  349. onClose={mockOnClose}
  350. />
  351. );
  352. expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
  353. });
  354. it('shows print options toggle', () => {
  355. const item = createMockQueueItem();
  356. render(
  357. <PrintModal
  358. mode="edit-queue-item"
  359. archiveId={1}
  360. archiveName="Test Print"
  361. queueItem={item}
  362. onClose={mockOnClose}
  363. />
  364. );
  365. expect(screen.getByText('Print Options')).toBeInTheDocument();
  366. });
  367. it('shows Queue option', () => {
  368. const item = createMockQueueItem();
  369. render(
  370. <PrintModal
  371. mode="edit-queue-item"
  372. archiveId={1}
  373. archiveName="Test Print"
  374. queueItem={item}
  375. onClose={mockOnClose}
  376. />
  377. );
  378. expect(screen.getByRole('button', { name: /queue/i })).toBeInTheDocument();
  379. });
  380. it('shows power off option', () => {
  381. const item = createMockQueueItem();
  382. render(
  383. <PrintModal
  384. mode="edit-queue-item"
  385. archiveId={1}
  386. archiveName="Test Print"
  387. queueItem={item}
  388. onClose={mockOnClose}
  389. />
  390. );
  391. expect(screen.getByText(/power off/i)).toBeInTheDocument();
  392. });
  393. it('calls onClose when cancel button is clicked', async () => {
  394. const user = userEvent.setup();
  395. const item = createMockQueueItem();
  396. render(
  397. <PrintModal
  398. mode="edit-queue-item"
  399. archiveId={1}
  400. archiveName="Test Print"
  401. queueItem={item}
  402. onClose={mockOnClose}
  403. />
  404. );
  405. const cancelButton = screen.getByRole('button', { name: /cancel/i });
  406. await user.click(cancelButton);
  407. expect(mockOnClose).toHaveBeenCalled();
  408. });
  409. it('shows printer selector for single selection', async () => {
  410. const item = createMockQueueItem();
  411. render(
  412. <PrintModal
  413. mode="edit-queue-item"
  414. archiveId={1}
  415. archiveName="Test Print"
  416. queueItem={item}
  417. onClose={mockOnClose}
  418. />
  419. );
  420. // PrinterSelector shows printer names directly
  421. await waitFor(() => {
  422. expect(screen.getByText('P1S')).toBeInTheDocument();
  423. });
  424. });
  425. });
  426. describe('multi-printer selection', () => {
  427. it('shows select all button when multiple printers available', async () => {
  428. render(
  429. <PrintModal
  430. mode="create"
  431. archiveId={1}
  432. archiveName="Benchy"
  433. onClose={mockOnClose}
  434. />
  435. );
  436. await waitFor(() => {
  437. expect(screen.getByText('Select all')).toBeInTheDocument();
  438. });
  439. });
  440. it('shows selected count when multiple printers selected', async () => {
  441. const user = userEvent.setup();
  442. render(
  443. <PrintModal
  444. mode="create"
  445. archiveId={1}
  446. archiveName="Benchy"
  447. onClose={mockOnClose}
  448. />
  449. );
  450. await waitFor(() => {
  451. expect(screen.getByText('Select all')).toBeInTheDocument();
  452. });
  453. await user.click(screen.getByText('Select all'));
  454. await waitFor(() => {
  455. expect(screen.getByText(/3 printers selected/)).toBeInTheDocument();
  456. });
  457. });
  458. it('updates button text when multiple printers selected', async () => {
  459. const user = userEvent.setup();
  460. render(
  461. <PrintModal
  462. mode="create"
  463. archiveId={1}
  464. archiveName="Benchy"
  465. onClose={mockOnClose}
  466. />
  467. );
  468. await waitFor(() => {
  469. expect(screen.getByText('Select all')).toBeInTheDocument();
  470. });
  471. await user.click(screen.getByText('Select all'));
  472. await waitFor(() => {
  473. expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
  474. });
  475. });
  476. });
  477. describe('busy printer handling (#622)', () => {
  478. beforeEach(() => {
  479. // Set up per-printer statuses: printer 1 RUNNING, printer 2 IDLE, printer 3 FINISH
  480. server.use(
  481. http.get('/api/v1/printers/:id/status', ({ params }) => {
  482. const id = Number(params.id);
  483. if (id === 1) {
  484. return HttpResponse.json({
  485. connected: true, state: 'RUNNING', stg_cur_name: null,
  486. ams: [], vt_tray: [], nozzles: [],
  487. });
  488. }
  489. if (id === 2) {
  490. return HttpResponse.json({
  491. connected: true, state: 'IDLE', stg_cur_name: null,
  492. ams: [], vt_tray: [], nozzles: [],
  493. });
  494. }
  495. // printer 3
  496. return HttpResponse.json({
  497. connected: true, state: 'FINISH', stg_cur_name: null,
  498. ams: [], vt_tray: [], nozzles: [],
  499. });
  500. })
  501. );
  502. });
  503. it('shows state badges on printers in create mode', async () => {
  504. render(
  505. <PrintModal
  506. mode="create"
  507. archiveId={1}
  508. archiveName="Benchy"
  509. onClose={mockOnClose}
  510. />
  511. );
  512. await waitFor(() => {
  513. expect(screen.getByText('Printing')).toBeInTheDocument();
  514. expect(screen.getByText('Idle')).toBeInTheDocument();
  515. expect(screen.getByText('Finished')).toBeInTheDocument();
  516. });
  517. });
  518. it('allows selecting a busy printer in create mode', async () => {
  519. const user = userEvent.setup();
  520. render(
  521. <PrintModal
  522. mode="create"
  523. archiveId={1}
  524. archiveName="Benchy"
  525. onClose={mockOnClose}
  526. />
  527. );
  528. await waitFor(() => {
  529. expect(screen.getByText('Printing')).toBeInTheDocument();
  530. });
  531. const busyButton = screen.getByText('X1 Carbon').closest('button');
  532. expect(busyButton).not.toBeDisabled();
  533. await user.click(busyButton!);
  534. await waitFor(() => {
  535. expect(screen.getByText('1 printer selected')).toBeInTheDocument();
  536. });
  537. });
  538. it('select all includes busy printers in create mode', async () => {
  539. const user = userEvent.setup();
  540. render(
  541. <PrintModal
  542. mode="create"
  543. archiveId={1}
  544. archiveName="Benchy"
  545. onClose={mockOnClose}
  546. />
  547. );
  548. await waitFor(() => {
  549. expect(screen.getByText('Select all')).toBeInTheDocument();
  550. expect(screen.getByText('Printing')).toBeInTheDocument();
  551. });
  552. await user.click(screen.getByText('Select all'));
  553. await waitFor(() => {
  554. expect(screen.getByText(/3 printers selected/)).toBeInTheDocument();
  555. });
  556. });
  557. it('allows selecting busy printers in create mode', async () => {
  558. const user = userEvent.setup();
  559. render(
  560. <PrintModal
  561. mode="create"
  562. archiveId={1}
  563. archiveName="Benchy"
  564. onClose={mockOnClose}
  565. />
  566. );
  567. await waitFor(() => {
  568. expect(screen.getByText('Printing')).toBeInTheDocument();
  569. });
  570. // The busy printer button should NOT be disabled in queue mode
  571. const busyButton = screen.getByText('X1 Carbon').closest('button');
  572. expect(busyButton).not.toBeDisabled();
  573. await user.click(busyButton!);
  574. await waitFor(() => {
  575. expect(screen.getByText('1 printer selected')).toBeInTheDocument();
  576. });
  577. });
  578. it('shows Offline badge for disconnected printers', async () => {
  579. server.use(
  580. http.get('/api/v1/printers/:id/status', () => {
  581. return HttpResponse.json({
  582. connected: false, state: null, stg_cur_name: null,
  583. ams: [], vt_tray: [], nozzles: [],
  584. });
  585. })
  586. );
  587. render(
  588. <PrintModal
  589. mode="create"
  590. archiveId={1}
  591. archiveName="Benchy"
  592. onClose={mockOnClose}
  593. />
  594. );
  595. await waitFor(() => {
  596. const offlineBadges = screen.getAllByText('Offline');
  597. expect(offlineBadges.length).toBeGreaterThanOrEqual(1);
  598. });
  599. });
  600. it('shows calibration stage name when printer is calibrating', async () => {
  601. server.use(
  602. http.get('/api/v1/printers/:id/status', () => {
  603. return HttpResponse.json({
  604. connected: true, state: 'RUNNING', stg_cur_name: 'Auto bed leveling',
  605. ams: [], vt_tray: [], nozzles: [],
  606. });
  607. })
  608. );
  609. render(
  610. <PrintModal
  611. mode="create"
  612. archiveId={1}
  613. archiveName="Benchy"
  614. onClose={mockOnClose}
  615. />
  616. );
  617. await waitFor(() => {
  618. const badges = screen.getAllByText('Auto bed leveling');
  619. expect(badges.length).toBeGreaterThanOrEqual(1);
  620. });
  621. });
  622. });
  623. describe('stagger start', () => {
  624. it('does not show stagger option with single printer in queue mode', async () => {
  625. const user = userEvent.setup();
  626. render(
  627. <PrintModal
  628. mode="create"
  629. archiveId={1}
  630. archiveName="Test Print"
  631. onClose={mockOnClose}
  632. />
  633. );
  634. await waitFor(() => {
  635. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  636. });
  637. // Select single printer
  638. await user.click(screen.getByText('X1 Carbon'));
  639. expect(screen.queryByText('Stagger printer starts')).not.toBeInTheDocument();
  640. });
  641. it('shows stagger option when multiple printers selected in queue mode', async () => {
  642. const user = userEvent.setup();
  643. render(
  644. <PrintModal
  645. mode="create"
  646. archiveId={1}
  647. archiveName="Test Print"
  648. onClose={mockOnClose}
  649. />
  650. );
  651. await waitFor(() => {
  652. expect(screen.getByText('Select all')).toBeInTheDocument();
  653. });
  654. await user.click(screen.getByText('Select all'));
  655. await waitFor(() => {
  656. expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();
  657. });
  658. });
  659. it('shows stagger option in create mode with multiple printers', async () => {
  660. const user = userEvent.setup();
  661. render(
  662. <PrintModal
  663. mode="create"
  664. archiveId={1}
  665. archiveName="Test Print"
  666. onClose={mockOnClose}
  667. />
  668. );
  669. await waitFor(() => {
  670. expect(screen.getByText('Select all')).toBeInTheDocument();
  671. });
  672. await user.click(screen.getByText('Select all'));
  673. await waitFor(() => {
  674. expect(screen.getByText(/2 printers selected|3 printers selected/)).toBeInTheDocument();
  675. });
  676. expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();
  677. });
  678. it('shows stagger preview in create mode when enabled', async () => {
  679. const user = userEvent.setup();
  680. render(
  681. <PrintModal
  682. mode="create"
  683. archiveId={1}
  684. archiveName="Test Print"
  685. onClose={mockOnClose}
  686. />
  687. );
  688. await waitFor(() => {
  689. expect(screen.getByText('Select all')).toBeInTheDocument();
  690. });
  691. await user.click(screen.getByText('Select all'));
  692. await waitFor(() => {
  693. expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();
  694. });
  695. await user.click(screen.getByLabelText('Stagger printer starts'));
  696. await waitFor(() => {
  697. // Default: 3 printers, group size 2 = 2 groups — preview text shown
  698. expect(screen.getByText(/3 printers.*2 groups/)).toBeInTheDocument();
  699. });
  700. });
  701. it('does not show stagger option in create mode with single printer', async () => {
  702. const user = userEvent.setup();
  703. render(
  704. <PrintModal
  705. mode="create"
  706. archiveId={1}
  707. archiveName="Test Print"
  708. onClose={mockOnClose}
  709. />
  710. );
  711. await waitFor(() => {
  712. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  713. });
  714. // Select only one printer
  715. await user.click(screen.getByText('X1 Carbon'));
  716. expect(screen.queryByText('Stagger printer starts')).not.toBeInTheDocument();
  717. });
  718. it('shows stagger inputs when stagger checkbox is enabled', async () => {
  719. const user = userEvent.setup();
  720. render(
  721. <PrintModal
  722. mode="create"
  723. archiveId={1}
  724. archiveName="Test Print"
  725. onClose={mockOnClose}
  726. />
  727. );
  728. await waitFor(() => {
  729. expect(screen.getByText('Select all')).toBeInTheDocument();
  730. });
  731. await user.click(screen.getByText('Select all'));
  732. await waitFor(() => {
  733. expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();
  734. });
  735. await user.click(screen.getByLabelText('Stagger printer starts'));
  736. await waitFor(() => {
  737. expect(screen.getByText('Group size')).toBeInTheDocument();
  738. expect(screen.getByText('Interval (min)')).toBeInTheDocument();
  739. });
  740. });
  741. it('shows stagger preview with printer count', async () => {
  742. const user = userEvent.setup();
  743. render(
  744. <PrintModal
  745. mode="create"
  746. archiveId={1}
  747. archiveName="Test Print"
  748. onClose={mockOnClose}
  749. />
  750. );
  751. await waitFor(() => {
  752. expect(screen.getByText('Select all')).toBeInTheDocument();
  753. });
  754. await user.click(screen.getByText('Select all'));
  755. await waitFor(() => {
  756. expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();
  757. });
  758. await user.click(screen.getByLabelText('Stagger printer starts'));
  759. await waitFor(() => {
  760. // Default: 3 printers, group size 2 = 2 groups — preview text includes printer count
  761. expect(screen.getByText(/3 printers.*2 groups/)).toBeInTheDocument();
  762. });
  763. });
  764. });
  765. describe('multi-plate selection', () => {
  766. const multiPlateResponse = {
  767. is_multi_plate: true,
  768. plates: [
  769. { index: 1, name: 'Plate 1', has_thumbnail: false, thumbnail_url: null, objects: ['Part A'], filaments: [{ type: 'PLA', color: '#FF0000' }], print_time_seconds: 1800, filament_used_grams: 50 },
  770. { index: 2, name: 'Plate 2', has_thumbnail: false, thumbnail_url: null, objects: ['Part B'], filaments: [{ type: 'PLA', color: '#00FF00' }], print_time_seconds: 2400, filament_used_grams: 60 },
  771. { index: 3, name: 'Plate 3', has_thumbnail: false, thumbnail_url: null, objects: ['Part C'], filaments: [{ type: 'PETG', color: '#0000FF' }], print_time_seconds: 3000, filament_used_grams: 70 },
  772. ],
  773. };
  774. beforeEach(() => {
  775. server.use(
  776. http.get('/api/v1/archives/:id/plates', () => {
  777. return HttpResponse.json(multiPlateResponse);
  778. }),
  779. );
  780. });
  781. it('shows "Select All" button only in create mode', async () => {
  782. render(
  783. <PrintModal
  784. mode="create"
  785. archiveId={1}
  786. archiveName="MultiPlate.3mf"
  787. onClose={mockOnClose}
  788. />
  789. );
  790. await waitFor(() => {
  791. expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();
  792. });
  793. });
  794. it('shows "Select All" button in create mode', async () => {
  795. render(
  796. <PrintModal
  797. mode="create"
  798. archiveId={1}
  799. archiveName="MultiPlate.3mf"
  800. initialSelectedPrinterIds={[1]}
  801. onClose={mockOnClose}
  802. />
  803. );
  804. await waitFor(() => {
  805. expect(screen.getByText('Plate 1')).toBeInTheDocument();
  806. });
  807. expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();
  808. });
  809. it('selects all plates when "Select All" is clicked', async () => {
  810. const user = userEvent.setup();
  811. render(
  812. <PrintModal
  813. mode="create"
  814. archiveId={1}
  815. archiveName="MultiPlate.3mf"
  816. onClose={mockOnClose}
  817. />
  818. );
  819. await waitFor(() => {
  820. expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();
  821. });
  822. await user.click(screen.getByText('Select All 3 Plates'));
  823. // All plates should be highlighted (green border)
  824. await waitFor(() => {
  825. const plateButtons = document.querySelectorAll('button[type="button"].border-bambu-green');
  826. // 3 plate buttons + the "Deselect All" toggle button = 4 green-bordered buttons
  827. expect(plateButtons.length).toBeGreaterThanOrEqual(3);
  828. });
  829. });
  830. it('allows selecting a subset of plates to queue', async () => {
  831. const queueRequests: unknown[] = [];
  832. server.use(
  833. http.post('/api/v1/queue/', async ({ request }) => {
  834. const body = await request.json();
  835. queueRequests.push(body);
  836. return HttpResponse.json({ id: queueRequests.length, status: 'pending' });
  837. }),
  838. );
  839. const user = userEvent.setup();
  840. render(
  841. <PrintModal
  842. mode="create"
  843. archiveId={1}
  844. archiveName="MultiPlate.3mf"
  845. onClose={mockOnClose}
  846. onSuccess={mockOnSuccess}
  847. />
  848. );
  849. // Wait for plates and select a printer
  850. await waitFor(() => {
  851. expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();
  852. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  853. });
  854. // Select printer
  855. await user.click(screen.getByText('X1 Carbon'));
  856. // Plate 1 is auto-selected. Click Plate 3 to add it (multi-select in create mode)
  857. await user.click(screen.getByText('Plate 3'));
  858. // Submit — should queue plates 1 and 3
  859. const submitButton = document.querySelector('button[type="submit"]') as HTMLElement;
  860. await user.click(submitButton);
  861. await waitFor(() => {
  862. expect(queueRequests.length).toBe(2);
  863. });
  864. expect((queueRequests[0] as { plate_id: number }).plate_id).toBe(1);
  865. expect((queueRequests[1] as { plate_id: number }).plate_id).toBe(3);
  866. });
  867. it('creates one queue item per plate when submitting with select-all', async () => {
  868. const queueRequests: unknown[] = [];
  869. server.use(
  870. http.post('/api/v1/queue/', async ({ request }) => {
  871. const body = await request.json();
  872. queueRequests.push(body);
  873. return HttpResponse.json({ id: queueRequests.length, status: 'pending' });
  874. }),
  875. );
  876. const user = userEvent.setup();
  877. render(
  878. <PrintModal
  879. mode="create"
  880. archiveId={1}
  881. archiveName="MultiPlate.3mf"
  882. onClose={mockOnClose}
  883. onSuccess={mockOnSuccess}
  884. />
  885. );
  886. // Wait for plates and select a printer
  887. await waitFor(() => {
  888. expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();
  889. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  890. });
  891. // Select printer
  892. await user.click(screen.getByText('X1 Carbon'));
  893. // Click select all
  894. await user.click(screen.getByText('Select All 3 Plates'));
  895. // Find the submit button (type="submit") — distinct from the toggle button (type="button")
  896. const submitButton = document.querySelector('button[type="submit"]') as HTMLElement;
  897. await user.click(submitButton);
  898. await waitFor(() => {
  899. expect(queueRequests.length).toBe(3);
  900. });
  901. // Verify each request has the correct plate_id
  902. expect((queueRequests[0] as { plate_id: number }).plate_id).toBe(1);
  903. expect((queueRequests[1] as { plate_id: number }).plate_id).toBe(2);
  904. expect((queueRequests[2] as { plate_id: number }).plate_id).toBe(3);
  905. });
  906. });
  907. describe('batch quantity', () => {
  908. it('shows quantity input in create mode', () => {
  909. render(
  910. <PrintModal
  911. mode="create"
  912. archiveId={1}
  913. archiveName="Benchy"
  914. onClose={mockOnClose}
  915. onSuccess={mockOnSuccess}
  916. />
  917. );
  918. expect(screen.getByLabelText('Quantity')).toBeInTheDocument();
  919. });
  920. it('shows quantity input in create mode', () => {
  921. render(
  922. <PrintModal
  923. mode="create"
  924. archiveId={1}
  925. archiveName="Benchy"
  926. onClose={mockOnClose}
  927. onSuccess={mockOnSuccess}
  928. />
  929. );
  930. expect(screen.getByLabelText('Quantity')).toBeInTheDocument();
  931. });
  932. it('does not show quantity input in edit-queue-item mode', () => {
  933. render(
  934. <PrintModal
  935. mode="edit-queue-item"
  936. archiveId={1}
  937. archiveName="Benchy"
  938. queueItem={createMockQueueItem()}
  939. onClose={mockOnClose}
  940. onSuccess={mockOnSuccess}
  941. />
  942. );
  943. expect(screen.queryByLabelText('Quantity')).not.toBeInTheDocument();
  944. });
  945. it('defaults quantity to 1', () => {
  946. render(
  947. <PrintModal
  948. mode="create"
  949. archiveId={1}
  950. archiveName="Benchy"
  951. onClose={mockOnClose}
  952. onSuccess={mockOnSuccess}
  953. />
  954. );
  955. const input = screen.getByLabelText('Quantity') as HTMLInputElement;
  956. expect(input.value).toBe('1');
  957. });
  958. it('quantity input has default value of 1 and accepts changes', async () => {
  959. const user = userEvent.setup();
  960. render(
  961. <PrintModal
  962. mode="create"
  963. archiveId={1}
  964. archiveName="Benchy"
  965. initialSelectedPrinterIds={[1]}
  966. onClose={mockOnClose}
  967. onSuccess={mockOnSuccess}
  968. />
  969. );
  970. const input = screen.getByLabelText('Quantity') as HTMLInputElement;
  971. expect(input.value).toBe('1');
  972. await user.tripleClick(input);
  973. await user.keyboard('5');
  974. expect(input.value).toBe('5');
  975. });
  976. });
  977. describe('reprint G-code injection dispatch (#422 / auto-eject)', () => {
  978. // Guards the fix: when "Inject auto-print G-code" is ticked on a create with
  979. // quantity > 1, ALL copies must go through the queue so every one is injected by
  980. // the scheduler. The first copy must NOT be dispatched immediately via the direct
  981. // create path — that path bypasses injection and would leave the first copy stuck
  982. // on the plate for auto-eject setups.
  983. const withSnippets = () =>
  984. http.get('/api/v1/settings/', () =>
  985. HttpResponse.json({ gcode_snippets: { X1C: { start_gcode: '', end_gcode: 'M400' } } }),
  986. );
  987. it('injection ON queues all copies and dispatches none immediately', async () => {
  988. const queueCalls: Record<string, unknown>[] = [];
  989. server.use(
  990. withSnippets(),
  991. http.post('/api/v1/queue/', async ({ request }) => {
  992. queueCalls.push((await request.json()) as Record<string, unknown>);
  993. return HttpResponse.json({ id: queueCalls.length, status: 'pending' });
  994. }),
  995. );
  996. const user = userEvent.setup();
  997. render(
  998. <PrintModal
  999. mode="create"
  1000. archiveId={1}
  1001. archiveName="Benchy"
  1002. initialSelectedPrinterIds={[1]}
  1003. onClose={mockOnClose}
  1004. onSuccess={mockOnSuccess}
  1005. />
  1006. );
  1007. // Quantity > 1 so the injection checkbox is offered
  1008. const qty = (await screen.findByLabelText('Quantity')) as HTMLInputElement;
  1009. await user.tripleClick(qty);
  1010. await user.keyboard('3');
  1011. expect(qty.value).toBe('3');
  1012. // Checkbox only renders once snippets are loaded AND quantity > 1
  1013. await user.click(await screen.findByLabelText(/inject auto-print/i));
  1014. await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
  1015. await waitFor(() => expect(queueCalls.length).toBe(1));
  1016. // One queue item carrying all copies, and zero immediate reprint dispatches
  1017. expect(queueCalls[0].quantity).toBe(3);
  1018. });
  1019. it('quantity 1 + snippets configured: checkbox toggles cleanly (#1852)', async () => {
  1020. // The previous reset effect in PrintModal/index.tsx silently flipped
  1021. // gcodeInjection back to false whenever effectiveQuantity <= 1 in create
  1022. // mode, so a single print with snippets configured couldn't tick the
  1023. // checkbox — every click visibly un-ticked itself within the next render.
  1024. // The scheduler reads item.gcode_injection per queue item regardless of
  1025. // batch size, so quantity 1 must be allowed too.
  1026. const queueCalls: Record<string, unknown>[] = [];
  1027. server.use(
  1028. withSnippets(),
  1029. http.post('/api/v1/queue/', async ({ request }) => {
  1030. queueCalls.push((await request.json()) as Record<string, unknown>);
  1031. return HttpResponse.json({ id: queueCalls.length, status: 'pending' });
  1032. }),
  1033. );
  1034. const user = userEvent.setup();
  1035. render(
  1036. <PrintModal
  1037. mode="create"
  1038. archiveId={1}
  1039. archiveName="Benchy"
  1040. initialSelectedPrinterIds={[1]}
  1041. onClose={mockOnClose}
  1042. onSuccess={mockOnSuccess}
  1043. />
  1044. );
  1045. // Quantity stays at default (1) — this is the OP's exact scenario.
  1046. const checkbox = (await screen.findByLabelText(/inject auto-print/i)) as HTMLInputElement;
  1047. expect(checkbox.checked).toBe(false);
  1048. await user.click(checkbox);
  1049. // CRITICAL: after the click the checkbox must stay checked. Under the
  1050. // pre-fix reset effect, the parent state would flip back to false within
  1051. // a render and the displayed `checked` attribute would track it.
  1052. await waitFor(() => expect(checkbox.checked).toBe(true));
  1053. await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
  1054. await waitFor(() => expect(queueCalls.length).toBe(1));
  1055. // The injection flag actually reaches the API — pre-fix the parent reset
  1056. // would have stripped it before submit.
  1057. expect(queueCalls[0].gcode_injection).toBe(true);
  1058. });
  1059. it('injection OFF queues all copies through the scheduler path', async () => {
  1060. const queueCalls: Record<string, unknown>[] = [];
  1061. server.use(
  1062. withSnippets(),
  1063. http.post('/api/v1/queue/', async ({ request }) => {
  1064. queueCalls.push((await request.json()) as Record<string, unknown>);
  1065. return HttpResponse.json({ id: queueCalls.length, status: 'pending' });
  1066. }),
  1067. );
  1068. const user = userEvent.setup();
  1069. render(
  1070. <PrintModal
  1071. mode="create"
  1072. archiveId={1}
  1073. archiveName="Benchy"
  1074. initialSelectedPrinterIds={[1]}
  1075. onClose={mockOnClose}
  1076. onSuccess={mockOnSuccess}
  1077. />
  1078. );
  1079. const qty = (await screen.findByLabelText('Quantity')) as HTMLInputElement;
  1080. await user.tripleClick(qty);
  1081. await user.keyboard('3');
  1082. expect(qty.value).toBe('3');
  1083. // Leave injection unticked: unified dispatch still queues all copies.
  1084. await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
  1085. await waitFor(() => expect(queueCalls.length).toBe(1));
  1086. expect(queueCalls[0].quantity).toBe(3);
  1087. });
  1088. });
  1089. describe('project_id forwarding', () => {
  1090. beforeEach(() => {
  1091. // Additional handlers needed for library file mode
  1092. server.use(
  1093. http.get('/api/v1/library/files/:id', () => {
  1094. return HttpResponse.json({
  1095. id: 5,
  1096. filename: 'benchy.gcode.3mf',
  1097. print_name: null,
  1098. file_type: '3mf',
  1099. folder_id: null,
  1100. project_id: null,
  1101. file_hash: null,
  1102. file_size_bytes: 1024,
  1103. thumbnail_path: null,
  1104. created_at: '2024-01-01T00:00:00Z',
  1105. updated_at: '2024-01-01T00:00:00Z',
  1106. });
  1107. }),
  1108. http.get('/api/v1/library/files/:id/plates', () => {
  1109. return HttpResponse.json({ is_multi_plate: false, plates: [] });
  1110. }),
  1111. http.get('/api/v1/library/files/:id/filament-requirements', () => {
  1112. return HttpResponse.json({ file_id: 5, filename: 'benchy.gcode.3mf', filaments: [] });
  1113. }),
  1114. http.get('/api/v1/printers/:id/status', () => {
  1115. return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] });
  1116. }),
  1117. );
  1118. });
  1119. it('includes project_id in queue item when printing a library file with projectId set', async () => {
  1120. let capturedBody: Record<string, unknown> | null = null;
  1121. server.use(
  1122. http.post('/api/v1/queue/', async ({ request }) => {
  1123. capturedBody = await request.json() as Record<string, unknown>;
  1124. return HttpResponse.json({ id: 1, status: 'pending' });
  1125. })
  1126. );
  1127. const user = userEvent.setup();
  1128. render(
  1129. <PrintModal
  1130. mode="create"
  1131. libraryFileId={5}
  1132. archiveName="Benchy"
  1133. projectId={42}
  1134. initialSelectedPrinterIds={[1]}
  1135. onClose={mockOnClose}
  1136. onSuccess={mockOnSuccess}
  1137. />
  1138. );
  1139. // Wait for the modal to load printer and file data
  1140. await waitFor(() => {
  1141. expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
  1142. });
  1143. await user.click(screen.getByRole('button', { name: /^print$/i }));
  1144. await waitFor(() => {
  1145. expect(capturedBody).not.toBeNull();
  1146. expect(capturedBody?.library_file_id).toBe(5);
  1147. expect(capturedBody?.project_id).toBe(42);
  1148. });
  1149. });
  1150. it('queues archive prints through the scheduler path', async () => {
  1151. let capturedBody: Record<string, unknown> | null = null;
  1152. server.use(
  1153. http.post('/api/v1/queue/', async ({ request }) => {
  1154. capturedBody = await request.json() as Record<string, unknown>;
  1155. return HttpResponse.json({ id: 1, status: 'pending' });
  1156. })
  1157. );
  1158. const user = userEvent.setup();
  1159. render(
  1160. <PrintModal
  1161. mode="create"
  1162. archiveId={1}
  1163. archiveName="Benchy"
  1164. projectId={42}
  1165. initialSelectedPrinterIds={[1]}
  1166. onClose={mockOnClose}
  1167. onSuccess={mockOnSuccess}
  1168. />
  1169. );
  1170. await waitFor(() => {
  1171. expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
  1172. });
  1173. await user.click(screen.getByRole('button', { name: /^print$/i }));
  1174. await waitFor(() => {
  1175. expect(capturedBody).not.toBeNull();
  1176. expect(capturedBody?.archive_id).toBe(1);
  1177. expect(capturedBody?.project_id).toBe(42);
  1178. });
  1179. });
  1180. it('adds ASAP prints to the top of the queue', async () => {
  1181. let capturedBody: Record<string, unknown> | null = null;
  1182. server.use(
  1183. http.post('/api/v1/queue/', async ({ request }) => {
  1184. capturedBody = await request.json() as Record<string, unknown>;
  1185. return HttpResponse.json({ id: 1, status: 'pending' });
  1186. })
  1187. );
  1188. const user = userEvent.setup();
  1189. render(
  1190. <PrintModal
  1191. mode="create"
  1192. archiveId={1}
  1193. archiveName="Benchy"
  1194. initialSelectedPrinterIds={[1]}
  1195. onClose={mockOnClose}
  1196. onSuccess={mockOnSuccess}
  1197. />
  1198. );
  1199. await waitFor(() => {
  1200. expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
  1201. });
  1202. await user.click(screen.getByRole('button', { name: /^print$/i }));
  1203. await waitFor(() => {
  1204. expect(capturedBody).not.toBeNull();
  1205. expect(capturedBody?.insert_at_top).toBe(true);
  1206. expect(capturedBody?.insert_position).toBe(1);
  1207. expect(capturedBody?.manual_start).toBe(false);
  1208. expect(capturedBody?.scheduled_time).toBeUndefined();
  1209. });
  1210. });
  1211. it('adds Queue prints to the back unless manual start is required', async () => {
  1212. let capturedBody: Record<string, unknown> | null = null;
  1213. server.use(
  1214. http.post('/api/v1/queue/', async ({ request }) => {
  1215. capturedBody = await request.json() as Record<string, unknown>;
  1216. return HttpResponse.json({ id: 1, status: 'pending' });
  1217. })
  1218. );
  1219. const user = userEvent.setup();
  1220. render(
  1221. <PrintModal
  1222. mode="create"
  1223. archiveId={1}
  1224. archiveName="Benchy"
  1225. initialSelectedPrinterIds={[1]}
  1226. onClose={mockOnClose}
  1227. onSuccess={mockOnSuccess}
  1228. />
  1229. );
  1230. await user.click(screen.getByRole('button', { name: /^queue$/i }));
  1231. expect(screen.getByLabelText(/require manual start/i)).toBeInTheDocument();
  1232. await user.click(screen.getByLabelText(/require manual start/i));
  1233. await user.click(screen.getByRole('button', { name: /^print$/i }));
  1234. await waitFor(() => {
  1235. expect(capturedBody).not.toBeNull();
  1236. expect(capturedBody?.insert_at_top).toBeUndefined();
  1237. expect(capturedBody?.insert_position).toBeUndefined();
  1238. expect(capturedBody?.manual_start).toBe(true);
  1239. });
  1240. });
  1241. });
  1242. describe('cleanup_library_after_dispatch forwarding (#730)', () => {
  1243. // The Printers-page Direct-Print flow passes cleanupLibraryAfterDispatch so the
  1244. // transient LibraryFile created by FileUploadModal is deleted once the archive
  1245. // owns its own copy. File Manager / Project Detail flows leave the prop unset so
  1246. // their deliberately-added library entries survive the print.
  1247. beforeEach(() => {
  1248. server.use(
  1249. http.get('/api/v1/library/files/:id', () => {
  1250. return HttpResponse.json({
  1251. id: 5,
  1252. filename: 'benchy.gcode.3mf',
  1253. file_type: '3mf',
  1254. folder_id: null,
  1255. project_id: null,
  1256. file_hash: null,
  1257. file_size_bytes: 1024,
  1258. thumbnail_path: null,
  1259. created_at: '2024-01-01T00:00:00Z',
  1260. updated_at: '2024-01-01T00:00:00Z',
  1261. });
  1262. }),
  1263. http.get('/api/v1/library/files/:id/plates', () => {
  1264. return HttpResponse.json({ is_multi_plate: false, plates: [] });
  1265. }),
  1266. http.get('/api/v1/library/files/:id/filament-requirements', () => {
  1267. return HttpResponse.json({ file_id: 5, filename: 'benchy.gcode.3mf', filaments: [] });
  1268. }),
  1269. http.get('/api/v1/printers/:id/status', () => {
  1270. return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] });
  1271. }),
  1272. );
  1273. });
  1274. it('forwards cleanup_library_after_dispatch=true when the Direct-Print prop is set', async () => {
  1275. let capturedBody: Record<string, unknown> | null = null;
  1276. server.use(
  1277. http.post('/api/v1/queue/', async ({ request }) => {
  1278. capturedBody = (await request.json()) as Record<string, unknown>;
  1279. return HttpResponse.json({ id: 1, status: 'pending' });
  1280. })
  1281. );
  1282. const user = userEvent.setup();
  1283. render(
  1284. <PrintModal
  1285. mode="create"
  1286. libraryFileId={5}
  1287. archiveName="Benchy"
  1288. cleanupLibraryAfterDispatch
  1289. initialSelectedPrinterIds={[1]}
  1290. onClose={mockOnClose}
  1291. onSuccess={mockOnSuccess}
  1292. />
  1293. );
  1294. await waitFor(() => {
  1295. expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
  1296. });
  1297. await user.click(screen.getByRole('button', { name: /^print$/i }));
  1298. await waitFor(() => {
  1299. expect(capturedBody).not.toBeNull();
  1300. expect(capturedBody?.cleanup_library_after_dispatch).toBe(true);
  1301. });
  1302. });
  1303. it('defaults to omitting cleanup_library_after_dispatch (File Manager / Project flows survive)', async () => {
  1304. let capturedBody: Record<string, unknown> | null = null;
  1305. server.use(
  1306. http.post('/api/v1/queue/', async ({ request }) => {
  1307. capturedBody = (await request.json()) as Record<string, unknown>;
  1308. return HttpResponse.json({ id: 1, status: 'pending' });
  1309. })
  1310. );
  1311. const user = userEvent.setup();
  1312. render(
  1313. <PrintModal
  1314. mode="create"
  1315. libraryFileId={5}
  1316. archiveName="Benchy"
  1317. initialSelectedPrinterIds={[1]}
  1318. onClose={mockOnClose}
  1319. onSuccess={mockOnSuccess}
  1320. />
  1321. );
  1322. await waitFor(() => {
  1323. expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
  1324. });
  1325. await user.click(screen.getByRole('button', { name: /^print$/i }));
  1326. await waitFor(() => {
  1327. expect(capturedBody).not.toBeNull();
  1328. });
  1329. // Either omitted entirely or explicitly undefined — both interpret as "keep file"
  1330. expect(capturedBody?.cleanup_library_after_dispatch).toBeUndefined();
  1331. });
  1332. });
  1333. });