PrintModal.test.tsx 39 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349
  1. /**
  2. * Tests for the unified PrintModal component.
  3. *
  4. * The PrintModal supports three modes:
  5. * - 'reprint': Immediate print from archive (multi-printer support)
  6. * - 'add-to-queue': Schedule print to queue (multi-printer support)
  7. * - 'edit-queue-item': Edit existing queue item (single printer)
  8. */
  9. import { describe, it, expect, vi, beforeEach } from 'vitest';
  10. import { screen, waitFor } from '@testing-library/react';
  11. import userEvent from '@testing-library/user-event';
  12. import { render } from '../utils';
  13. import { PrintModal } from '../../components/PrintModal';
  14. import { http, HttpResponse } from 'msw';
  15. import { server } from '../mocks/server';
  16. import type { PrintQueueItem } from '../../api/client';
  17. const mockPrinters = [
  18. { id: 1, name: 'X1 Carbon', model: 'X1C', ip_address: '192.168.1.100', enabled: true, is_active: true },
  19. { id: 2, name: 'P1S', model: 'P1S', ip_address: '192.168.1.101', enabled: true, is_active: true },
  20. { id: 3, name: 'A1 Mini', model: 'A1M', ip_address: '192.168.1.102', enabled: true, is_active: true },
  21. ];
  22. const createMockQueueItem = (overrides: Partial<PrintQueueItem> = {}): PrintQueueItem => ({
  23. id: 1,
  24. printer_id: 1,
  25. archive_id: 1,
  26. position: 1,
  27. scheduled_time: null,
  28. require_previous_success: false,
  29. auto_off_after: false,
  30. gcode_injection: false,
  31. manual_start: false,
  32. ams_mapping: null,
  33. plate_id: null,
  34. bed_levelling: true,
  35. flow_cali: false,
  36. vibration_cali: true,
  37. layer_inspect: false,
  38. timelapse: false,
  39. use_ams: true,
  40. status: 'pending',
  41. started_at: null,
  42. completed_at: null,
  43. error_message: null,
  44. created_at: '2024-01-01T00:00:00Z',
  45. archive_name: 'Test Print',
  46. archive_thumbnail: null,
  47. printer_name: 'Test Printer',
  48. print_time_seconds: 3600,
  49. batch_id: null,
  50. batch_name: null,
  51. ...overrides,
  52. });
  53. describe('PrintModal', () => {
  54. const mockOnClose = vi.fn();
  55. const mockOnSuccess = vi.fn();
  56. beforeEach(() => {
  57. vi.clearAllMocks();
  58. server.use(
  59. http.get('/api/v1/printers/', () => {
  60. return HttpResponse.json(mockPrinters);
  61. }),
  62. http.get('/api/v1/archives/:id/plates', () => {
  63. return HttpResponse.json({ is_multi_plate: false, plates: [] });
  64. }),
  65. http.get('/api/v1/archives/:id/filament-requirements', () => {
  66. return HttpResponse.json({ filaments: [] });
  67. }),
  68. http.get('/api/v1/printers/:id/status', () => {
  69. return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] });
  70. }),
  71. http.post('/api/v1/archives/:id/reprint', () => {
  72. return HttpResponse.json({ success: true });
  73. }),
  74. http.post('/api/v1/queue/', () => {
  75. return HttpResponse.json({ id: 1, status: 'pending' });
  76. }),
  77. http.patch('/api/v1/queue/:id', () => {
  78. return HttpResponse.json({ id: 1, status: 'pending' });
  79. })
  80. );
  81. });
  82. describe('reprint mode', () => {
  83. it('renders the modal title', () => {
  84. render(
  85. <PrintModal
  86. mode="reprint"
  87. archiveId={1}
  88. archiveName="Benchy"
  89. onClose={mockOnClose}
  90. onSuccess={mockOnSuccess}
  91. />
  92. );
  93. expect(screen.getByText('Re-print')).toBeInTheDocument();
  94. });
  95. it('shows archive name', () => {
  96. render(
  97. <PrintModal
  98. mode="reprint"
  99. archiveId={1}
  100. archiveName="Benchy"
  101. onClose={mockOnClose}
  102. onSuccess={mockOnSuccess}
  103. />
  104. );
  105. expect(screen.getByText('Benchy')).toBeInTheDocument();
  106. });
  107. it('shows printer selection with checkboxes for multi-select', async () => {
  108. render(
  109. <PrintModal
  110. mode="reprint"
  111. archiveId={1}
  112. archiveName="Benchy"
  113. onClose={mockOnClose}
  114. onSuccess={mockOnSuccess}
  115. />
  116. );
  117. await waitFor(() => {
  118. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  119. expect(screen.getByText('P1S')).toBeInTheDocument();
  120. });
  121. });
  122. it('has print button', () => {
  123. render(
  124. <PrintModal
  125. mode="reprint"
  126. archiveId={1}
  127. archiveName="Benchy"
  128. onClose={mockOnClose}
  129. onSuccess={mockOnSuccess}
  130. />
  131. );
  132. // Get the submit button specifically (not printer selection buttons)
  133. const submitButton = screen.getByRole('button', { name: /^print$/i });
  134. expect(submitButton).toBeInTheDocument();
  135. });
  136. it('has cancel button', () => {
  137. render(
  138. <PrintModal
  139. mode="reprint"
  140. archiveId={1}
  141. archiveName="Benchy"
  142. onClose={mockOnClose}
  143. onSuccess={mockOnSuccess}
  144. />
  145. );
  146. expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
  147. });
  148. it('calls onClose when cancel is clicked', async () => {
  149. const user = userEvent.setup();
  150. render(
  151. <PrintModal
  152. mode="reprint"
  153. archiveId={1}
  154. archiveName="Benchy"
  155. onClose={mockOnClose}
  156. onSuccess={mockOnSuccess}
  157. />
  158. );
  159. await user.click(screen.getByRole('button', { name: /cancel/i }));
  160. expect(mockOnClose).toHaveBeenCalled();
  161. });
  162. it('print button is disabled until printer is selected', () => {
  163. render(
  164. <PrintModal
  165. mode="reprint"
  166. archiveId={1}
  167. archiveName="Benchy"
  168. onClose={mockOnClose}
  169. onSuccess={mockOnSuccess}
  170. />
  171. );
  172. // Get the submit button specifically (not printer selection buttons)
  173. const printButton = screen.getByRole('button', { name: /^print$/i });
  174. expect(printButton).toBeDisabled();
  175. });
  176. it('shows no printers message when none active', async () => {
  177. server.use(
  178. http.get('/api/v1/printers/', () => {
  179. return HttpResponse.json([]);
  180. })
  181. );
  182. render(
  183. <PrintModal
  184. mode="reprint"
  185. archiveId={1}
  186. archiveName="Benchy"
  187. onClose={mockOnClose}
  188. onSuccess={mockOnSuccess}
  189. />
  190. );
  191. await waitFor(() => {
  192. expect(screen.getByText('No active printers available')).toBeInTheDocument();
  193. });
  194. });
  195. it('shows print options toggle', () => {
  196. render(
  197. <PrintModal
  198. mode="reprint"
  199. archiveId={1}
  200. archiveName="Benchy"
  201. onClose={mockOnClose}
  202. onSuccess={mockOnSuccess}
  203. />
  204. );
  205. expect(screen.getByText('Print Options')).toBeInTheDocument();
  206. });
  207. });
  208. describe('add-to-queue mode', () => {
  209. it('renders the modal title', () => {
  210. render(
  211. <PrintModal
  212. mode="add-to-queue"
  213. archiveId={1}
  214. archiveName="Test Print"
  215. onClose={mockOnClose}
  216. />
  217. );
  218. expect(screen.getByText('Schedule Print')).toBeInTheDocument();
  219. });
  220. it('shows archive name', () => {
  221. render(
  222. <PrintModal
  223. mode="add-to-queue"
  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="add-to-queue"
  235. archiveId={1}
  236. archiveName="Test Print"
  237. onClose={mockOnClose}
  238. />
  239. );
  240. expect(screen.getByRole('button', { name: /add to queue/i })).toBeInTheDocument();
  241. });
  242. it('shows cancel button', () => {
  243. render(
  244. <PrintModal
  245. mode="add-to-queue"
  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 Only option', () => {
  254. render(
  255. <PrintModal
  256. mode="add-to-queue"
  257. archiveId={1}
  258. archiveName="Test Print"
  259. onClose={mockOnClose}
  260. />
  261. );
  262. expect(screen.getByText('Queue Only')).toBeInTheDocument();
  263. });
  264. it('shows power off option', () => {
  265. render(
  266. <PrintModal
  267. mode="add-to-queue"
  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="add-to-queue"
  279. archiveId={1}
  280. archiveName="Test Print"
  281. onClose={mockOnClose}
  282. />
  283. );
  284. expect(screen.getByText('ASAP')).toBeInTheDocument();
  285. expect(screen.getByText('Scheduled')).toBeInTheDocument();
  286. });
  287. it('calls onClose when cancel is clicked', async () => {
  288. const user = userEvent.setup();
  289. render(
  290. <PrintModal
  291. mode="add-to-queue"
  292. archiveId={1}
  293. archiveName="Test Print"
  294. onClose={mockOnClose}
  295. />
  296. );
  297. await user.click(screen.getByRole('button', { name: /cancel/i }));
  298. expect(mockOnClose).toHaveBeenCalled();
  299. });
  300. });
  301. describe('edit-queue-item mode', () => {
  302. it('renders the modal title', () => {
  303. const item = createMockQueueItem();
  304. render(
  305. <PrintModal
  306. mode="edit-queue-item"
  307. archiveId={1}
  308. archiveName="Test Print"
  309. queueItem={item}
  310. onClose={mockOnClose}
  311. />
  312. );
  313. expect(screen.getByText('Edit Queue Item')).toBeInTheDocument();
  314. });
  315. it('shows save button', () => {
  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.getByRole('button', { name: /save/i })).toBeInTheDocument();
  327. });
  328. it('shows cancel 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: /cancel/i })).toBeInTheDocument();
  340. });
  341. it('shows print options toggle', () => {
  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.getByText('Print Options')).toBeInTheDocument();
  353. });
  354. it('shows Queue Only option', () => {
  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('Queue Only')).toBeInTheDocument();
  366. });
  367. it('shows power off 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.getByText(/power off/i)).toBeInTheDocument();
  379. });
  380. it('calls onClose when cancel button is clicked', async () => {
  381. const user = userEvent.setup();
  382. const item = createMockQueueItem();
  383. render(
  384. <PrintModal
  385. mode="edit-queue-item"
  386. archiveId={1}
  387. archiveName="Test Print"
  388. queueItem={item}
  389. onClose={mockOnClose}
  390. />
  391. );
  392. const cancelButton = screen.getByRole('button', { name: /cancel/i });
  393. await user.click(cancelButton);
  394. expect(mockOnClose).toHaveBeenCalled();
  395. });
  396. it('shows printer selector for single selection', async () => {
  397. const item = createMockQueueItem();
  398. render(
  399. <PrintModal
  400. mode="edit-queue-item"
  401. archiveId={1}
  402. archiveName="Test Print"
  403. queueItem={item}
  404. onClose={mockOnClose}
  405. />
  406. );
  407. // PrinterSelector shows printer names directly
  408. await waitFor(() => {
  409. expect(screen.getByText('P1S')).toBeInTheDocument();
  410. });
  411. });
  412. });
  413. describe('multi-printer selection', () => {
  414. it('shows select all button when multiple printers available', async () => {
  415. render(
  416. <PrintModal
  417. mode="reprint"
  418. archiveId={1}
  419. archiveName="Benchy"
  420. onClose={mockOnClose}
  421. />
  422. );
  423. await waitFor(() => {
  424. expect(screen.getByText('Select all')).toBeInTheDocument();
  425. });
  426. });
  427. it('shows selected count when multiple printers selected', async () => {
  428. const user = userEvent.setup();
  429. render(
  430. <PrintModal
  431. mode="reprint"
  432. archiveId={1}
  433. archiveName="Benchy"
  434. onClose={mockOnClose}
  435. />
  436. );
  437. await waitFor(() => {
  438. expect(screen.getByText('Select all')).toBeInTheDocument();
  439. });
  440. await user.click(screen.getByText('Select all'));
  441. await waitFor(() => {
  442. expect(screen.getByText(/3 printers selected/)).toBeInTheDocument();
  443. });
  444. });
  445. it('updates button text when multiple printers selected', async () => {
  446. const user = userEvent.setup();
  447. render(
  448. <PrintModal
  449. mode="reprint"
  450. archiveId={1}
  451. archiveName="Benchy"
  452. onClose={mockOnClose}
  453. />
  454. );
  455. await waitFor(() => {
  456. expect(screen.getByText('Select all')).toBeInTheDocument();
  457. });
  458. await user.click(screen.getByText('Select all'));
  459. await waitFor(() => {
  460. expect(screen.getByRole('button', { name: /print to 3 printers/i })).toBeInTheDocument();
  461. });
  462. });
  463. });
  464. describe('busy printer handling (#622)', () => {
  465. beforeEach(() => {
  466. // Set up per-printer statuses: printer 1 RUNNING, printer 2 IDLE, printer 3 FINISH
  467. server.use(
  468. http.get('/api/v1/printers/:id/status', ({ params }) => {
  469. const id = Number(params.id);
  470. if (id === 1) {
  471. return HttpResponse.json({
  472. connected: true, state: 'RUNNING', stg_cur_name: null,
  473. ams: [], vt_tray: [], nozzles: [],
  474. });
  475. }
  476. if (id === 2) {
  477. return HttpResponse.json({
  478. connected: true, state: 'IDLE', stg_cur_name: null,
  479. ams: [], vt_tray: [], nozzles: [],
  480. });
  481. }
  482. // printer 3
  483. return HttpResponse.json({
  484. connected: true, state: 'FINISH', stg_cur_name: null,
  485. ams: [], vt_tray: [], nozzles: [],
  486. });
  487. })
  488. );
  489. });
  490. it('shows state badges on printers in reprint mode', async () => {
  491. render(
  492. <PrintModal
  493. mode="reprint"
  494. archiveId={1}
  495. archiveName="Benchy"
  496. onClose={mockOnClose}
  497. />
  498. );
  499. await waitFor(() => {
  500. expect(screen.getByText('Printing')).toBeInTheDocument();
  501. expect(screen.getByText('Idle')).toBeInTheDocument();
  502. expect(screen.getByText('Finished')).toBeInTheDocument();
  503. });
  504. });
  505. it('prevents selecting a busy printer in reprint mode', async () => {
  506. const user = userEvent.setup();
  507. render(
  508. <PrintModal
  509. mode="reprint"
  510. archiveId={1}
  511. archiveName="Benchy"
  512. onClose={mockOnClose}
  513. />
  514. );
  515. await waitFor(() => {
  516. expect(screen.getByText('Printing')).toBeInTheDocument();
  517. });
  518. // The busy printer button should be disabled
  519. const busyButton = screen.getByText('X1 Carbon').closest('button');
  520. expect(busyButton).toBeDisabled();
  521. // Click the busy printer — selection should not change
  522. await user.click(busyButton!);
  523. // Idle printer should still be selectable
  524. const idleButton = screen.getByText('P1S').closest('button');
  525. expect(idleButton).not.toBeDisabled();
  526. await user.click(idleButton!);
  527. await waitFor(() => {
  528. expect(screen.getByText('1 printer selected')).toBeInTheDocument();
  529. });
  530. });
  531. it('select all skips busy printers in reprint mode', async () => {
  532. const user = userEvent.setup();
  533. render(
  534. <PrintModal
  535. mode="reprint"
  536. archiveId={1}
  537. archiveName="Benchy"
  538. onClose={mockOnClose}
  539. />
  540. );
  541. await waitFor(() => {
  542. expect(screen.getByText('Select all')).toBeInTheDocument();
  543. expect(screen.getByText('Printing')).toBeInTheDocument();
  544. });
  545. await user.click(screen.getByText('Select all'));
  546. await waitFor(() => {
  547. // Only 2 available printers selected (IDLE + FINISH), not the RUNNING one
  548. expect(screen.getByText(/2 printers selected/)).toBeInTheDocument();
  549. });
  550. });
  551. it('allows selecting busy printers in add-to-queue mode', async () => {
  552. const user = userEvent.setup();
  553. render(
  554. <PrintModal
  555. mode="add-to-queue"
  556. archiveId={1}
  557. archiveName="Benchy"
  558. onClose={mockOnClose}
  559. />
  560. );
  561. await waitFor(() => {
  562. expect(screen.getByText('Printing')).toBeInTheDocument();
  563. });
  564. // The busy printer button should NOT be disabled in queue mode
  565. const busyButton = screen.getByText('X1 Carbon').closest('button');
  566. expect(busyButton).not.toBeDisabled();
  567. await user.click(busyButton!);
  568. await waitFor(() => {
  569. expect(screen.getByText('1 printer selected')).toBeInTheDocument();
  570. });
  571. });
  572. it('shows Offline badge for disconnected printers', async () => {
  573. server.use(
  574. http.get('/api/v1/printers/:id/status', () => {
  575. return HttpResponse.json({
  576. connected: false, state: null, stg_cur_name: null,
  577. ams: [], vt_tray: [], nozzles: [],
  578. });
  579. })
  580. );
  581. render(
  582. <PrintModal
  583. mode="reprint"
  584. archiveId={1}
  585. archiveName="Benchy"
  586. onClose={mockOnClose}
  587. />
  588. );
  589. await waitFor(() => {
  590. const offlineBadges = screen.getAllByText('Offline');
  591. expect(offlineBadges.length).toBeGreaterThanOrEqual(1);
  592. });
  593. });
  594. it('shows calibration stage name when printer is calibrating', async () => {
  595. server.use(
  596. http.get('/api/v1/printers/:id/status', () => {
  597. return HttpResponse.json({
  598. connected: true, state: 'RUNNING', stg_cur_name: 'Auto bed leveling',
  599. ams: [], vt_tray: [], nozzles: [],
  600. });
  601. })
  602. );
  603. render(
  604. <PrintModal
  605. mode="reprint"
  606. archiveId={1}
  607. archiveName="Benchy"
  608. onClose={mockOnClose}
  609. />
  610. );
  611. await waitFor(() => {
  612. const badges = screen.getAllByText('Auto bed leveling');
  613. expect(badges.length).toBeGreaterThanOrEqual(1);
  614. });
  615. });
  616. });
  617. describe('stagger start', () => {
  618. it('does not show stagger option with single printer in queue mode', async () => {
  619. const user = userEvent.setup();
  620. render(
  621. <PrintModal
  622. mode="add-to-queue"
  623. archiveId={1}
  624. archiveName="Test Print"
  625. onClose={mockOnClose}
  626. />
  627. );
  628. await waitFor(() => {
  629. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  630. });
  631. // Select single printer
  632. await user.click(screen.getByText('X1 Carbon'));
  633. expect(screen.queryByText('Stagger printer starts')).not.toBeInTheDocument();
  634. });
  635. it('shows stagger option when multiple printers selected in queue mode', async () => {
  636. const user = userEvent.setup();
  637. render(
  638. <PrintModal
  639. mode="add-to-queue"
  640. archiveId={1}
  641. archiveName="Test Print"
  642. onClose={mockOnClose}
  643. />
  644. );
  645. await waitFor(() => {
  646. expect(screen.getByText('Select all')).toBeInTheDocument();
  647. });
  648. await user.click(screen.getByText('Select all'));
  649. await waitFor(() => {
  650. expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();
  651. });
  652. });
  653. it('shows stagger option in reprint mode with multiple printers', async () => {
  654. const user = userEvent.setup();
  655. render(
  656. <PrintModal
  657. mode="reprint"
  658. archiveId={1}
  659. archiveName="Test Print"
  660. onClose={mockOnClose}
  661. />
  662. );
  663. await waitFor(() => {
  664. expect(screen.getByText('Select all')).toBeInTheDocument();
  665. });
  666. await user.click(screen.getByText('Select all'));
  667. await waitFor(() => {
  668. expect(screen.getByText(/2 printers selected|3 printers selected/)).toBeInTheDocument();
  669. });
  670. expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();
  671. });
  672. it('shows stagger preview in reprint mode when enabled', async () => {
  673. const user = userEvent.setup();
  674. render(
  675. <PrintModal
  676. mode="reprint"
  677. archiveId={1}
  678. archiveName="Test Print"
  679. onClose={mockOnClose}
  680. />
  681. );
  682. await waitFor(() => {
  683. expect(screen.getByText('Select all')).toBeInTheDocument();
  684. });
  685. await user.click(screen.getByText('Select all'));
  686. await waitFor(() => {
  687. expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();
  688. });
  689. await user.click(screen.getByLabelText('Stagger printer starts'));
  690. await waitFor(() => {
  691. // Default: 3 printers, group size 2 = 2 groups — preview text shown
  692. expect(screen.getByText(/3 printers.*2 groups/)).toBeInTheDocument();
  693. });
  694. });
  695. it('does not show stagger option in reprint mode with single printer', async () => {
  696. const user = userEvent.setup();
  697. render(
  698. <PrintModal
  699. mode="reprint"
  700. archiveId={1}
  701. archiveName="Test Print"
  702. onClose={mockOnClose}
  703. />
  704. );
  705. await waitFor(() => {
  706. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  707. });
  708. // Select only one printer
  709. await user.click(screen.getByText('X1 Carbon'));
  710. expect(screen.queryByText('Stagger printer starts')).not.toBeInTheDocument();
  711. });
  712. it('shows stagger inputs when stagger checkbox is enabled', async () => {
  713. const user = userEvent.setup();
  714. render(
  715. <PrintModal
  716. mode="add-to-queue"
  717. archiveId={1}
  718. archiveName="Test Print"
  719. onClose={mockOnClose}
  720. />
  721. );
  722. await waitFor(() => {
  723. expect(screen.getByText('Select all')).toBeInTheDocument();
  724. });
  725. await user.click(screen.getByText('Select all'));
  726. await waitFor(() => {
  727. expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();
  728. });
  729. await user.click(screen.getByLabelText('Stagger printer starts'));
  730. await waitFor(() => {
  731. expect(screen.getByText('Group size')).toBeInTheDocument();
  732. expect(screen.getByText('Interval (min)')).toBeInTheDocument();
  733. });
  734. });
  735. it('shows stagger preview with printer count', async () => {
  736. const user = userEvent.setup();
  737. render(
  738. <PrintModal
  739. mode="add-to-queue"
  740. archiveId={1}
  741. archiveName="Test Print"
  742. onClose={mockOnClose}
  743. />
  744. );
  745. await waitFor(() => {
  746. expect(screen.getByText('Select all')).toBeInTheDocument();
  747. });
  748. await user.click(screen.getByText('Select all'));
  749. await waitFor(() => {
  750. expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();
  751. });
  752. await user.click(screen.getByLabelText('Stagger printer starts'));
  753. await waitFor(() => {
  754. // Default: 3 printers, group size 2 = 2 groups — preview text includes printer count
  755. expect(screen.getByText(/3 printers.*2 groups/)).toBeInTheDocument();
  756. });
  757. });
  758. });
  759. describe('multi-plate selection', () => {
  760. const multiPlateResponse = {
  761. is_multi_plate: true,
  762. plates: [
  763. { 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 },
  764. { 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 },
  765. { 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 },
  766. ],
  767. };
  768. beforeEach(() => {
  769. server.use(
  770. http.get('/api/v1/archives/:id/plates', () => {
  771. return HttpResponse.json(multiPlateResponse);
  772. }),
  773. );
  774. });
  775. it('shows "Select All" button only in add-to-queue mode', async () => {
  776. render(
  777. <PrintModal
  778. mode="add-to-queue"
  779. archiveId={1}
  780. archiveName="MultiPlate.3mf"
  781. onClose={mockOnClose}
  782. />
  783. );
  784. await waitFor(() => {
  785. expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();
  786. });
  787. });
  788. it('does not show "Select All" button in reprint mode', async () => {
  789. render(
  790. <PrintModal
  791. mode="reprint"
  792. archiveId={1}
  793. archiveName="MultiPlate.3mf"
  794. initialSelectedPrinterIds={[1]}
  795. onClose={mockOnClose}
  796. />
  797. );
  798. await waitFor(() => {
  799. expect(screen.getByText('Plate 1')).toBeInTheDocument();
  800. });
  801. expect(screen.queryByText('Select All 3 Plates')).not.toBeInTheDocument();
  802. });
  803. it('selects all plates when "Select All" is clicked', async () => {
  804. const user = userEvent.setup();
  805. render(
  806. <PrintModal
  807. mode="add-to-queue"
  808. archiveId={1}
  809. archiveName="MultiPlate.3mf"
  810. onClose={mockOnClose}
  811. />
  812. );
  813. await waitFor(() => {
  814. expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();
  815. });
  816. await user.click(screen.getByText('Select All 3 Plates'));
  817. // All plates should be highlighted (green border)
  818. await waitFor(() => {
  819. const plateButtons = document.querySelectorAll('button[type="button"].border-bambu-green');
  820. // 3 plate buttons + the "Deselect All" toggle button = 4 green-bordered buttons
  821. expect(plateButtons.length).toBeGreaterThanOrEqual(3);
  822. });
  823. });
  824. it('allows selecting a subset of plates to queue', async () => {
  825. const queueRequests: unknown[] = [];
  826. server.use(
  827. http.post('/api/v1/queue/', async ({ request }) => {
  828. const body = await request.json();
  829. queueRequests.push(body);
  830. return HttpResponse.json({ id: queueRequests.length, status: 'pending' });
  831. }),
  832. );
  833. const user = userEvent.setup();
  834. render(
  835. <PrintModal
  836. mode="add-to-queue"
  837. archiveId={1}
  838. archiveName="MultiPlate.3mf"
  839. onClose={mockOnClose}
  840. onSuccess={mockOnSuccess}
  841. />
  842. );
  843. // Wait for plates and select a printer
  844. await waitFor(() => {
  845. expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();
  846. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  847. });
  848. // Select printer
  849. await user.click(screen.getByText('X1 Carbon'));
  850. // Plate 1 is auto-selected. Click Plate 3 to add it (multi-select in add-to-queue mode)
  851. await user.click(screen.getByText('Plate 3'));
  852. // Submit — should queue plates 1 and 3
  853. const submitButton = document.querySelector('button[type="submit"]') as HTMLElement;
  854. await user.click(submitButton);
  855. await waitFor(() => {
  856. expect(queueRequests.length).toBe(2);
  857. });
  858. expect((queueRequests[0] as { plate_id: number }).plate_id).toBe(1);
  859. expect((queueRequests[1] as { plate_id: number }).plate_id).toBe(3);
  860. });
  861. it('creates one queue item per plate when submitting with select-all', async () => {
  862. const queueRequests: unknown[] = [];
  863. server.use(
  864. http.post('/api/v1/queue/', async ({ request }) => {
  865. const body = await request.json();
  866. queueRequests.push(body);
  867. return HttpResponse.json({ id: queueRequests.length, status: 'pending' });
  868. }),
  869. );
  870. const user = userEvent.setup();
  871. render(
  872. <PrintModal
  873. mode="add-to-queue"
  874. archiveId={1}
  875. archiveName="MultiPlate.3mf"
  876. onClose={mockOnClose}
  877. onSuccess={mockOnSuccess}
  878. />
  879. );
  880. // Wait for plates and select a printer
  881. await waitFor(() => {
  882. expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();
  883. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  884. });
  885. // Select printer
  886. await user.click(screen.getByText('X1 Carbon'));
  887. // Click select all
  888. await user.click(screen.getByText('Select All 3 Plates'));
  889. // Find the submit button (type="submit") — distinct from the toggle button (type="button")
  890. const submitButton = document.querySelector('button[type="submit"]') as HTMLElement;
  891. await user.click(submitButton);
  892. await waitFor(() => {
  893. expect(queueRequests.length).toBe(3);
  894. });
  895. // Verify each request has the correct plate_id
  896. expect((queueRequests[0] as { plate_id: number }).plate_id).toBe(1);
  897. expect((queueRequests[1] as { plate_id: number }).plate_id).toBe(2);
  898. expect((queueRequests[2] as { plate_id: number }).plate_id).toBe(3);
  899. });
  900. });
  901. describe('batch quantity', () => {
  902. it('shows quantity input in reprint mode', () => {
  903. render(
  904. <PrintModal
  905. mode="reprint"
  906. archiveId={1}
  907. archiveName="Benchy"
  908. onClose={mockOnClose}
  909. onSuccess={mockOnSuccess}
  910. />
  911. );
  912. expect(screen.getByLabelText('Quantity')).toBeInTheDocument();
  913. });
  914. it('shows quantity input in add-to-queue mode', () => {
  915. render(
  916. <PrintModal
  917. mode="add-to-queue"
  918. archiveId={1}
  919. archiveName="Benchy"
  920. onClose={mockOnClose}
  921. onSuccess={mockOnSuccess}
  922. />
  923. );
  924. expect(screen.getByLabelText('Quantity')).toBeInTheDocument();
  925. });
  926. it('does not show quantity input in edit-queue-item mode', () => {
  927. render(
  928. <PrintModal
  929. mode="edit-queue-item"
  930. archiveId={1}
  931. archiveName="Benchy"
  932. queueItem={createMockQueueItem()}
  933. onClose={mockOnClose}
  934. onSuccess={mockOnSuccess}
  935. />
  936. );
  937. expect(screen.queryByLabelText('Quantity')).not.toBeInTheDocument();
  938. });
  939. it('defaults quantity to 1', () => {
  940. render(
  941. <PrintModal
  942. mode="add-to-queue"
  943. archiveId={1}
  944. archiveName="Benchy"
  945. onClose={mockOnClose}
  946. onSuccess={mockOnSuccess}
  947. />
  948. );
  949. const input = screen.getByLabelText('Quantity') as HTMLInputElement;
  950. expect(input.value).toBe('1');
  951. });
  952. it('quantity input has default value of 1 and accepts changes', async () => {
  953. const user = userEvent.setup();
  954. render(
  955. <PrintModal
  956. mode="reprint"
  957. archiveId={1}
  958. archiveName="Benchy"
  959. initialSelectedPrinterIds={[1]}
  960. onClose={mockOnClose}
  961. onSuccess={mockOnSuccess}
  962. />
  963. );
  964. const input = screen.getByLabelText('Quantity') as HTMLInputElement;
  965. expect(input.value).toBe('1');
  966. await user.tripleClick(input);
  967. await user.keyboard('5');
  968. expect(input.value).toBe('5');
  969. });
  970. });
  971. describe('project_id forwarding', () => {
  972. beforeEach(() => {
  973. // Additional handlers needed for library file mode
  974. server.use(
  975. http.get('/api/v1/library/files/:id', () => {
  976. return HttpResponse.json({
  977. id: 5,
  978. filename: 'benchy.gcode.3mf',
  979. print_name: null,
  980. file_type: '3mf',
  981. folder_id: null,
  982. project_id: null,
  983. file_hash: null,
  984. file_size_bytes: 1024,
  985. thumbnail_path: null,
  986. created_at: '2024-01-01T00:00:00Z',
  987. updated_at: '2024-01-01T00:00:00Z',
  988. });
  989. }),
  990. http.get('/api/v1/library/files/:id/plates', () => {
  991. return HttpResponse.json({ is_multi_plate: false, plates: [] });
  992. }),
  993. http.get('/api/v1/library/files/:id/filament-requirements', () => {
  994. return HttpResponse.json({ file_id: 5, filename: 'benchy.gcode.3mf', filaments: [] });
  995. }),
  996. http.get('/api/v1/printers/:id/status', () => {
  997. return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] });
  998. }),
  999. );
  1000. });
  1001. it('includes project_id in printLibraryFile call when projectId prop is set', async () => {
  1002. let capturedBody: Record<string, unknown> | null = null;
  1003. server.use(
  1004. http.post('/api/v1/library/files/:id/print', async ({ request }) => {
  1005. capturedBody = await request.json() as Record<string, unknown>;
  1006. return HttpResponse.json({ status: 'dispatched', dispatch_job_id: 'abc', dispatch_position: 0 });
  1007. })
  1008. );
  1009. const user = userEvent.setup();
  1010. render(
  1011. <PrintModal
  1012. mode="reprint"
  1013. libraryFileId={5}
  1014. archiveName="Benchy"
  1015. projectId={42}
  1016. initialSelectedPrinterIds={[1]}
  1017. onClose={mockOnClose}
  1018. onSuccess={mockOnSuccess}
  1019. />
  1020. );
  1021. // Wait for the modal to load printer and file data
  1022. await waitFor(() => {
  1023. expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
  1024. });
  1025. await user.click(screen.getByRole('button', { name: /^print$/i }));
  1026. await waitFor(() => {
  1027. expect(capturedBody).not.toBeNull();
  1028. expect(capturedBody?.project_id).toBe(42);
  1029. });
  1030. });
  1031. it('does NOT include project_id in reprintArchive call (archives carry their own project association)', async () => {
  1032. // The reprintArchive branch omits project_id by design — archives already carry
  1033. // their project association from the original print. This test guards that intent.
  1034. let capturedBody: Record<string, unknown> | null = null;
  1035. server.use(
  1036. http.post('/api/v1/archives/:id/reprint', async ({ request }) => {
  1037. capturedBody = await request.json() as Record<string, unknown>;
  1038. return HttpResponse.json({ status: 'dispatched' });
  1039. })
  1040. );
  1041. const user = userEvent.setup();
  1042. render(
  1043. <PrintModal
  1044. mode="reprint"
  1045. archiveId={1}
  1046. archiveName="Benchy"
  1047. projectId={42}
  1048. initialSelectedPrinterIds={[1]}
  1049. onClose={mockOnClose}
  1050. onSuccess={mockOnSuccess}
  1051. />
  1052. );
  1053. await waitFor(() => {
  1054. expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
  1055. });
  1056. await user.click(screen.getByRole('button', { name: /^print$/i }));
  1057. await waitFor(() => {
  1058. expect(capturedBody).not.toBeNull();
  1059. expect(capturedBody).not.toHaveProperty('project_id');
  1060. });
  1061. });
  1062. });
  1063. describe('cleanup_library_after_dispatch forwarding (#730)', () => {
  1064. // The Printers-page Direct-Print flow passes cleanupLibraryAfterDispatch so the
  1065. // transient LibraryFile created by FileUploadModal is deleted once the archive
  1066. // owns its own copy. File Manager / Project Detail flows leave the prop unset so
  1067. // their deliberately-added library entries survive the print.
  1068. beforeEach(() => {
  1069. server.use(
  1070. http.get('/api/v1/library/files/:id', () => {
  1071. return HttpResponse.json({
  1072. id: 5,
  1073. filename: 'benchy.gcode.3mf',
  1074. file_type: '3mf',
  1075. folder_id: null,
  1076. project_id: null,
  1077. file_hash: null,
  1078. file_size_bytes: 1024,
  1079. thumbnail_path: null,
  1080. created_at: '2024-01-01T00:00:00Z',
  1081. updated_at: '2024-01-01T00:00:00Z',
  1082. });
  1083. }),
  1084. http.get('/api/v1/library/files/:id/plates', () => {
  1085. return HttpResponse.json({ is_multi_plate: false, plates: [] });
  1086. }),
  1087. http.get('/api/v1/library/files/:id/filament-requirements', () => {
  1088. return HttpResponse.json({ file_id: 5, filename: 'benchy.gcode.3mf', filaments: [] });
  1089. }),
  1090. http.get('/api/v1/printers/:id/status', () => {
  1091. return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] });
  1092. }),
  1093. );
  1094. });
  1095. it('forwards cleanup_library_after_dispatch=true when the Direct-Print prop is set', async () => {
  1096. let capturedBody: Record<string, unknown> | null = null;
  1097. server.use(
  1098. http.post('/api/v1/library/files/:id/print', async ({ request }) => {
  1099. capturedBody = (await request.json()) as Record<string, unknown>;
  1100. return HttpResponse.json({ status: 'dispatched', dispatch_job_id: 'abc', dispatch_position: 0 });
  1101. })
  1102. );
  1103. const user = userEvent.setup();
  1104. render(
  1105. <PrintModal
  1106. mode="reprint"
  1107. libraryFileId={5}
  1108. archiveName="Benchy"
  1109. cleanupLibraryAfterDispatch
  1110. initialSelectedPrinterIds={[1]}
  1111. onClose={mockOnClose}
  1112. onSuccess={mockOnSuccess}
  1113. />
  1114. );
  1115. await waitFor(() => {
  1116. expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
  1117. });
  1118. await user.click(screen.getByRole('button', { name: /^print$/i }));
  1119. await waitFor(() => {
  1120. expect(capturedBody).not.toBeNull();
  1121. expect(capturedBody?.cleanup_library_after_dispatch).toBe(true);
  1122. });
  1123. });
  1124. it('defaults to omitting cleanup_library_after_dispatch (File Manager / Project flows survive)', async () => {
  1125. let capturedBody: Record<string, unknown> | null = null;
  1126. server.use(
  1127. http.post('/api/v1/library/files/:id/print', async ({ request }) => {
  1128. capturedBody = (await request.json()) as Record<string, unknown>;
  1129. return HttpResponse.json({ status: 'dispatched', dispatch_job_id: 'abc', dispatch_position: 0 });
  1130. })
  1131. );
  1132. const user = userEvent.setup();
  1133. render(
  1134. <PrintModal
  1135. mode="reprint"
  1136. libraryFileId={5}
  1137. archiveName="Benchy"
  1138. initialSelectedPrinterIds={[1]}
  1139. onClose={mockOnClose}
  1140. onSuccess={mockOnSuccess}
  1141. />
  1142. );
  1143. await waitFor(() => {
  1144. expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
  1145. });
  1146. await user.click(screen.getByRole('button', { name: /^print$/i }));
  1147. await waitFor(() => {
  1148. expect(capturedBody).not.toBeNull();
  1149. });
  1150. // Either omitted entirely or explicitly undefined — both interpret as "keep file"
  1151. expect(capturedBody?.cleanup_library_after_dispatch).toBeUndefined();
  1152. });
  1153. });
  1154. });