PrintModal.test.tsx 66 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990
  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 type React from 'react';
  10. import { screen, waitFor, render as rtlRender } from '@testing-library/react';
  11. import userEvent from '@testing-library/user-event';
  12. import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
  13. import { BrowserRouter } from 'react-router-dom';
  14. import { render } from '../utils';
  15. import { PrintModal } from '../../components/PrintModal';
  16. import { AuthProvider } from '../../contexts/AuthContext';
  17. import { ThemeProvider } from '../../contexts/ThemeContext';
  18. import { ToastProvider } from '../../contexts/ToastContext';
  19. import { http, HttpResponse } from 'msw';
  20. import { server } from '../mocks/server';
  21. import type { PrintQueueItem } from '../../api/client';
  22. const mockPrinters = [
  23. { id: 1, name: 'X1 Carbon', model: 'X1C', ip_address: '192.168.1.100', enabled: true, is_active: true },
  24. { id: 2, name: 'P1S', model: 'P1S', ip_address: '192.168.1.101', enabled: true, is_active: true },
  25. { id: 3, name: 'A1 Mini', model: 'A1M', ip_address: '192.168.1.102', enabled: true, is_active: true },
  26. ];
  27. const createMockQueueItem = (overrides: Partial<PrintQueueItem> = {}): PrintQueueItem => ({
  28. id: 1,
  29. printer_id: 1,
  30. archive_id: 1,
  31. position: 1,
  32. scheduled_time: null,
  33. require_previous_success: false,
  34. auto_off_after: false,
  35. gcode_injection: false,
  36. manual_start: false,
  37. ams_mapping: null,
  38. plate_id: null,
  39. bed_levelling: 'on',
  40. flow_cali: 'off',
  41. vibration_cali: true,
  42. layer_inspect: false,
  43. timelapse: false,
  44. use_ams: true,
  45. status: 'pending',
  46. started_at: null,
  47. completed_at: null,
  48. error_message: null,
  49. created_at: '2024-01-01T00:00:00Z',
  50. archive_name: 'Test Print',
  51. archive_thumbnail: null,
  52. printer_name: 'Test Printer',
  53. print_time_seconds: 3600,
  54. batch_id: null,
  55. batch_name: null,
  56. ...overrides,
  57. });
  58. describe('PrintModal', () => {
  59. const mockOnClose = vi.fn();
  60. const mockOnSuccess = vi.fn();
  61. beforeEach(() => {
  62. vi.clearAllMocks();
  63. server.use(
  64. http.get('/api/v1/printers/', () => {
  65. return HttpResponse.json(mockPrinters);
  66. }),
  67. http.get('/api/v1/archives/:id/plates', () => {
  68. return HttpResponse.json({ is_multi_plate: false, plates: [] });
  69. }),
  70. http.get('/api/v1/archives/:id/filament-requirements', () => {
  71. return HttpResponse.json({ filaments: [] });
  72. }),
  73. http.get('/api/v1/printers/:id/status', () => {
  74. return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] });
  75. }),
  76. http.post('/api/v1/queue/', () => {
  77. return HttpResponse.json({ id: 1, status: 'pending' });
  78. }),
  79. http.patch('/api/v1/queue/:id', () => {
  80. return HttpResponse.json({ id: 1, status: 'pending' });
  81. })
  82. );
  83. });
  84. describe('create mode', () => {
  85. it('renders the modal title', () => {
  86. render(
  87. <PrintModal
  88. mode="create"
  89. archiveId={1}
  90. archiveName="Benchy"
  91. initialSelectedPrinterIds={[1]}
  92. onClose={mockOnClose}
  93. onSuccess={mockOnSuccess}
  94. />
  95. );
  96. expect(screen.getByRole('heading', { name: 'Print' })).toBeInTheDocument();
  97. });
  98. it('shows archive name', () => {
  99. render(
  100. <PrintModal
  101. mode="create"
  102. archiveId={1}
  103. archiveName="Benchy"
  104. onClose={mockOnClose}
  105. onSuccess={mockOnSuccess}
  106. />
  107. );
  108. expect(screen.getByText('Benchy')).toBeInTheDocument();
  109. });
  110. it('shows printer selection with checkboxes for multi-select', async () => {
  111. render(
  112. <PrintModal
  113. mode="create"
  114. archiveId={1}
  115. archiveName="Benchy"
  116. onClose={mockOnClose}
  117. onSuccess={mockOnSuccess}
  118. />
  119. );
  120. await waitFor(() => {
  121. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  122. expect(screen.getByText('P1S')).toBeInTheDocument();
  123. });
  124. });
  125. it('has print button', () => {
  126. render(
  127. <PrintModal
  128. mode="create"
  129. archiveId={1}
  130. archiveName="Benchy"
  131. onClose={mockOnClose}
  132. onSuccess={mockOnSuccess}
  133. />
  134. );
  135. // Get the submit button specifically (not printer selection buttons)
  136. const submitButton = screen.getByRole('button', { name: /^print$/i });
  137. expect(submitButton).toBeInTheDocument();
  138. });
  139. it('has cancel button', () => {
  140. render(
  141. <PrintModal
  142. mode="create"
  143. archiveId={1}
  144. archiveName="Benchy"
  145. onClose={mockOnClose}
  146. onSuccess={mockOnSuccess}
  147. />
  148. );
  149. expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
  150. });
  151. it('calls onClose when cancel is clicked', async () => {
  152. const user = userEvent.setup();
  153. render(
  154. <PrintModal
  155. mode="create"
  156. archiveId={1}
  157. archiveName="Benchy"
  158. onClose={mockOnClose}
  159. onSuccess={mockOnSuccess}
  160. />
  161. );
  162. await user.click(screen.getByRole('button', { name: /cancel/i }));
  163. expect(mockOnClose).toHaveBeenCalled();
  164. });
  165. it('print button is disabled until printer is selected', () => {
  166. render(
  167. <PrintModal
  168. mode="create"
  169. archiveId={1}
  170. archiveName="Benchy"
  171. onClose={mockOnClose}
  172. onSuccess={mockOnSuccess}
  173. />
  174. );
  175. // Get the submit button specifically (not printer selection buttons)
  176. const printButton = screen.getByRole('button', { name: /^print$/i });
  177. expect(printButton).toBeDisabled();
  178. });
  179. it('shows no printers message when none active', async () => {
  180. server.use(
  181. http.get('/api/v1/printers/', () => {
  182. return HttpResponse.json([]);
  183. })
  184. );
  185. render(
  186. <PrintModal
  187. mode="create"
  188. archiveId={1}
  189. archiveName="Benchy"
  190. onClose={mockOnClose}
  191. onSuccess={mockOnSuccess}
  192. />
  193. );
  194. await waitFor(() => {
  195. expect(screen.getByText('No active printers available')).toBeInTheDocument();
  196. });
  197. });
  198. it('shows print options toggle', async () => {
  199. render(
  200. <PrintModal
  201. mode="create"
  202. archiveId={1}
  203. archiveName="Benchy"
  204. initialSelectedPrinterIds={[1]}
  205. onClose={mockOnClose}
  206. onSuccess={mockOnSuccess}
  207. />
  208. );
  209. await waitFor(() => {
  210. expect(screen.getByText('Print Options')).toBeInTheDocument();
  211. });
  212. });
  213. });
  214. describe('create mode', () => {
  215. it('renders the modal title', () => {
  216. render(
  217. <PrintModal
  218. mode="create"
  219. archiveId={1}
  220. archiveName="Test Print"
  221. onClose={mockOnClose}
  222. />
  223. );
  224. expect(screen.getByRole('heading', { name: 'Print' })).toBeInTheDocument();
  225. });
  226. it('shows archive name', () => {
  227. render(
  228. <PrintModal
  229. mode="create"
  230. archiveId={1}
  231. archiveName="Test Print"
  232. onClose={mockOnClose}
  233. />
  234. );
  235. expect(screen.getByText('Test Print')).toBeInTheDocument();
  236. });
  237. it('shows add button', () => {
  238. render(
  239. <PrintModal
  240. mode="create"
  241. archiveId={1}
  242. archiveName="Test Print"
  243. onClose={mockOnClose}
  244. />
  245. );
  246. expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
  247. });
  248. it('shows cancel button', () => {
  249. render(
  250. <PrintModal
  251. mode="create"
  252. archiveId={1}
  253. archiveName="Test Print"
  254. onClose={mockOnClose}
  255. />
  256. );
  257. expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
  258. });
  259. it('shows Queue option', () => {
  260. render(
  261. <PrintModal
  262. mode="create"
  263. archiveId={1}
  264. archiveName="Test Print"
  265. onClose={mockOnClose}
  266. />
  267. );
  268. expect(screen.getByRole('button', { name: /queue/i })).toBeInTheDocument();
  269. });
  270. it('shows power off option', () => {
  271. render(
  272. <PrintModal
  273. mode="create"
  274. archiveId={1}
  275. archiveName="Test Print"
  276. onClose={mockOnClose}
  277. />
  278. );
  279. expect(screen.getByText(/power off/i)).toBeInTheDocument();
  280. });
  281. it('shows schedule options', () => {
  282. render(
  283. <PrintModal
  284. mode="create"
  285. archiveId={1}
  286. archiveName="Test Print"
  287. onClose={mockOnClose}
  288. />
  289. );
  290. expect(screen.getByRole('button', { name: /asap/i })).toBeInTheDocument();
  291. expect(screen.getByRole('button', { name: /queue/i })).toBeInTheDocument();
  292. expect(screen.getByRole('button', { name: /schedule/i })).toBeInTheDocument();
  293. });
  294. it('orders schedule options by time', () => {
  295. render(
  296. <PrintModal
  297. mode="create"
  298. archiveId={1}
  299. archiveName="Test Print"
  300. onClose={mockOnClose}
  301. />
  302. );
  303. const options = screen.getAllByRole('button', { name: /^(asap|queue|schedule)$/i });
  304. expect(options.map(button => button.textContent?.trim())).toEqual(['ASAP', 'Queue', 'Schedule']);
  305. });
  306. it('calls onClose when cancel is clicked', async () => {
  307. const user = userEvent.setup();
  308. render(
  309. <PrintModal
  310. mode="create"
  311. archiveId={1}
  312. archiveName="Test Print"
  313. onClose={mockOnClose}
  314. />
  315. );
  316. await user.click(screen.getByRole('button', { name: /cancel/i }));
  317. expect(mockOnClose).toHaveBeenCalled();
  318. });
  319. });
  320. describe('edit-queue-item mode', () => {
  321. it('renders the modal title', () => {
  322. const item = createMockQueueItem();
  323. render(
  324. <PrintModal
  325. mode="edit-queue-item"
  326. archiveId={1}
  327. archiveName="Test Print"
  328. queueItem={item}
  329. onClose={mockOnClose}
  330. />
  331. );
  332. expect(screen.getByText('Edit Queue Item')).toBeInTheDocument();
  333. });
  334. it('shows save button', () => {
  335. const item = createMockQueueItem();
  336. render(
  337. <PrintModal
  338. mode="edit-queue-item"
  339. archiveId={1}
  340. archiveName="Test Print"
  341. queueItem={item}
  342. onClose={mockOnClose}
  343. />
  344. );
  345. expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
  346. });
  347. it('shows cancel button', () => {
  348. const item = createMockQueueItem();
  349. render(
  350. <PrintModal
  351. mode="edit-queue-item"
  352. archiveId={1}
  353. archiveName="Test Print"
  354. queueItem={item}
  355. onClose={mockOnClose}
  356. />
  357. );
  358. expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
  359. });
  360. it('shows print options toggle', () => {
  361. const item = createMockQueueItem();
  362. render(
  363. <PrintModal
  364. mode="edit-queue-item"
  365. archiveId={1}
  366. archiveName="Test Print"
  367. queueItem={item}
  368. onClose={mockOnClose}
  369. />
  370. );
  371. expect(screen.getByText('Print Options')).toBeInTheDocument();
  372. });
  373. it('shows Queue option', () => {
  374. const item = createMockQueueItem();
  375. render(
  376. <PrintModal
  377. mode="edit-queue-item"
  378. archiveId={1}
  379. archiveName="Test Print"
  380. queueItem={item}
  381. onClose={mockOnClose}
  382. />
  383. );
  384. expect(screen.getByRole('button', { name: /queue/i })).toBeInTheDocument();
  385. });
  386. it('shows power off option', () => {
  387. const item = createMockQueueItem();
  388. render(
  389. <PrintModal
  390. mode="edit-queue-item"
  391. archiveId={1}
  392. archiveName="Test Print"
  393. queueItem={item}
  394. onClose={mockOnClose}
  395. />
  396. );
  397. expect(screen.getByText(/power off/i)).toBeInTheDocument();
  398. });
  399. it('calls onClose when cancel button is clicked', async () => {
  400. const user = userEvent.setup();
  401. const item = createMockQueueItem();
  402. render(
  403. <PrintModal
  404. mode="edit-queue-item"
  405. archiveId={1}
  406. archiveName="Test Print"
  407. queueItem={item}
  408. onClose={mockOnClose}
  409. />
  410. );
  411. const cancelButton = screen.getByRole('button', { name: /cancel/i });
  412. await user.click(cancelButton);
  413. expect(mockOnClose).toHaveBeenCalled();
  414. });
  415. it('shows printer selector for single selection', async () => {
  416. const item = createMockQueueItem();
  417. render(
  418. <PrintModal
  419. mode="edit-queue-item"
  420. archiveId={1}
  421. archiveName="Test Print"
  422. queueItem={item}
  423. onClose={mockOnClose}
  424. />
  425. );
  426. // PrinterSelector shows printer names directly
  427. await waitFor(() => {
  428. expect(screen.getByText('P1S')).toBeInTheDocument();
  429. });
  430. });
  431. });
  432. describe('multi-printer selection', () => {
  433. it('shows select all button when multiple printers available', async () => {
  434. render(
  435. <PrintModal
  436. mode="create"
  437. archiveId={1}
  438. archiveName="Benchy"
  439. onClose={mockOnClose}
  440. />
  441. );
  442. await waitFor(() => {
  443. expect(screen.getByText('Select all')).toBeInTheDocument();
  444. });
  445. });
  446. it('shows selected count when multiple printers selected', async () => {
  447. const user = userEvent.setup();
  448. render(
  449. <PrintModal
  450. mode="create"
  451. archiveId={1}
  452. archiveName="Benchy"
  453. onClose={mockOnClose}
  454. />
  455. );
  456. await waitFor(() => {
  457. expect(screen.getByText('Select all')).toBeInTheDocument();
  458. });
  459. await user.click(screen.getByText('Select all'));
  460. await waitFor(() => {
  461. expect(screen.getByText(/3 printers selected/)).toBeInTheDocument();
  462. });
  463. });
  464. it('updates button text when multiple printers selected', async () => {
  465. const user = userEvent.setup();
  466. render(
  467. <PrintModal
  468. mode="create"
  469. archiveId={1}
  470. archiveName="Benchy"
  471. onClose={mockOnClose}
  472. />
  473. );
  474. await waitFor(() => {
  475. expect(screen.getByText('Select all')).toBeInTheDocument();
  476. });
  477. await user.click(screen.getByText('Select all'));
  478. await waitFor(() => {
  479. expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
  480. });
  481. });
  482. });
  483. describe('busy printer handling (#622)', () => {
  484. beforeEach(() => {
  485. // Set up per-printer statuses: printer 1 RUNNING, printer 2 IDLE, printer 3 FINISH
  486. server.use(
  487. http.get('/api/v1/printers/:id/status', ({ params }) => {
  488. const id = Number(params.id);
  489. if (id === 1) {
  490. return HttpResponse.json({
  491. connected: true, state: 'RUNNING', stg_cur_name: null,
  492. ams: [], vt_tray: [], nozzles: [],
  493. });
  494. }
  495. if (id === 2) {
  496. return HttpResponse.json({
  497. connected: true, state: 'IDLE', stg_cur_name: null,
  498. ams: [], vt_tray: [], nozzles: [],
  499. });
  500. }
  501. // printer 3
  502. return HttpResponse.json({
  503. connected: true, state: 'FINISH', stg_cur_name: null,
  504. ams: [], vt_tray: [], nozzles: [],
  505. });
  506. })
  507. );
  508. });
  509. it('shows state badges on printers in create mode', async () => {
  510. render(
  511. <PrintModal
  512. mode="create"
  513. archiveId={1}
  514. archiveName="Benchy"
  515. onClose={mockOnClose}
  516. />
  517. );
  518. await waitFor(() => {
  519. expect(screen.getByText('Printing')).toBeInTheDocument();
  520. expect(screen.getByText('Idle')).toBeInTheDocument();
  521. expect(screen.getByText('Finished')).toBeInTheDocument();
  522. });
  523. });
  524. it('allows selecting a busy printer in create mode', async () => {
  525. const user = userEvent.setup();
  526. render(
  527. <PrintModal
  528. mode="create"
  529. archiveId={1}
  530. archiveName="Benchy"
  531. onClose={mockOnClose}
  532. />
  533. );
  534. await waitFor(() => {
  535. expect(screen.getByText('Printing')).toBeInTheDocument();
  536. });
  537. const busyButton = screen.getByText('X1 Carbon').closest('button');
  538. expect(busyButton).not.toBeDisabled();
  539. await user.click(busyButton!);
  540. await waitFor(() => {
  541. expect(screen.getByText('1 printer selected')).toBeInTheDocument();
  542. });
  543. });
  544. it('select all includes busy printers in create mode', async () => {
  545. const user = userEvent.setup();
  546. render(
  547. <PrintModal
  548. mode="create"
  549. archiveId={1}
  550. archiveName="Benchy"
  551. onClose={mockOnClose}
  552. />
  553. );
  554. await waitFor(() => {
  555. expect(screen.getByText('Select all')).toBeInTheDocument();
  556. expect(screen.getByText('Printing')).toBeInTheDocument();
  557. });
  558. await user.click(screen.getByText('Select all'));
  559. await waitFor(() => {
  560. expect(screen.getByText(/3 printers selected/)).toBeInTheDocument();
  561. });
  562. });
  563. it('allows selecting busy printers in create mode', async () => {
  564. const user = userEvent.setup();
  565. render(
  566. <PrintModal
  567. mode="create"
  568. archiveId={1}
  569. archiveName="Benchy"
  570. onClose={mockOnClose}
  571. />
  572. );
  573. await waitFor(() => {
  574. expect(screen.getByText('Printing')).toBeInTheDocument();
  575. });
  576. // The busy printer button should NOT be disabled in queue mode
  577. const busyButton = screen.getByText('X1 Carbon').closest('button');
  578. expect(busyButton).not.toBeDisabled();
  579. await user.click(busyButton!);
  580. await waitFor(() => {
  581. expect(screen.getByText('1 printer selected')).toBeInTheDocument();
  582. });
  583. });
  584. it('shows Offline badge for disconnected printers', async () => {
  585. server.use(
  586. http.get('/api/v1/printers/:id/status', () => {
  587. return HttpResponse.json({
  588. connected: false, state: null, stg_cur_name: null,
  589. ams: [], vt_tray: [], nozzles: [],
  590. });
  591. })
  592. );
  593. render(
  594. <PrintModal
  595. mode="create"
  596. archiveId={1}
  597. archiveName="Benchy"
  598. onClose={mockOnClose}
  599. />
  600. );
  601. await waitFor(() => {
  602. const offlineBadges = screen.getAllByText('Offline');
  603. expect(offlineBadges.length).toBeGreaterThanOrEqual(1);
  604. });
  605. });
  606. it('shows calibration stage name when printer is calibrating', async () => {
  607. server.use(
  608. http.get('/api/v1/printers/:id/status', () => {
  609. return HttpResponse.json({
  610. connected: true, state: 'RUNNING', stg_cur_name: 'Auto bed leveling',
  611. ams: [], vt_tray: [], nozzles: [],
  612. });
  613. })
  614. );
  615. render(
  616. <PrintModal
  617. mode="create"
  618. archiveId={1}
  619. archiveName="Benchy"
  620. onClose={mockOnClose}
  621. />
  622. );
  623. await waitFor(() => {
  624. const badges = screen.getAllByText('Auto bed leveling');
  625. expect(badges.length).toBeGreaterThanOrEqual(1);
  626. });
  627. });
  628. });
  629. describe('stagger start', () => {
  630. it('does not show stagger option with single printer in queue mode', async () => {
  631. const user = userEvent.setup();
  632. render(
  633. <PrintModal
  634. mode="create"
  635. archiveId={1}
  636. archiveName="Test Print"
  637. onClose={mockOnClose}
  638. />
  639. );
  640. await waitFor(() => {
  641. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  642. });
  643. // Select single printer
  644. await user.click(screen.getByText('X1 Carbon'));
  645. expect(screen.queryByText('Stagger printer starts')).not.toBeInTheDocument();
  646. });
  647. it('shows stagger option when multiple printers selected in queue mode', async () => {
  648. const user = userEvent.setup();
  649. render(
  650. <PrintModal
  651. mode="create"
  652. archiveId={1}
  653. archiveName="Test Print"
  654. onClose={mockOnClose}
  655. />
  656. );
  657. await waitFor(() => {
  658. expect(screen.getByText('Select all')).toBeInTheDocument();
  659. });
  660. await user.click(screen.getByText('Select all'));
  661. await waitFor(() => {
  662. expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();
  663. });
  664. });
  665. it('shows stagger option in create mode with multiple printers', async () => {
  666. const user = userEvent.setup();
  667. render(
  668. <PrintModal
  669. mode="create"
  670. archiveId={1}
  671. archiveName="Test Print"
  672. onClose={mockOnClose}
  673. />
  674. );
  675. await waitFor(() => {
  676. expect(screen.getByText('Select all')).toBeInTheDocument();
  677. });
  678. await user.click(screen.getByText('Select all'));
  679. await waitFor(() => {
  680. expect(screen.getByText(/2 printers selected|3 printers selected/)).toBeInTheDocument();
  681. });
  682. expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();
  683. });
  684. it('shows stagger preview in create mode when enabled', async () => {
  685. const user = userEvent.setup();
  686. render(
  687. <PrintModal
  688. mode="create"
  689. archiveId={1}
  690. archiveName="Test Print"
  691. onClose={mockOnClose}
  692. />
  693. );
  694. await waitFor(() => {
  695. expect(screen.getByText('Select all')).toBeInTheDocument();
  696. });
  697. await user.click(screen.getByText('Select all'));
  698. await waitFor(() => {
  699. expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();
  700. });
  701. await user.click(screen.getByLabelText('Stagger printer starts'));
  702. await waitFor(() => {
  703. // Default: 3 printers, group size 2 = 2 groups — preview text shown
  704. expect(screen.getByText(/3 printers.*2 groups/)).toBeInTheDocument();
  705. });
  706. });
  707. it('does not show stagger option in create mode with single printer', async () => {
  708. const user = userEvent.setup();
  709. render(
  710. <PrintModal
  711. mode="create"
  712. archiveId={1}
  713. archiveName="Test Print"
  714. onClose={mockOnClose}
  715. />
  716. );
  717. await waitFor(() => {
  718. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  719. });
  720. // Select only one printer
  721. await user.click(screen.getByText('X1 Carbon'));
  722. expect(screen.queryByText('Stagger printer starts')).not.toBeInTheDocument();
  723. });
  724. it('shows stagger inputs when stagger checkbox is enabled', async () => {
  725. const user = userEvent.setup();
  726. render(
  727. <PrintModal
  728. mode="create"
  729. archiveId={1}
  730. archiveName="Test Print"
  731. onClose={mockOnClose}
  732. />
  733. );
  734. await waitFor(() => {
  735. expect(screen.getByText('Select all')).toBeInTheDocument();
  736. });
  737. await user.click(screen.getByText('Select all'));
  738. await waitFor(() => {
  739. expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();
  740. });
  741. await user.click(screen.getByLabelText('Stagger printer starts'));
  742. await waitFor(() => {
  743. expect(screen.getByText('Group size')).toBeInTheDocument();
  744. expect(screen.getByText('Interval (min)')).toBeInTheDocument();
  745. });
  746. });
  747. it('shows stagger preview with printer count', async () => {
  748. const user = userEvent.setup();
  749. render(
  750. <PrintModal
  751. mode="create"
  752. archiveId={1}
  753. archiveName="Test Print"
  754. onClose={mockOnClose}
  755. />
  756. );
  757. await waitFor(() => {
  758. expect(screen.getByText('Select all')).toBeInTheDocument();
  759. });
  760. await user.click(screen.getByText('Select all'));
  761. await waitFor(() => {
  762. expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();
  763. });
  764. await user.click(screen.getByLabelText('Stagger printer starts'));
  765. await waitFor(() => {
  766. // Default: 3 printers, group size 2 = 2 groups — preview text includes printer count
  767. expect(screen.getByText(/3 printers.*2 groups/)).toBeInTheDocument();
  768. });
  769. });
  770. });
  771. describe('multi-plate selection', () => {
  772. const multiPlateResponse = {
  773. is_multi_plate: true,
  774. plates: [
  775. { 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 },
  776. { 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 },
  777. { 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 },
  778. ],
  779. };
  780. beforeEach(() => {
  781. server.use(
  782. http.get('/api/v1/archives/:id/plates', () => {
  783. return HttpResponse.json(multiPlateResponse);
  784. }),
  785. );
  786. });
  787. it('shows "Select All" button only in create mode', async () => {
  788. render(
  789. <PrintModal
  790. mode="create"
  791. archiveId={1}
  792. archiveName="MultiPlate.3mf"
  793. onClose={mockOnClose}
  794. />
  795. );
  796. await waitFor(() => {
  797. expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();
  798. });
  799. });
  800. it('shows "Select All" button in create mode', async () => {
  801. render(
  802. <PrintModal
  803. mode="create"
  804. archiveId={1}
  805. archiveName="MultiPlate.3mf"
  806. initialSelectedPrinterIds={[1]}
  807. onClose={mockOnClose}
  808. />
  809. );
  810. await waitFor(() => {
  811. expect(screen.getByText('Plate 1')).toBeInTheDocument();
  812. });
  813. expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();
  814. });
  815. it('selects all plates when "Select All" is clicked', async () => {
  816. const user = userEvent.setup();
  817. render(
  818. <PrintModal
  819. mode="create"
  820. archiveId={1}
  821. archiveName="MultiPlate.3mf"
  822. onClose={mockOnClose}
  823. />
  824. );
  825. await waitFor(() => {
  826. expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();
  827. });
  828. await user.click(screen.getByText('Select All 3 Plates'));
  829. // All plates should be highlighted (green border)
  830. await waitFor(() => {
  831. const plateButtons = document.querySelectorAll('button[type="button"].border-bambu-green');
  832. // 3 plate buttons + the "Deselect All" toggle button = 4 green-bordered buttons
  833. expect(plateButtons.length).toBeGreaterThanOrEqual(3);
  834. });
  835. });
  836. it('allows selecting a subset of plates to queue', async () => {
  837. const queueRequests: unknown[] = [];
  838. server.use(
  839. http.post('/api/v1/queue/', async ({ request }) => {
  840. const body = await request.json();
  841. queueRequests.push(body);
  842. return HttpResponse.json({ id: queueRequests.length, status: 'pending' });
  843. }),
  844. );
  845. const user = userEvent.setup();
  846. render(
  847. <PrintModal
  848. mode="create"
  849. archiveId={1}
  850. archiveName="MultiPlate.3mf"
  851. onClose={mockOnClose}
  852. onSuccess={mockOnSuccess}
  853. />
  854. );
  855. // Wait for plates and select a printer
  856. await waitFor(() => {
  857. expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();
  858. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  859. });
  860. // Select printer
  861. await user.click(screen.getByText('X1 Carbon'));
  862. // Plate 1 is auto-selected. Click Plate 3 to add it (multi-select in create mode)
  863. await user.click(screen.getByText('Plate 3'));
  864. // Submit — should queue plates 1 and 3
  865. const submitButton = document.querySelector('button[type="submit"]') as HTMLElement;
  866. await user.click(submitButton);
  867. await waitFor(() => {
  868. expect(queueRequests.length).toBe(2);
  869. });
  870. expect((queueRequests[0] as { plate_id: number }).plate_id).toBe(1);
  871. expect((queueRequests[1] as { plate_id: number }).plate_id).toBe(3);
  872. });
  873. it('creates one queue item per plate when submitting with select-all', async () => {
  874. const queueRequests: unknown[] = [];
  875. server.use(
  876. http.post('/api/v1/queue/', async ({ request }) => {
  877. const body = await request.json();
  878. queueRequests.push(body);
  879. return HttpResponse.json({ id: queueRequests.length, status: 'pending' });
  880. }),
  881. );
  882. const user = userEvent.setup();
  883. render(
  884. <PrintModal
  885. mode="create"
  886. archiveId={1}
  887. archiveName="MultiPlate.3mf"
  888. onClose={mockOnClose}
  889. onSuccess={mockOnSuccess}
  890. />
  891. );
  892. // Wait for plates and select a printer
  893. await waitFor(() => {
  894. expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();
  895. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  896. });
  897. // Select printer
  898. await user.click(screen.getByText('X1 Carbon'));
  899. // Click select all
  900. await user.click(screen.getByText('Select All 3 Plates'));
  901. // Find the submit button (type="submit") — distinct from the toggle button (type="button")
  902. const submitButton = document.querySelector('button[type="submit"]') as HTMLElement;
  903. await user.click(submitButton);
  904. await waitFor(() => {
  905. expect(queueRequests.length).toBe(3);
  906. });
  907. // Verify each request has the correct plate_id
  908. expect((queueRequests[0] as { plate_id: number }).plate_id).toBe(1);
  909. expect((queueRequests[1] as { plate_id: number }).plate_id).toBe(2);
  910. expect((queueRequests[2] as { plate_id: number }).plate_id).toBe(3);
  911. });
  912. });
  913. describe('batch quantity', () => {
  914. it('shows quantity input in create mode', () => {
  915. render(
  916. <PrintModal
  917. mode="create"
  918. archiveId={1}
  919. archiveName="Benchy"
  920. onClose={mockOnClose}
  921. onSuccess={mockOnSuccess}
  922. />
  923. );
  924. expect(screen.getByLabelText('Quantity')).toBeInTheDocument();
  925. });
  926. it('shows quantity input in create mode', () => {
  927. render(
  928. <PrintModal
  929. mode="create"
  930. archiveId={1}
  931. archiveName="Benchy"
  932. onClose={mockOnClose}
  933. onSuccess={mockOnSuccess}
  934. />
  935. );
  936. expect(screen.getByLabelText('Quantity')).toBeInTheDocument();
  937. });
  938. it('does not show quantity input in edit-queue-item mode', () => {
  939. render(
  940. <PrintModal
  941. mode="edit-queue-item"
  942. archiveId={1}
  943. archiveName="Benchy"
  944. queueItem={createMockQueueItem()}
  945. onClose={mockOnClose}
  946. onSuccess={mockOnSuccess}
  947. />
  948. );
  949. expect(screen.queryByLabelText('Quantity')).not.toBeInTheDocument();
  950. });
  951. it('defaults quantity to 1', () => {
  952. render(
  953. <PrintModal
  954. mode="create"
  955. archiveId={1}
  956. archiveName="Benchy"
  957. onClose={mockOnClose}
  958. onSuccess={mockOnSuccess}
  959. />
  960. );
  961. const input = screen.getByLabelText('Quantity') as HTMLInputElement;
  962. expect(input.value).toBe('1');
  963. });
  964. it('quantity input has default value of 1 and accepts changes', async () => {
  965. const user = userEvent.setup();
  966. render(
  967. <PrintModal
  968. mode="create"
  969. archiveId={1}
  970. archiveName="Benchy"
  971. initialSelectedPrinterIds={[1]}
  972. onClose={mockOnClose}
  973. onSuccess={mockOnSuccess}
  974. />
  975. );
  976. const input = screen.getByLabelText('Quantity') as HTMLInputElement;
  977. expect(input.value).toBe('1');
  978. await user.tripleClick(input);
  979. await user.keyboard('5');
  980. expect(input.value).toBe('5');
  981. });
  982. });
  983. describe('reprint G-code injection dispatch (#422 / auto-eject)', () => {
  984. // Guards the fix: when "Inject auto-print G-code" is ticked on a create with
  985. // quantity > 1, ALL copies must go through the queue so every one is injected by
  986. // the scheduler. The first copy must NOT be dispatched immediately via the direct
  987. // create path — that path bypasses injection and would leave the first copy stuck
  988. // on the plate for auto-eject setups.
  989. const withSnippets = () =>
  990. http.get('/api/v1/settings/', () =>
  991. HttpResponse.json({ gcode_snippets: { X1C: { start_gcode: '', end_gcode: 'M400' } } }),
  992. );
  993. it('injection ON queues all copies and dispatches none immediately', async () => {
  994. const queueCalls: Record<string, unknown>[] = [];
  995. server.use(
  996. withSnippets(),
  997. http.post('/api/v1/queue/', async ({ request }) => {
  998. queueCalls.push((await request.json()) as Record<string, unknown>);
  999. return HttpResponse.json({ id: queueCalls.length, status: 'pending' });
  1000. }),
  1001. );
  1002. const user = userEvent.setup();
  1003. render(
  1004. <PrintModal
  1005. mode="create"
  1006. archiveId={1}
  1007. archiveName="Benchy"
  1008. initialSelectedPrinterIds={[1]}
  1009. onClose={mockOnClose}
  1010. onSuccess={mockOnSuccess}
  1011. />
  1012. );
  1013. // Quantity > 1 so the injection checkbox is offered
  1014. const qty = (await screen.findByLabelText('Quantity')) as HTMLInputElement;
  1015. await user.tripleClick(qty);
  1016. await user.keyboard('3');
  1017. expect(qty.value).toBe('3');
  1018. // Checkbox only renders once snippets are loaded AND quantity > 1
  1019. await user.click(await screen.findByLabelText(/inject auto-print/i));
  1020. await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
  1021. await waitFor(() => expect(queueCalls.length).toBe(1));
  1022. // One queue item carrying all copies, and zero immediate reprint dispatches
  1023. expect(queueCalls[0].quantity).toBe(3);
  1024. });
  1025. it('quantity 1 + snippets configured: checkbox toggles cleanly (#1852)', async () => {
  1026. // The previous reset effect in PrintModal/index.tsx silently flipped
  1027. // gcodeInjection back to false whenever effectiveQuantity <= 1 in create
  1028. // mode, so a single print with snippets configured couldn't tick the
  1029. // checkbox — every click visibly un-ticked itself within the next render.
  1030. // The scheduler reads item.gcode_injection per queue item regardless of
  1031. // batch size, so quantity 1 must be allowed too.
  1032. const queueCalls: Record<string, unknown>[] = [];
  1033. server.use(
  1034. withSnippets(),
  1035. http.post('/api/v1/queue/', async ({ request }) => {
  1036. queueCalls.push((await request.json()) as Record<string, unknown>);
  1037. return HttpResponse.json({ id: queueCalls.length, status: 'pending' });
  1038. }),
  1039. );
  1040. const user = userEvent.setup();
  1041. render(
  1042. <PrintModal
  1043. mode="create"
  1044. archiveId={1}
  1045. archiveName="Benchy"
  1046. initialSelectedPrinterIds={[1]}
  1047. onClose={mockOnClose}
  1048. onSuccess={mockOnSuccess}
  1049. />
  1050. );
  1051. // Quantity stays at default (1) — this is the OP's exact scenario.
  1052. const checkbox = (await screen.findByLabelText(/inject auto-print/i)) as HTMLInputElement;
  1053. expect(checkbox.checked).toBe(false);
  1054. await user.click(checkbox);
  1055. // CRITICAL: after the click the checkbox must stay checked. Under the
  1056. // pre-fix reset effect, the parent state would flip back to false within
  1057. // a render and the displayed `checked` attribute would track it.
  1058. await waitFor(() => expect(checkbox.checked).toBe(true));
  1059. await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
  1060. await waitFor(() => expect(queueCalls.length).toBe(1));
  1061. // The injection flag actually reaches the API — pre-fix the parent reset
  1062. // would have stripped it before submit.
  1063. expect(queueCalls[0].gcode_injection).toBe(true);
  1064. });
  1065. it('injection OFF queues all copies through the scheduler path', async () => {
  1066. const queueCalls: Record<string, unknown>[] = [];
  1067. server.use(
  1068. withSnippets(),
  1069. http.post('/api/v1/queue/', async ({ request }) => {
  1070. queueCalls.push((await request.json()) as Record<string, unknown>);
  1071. return HttpResponse.json({ id: queueCalls.length, status: 'pending' });
  1072. }),
  1073. );
  1074. const user = userEvent.setup();
  1075. render(
  1076. <PrintModal
  1077. mode="create"
  1078. archiveId={1}
  1079. archiveName="Benchy"
  1080. initialSelectedPrinterIds={[1]}
  1081. onClose={mockOnClose}
  1082. onSuccess={mockOnSuccess}
  1083. />
  1084. );
  1085. const qty = (await screen.findByLabelText('Quantity')) as HTMLInputElement;
  1086. await user.tripleClick(qty);
  1087. await user.keyboard('3');
  1088. expect(qty.value).toBe('3');
  1089. // Leave injection unticked: unified dispatch still queues all copies.
  1090. await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
  1091. await waitFor(() => expect(queueCalls.length).toBe(1));
  1092. expect(queueCalls[0].quantity).toBe(3);
  1093. });
  1094. });
  1095. describe('project_id forwarding', () => {
  1096. beforeEach(() => {
  1097. // Additional handlers needed for library file mode
  1098. server.use(
  1099. http.get('/api/v1/library/files/:id', () => {
  1100. return HttpResponse.json({
  1101. id: 5,
  1102. filename: 'benchy.gcode.3mf',
  1103. print_name: null,
  1104. file_type: '3mf',
  1105. folder_id: null,
  1106. project_id: null,
  1107. file_hash: null,
  1108. file_size_bytes: 1024,
  1109. thumbnail_path: null,
  1110. created_at: '2024-01-01T00:00:00Z',
  1111. updated_at: '2024-01-01T00:00:00Z',
  1112. });
  1113. }),
  1114. http.get('/api/v1/library/files/:id/plates', () => {
  1115. return HttpResponse.json({ is_multi_plate: false, plates: [] });
  1116. }),
  1117. http.get('/api/v1/library/files/:id/filament-requirements', () => {
  1118. return HttpResponse.json({ file_id: 5, filename: 'benchy.gcode.3mf', filaments: [] });
  1119. }),
  1120. http.get('/api/v1/printers/:id/status', () => {
  1121. return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] });
  1122. }),
  1123. );
  1124. });
  1125. it('includes project_id in queue item when printing a library file with projectId set', async () => {
  1126. let capturedBody: Record<string, unknown> | null = null;
  1127. server.use(
  1128. http.post('/api/v1/queue/', async ({ request }) => {
  1129. capturedBody = await request.json() as Record<string, unknown>;
  1130. return HttpResponse.json({ id: 1, status: 'pending' });
  1131. })
  1132. );
  1133. const user = userEvent.setup();
  1134. render(
  1135. <PrintModal
  1136. mode="create"
  1137. libraryFileId={5}
  1138. archiveName="Benchy"
  1139. projectId={42}
  1140. initialSelectedPrinterIds={[1]}
  1141. onClose={mockOnClose}
  1142. onSuccess={mockOnSuccess}
  1143. />
  1144. );
  1145. // Wait for the modal to load printer and file data
  1146. await waitFor(() => {
  1147. expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
  1148. });
  1149. await user.click(screen.getByRole('button', { name: /^print$/i }));
  1150. await waitFor(() => {
  1151. expect(capturedBody).not.toBeNull();
  1152. expect(capturedBody?.library_file_id).toBe(5);
  1153. expect(capturedBody?.project_id).toBe(42);
  1154. });
  1155. });
  1156. it('queues archive prints through the scheduler path', async () => {
  1157. let capturedBody: Record<string, unknown> | null = null;
  1158. server.use(
  1159. http.post('/api/v1/queue/', async ({ request }) => {
  1160. capturedBody = await request.json() as Record<string, unknown>;
  1161. return HttpResponse.json({ id: 1, status: 'pending' });
  1162. })
  1163. );
  1164. const user = userEvent.setup();
  1165. render(
  1166. <PrintModal
  1167. mode="create"
  1168. archiveId={1}
  1169. archiveName="Benchy"
  1170. projectId={42}
  1171. initialSelectedPrinterIds={[1]}
  1172. onClose={mockOnClose}
  1173. onSuccess={mockOnSuccess}
  1174. />
  1175. );
  1176. await waitFor(() => {
  1177. expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
  1178. });
  1179. await user.click(screen.getByRole('button', { name: /^print$/i }));
  1180. await waitFor(() => {
  1181. expect(capturedBody).not.toBeNull();
  1182. expect(capturedBody?.archive_id).toBe(1);
  1183. expect(capturedBody?.project_id).toBe(42);
  1184. });
  1185. });
  1186. it('adds ASAP prints to the top of the queue', async () => {
  1187. let capturedBody: Record<string, unknown> | null = null;
  1188. server.use(
  1189. http.post('/api/v1/queue/', async ({ request }) => {
  1190. capturedBody = await request.json() as Record<string, unknown>;
  1191. return HttpResponse.json({ id: 1, status: 'pending' });
  1192. })
  1193. );
  1194. const user = userEvent.setup();
  1195. render(
  1196. <PrintModal
  1197. mode="create"
  1198. archiveId={1}
  1199. archiveName="Benchy"
  1200. initialSelectedPrinterIds={[1]}
  1201. onClose={mockOnClose}
  1202. onSuccess={mockOnSuccess}
  1203. />
  1204. );
  1205. await waitFor(() => {
  1206. expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
  1207. });
  1208. await user.click(screen.getByRole('button', { name: /^print$/i }));
  1209. await waitFor(() => {
  1210. expect(capturedBody).not.toBeNull();
  1211. expect(capturedBody?.insert_at_top).toBe(true);
  1212. expect(capturedBody?.insert_position).toBe(1);
  1213. expect(capturedBody?.manual_start).toBe(false);
  1214. expect(capturedBody?.scheduled_time).toBeUndefined();
  1215. });
  1216. });
  1217. it('adds Queue prints to the back unless manual start is required', async () => {
  1218. let capturedBody: Record<string, unknown> | null = null;
  1219. server.use(
  1220. http.post('/api/v1/queue/', async ({ request }) => {
  1221. capturedBody = await request.json() as Record<string, unknown>;
  1222. return HttpResponse.json({ id: 1, status: 'pending' });
  1223. })
  1224. );
  1225. const user = userEvent.setup();
  1226. render(
  1227. <PrintModal
  1228. mode="create"
  1229. archiveId={1}
  1230. archiveName="Benchy"
  1231. initialSelectedPrinterIds={[1]}
  1232. onClose={mockOnClose}
  1233. onSuccess={mockOnSuccess}
  1234. />
  1235. );
  1236. await user.click(screen.getByRole('button', { name: /^queue$/i }));
  1237. expect(screen.getByLabelText(/require manual start/i)).toBeInTheDocument();
  1238. await user.click(screen.getByLabelText(/require manual start/i));
  1239. await user.click(screen.getByRole('button', { name: /^print$/i }));
  1240. await waitFor(() => {
  1241. expect(capturedBody).not.toBeNull();
  1242. expect(capturedBody?.insert_at_top).toBeUndefined();
  1243. expect(capturedBody?.insert_position).toBeUndefined();
  1244. expect(capturedBody?.manual_start).toBe(true);
  1245. });
  1246. });
  1247. });
  1248. describe('cleanup_library_after_dispatch forwarding (#730)', () => {
  1249. // The Printers-page Direct-Print flow passes cleanupLibraryAfterDispatch so the
  1250. // transient LibraryFile created by FileUploadModal is deleted once the archive
  1251. // owns its own copy. File Manager / Project Detail flows leave the prop unset so
  1252. // their deliberately-added library entries survive the print.
  1253. beforeEach(() => {
  1254. server.use(
  1255. http.get('/api/v1/library/files/:id', () => {
  1256. return HttpResponse.json({
  1257. id: 5,
  1258. filename: 'benchy.gcode.3mf',
  1259. file_type: '3mf',
  1260. folder_id: null,
  1261. project_id: null,
  1262. file_hash: null,
  1263. file_size_bytes: 1024,
  1264. thumbnail_path: null,
  1265. created_at: '2024-01-01T00:00:00Z',
  1266. updated_at: '2024-01-01T00:00:00Z',
  1267. });
  1268. }),
  1269. http.get('/api/v1/library/files/:id/plates', () => {
  1270. return HttpResponse.json({ is_multi_plate: false, plates: [] });
  1271. }),
  1272. http.get('/api/v1/library/files/:id/filament-requirements', () => {
  1273. return HttpResponse.json({ file_id: 5, filename: 'benchy.gcode.3mf', filaments: [] });
  1274. }),
  1275. http.get('/api/v1/printers/:id/status', () => {
  1276. return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] });
  1277. }),
  1278. );
  1279. });
  1280. it('forwards cleanup_library_after_dispatch=true when the Direct-Print prop is set', async () => {
  1281. let capturedBody: Record<string, unknown> | null = null;
  1282. server.use(
  1283. http.post('/api/v1/queue/', async ({ request }) => {
  1284. capturedBody = (await request.json()) as Record<string, unknown>;
  1285. return HttpResponse.json({ id: 1, status: 'pending' });
  1286. })
  1287. );
  1288. const user = userEvent.setup();
  1289. render(
  1290. <PrintModal
  1291. mode="create"
  1292. libraryFileId={5}
  1293. archiveName="Benchy"
  1294. cleanupLibraryAfterDispatch
  1295. initialSelectedPrinterIds={[1]}
  1296. onClose={mockOnClose}
  1297. onSuccess={mockOnSuccess}
  1298. />
  1299. );
  1300. await waitFor(() => {
  1301. expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
  1302. });
  1303. await user.click(screen.getByRole('button', { name: /^print$/i }));
  1304. await waitFor(() => {
  1305. expect(capturedBody).not.toBeNull();
  1306. expect(capturedBody?.cleanup_library_after_dispatch).toBe(true);
  1307. });
  1308. });
  1309. it('defaults to omitting cleanup_library_after_dispatch (File Manager / Project flows survive)', async () => {
  1310. let capturedBody: Record<string, unknown> | null = null;
  1311. server.use(
  1312. http.post('/api/v1/queue/', async ({ request }) => {
  1313. capturedBody = (await request.json()) as Record<string, unknown>;
  1314. return HttpResponse.json({ id: 1, status: 'pending' });
  1315. })
  1316. );
  1317. const user = userEvent.setup();
  1318. render(
  1319. <PrintModal
  1320. mode="create"
  1321. libraryFileId={5}
  1322. archiveName="Benchy"
  1323. initialSelectedPrinterIds={[1]}
  1324. onClose={mockOnClose}
  1325. onSuccess={mockOnSuccess}
  1326. />
  1327. );
  1328. await waitFor(() => {
  1329. expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
  1330. });
  1331. await user.click(screen.getByRole('button', { name: /^print$/i }));
  1332. await waitFor(() => {
  1333. expect(capturedBody).not.toBeNull();
  1334. });
  1335. // Either omitted entirely or explicitly undefined — both interpret as "keep file"
  1336. expect(capturedBody?.cleanup_library_after_dispatch).toBeUndefined();
  1337. });
  1338. });
  1339. });
  1340. describe('PrintModal — per-plate filament mapping (#2551 follow-up)', () => {
  1341. const mockOnClose = vi.fn();
  1342. // These tests deliberately do NOT use the shared `render` from '../utils': its
  1343. // QueryClient sets `gcTime: 0`, which evicts a query the instant it loses its
  1344. // observer. The whole-file filament requirements are fetched on open and then
  1345. // orphaned when a plate is auto-selected, so under gcTime:0 they vanish and the
  1346. // modal simply sends no mapping — the bug cannot reproduce. A real browser keeps
  1347. // that entry for 5 minutes and hands the union straight back when the user picks
  1348. // a second plate, which is what got mapped onto every plate. Same providers,
  1349. // production cache behaviour.
  1350. const render = (ui: React.ReactElement) => {
  1351. const queryClient = new QueryClient({
  1352. defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
  1353. });
  1354. return rtlRender(
  1355. <QueryClientProvider client={queryClient}>
  1356. <BrowserRouter>
  1357. <AuthProvider>
  1358. <ThemeProvider>
  1359. <ToastProvider>{ui}</ToastProvider>
  1360. </ThemeProvider>
  1361. </AuthProvider>
  1362. </BrowserRouter>
  1363. </QueryClientProvider>,
  1364. );
  1365. };
  1366. // Two plates, each printing one red object, but on different slots of the same
  1367. // file. The printer has exactly one red spool (tray 0) and one black (tray 1).
  1368. // Matching the union of both plates makes slot 1 claim the red tray and drops
  1369. // slot 2 onto the black one — so plate 2 would print in the wrong colour.
  1370. const PLATES = {
  1371. is_multi_plate: true,
  1372. plates: [
  1373. { index: 1, name: 'Plate 1', has_thumbnail: false, thumbnail_url: null, objects: ['A'], filaments: [{ type: 'PLA', color: '#FF0000' }], print_time_seconds: 1800, filament_used_grams: 50 },
  1374. { index: 2, name: 'Plate 2', has_thumbnail: false, thumbnail_url: null, objects: ['B'], filaments: [{ type: 'PLA', color: '#FF0000' }], print_time_seconds: 1800, filament_used_grams: 50 },
  1375. ],
  1376. };
  1377. const SLOT_1_RED = { slot_id: 1, type: 'PLA', color: '#FF0000', tray_info_idx: '', used_grams: 50 };
  1378. const SLOT_2_RED = { slot_id: 2, type: 'PLA', color: '#FF0000', tray_info_idx: '', used_grams: 50 };
  1379. beforeEach(() => {
  1380. vi.clearAllMocks();
  1381. server.use(
  1382. http.get('/api/v1/printers/', () => HttpResponse.json(mockPrinters)),
  1383. http.get('/api/v1/archives/:id/plates', () => HttpResponse.json(PLATES)),
  1384. // The endpoint is plate-aware: no plate_id yields the whole-file union.
  1385. http.get('/api/v1/archives/:id/filament-requirements', ({ request }) => {
  1386. const plateId = new URL(request.url).searchParams.get('plate_id');
  1387. if (plateId === '1') return HttpResponse.json({ filaments: [SLOT_1_RED] });
  1388. if (plateId === '2') return HttpResponse.json({ filaments: [SLOT_2_RED] });
  1389. return HttpResponse.json({ filaments: [SLOT_1_RED, SLOT_2_RED] });
  1390. }),
  1391. http.get('/api/v1/printers/:id/status', () =>
  1392. HttpResponse.json({
  1393. connected: true,
  1394. state: 'IDLE',
  1395. ams: [{ id: 0, tray: [
  1396. { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },
  1397. { id: 1, tray_type: 'PLA', tray_color: '000000' },
  1398. ] }],
  1399. vt_tray: [],
  1400. }),
  1401. ),
  1402. http.get('/api/v1/printers/:id/assignments', () => HttpResponse.json([])),
  1403. http.post('/api/v1/queue/', () => HttpResponse.json({ id: 1, status: 'pending' })),
  1404. );
  1405. });
  1406. const selectBothPlates = async (user: ReturnType<typeof userEvent.setup>) => {
  1407. await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());
  1408. await user.click(screen.getByText('X1 Carbon'));
  1409. await waitFor(() => expect(screen.getByText('Plate 2')).toBeInTheDocument());
  1410. await user.click(screen.getByText('Plate 2')); // Plate 1 is auto-selected
  1411. };
  1412. it('shows one mapping panel per selected plate, named after the plate', async () => {
  1413. const user = userEvent.setup();
  1414. render(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
  1415. await selectBothPlates(user);
  1416. await waitFor(() => {
  1417. expect(screen.getByText(/Filament Mapping — Plate 1/)).toBeInTheDocument();
  1418. expect(screen.getByText(/Filament Mapping — Plate 2/)).toBeInTheDocument();
  1419. });
  1420. });
  1421. it('sends each plate the mapping for its own slots, not the whole-file union', async () => {
  1422. const queued: { plate_id: number; ams_mapping: number[] | null }[] = [];
  1423. server.use(
  1424. http.post('/api/v1/queue/', async ({ request }) => {
  1425. queued.push((await request.json()) as { plate_id: number; ams_mapping: number[] | null });
  1426. return HttpResponse.json({ id: queued.length, status: 'pending' });
  1427. }),
  1428. );
  1429. const user = userEvent.setup();
  1430. render(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
  1431. await selectBothPlates(user);
  1432. await waitFor(() => expect(screen.getByText(/Filament Mapping — Plate 2/)).toBeInTheDocument());
  1433. await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
  1434. await waitFor(() => expect(queued.length).toBe(2));
  1435. const plate1 = queued.find((q) => q.plate_id === 1);
  1436. const plate2 = queued.find((q) => q.plate_id === 2);
  1437. // Plate 1 prints slot 1 from the red tray.
  1438. expect(plate1?.ams_mapping).toEqual([0]);
  1439. // Plate 2 prints slot 2 from the SAME red tray — slot 1 is not its business.
  1440. // The union mapping would have been [0, 1], sending this plate to the black tray.
  1441. expect(plate2?.ams_mapping).toEqual([-1, 0]);
  1442. });
  1443. it('sends no mapping when several plates fan out across several printers', async () => {
  1444. // A tray id is meaningless on a different printer, so the first printer's
  1445. // mapping must not be handed to the second. The scheduler maps each plate
  1446. // against the printer it actually dispatches to.
  1447. const queued: { printer_id: number; ams_mapping?: number[] | null }[] = [];
  1448. server.use(
  1449. http.post('/api/v1/queue/', async ({ request }) => {
  1450. queued.push((await request.json()) as { printer_id: number; ams_mapping?: number[] | null });
  1451. return HttpResponse.json({ id: queued.length, status: 'pending' });
  1452. }),
  1453. );
  1454. const user = userEvent.setup();
  1455. render(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
  1456. await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());
  1457. await user.click(screen.getByText('X1 Carbon'));
  1458. await user.click(screen.getByText('P1S'));
  1459. await waitFor(() => expect(screen.getByText('Plate 2')).toBeInTheDocument());
  1460. await user.click(screen.getByText('Plate 2'));
  1461. await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
  1462. await waitFor(() => expect(queued.length).toBe(4)); // 2 plates x 2 printers
  1463. queued.forEach((q) => expect(q.ams_mapping ?? null).toBeNull());
  1464. });
  1465. it('sends no mapping for a model-assigned multi-plate job, leaving it to the scheduler', async () => {
  1466. const queued: { ams_mapping?: number[] | null }[] = [];
  1467. server.use(
  1468. http.post('/api/v1/queue/', async ({ request }) => {
  1469. queued.push((await request.json()) as { ams_mapping?: number[] | null });
  1470. return HttpResponse.json({ id: queued.length, status: 'pending' });
  1471. }),
  1472. );
  1473. const user = userEvent.setup();
  1474. render(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
  1475. // "Any model" mode: no printer is chosen, so there is no AMS to map onto and
  1476. // the scheduler computes the mapping per plate once it picks one.
  1477. await waitFor(() => expect(screen.getByText('Plate 2')).toBeInTheDocument());
  1478. await user.click(screen.getByText('Plate 2'));
  1479. await user.click(screen.getByRole('button', { name: /any model/i }));
  1480. await waitFor(() => expect(screen.getByRole('combobox')).toBeInTheDocument());
  1481. await user.selectOptions(screen.getByRole('combobox'), 'X1C');
  1482. await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
  1483. await waitFor(() => expect(queued.length).toBe(2));
  1484. queued.forEach((q) => expect(q.ams_mapping ?? null).toBeNull());
  1485. });
  1486. it('drops a plate\'s manual tray overrides when the printer changes', async () => {
  1487. // A manual override holds a global tray id, which names a different spool on a
  1488. // different printer. Carrying plate 1's "slot 1 -> tray 1" from the X1C over to
  1489. // the P1S would dispatch a tray the user never picked.
  1490. const queued: { plate_id: number; ams_mapping: number[] | null }[] = [];
  1491. server.use(
  1492. http.post('/api/v1/queue/', async ({ request }) => {
  1493. queued.push((await request.json()) as { plate_id: number; ams_mapping: number[] | null });
  1494. return HttpResponse.json({ id: queued.length, status: 'pending' });
  1495. }),
  1496. );
  1497. const user = userEvent.setup();
  1498. render(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
  1499. await selectBothPlates(user);
  1500. await waitFor(() => expect(screen.getByText(/Filament Mapping — Plate 1/)).toBeInTheDocument());
  1501. // Force plate 1's slot 1 onto the black tray (1) instead of the auto-matched red (0).
  1502. await user.click(screen.getByText(/Filament Mapping — Plate 1/));
  1503. const traySelects = await waitFor(() => {
  1504. const found = screen.getAllByRole('combobox').filter((el) =>
  1505. Array.from((el as HTMLSelectElement).options).some((o) => o.value === '1'),
  1506. );
  1507. if (found.length === 0) throw new Error('no tray select rendered');
  1508. return found;
  1509. });
  1510. await user.selectOptions(traySelects[0], '1');
  1511. // Now move the job to the other printer.
  1512. await user.click(screen.getByText('X1 Carbon')); // deselect
  1513. await user.click(screen.getByText('P1S'));
  1514. await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
  1515. await waitFor(() => expect(queued.length).toBe(2));
  1516. // Plate 1 auto-matches red (tray 0) on the new printer. Tray 1 is the stale pick.
  1517. const plate1 = queued.find((q) => q.plate_id === 1);
  1518. expect(plate1?.ams_mapping).toEqual([0]);
  1519. });
  1520. it('sums what the plates draw from one spool before warning about it', async () => {
  1521. // Both plates map to the same red tray. 60 g left covers either plate on its
  1522. // own (40 g), but not both — the check has to add them up, not test them
  1523. // one at a time.
  1524. server.use(
  1525. http.get('/api/v1/archives/:id/filament-requirements', ({ request }) => {
  1526. const plateId = new URL(request.url).searchParams.get('plate_id');
  1527. if (plateId === '1') return HttpResponse.json({ filaments: [{ ...SLOT_1_RED, used_grams: 40 }] });
  1528. if (plateId === '2') return HttpResponse.json({ filaments: [{ ...SLOT_2_RED, used_grams: 40 }] });
  1529. return HttpResponse.json({ filaments: [SLOT_1_RED, SLOT_2_RED] });
  1530. }),
  1531. http.get('/api/v1/inventory/assignments', () =>
  1532. HttpResponse.json([
  1533. {
  1534. id: 1, spool_id: 1, printer_id: 1, printer_name: 'X1 Carbon', ams_id: 0, tray_id: 0,
  1535. fingerprint_color: null, fingerprint_type: null, configured: true,
  1536. spool: { id: 1, label_weight: 1000, weight_used: 940 },
  1537. },
  1538. ]),
  1539. ),
  1540. );
  1541. const user = userEvent.setup();
  1542. render(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
  1543. await selectBothPlates(user);
  1544. await waitFor(() => expect(screen.getByText(/Filament Mapping — Plate 2/)).toBeInTheDocument());
  1545. await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
  1546. // 80 g needed from a spool with 60 g left → the acknowledgement dialog, not a
  1547. // silent queue.
  1548. await waitFor(() => expect(screen.getByText(/Not enough filament/i)).toBeInTheDocument());
  1549. expect(screen.getByText(/needs 80g, remaining 60g/i)).toBeInTheDocument();
  1550. });
  1551. it('holds the Print button until every selected plate has answered', async () => {
  1552. // A plate whose filaments cannot be read has no mapping and no forced colours;
  1553. // queueing it anyway prints it in whatever happens to be loaded.
  1554. server.use(
  1555. http.get('/api/v1/archives/:id/filament-requirements', ({ request }) => {
  1556. const plateId = new URL(request.url).searchParams.get('plate_id');
  1557. if (plateId === '1') return HttpResponse.json({ filaments: [SLOT_1_RED] });
  1558. if (plateId === '2') return new HttpResponse(null, { status: 500 });
  1559. return HttpResponse.json({ filaments: [SLOT_1_RED, SLOT_2_RED] });
  1560. }),
  1561. );
  1562. const user = userEvent.setup();
  1563. render(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
  1564. await selectBothPlates(user);
  1565. await waitFor(() =>
  1566. expect(document.querySelector('button[type="submit"]')).toBeDisabled(),
  1567. );
  1568. expect(screen.getByText(/could not be read/i)).toBeInTheDocument();
  1569. // Deselecting the unreadable plate frees the rest of the job.
  1570. await user.click(screen.getByText('Plate 2'));
  1571. await waitFor(() =>
  1572. expect(document.querySelector('button[type="submit"]')).not.toBeDisabled(),
  1573. );
  1574. });
  1575. });
  1576. describe('PrintModal — per-plate filament override in model mode (#2552)', () => {
  1577. const mockOnClose = vi.fn();
  1578. // The reporter's trigger — "only after the file was previously queued or printed"
  1579. // — is really "only once the plates query is warm in the cache". On a cold cache
  1580. // the plates data is undefined for the first render, so the whole-file filament
  1581. // requirements are fetched and left behind; the override panel then rendered from
  1582. // that union. On a warm cache the modal knows it is multi-plate from the first
  1583. // render, the whole-file query never runs, and the panel disappeared entirely.
  1584. // Both states are exercised here, and neither may depend on the cache.
  1585. const renderWithCache = (ui: React.ReactElement, prewarm?: (qc: QueryClient) => void) => {
  1586. const queryClient = new QueryClient({
  1587. defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
  1588. });
  1589. prewarm?.(queryClient);
  1590. return rtlRender(
  1591. <QueryClientProvider client={queryClient}>
  1592. <BrowserRouter>
  1593. <AuthProvider>
  1594. <ThemeProvider>
  1595. <ToastProvider>{ui}</ToastProvider>
  1596. </ThemeProvider>
  1597. </AuthProvider>
  1598. </BrowserRouter>
  1599. </QueryClientProvider>,
  1600. );
  1601. };
  1602. // Plate 1 prints slot 1 (red), plate 2 prints slot 2 (blue). Nothing is shared.
  1603. const PLATES = {
  1604. is_multi_plate: true,
  1605. plates: [
  1606. { index: 1, name: 'Plate 1', has_thumbnail: false, thumbnail_url: null, objects: ['A'], filaments: [{ type: 'PLA', color: '#FF0000' }], print_time_seconds: 1800, filament_used_grams: 50 },
  1607. { index: 2, name: 'Plate 2', has_thumbnail: false, thumbnail_url: null, objects: ['B'], filaments: [{ type: 'PLA', color: '#0000FF' }], print_time_seconds: 1800, filament_used_grams: 50 },
  1608. ],
  1609. };
  1610. const SLOT_1_RED = { slot_id: 1, type: 'PLA', color: '#FF0000', tray_info_idx: '', used_grams: 50 };
  1611. const SLOT_2_BLUE = { slot_id: 2, type: 'PLA', color: '#0000FF', tray_info_idx: '', used_grams: 50 };
  1612. beforeEach(() => {
  1613. vi.clearAllMocks();
  1614. server.use(
  1615. http.get('/api/v1/printers/', () => HttpResponse.json(mockPrinters)),
  1616. http.get('/api/v1/archives/:id/plates', () => HttpResponse.json(PLATES)),
  1617. http.get('/api/v1/archives/:id/filament-requirements', ({ request }) => {
  1618. const plateId = new URL(request.url).searchParams.get('plate_id');
  1619. if (plateId === '1') return HttpResponse.json({ filaments: [SLOT_1_RED] });
  1620. if (plateId === '2') return HttpResponse.json({ filaments: [SLOT_2_BLUE] });
  1621. return HttpResponse.json({ filaments: [SLOT_1_RED, SLOT_2_BLUE] });
  1622. }),
  1623. http.get('/api/v1/printers/available-filaments', () =>
  1624. HttpResponse.json([
  1625. { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: null },
  1626. { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: null },
  1627. ]),
  1628. ),
  1629. http.post('/api/v1/queue/', () => HttpResponse.json({ id: 1, status: 'pending' })),
  1630. );
  1631. });
  1632. const selectAnyX1CAndBothPlates = async (user: ReturnType<typeof userEvent.setup>) => {
  1633. await waitFor(() => expect(screen.getByText('Plate 2')).toBeInTheDocument());
  1634. await user.click(screen.getByText('Plate 2')); // Plate 1 is auto-selected
  1635. await user.click(screen.getByRole('button', { name: /any model/i }));
  1636. // Switching to model mode pre-selects the first model, so the override panels
  1637. // are already up; the target-model select is the one offering the models.
  1638. const modelSelect = await waitFor(() => {
  1639. const select = screen
  1640. .getAllByRole('combobox')
  1641. .find((el) => (el as HTMLSelectElement).options[0]?.value === '' && (el as HTMLSelectElement).options[0]?.text === 'Select a model...');
  1642. if (!select) throw new Error('target model select not rendered');
  1643. return select as HTMLSelectElement;
  1644. });
  1645. await user.selectOptions(modelSelect, 'X1C');
  1646. };
  1647. it('shows one override panel per selected plate even when the plates query is already cached', async () => {
  1648. const user = userEvent.setup();
  1649. renderWithCache(
  1650. <PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />,
  1651. // The file has been opened before: the plates are known on the first render,
  1652. // so the whole-file requirements query never runs. This is the state in which
  1653. // the override section used to vanish for a multi-plate selection.
  1654. (qc) => qc.setQueryData(['archive-plates', 1], PLATES),
  1655. );
  1656. await selectAnyX1CAndBothPlates(user);
  1657. await waitFor(() => {
  1658. expect(screen.getByText(/Filament Override — Plate 1/)).toBeInTheDocument();
  1659. expect(screen.getByText(/Filament Override — Plate 2/)).toBeInTheDocument();
  1660. });
  1661. });
  1662. it('shows the same per-plate panels on a cold cache', async () => {
  1663. const user = userEvent.setup();
  1664. renderWithCache(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
  1665. await selectAnyX1CAndBothPlates(user);
  1666. await waitFor(() => {
  1667. expect(screen.getByText(/Filament Override — Plate 1/)).toBeInTheDocument();
  1668. expect(screen.getByText(/Filament Override — Plate 2/)).toBeInTheDocument();
  1669. });
  1670. // Not the whole-file union in a single unnamed panel.
  1671. expect(screen.queryByText('Filament Override')).not.toBeInTheDocument();
  1672. });
  1673. it('sends each plate only the overrides for the slots it prints', async () => {
  1674. type Queued = {
  1675. plate_id: number;
  1676. filament_overrides?: Array<{ slot_id: number; force_color_match: boolean }> | null;
  1677. };
  1678. const queued: Queued[] = [];
  1679. server.use(
  1680. http.post('/api/v1/queue/', async ({ request }) => {
  1681. queued.push((await request.json()) as Queued);
  1682. return HttpResponse.json({ id: queued.length, status: 'pending' });
  1683. }),
  1684. );
  1685. const user = userEvent.setup();
  1686. renderWithCache(
  1687. <PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />,
  1688. (qc) => qc.setQueryData(['archive-plates', 1], PLATES),
  1689. );
  1690. await selectAnyX1CAndBothPlates(user);
  1691. await waitFor(() => expect(screen.getByText(/Filament Override — Plate 2/)).toBeInTheDocument());
  1692. // Force the colour on plate 2's only slot. Plate 1 does not print slot 2 and
  1693. // must not be held back waiting for a blue spool it never uses (#2551).
  1694. const forceBoxes = screen.getAllByRole('checkbox', { name: /force color match/i });
  1695. expect(forceBoxes).toHaveLength(2); // one slot per plate
  1696. await user.click(forceBoxes[1]);
  1697. await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
  1698. await waitFor(() => expect(queued.length).toBe(2));
  1699. const plate1 = queued.find((q) => q.plate_id === 1);
  1700. const plate2 = queued.find((q) => q.plate_id === 2);
  1701. expect(plate1?.filament_overrides ?? null).toBeNull();
  1702. expect(plate2?.filament_overrides).toEqual([
  1703. expect.objectContaining({ slot_id: 2, force_color_match: true }),
  1704. ]);
  1705. });
  1706. });