PrintModalSeparateJobs.test.tsx 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785
  1. /**
  2. * PrintModal with several sliced files marked in the File Manager (#3462).
  3. *
  4. * The selection used to always become printer alternatives (#671): one job, the
  5. * first free printer of a matching model prints its file. Two files for the same
  6. * model could therefore not be queued at all ("variants must target different
  7. * printers"). The dialog now asks what the selection is: alternatives, or one
  8. * queue item per file in list order, with the ordinary printer picker and
  9. * options applying to every item.
  10. */
  11. import type { ComponentProps } from 'react';
  12. import { describe, it, expect, beforeEach, vi } from 'vitest';
  13. import { fireEvent, screen, waitFor } from '@testing-library/react';
  14. import userEvent from '@testing-library/user-event';
  15. import { delay, http, HttpResponse } from 'msw';
  16. import { render } from '../utils';
  17. import { server } from '../mocks/server';
  18. import { PrintModal } from '../../components/PrintModal';
  19. import type { VariantCandidate } from '../../components/PrintModal/VariantCandidates';
  20. import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from '../../components/PrintModal/types';
  21. const PART_A: VariantCandidate = { id: 11, filename: 'part-a.gcode.3mf', sliced_for_model: 'A1' };
  22. const PART_B: VariantCandidate = { id: 12, filename: 'part-b.gcode.3mf', sliced_for_model: 'A1' };
  23. const PART_C: VariantCandidate = { id: 13, filename: 'part-c.gcode.3mf', sliced_for_model: 'P1S' };
  24. // No sliced-for model, so the printer picker lists every printer.
  25. const PART_D: VariantCandidate = { id: 14, filename: 'part-d.gcode.3mf', sliced_for_model: null };
  26. const BY_ID: Record<number, VariantCandidate> = { 11: PART_A, 12: PART_B, 13: PART_C, 14: PART_D };
  27. type Body = Record<string, unknown>;
  28. let posted: Body[];
  29. /** Answers each POST /queue/ by its position; the default accepts it. */
  30. let respond: (body: Body, index: number) => Response | Promise<Response>;
  31. function mockBackend(printers: Array<Record<string, unknown>> = [
  32. { id: 1, name: 'A1 Test', model: 'A1', ip_address: '192.0.2.1', is_active: true, enabled: true },
  33. { id: 2, name: 'P1S Test', model: 'P1S', ip_address: '192.0.2.2', is_active: true, enabled: true },
  34. ]) {
  35. server.use(
  36. http.get('/api/v1/printers/', () => HttpResponse.json(printers)),
  37. http.get('/api/v1/printers/:id/status', () =>
  38. HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] }),
  39. ),
  40. http.get('/api/v1/printers/available-filaments', () =>
  41. HttpResponse.json([
  42. { type: 'PLA', color: '#FFFFFF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: null },
  43. ]),
  44. ),
  45. http.get('/api/v1/library/files/:id', ({ params }) => {
  46. const file = BY_ID[Number(params.id)];
  47. return HttpResponse.json({
  48. id: file.id,
  49. filename: file.filename,
  50. file_type: 'gcode.3mf',
  51. sliced_for_model: file.sliced_for_model,
  52. });
  53. }),
  54. http.get('/api/v1/library/files/:id/plates', ({ params }) =>
  55. HttpResponse.json({ file_id: Number(params.id), filename: 'x', plates: [], is_multi_plate: false }),
  56. ),
  57. http.get('/api/v1/library/files/:id/filament-requirements', () =>
  58. HttpResponse.json({
  59. filaments: [{ slot_id: 1, type: 'PLA', color: '#FFFFFF', used_grams: 15, used_meters: 5 }],
  60. }),
  61. ),
  62. http.post('/api/v1/queue/', async ({ request }) => {
  63. const body = (await request.json()) as Body;
  64. posted.push(body);
  65. return respond(body, posted.length - 1);
  66. }),
  67. );
  68. }
  69. function renderModal(
  70. files: VariantCandidate[],
  71. props: Pick<
  72. ComponentProps<typeof PrintModal>,
  73. 'separateJobsAllowed' | 'onClose' | 'onSuccess' | 'resumeSeparateJobs' | 'onSeparateJobsProgress'
  74. > = {},
  75. ) {
  76. render(
  77. <PrintModal
  78. mode="create"
  79. libraryFileId={files[0].id}
  80. variantFiles={files}
  81. archiveName="parts"
  82. {...props}
  83. onClose={props.onClose ?? (() => {})}
  84. />,
  85. );
  86. }
  87. const printButton = () => screen.getByRole('button', { name: /^Print$/i });
  88. /** Picks a printer card and waits until its status is in and Print is enabled. */
  89. async function pickPrinterAndWait(user: ReturnType<typeof userEvent.setup>, name: string) {
  90. await user.click(await screen.findByText(name));
  91. await waitFor(() => expect(printButton()).toBeEnabled());
  92. }
  93. const fileIds = (bodies: Body[]) => bodies.map((b) => b.library_file_id);
  94. const ALTERNATIVES_LIST_HELP = 'One job, one queue slot. The first matching printer to free up runs its file.';
  95. const ALTERNATIVES_HINT = 'One job. The first matching printer to free up prints its file.';
  96. const SEPARATE_HINT = /One queue item per file/;
  97. describe('PrintModal with several selected files (#3462)', () => {
  98. beforeEach(() => {
  99. posted = [];
  100. respond = () => HttpResponse.json({ id: 1, status: 'pending' });
  101. mockBackend();
  102. });
  103. it('keeps a version group as alternatives, without the toggle', async () => {
  104. renderModal([PART_A, PART_C]);
  105. expect(await screen.findByText('part-c.gcode.3mf')).toBeInTheDocument();
  106. expect(screen.getByText('Printer alternatives')).toBeInTheDocument();
  107. expect(screen.getByText(ALTERNATIVES_LIST_HELP)).toBeInTheDocument();
  108. expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument();
  109. });
  110. it('explains each mode once, under the toggle', async () => {
  111. const user = userEvent.setup();
  112. renderModal([PART_A, PART_C], { separateJobsAllowed: true });
  113. await screen.findByRole('radio', { name: 'Alternatives' });
  114. expect(screen.getAllByText(ALTERNATIVES_HINT)).toHaveLength(1);
  115. expect(screen.queryByText(ALTERNATIVES_LIST_HELP)).not.toBeInTheDocument();
  116. await user.click(screen.getByRole('radio', { name: 'Separate jobs' }));
  117. expect(screen.getAllByText(SEPARATE_HINT)).toHaveLength(1);
  118. expect(screen.queryByText(ALTERNATIVES_HINT)).not.toBeInTheDocument();
  119. });
  120. it('starts on separate jobs for two files of one model and shows the printer picker', async () => {
  121. const user = userEvent.setup();
  122. renderModal([PART_A, PART_B], { separateJobsAllowed: true });
  123. expect(await screen.findByRole('radio', { name: 'Separate jobs' })).toHaveAttribute('aria-checked', 'true');
  124. expect(screen.getByRole('radio', { name: 'Alternatives' })).toBeDisabled();
  125. expect(screen.getByText('Queue order')).toBeInTheDocument();
  126. // The reason Alternatives is blocked is the one explanation shown.
  127. expect(screen.getAllByText(/More than one file is sliced for A1\./)).toHaveLength(1);
  128. expect(screen.queryByText(SEPARATE_HINT)).not.toBeInTheDocument();
  129. // A selected printer would bring up the first file's tray mapping; it is
  130. // not shown, because each file is mapped against its own 3MF at dispatch.
  131. await pickPrinterAndWait(user, 'A1 Test');
  132. expect(screen.queryByText(/Filament Mapping/)).not.toBeInTheDocument();
  133. // Same for the override panel of "Any A1".
  134. await user.click(screen.getByRole('button', { name: /Any A1/ }));
  135. await new Promise((resolve) => setTimeout(resolve, 200));
  136. expect(screen.queryByText(/Filament Override/)).not.toBeInTheDocument();
  137. });
  138. it('starts on alternatives for files of different models, without a printer picker', async () => {
  139. renderModal([PART_A, PART_C], { separateJobsAllowed: true });
  140. expect(await screen.findByRole('radio', { name: 'Alternatives' })).toHaveAttribute('aria-checked', 'true');
  141. expect(screen.getByText('Printer alternatives')).toBeInTheDocument();
  142. expect(screen.queryByText('A1 Test')).not.toBeInTheDocument();
  143. expect(screen.queryByText('Specific Printer')).not.toBeInTheDocument();
  144. });
  145. it('queues one item per file in list order on the chosen printer', async () => {
  146. const user = userEvent.setup();
  147. renderModal([PART_A, PART_B], { separateJobsAllowed: true });
  148. await pickPrinterAndWait(user, 'A1 Test');
  149. await user.click(printButton());
  150. await waitFor(() => expect(posted).toHaveLength(2));
  151. expect(fileIds(posted)).toEqual([11, 12]);
  152. for (const body of posted) {
  153. expect(body.printer_id).toBe(1);
  154. expect(body).not.toHaveProperty('variants');
  155. // Each file's own 3MF decides its trays at dispatch.
  156. expect(body.ams_mapping).toBeUndefined();
  157. expect(body.filament_overrides).toBeUndefined();
  158. // Nothing that belongs to the first file alone, nor a batch tying the
  159. // files together: the server prices and batches each file on its own.
  160. expect(body).not.toHaveProperty('estimated_cost');
  161. expect(body).not.toHaveProperty('skip_filament_check');
  162. expect(body).not.toHaveProperty('nozzle_rack_choice');
  163. expect(body).not.toHaveProperty('batch_id');
  164. }
  165. });
  166. it('counts the items of every listed file in the quantity hint', async () => {
  167. // Each file is posted with the quantity, so 2 files x 2 make 4 items.
  168. renderModal([PART_A, PART_B], { separateJobsAllowed: true });
  169. await screen.findByRole('radio', { name: 'Separate jobs' });
  170. fireEvent.change(screen.getByLabelText('Quantity'), { target: { value: '2' } });
  171. expect(await screen.findByText('Creates 4 queue items')).toBeInTheDocument();
  172. });
  173. it('warns per printer model about the files sliced for another model', async () => {
  174. // Every selected printer gets every file, so each printer's model is
  175. // checked against all of them. A warning, not a block, as for one file.
  176. const user = userEvent.setup();
  177. renderModal([PART_A, PART_C], { separateJobsAllowed: true });
  178. await user.click(await screen.findByRole('radio', { name: 'Separate jobs' }));
  179. await pickPrinterAndWait(user, 'A1 Test');
  180. expect(screen.getByText('Not sliced for A1: part-c.gcode.3mf')).toBeInTheDocument();
  181. expect(screen.queryByText(/Not sliced for P1S/)).not.toBeInTheDocument();
  182. await user.click(screen.getByText('P1S Test'));
  183. expect(await screen.findByText('Not sliced for P1S: part-a.gcode.3mf')).toBeInTheDocument();
  184. expect(screen.getByText('Not sliced for A1: part-c.gcode.3mf')).toBeInTheDocument();
  185. expect(printButton()).toBeEnabled();
  186. });
  187. describe('while the printer status is still loading', () => {
  188. beforeEach(() => {
  189. server.use(
  190. http.get('/api/v1/printers/:id/status', async () => {
  191. await delay('infinite');
  192. return HttpResponse.json({});
  193. }),
  194. );
  195. });
  196. it('lets separate jobs be queued, which send no AMS mapping to wait for', async () => {
  197. const user = userEvent.setup();
  198. renderModal([PART_A, PART_B], { separateJobsAllowed: true });
  199. await user.click(await screen.findByText('A1 Test'));
  200. await waitFor(() => expect(printButton()).toBeEnabled());
  201. expect(screen.queryByText(/Waiting for AMS status/)).not.toBeInTheDocument();
  202. });
  203. it('still holds a single file until the AMS status is in (#2589)', async () => {
  204. const user = userEvent.setup();
  205. renderModal([PART_A]);
  206. await user.click(await screen.findByText('A1 Test'));
  207. expect(await screen.findByText('Waiting for AMS status from A1 Test…')).toBeInTheDocument();
  208. expect(printButton()).toBeDisabled();
  209. });
  210. });
  211. it('keeps the mode while the files are being queued', async () => {
  212. // A switch mid-run could leave the rest of a partly queued run as
  213. // alternatives, with the toggle already gone.
  214. respond = () => new Promise<Response>(() => {});
  215. const user = userEvent.setup();
  216. renderModal([PART_A, PART_C], { separateJobsAllowed: true });
  217. await user.click(await screen.findByRole('radio', { name: 'Separate jobs' }));
  218. await pickPrinterAndWait(user, 'A1 Test');
  219. await user.click(printButton());
  220. await waitFor(() => expect(posted).toHaveLength(1));
  221. expect(screen.getByRole('radio', { name: 'Alternatives' })).toBeDisabled();
  222. expect(screen.getByRole('radio', { name: 'Separate jobs' })).toBeDisabled();
  223. });
  224. it('treats a file queued on one of two printers as partly queued', async () => {
  225. // part-a goes onto the A1 and is refused by the P1S: one item is in the
  226. // queue, although no file went through on every printer.
  227. respond = (_body, index) =>
  228. index === 0
  229. ? HttpResponse.json({ id: 1, status: 'pending' })
  230. : HttpResponse.json({ detail: 'refused' }, { status: 400 });
  231. const user = userEvent.setup();
  232. renderModal([PART_A, PART_C], { separateJobsAllowed: true });
  233. await user.click(await screen.findByRole('radio', { name: 'Separate jobs' }));
  234. await user.click(await screen.findByText('A1 Test'));
  235. await pickPrinterAndWait(user, 'P1S Test');
  236. await user.click(printButton());
  237. const message = await screen.findByText(/Not queued: part-a\.gcode\.3mf \(refused\)/);
  238. expect(posted).toHaveLength(2);
  239. expect(message).toHaveTextContent('Queued before it: part-a.gcode.3mf.');
  240. expect(message).not.toHaveTextContent('None');
  241. await waitFor(() =>
  242. expect(screen.queryByRole('radiogroup', { name: 'What the selected files become' })).not.toBeInTheDocument(),
  243. );
  244. // A second Print sends part-a only to the printer that refused it: the A1
  245. // already has it, and queuing it there again would print it twice.
  246. respond = () => HttpResponse.json({ id: 2, status: 'pending' });
  247. await user.click(printButton());
  248. await waitFor(() => expect(posted).toHaveLength(5));
  249. expect(posted.slice(2).map((b) => [b.library_file_id, b.printer_id])).toEqual([
  250. [11, 2],
  251. [13, 1],
  252. [13, 2],
  253. ]);
  254. });
  255. it('follows the reordered list', async () => {
  256. const user = userEvent.setup();
  257. renderModal([PART_A, PART_B], { separateJobsAllowed: true });
  258. await pickPrinterAndWait(user, 'A1 Test');
  259. await user.click(screen.getAllByRole('button', { name: 'Move up' })[1]);
  260. await user.click(printButton());
  261. await waitFor(() => expect(posted).toHaveLength(2));
  262. expect(fileIds(posted)).toEqual([12, 11]);
  263. });
  264. describe('with a multi-plate file first', () => {
  265. beforeEach(() => {
  266. server.use(
  267. http.get('/api/v1/library/files/:id/plates', ({ params }) =>
  268. Number(params.id) === 11
  269. ? HttpResponse.json({
  270. file_id: 11,
  271. filename: 'part-a.gcode.3mf',
  272. is_multi_plate: true,
  273. plates: [1, 2].map((index) => ({
  274. index,
  275. name: `Plate ${index}`,
  276. objects: ['part'],
  277. filaments: [{ slot_id: 1, type: 'PLA', color: '#FFFFFF' }],
  278. has_thumbnail: false,
  279. thumbnail_url: null,
  280. bed_type: null,
  281. print_time_seconds: 600,
  282. })),
  283. })
  284. : HttpResponse.json({ file_id: Number(params.id), filename: 'x', plates: [], is_multi_plate: false }),
  285. ),
  286. );
  287. });
  288. it('queues the plate chosen in the list', async () => {
  289. const user = userEvent.setup();
  290. renderModal([PART_A, PART_B], { separateJobsAllowed: true });
  291. await user.selectOptions(await screen.findByLabelText('Plate for part-a.gcode.3mf'), '2');
  292. await pickPrinterAndWait(user, 'A1 Test');
  293. // The first file's plate summary belongs to a single-file print.
  294. expect(screen.queryByTestId('selected-plates-summary')).not.toBeInTheDocument();
  295. await user.click(printButton());
  296. await waitFor(() => expect(posted).toHaveLength(2));
  297. expect(posted[0]).toMatchObject({ library_file_id: 11, plate_id: 2 });
  298. expect(posted[1]).toMatchObject({ library_file_id: 12, plate_id: null });
  299. });
  300. it('keeps the global quantity and sends it with every item', async () => {
  301. // The first file's per-plate steppers would own the number (#342) and
  302. // hide this field, but nothing would send them: the same trap as #3101.
  303. const user = userEvent.setup();
  304. renderModal([PART_A, PART_B], { separateJobsAllowed: true });
  305. await screen.findByLabelText('Plate for part-a.gcode.3mf');
  306. await pickPrinterAndWait(user, 'A1 Test');
  307. fireEvent.change(screen.getByLabelText('Quantity'), { target: { value: '3' } });
  308. await user.click(printButton());
  309. await waitFor(() => expect(posted).toHaveLength(2));
  310. expect(posted.map((b) => b.quantity)).toEqual([3, 3]);
  311. });
  312. });
  313. it('blocks "any A1" while a listed file cannot run on an A1', async () => {
  314. const user = userEvent.setup();
  315. renderModal([PART_A, PART_C], { separateJobsAllowed: true });
  316. await user.click(await screen.findByRole('radio', { name: 'Separate jobs' }));
  317. await user.click(screen.getByRole('button', { name: /Any Model/ }));
  318. await user.selectOptions(screen.getByDisplayValue('Select a model...'), 'A1');
  319. expect(await screen.findByText('Cannot be dispatched to A1 printers: part-c.gcode.3mf')).toBeInTheDocument();
  320. expect(printButton()).toBeDisabled();
  321. });
  322. it('switching to separate jobs and back queues the alternatives job unchanged', async () => {
  323. const user = userEvent.setup();
  324. renderModal([PART_A, PART_C], { separateJobsAllowed: true });
  325. await user.click(await screen.findByRole('radio', { name: 'Separate jobs' }));
  326. await pickPrinterAndWait(user, 'A1 Test');
  327. await user.click(screen.getByRole('radio', { name: 'Alternatives' }));
  328. // The printer chosen for separate jobs is gone with its picker.
  329. expect(screen.queryByText('A1 Test')).not.toBeInTheDocument();
  330. await waitFor(() => expect(printButton()).toBeEnabled());
  331. expect(screen.queryByText(/Filament Mapping/)).not.toBeInTheDocument();
  332. await user.click(printButton());
  333. await waitFor(() => expect(posted).toHaveLength(1));
  334. await new Promise((resolve) => setTimeout(resolve, 100));
  335. expect(posted).toHaveLength(1);
  336. expect(posted[0].variants).toHaveLength(2);
  337. expect(posted[0]).not.toHaveProperty('printer_id');
  338. expect(posted[0]).not.toHaveProperty('target_model');
  339. });
  340. it('does not carry a location picked for separate jobs into the alternatives', async () => {
  341. // The alternatives job always sends target_location, and no control in
  342. // that mode shows it, so a leftover would narrow it out of sight.
  343. server.use(
  344. http.get('/api/v1/printers/', () =>
  345. HttpResponse.json([
  346. { id: 1, name: 'A1 Test', model: 'A1', location: 'Shop', ip_address: '192.0.2.1', is_active: true, enabled: true },
  347. { id: 2, name: 'P1S Test', model: 'P1S', ip_address: '192.0.2.2', is_active: true, enabled: true },
  348. ]),
  349. ),
  350. );
  351. const user = userEvent.setup();
  352. renderModal([PART_A, PART_C], { separateJobsAllowed: true });
  353. await user.click(await screen.findByRole('radio', { name: 'Separate jobs' }));
  354. await user.click(screen.getByRole('button', { name: /Any Model/ }));
  355. await user.selectOptions(screen.getByDisplayValue('Select a model...'), 'A1');
  356. await user.selectOptions(await screen.findByDisplayValue('Any location'), 'Shop');
  357. await user.click(screen.getByRole('radio', { name: 'Alternatives' }));
  358. await waitFor(() => expect(printButton()).toBeEnabled());
  359. await user.click(printButton());
  360. await waitFor(() => expect(posted).toHaveLength(1));
  361. expect(posted[0].variants).toHaveLength(2);
  362. expect(posted[0].target_location).toBeNull();
  363. });
  364. it('stops at a refused file, drops what was queued, and continues on resubmit', async () => {
  365. const onClose = vi.fn();
  366. respond = (_body, index) =>
  367. index === 1
  368. ? HttpResponse.json({ detail: 'refused' }, { status: 400 })
  369. : HttpResponse.json({ id: index + 1, status: 'pending' });
  370. const user = userEvent.setup();
  371. renderModal([PART_A, PART_B], { separateJobsAllowed: true, onClose });
  372. await pickPrinterAndWait(user, 'A1 Test');
  373. await user.click(printButton());
  374. expect(await screen.findByText(/Not queued: part-b\.gcode\.3mf \(refused\)/)).toBeInTheDocument();
  375. expect(fileIds(posted)).toEqual([11, 12]);
  376. await waitFor(() => expect(screen.queryByText('part-a.gcode.3mf')).not.toBeInTheDocument());
  377. expect(screen.getByText('part-b.gcode.3mf')).toBeInTheDocument();
  378. expect(onClose).not.toHaveBeenCalled();
  379. // What is left is the rest of these separate jobs, not a new choice: with
  380. // one file left, "Alternatives" would post a job with a single candidate.
  381. expect(screen.queryByRole('radiogroup', { name: 'What the selected files become' })).not.toBeInTheDocument();
  382. // With the toggle gone, the list carries the explanation, once.
  383. expect(screen.getAllByText(SEPARATE_HINT)).toHaveLength(1);
  384. await waitFor(() => expect(printButton()).toBeEnabled());
  385. await user.click(printButton());
  386. await waitFor(() => expect(posted).toHaveLength(3));
  387. expect(fileIds(posted.slice(2))).toEqual([12]);
  388. expect(posted[2]).not.toHaveProperty('variants');
  389. expect(posted[2].printer_id).toBe(1);
  390. await waitFor(() => expect(onClose).toHaveBeenCalled());
  391. });
  392. it('checks the model of the files still listed after the first one was queued', async () => {
  393. // The dialog's own file (the first one) is queued and gone; "Any P1S" for
  394. // the P1S file that is left must not be judged by the A1 file's model.
  395. respond = (_body, index) =>
  396. index === 0
  397. ? HttpResponse.json({ id: 1, status: 'pending' })
  398. : HttpResponse.json({ detail: 'refused' }, { status: 400 });
  399. const user = userEvent.setup();
  400. renderModal([PART_A, PART_C], { separateJobsAllowed: true });
  401. await user.click(await screen.findByRole('radio', { name: 'Separate jobs' }));
  402. await pickPrinterAndWait(user, 'P1S Test');
  403. await user.click(printButton());
  404. await screen.findByText(/Not queued: part-c\.gcode\.3mf/);
  405. await waitFor(() => expect(screen.queryByText('part-a.gcode.3mf')).not.toBeInTheDocument());
  406. respond = () => HttpResponse.json({ id: 9, status: 'pending' });
  407. await user.click(screen.getByRole('button', { name: /Any P1S/ }));
  408. await waitFor(() => expect(printButton()).toBeEnabled());
  409. await user.click(printButton());
  410. await waitFor(() => expect(posted).toHaveLength(3));
  411. expect(posted[2]).toMatchObject({ library_file_id: 13, target_model: 'P1S', printer_id: null });
  412. });
  413. it('keeps Alternatives blocked for two files of one model, also on a click', async () => {
  414. renderModal([PART_A, PART_B], { separateJobsAllowed: true });
  415. const alternatives = await screen.findByRole('radio', { name: 'Alternatives' });
  416. fireEvent.click(alternatives);
  417. expect(screen.getByRole('radio', { name: 'Separate jobs' })).toHaveAttribute('aria-checked', 'true');
  418. expect(alternatives).toHaveAttribute('aria-checked', 'false');
  419. expect(screen.getByText('Queue order')).toBeInTheDocument();
  420. expect(screen.queryByText('Printer alternatives')).not.toBeInTheDocument();
  421. });
  422. it('queues the rest of an ASAP run after the items already inserted (#3471 review)', async () => {
  423. // part-a goes in at 1, part-b is refused. The second Print must put
  424. // part-b at 2, behind part-a, not ahead of it at 1.
  425. respond = (_body, index) =>
  426. index === 1
  427. ? HttpResponse.json({ detail: 'refused' }, { status: 400 })
  428. : HttpResponse.json({ id: index + 1, status: 'pending' });
  429. // The queue as the server then has it: part-a (item 1) on top.
  430. server.use(http.get('/api/v1/queue/', () => HttpResponse.json([{ id: 1, position: 1, batch_id: null }])));
  431. const user = userEvent.setup();
  432. renderModal([PART_A, PART_B], { separateJobsAllowed: true });
  433. await pickPrinterAndWait(user, 'A1 Test');
  434. await user.click(screen.getByRole('button', { name: /^ASAP$/i }));
  435. await user.click(printButton());
  436. await screen.findByText(/Not queued: part-b\.gcode\.3mf/);
  437. expect(posted[0]).toMatchObject({ library_file_id: 11, insert_at_top: true, insert_position: 1 });
  438. await waitFor(() => expect(printButton()).toBeEnabled());
  439. await user.click(printButton());
  440. await waitFor(() => expect(posted).toHaveLength(3));
  441. expect(posted[2]).toMatchObject({ library_file_id: 12, insert_at_top: true, insert_position: 2 });
  442. });
  443. it('closes without a "0 items queued" message when nothing is left to send (#3471 review)', async () => {
  444. // Everything goes through except part-c on the P1S. With the P1S
  445. // deselected, part-c on the A1 is already queued: nothing to post.
  446. respond = (_body, index) =>
  447. index === 3
  448. ? HttpResponse.json({ detail: 'refused' }, { status: 400 })
  449. : HttpResponse.json({ id: index + 1, status: 'pending' });
  450. const onClose = vi.fn();
  451. const onSuccess = vi.fn();
  452. const user = userEvent.setup();
  453. renderModal([PART_A, PART_C], { separateJobsAllowed: true, onClose, onSuccess });
  454. await user.click(await screen.findByRole('radio', { name: 'Separate jobs' }));
  455. await user.click(await screen.findByText('A1 Test'));
  456. await pickPrinterAndWait(user, 'P1S Test');
  457. await user.click(printButton());
  458. await screen.findByText(/Not queued: part-c\.gcode\.3mf/);
  459. expect(posted).toHaveLength(4);
  460. await user.click(screen.getByText('P1S Test'));
  461. await waitFor(() => expect(printButton()).toBeEnabled());
  462. await user.click(printButton());
  463. await waitFor(() => expect(onClose).toHaveBeenCalled());
  464. expect(onSuccess).toHaveBeenCalled();
  465. expect(posted).toHaveLength(4);
  466. expect(screen.queryByText(/0 items queued/)).not.toBeInTheDocument();
  467. });
  468. describe('while a file\'s plate list is still loading (#3471 review)', () => {
  469. let releasePlates: () => void;
  470. beforeEach(() => {
  471. const platesLoaded = new Promise<void>((resolve) => {
  472. releasePlates = resolve;
  473. });
  474. server.use(
  475. http.get('/api/v1/library/files/:id/plates', async ({ params }) => {
  476. if (Number(params.id) !== 12) {
  477. return HttpResponse.json({ file_id: Number(params.id), filename: 'x', plates: [], is_multi_plate: false });
  478. }
  479. await platesLoaded;
  480. return HttpResponse.json({
  481. file_id: 12,
  482. filename: 'part-b.gcode.3mf',
  483. is_multi_plate: true,
  484. plates: [1, 2].map((index) => ({
  485. index,
  486. name: `Plate ${index}`,
  487. objects: ['part'],
  488. filaments: [{ slot_id: 1, type: 'PLA', color: '#FFFFFF' }],
  489. has_thumbnail: false,
  490. thumbnail_url: null,
  491. bed_type: null,
  492. print_time_seconds: 600,
  493. })),
  494. });
  495. }),
  496. );
  497. });
  498. it('holds Print for separate jobs until the plate is chosen', async () => {
  499. const user = userEvent.setup();
  500. renderModal([PART_A, PART_B], { separateJobsAllowed: true });
  501. await user.click(await screen.findByText('A1 Test'));
  502. // Long enough for the printer status; only the plate list is missing.
  503. await delay(300);
  504. expect(printButton()).toBeDisabled();
  505. releasePlates();
  506. await screen.findByLabelText('Plate for part-b.gcode.3mf');
  507. await waitFor(() => expect(printButton()).toBeEnabled());
  508. await user.click(printButton());
  509. await waitFor(() => expect(posted).toHaveLength(2));
  510. expect(posted[1]).toMatchObject({ library_file_id: 12, plate_id: 1 });
  511. });
  512. it('holds Print for alternatives the same way', async () => {
  513. const user = userEvent.setup();
  514. renderModal([PART_C, PART_B]);
  515. await screen.findByText('part-b.gcode.3mf');
  516. await delay(300);
  517. expect(printButton()).toBeDisabled();
  518. releasePlates();
  519. await screen.findByLabelText('Plate for part-b.gcode.3mf');
  520. await waitFor(() => expect(printButton()).toBeEnabled());
  521. await user.click(printButton());
  522. await waitFor(() => expect(posted).toHaveLength(1));
  523. expect(posted[0].variants).toEqual(
  524. expect.arrayContaining([expect.objectContaining({ library_file_id: 12, plate_id: 1 })]),
  525. );
  526. });
  527. });
  528. describe('carried over from an earlier dialog (#3471 review)', () => {
  529. it('reports what a refused run got into the queue', async () => {
  530. respond = (_body, index) =>
  531. index === 1
  532. ? HttpResponse.json({ detail: 'refused' }, { status: 400 })
  533. : HttpResponse.json({ id: index + 1, status: 'pending' });
  534. const onSeparateJobsProgress = vi.fn();
  535. const user = userEvent.setup();
  536. renderModal([PART_A, PART_B], { separateJobsAllowed: true, onSeparateJobsProgress });
  537. await pickPrinterAndWait(user, 'A1 Test');
  538. await user.click(screen.getByRole('button', { name: /^ASAP$/i }));
  539. await user.click(printButton());
  540. await screen.findByText(/Not queued: part-b\.gcode\.3mf/);
  541. expect(onSeparateJobsProgress).toHaveBeenLastCalledWith({
  542. queuedPairs: ['11:1'],
  543. asapInserted: 1,
  544. queuedItemIds: [1],
  545. queuedBatchIds: [],
  546. queuedFileIds: [11],
  547. remainingFileIds: [12],
  548. // What the rest is queued with when the dialog is opened again.
  549. settings: expect.objectContaining({
  550. assignmentMode: 'printer',
  551. printerIds: [1],
  552. targetModel: null,
  553. quantity: 1,
  554. scheduleOptions: expect.objectContaining({ scheduleType: 'asap' }),
  555. }),
  556. });
  557. });
  558. it('skips the pairs and continues the ASAP positions of the earlier dialog', async () => {
  559. const user = userEvent.setup();
  560. renderModal([PART_A, PART_B], {
  561. separateJobsAllowed: true,
  562. resumeSeparateJobs: { queuedPairs: ['11:1'], asapInserted: 1 },
  563. });
  564. // The rest of a partly queued run: no new choice of mode.
  565. await screen.findByText('part-b.gcode.3mf');
  566. expect(screen.queryByRole('radiogroup', { name: 'What the selected files become' })).not.toBeInTheDocument();
  567. await pickPrinterAndWait(user, 'A1 Test');
  568. await user.click(screen.getByRole('button', { name: /^ASAP$/i }));
  569. await user.click(printButton());
  570. await waitFor(() => expect(posted).toHaveLength(1));
  571. expect(posted[0]).toMatchObject({ library_file_id: 12, printer_id: 1, insert_position: 2 });
  572. });
  573. it("finishes the run with the earlier dialog's printers, plates and options", async () => {
  574. server.use(
  575. http.get('/api/v1/library/files/:id/plates', ({ params }) =>
  576. HttpResponse.json({
  577. file_id: Number(params.id),
  578. filename: 'x',
  579. is_multi_plate: true,
  580. plates: [
  581. { index: 1, name: 'One', objects: [], has_thumbnail: false, filaments: [] },
  582. { index: 3, name: 'Three', objects: [], has_thumbnail: false, filaments: [] },
  583. ],
  584. }),
  585. ),
  586. );
  587. const user = userEvent.setup();
  588. renderModal([PART_D], {
  589. separateJobsAllowed: true,
  590. resumeSeparateJobs: {
  591. queuedPairs: ['14:1'],
  592. asapInserted: 0,
  593. settings: {
  594. plateByFile: { 14: 3 },
  595. assignmentMode: 'printer',
  596. printerIds: [1, 2],
  597. targetModel: null,
  598. targetLocation: null,
  599. quantity: 1,
  600. printOptions: {
  601. ...DEFAULT_PRINT_OPTIONS,
  602. timelapse: true,
  603. },
  604. scheduleOptions: { ...DEFAULT_SCHEDULE_OPTIONS, scheduleType: 'queue', requireManualStart: true },
  605. costCenterId: null,
  606. },
  607. },
  608. });
  609. // Nothing picked by hand: the run's printers, plate and options are back.
  610. await screen.findByLabelText('Plate for part-d.gcode.3mf');
  611. await waitFor(() => expect(printButton()).toBeEnabled());
  612. await user.click(printButton());
  613. await waitFor(() => expect(posted).toHaveLength(1));
  614. expect(posted[0]).toMatchObject({
  615. library_file_id: 14,
  616. printer_id: 2,
  617. plate_id: 3,
  618. timelapse: true,
  619. manual_start: true,
  620. });
  621. });
  622. it('puts the rest behind where the run ends in the queue now', async () => {
  623. // The run's item 5 moved to position 3 since the refusal.
  624. server.use(
  625. http.get('/api/v1/queue/', () =>
  626. HttpResponse.json([
  627. { id: 9, position: 1, batch_id: null },
  628. { id: 8, position: 2, batch_id: null },
  629. { id: 5, position: 3, batch_id: null },
  630. { id: 7, position: 4, batch_id: null },
  631. ]),
  632. ),
  633. );
  634. const user = userEvent.setup();
  635. renderModal([PART_A, PART_B], {
  636. separateJobsAllowed: true,
  637. resumeSeparateJobs: { queuedPairs: ['11:1'], asapInserted: 1, queuedItemIds: [5] },
  638. });
  639. await screen.findByText('part-b.gcode.3mf');
  640. await pickPrinterAndWait(user, 'A1 Test');
  641. await user.click(screen.getByRole('button', { name: /^ASAP$/i }));
  642. await user.click(printButton());
  643. await waitFor(() => expect(posted).toHaveLength(1));
  644. expect(posted[0]).toMatchObject({ library_file_id: 12, insert_position: 4 });
  645. });
  646. it('falls back to the positions taken when the queue cannot be read', async () => {
  647. server.use(http.get('/api/v1/queue/', () => HttpResponse.json({ detail: 'boom' }, { status: 500 })));
  648. const user = userEvent.setup();
  649. renderModal([PART_A, PART_B], {
  650. separateJobsAllowed: true,
  651. resumeSeparateJobs: { queuedPairs: ['11:1'], asapInserted: 1, queuedItemIds: [5] },
  652. });
  653. await screen.findByText('part-b.gcode.3mf');
  654. await pickPrinterAndWait(user, 'A1 Test');
  655. await user.click(screen.getByRole('button', { name: /^ASAP$/i }));
  656. await user.click(printButton());
  657. await waitFor(() => expect(posted).toHaveLength(1));
  658. expect(posted[0]).toMatchObject({ library_file_id: 12, insert_position: 2 });
  659. });
  660. it('continues a single file that one printer already took', async () => {
  661. const user = userEvent.setup();
  662. renderModal([PART_D], {
  663. separateJobsAllowed: true,
  664. resumeSeparateJobs: { queuedPairs: ['14:1'], asapInserted: 0 },
  665. });
  666. await user.click(await screen.findByText('A1 Test'));
  667. await pickPrinterAndWait(user, 'P1S Test');
  668. await user.click(printButton());
  669. await waitFor(() => expect(posted).toHaveLength(1));
  670. expect(posted.map((b) => [b.library_file_id, b.printer_id])).toEqual([[14, 2]]);
  671. });
  672. });
  673. });