PrintModalCrossModel.test.tsx 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. /**
  2. * PrintModal in cross-model mode (#671).
  3. *
  4. * Selecting several sliced files puts the modal in model-based assignment with
  5. * no single target model. That combination used to fall through every gate the
  6. * override UI depends on, leaving the user with *less* control than the
  7. * ordinary "Any X1C" flow — no AMS mapping (correct, there is no printer yet)
  8. * and no filament override either (wrong).
  9. */
  10. import { describe, it, expect, beforeEach } from 'vitest';
  11. import { screen, waitFor, fireEvent, within } from '@testing-library/react';
  12. import userEvent from '@testing-library/user-event';
  13. import { http, HttpResponse } from 'msw';
  14. import { render } from '../utils';
  15. import { server } from '../mocks/server';
  16. import { PrintModal } from '../../components/PrintModal';
  17. const CANDIDATES = [
  18. { id: 11, filename: 'x1c.gcode.3mf', sliced_for_model: 'X1C' },
  19. { id: 12, filename: 'h2d.gcode.3mf', sliced_for_model: 'H2D' },
  20. ];
  21. /** Loaded filaments differ per model — the union is what the user may pick.
  22. * The dropdown only ever offers the slot's own material (overriding PLA with
  23. * PETG is not a colour choice), so the spool that proves the union works has
  24. * to be a PLA the X1C does not have. */
  25. const BY_MODEL: Record<string, Array<Record<string, unknown>>> = {
  26. X1C: [{ type: 'PLA', color: '#FFFFFF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: null }],
  27. H2D: [
  28. { type: 'PLA', color: '#FFFFFF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: null },
  29. { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', tray_sub_brands: 'PLA Matte', extruder_id: null },
  30. ],
  31. };
  32. function mockBackend() {
  33. server.use(
  34. http.get('/api/v1/printers/', () =>
  35. HttpResponse.json([
  36. { id: 1, name: 'X1C-1', model: 'X1C', ip_address: '10.0.0.1', is_active: true, enabled: true },
  37. { id: 2, name: 'H2D-1', model: 'H2D', ip_address: '10.0.0.2', is_active: true, enabled: true },
  38. ]),
  39. ),
  40. http.get('/api/v1/printers/available-filaments', ({ request }) => {
  41. const model = new URL(request.url).searchParams.get('model') ?? '';
  42. return HttpResponse.json(BY_MODEL[model] ?? []);
  43. }),
  44. http.get('/api/v1/library/files/:id', ({ params }) =>
  45. HttpResponse.json({
  46. id: Number(params.id),
  47. filename: 'x1c.gcode.3mf',
  48. file_type: 'gcode.3mf',
  49. sliced_for_model: 'X1C',
  50. }),
  51. ),
  52. http.get('/api/v1/library/files/:id/plates', ({ params }) =>
  53. HttpResponse.json({ file_id: Number(params.id), filename: 'x', plates: [], is_multi_plate: false }),
  54. ),
  55. http.get('/api/v1/library/files/:id/filament-requirements', () =>
  56. HttpResponse.json({
  57. filaments: [{ slot_id: 1, type: 'PLA', color: '#FFFFFF', used_grams: 15, used_meters: 5 }],
  58. }),
  59. ),
  60. );
  61. }
  62. function renderCrossModel() {
  63. render(
  64. <PrintModal
  65. mode="create"
  66. libraryFileId={CANDIDATES[0].id}
  67. variantFiles={CANDIDATES}
  68. archiveName="bracket"
  69. onClose={() => {}}
  70. />,
  71. );
  72. }
  73. describe('PrintModal cross-model mode', () => {
  74. beforeEach(() => mockBackend());
  75. it('replaces the printer picker with the candidate list', async () => {
  76. renderCrossModel();
  77. expect(await screen.findByText('x1c.gcode.3mf')).toBeInTheDocument();
  78. expect(screen.getByText('h2d.gcode.3mf')).toBeInTheDocument();
  79. // Choosing these files already answered "which printer".
  80. expect(screen.queryByText('Select Printer')).not.toBeInTheDocument();
  81. });
  82. it('offers filament overrides drawn from every candidate model', async () => {
  83. renderCrossModel();
  84. expect(await screen.findByText('Filament Override')).toBeInTheDocument();
  85. // PLA Matte is loaded only on the H2D. It has to be offered anyway: the job
  86. // can land there, and choosing it simply narrows which candidates match.
  87. // The control is a listbox since it gained a swatch per choice (#3159), so
  88. // its rows exist only while it is open.
  89. const trigger = await screen.findByRole('combobox', { name: /filament override for/i });
  90. fireEvent.click(trigger);
  91. await waitFor(() => {
  92. const options = within(screen.getByRole('listbox'))
  93. .getAllByRole('option')
  94. .map((o) => o.textContent ?? '');
  95. expect(options.some((o) => o.includes('PLA Matte'))).toBe(true);
  96. expect(options.some((o) => o.includes('PLA Basic'))).toBe(true);
  97. });
  98. });
  99. it('shows a queued job its alternatives instead of a printer picker', async () => {
  100. // Before this, editing a cross-model item showed "Any H2D" with a live
  101. // Target Model dropdown and a Specific Printer toggle. Saving that left a
  102. // row with variants AND a printer_id, and the fixed-printer branch of the
  103. // scheduler wins — dispatching a row whose library_file_id is still null.
  104. render(
  105. <PrintModal
  106. mode="edit-queue-item"
  107. libraryFileId={CANDIDATES[0].id}
  108. archiveName="bracket"
  109. queueItem={
  110. {
  111. id: 9,
  112. printer_id: null,
  113. target_model: 'H2D',
  114. status: 'pending',
  115. variants: [
  116. { library_file_id: 12, filename: 'h2d.gcode.3mf', target_model: 'H2D', position: 0 },
  117. { library_file_id: 11, filename: 'x1c.gcode.3mf', target_model: 'X1C', position: 1 },
  118. ],
  119. } as never
  120. }
  121. onClose={() => {}}
  122. />,
  123. );
  124. expect(await screen.findByText('h2d.gcode.3mf')).toBeInTheDocument();
  125. expect(screen.getByText('x1c.gcode.3mf')).toBeInTheDocument();
  126. expect(screen.queryByText('Target Model')).not.toBeInTheDocument();
  127. // Read-only: reordering after queueing would need a variant-level API.
  128. expect(screen.queryByLabelText('Move down')).not.toBeInTheDocument();
  129. });
  130. it('shows no AMS slot mapping, because no printer has been chosen yet', async () => {
  131. renderCrossModel();
  132. await screen.findByText('x1c.gcode.3mf');
  133. // The scheduler derives the mapping against whichever printer it picks —
  134. // collecting tray numbers here would only be thrown away.
  135. expect(screen.queryByText('Filament Mapping')).not.toBeInTheDocument();
  136. });
  137. });
  138. /**
  139. * Cross-model on a multi-plate file (#3101).
  140. *
  141. * The two features met badly. A multi-plate file moves quantity onto the
  142. * per-plate steppers and hides the global field (#342), but the cross-model
  143. * submit posts the global one — which in that configuration nothing can
  144. * change. The reporter asked for 19 runs across an X1C and a P2S, watched the
  145. * modal say "19 runs in total", and got exactly one print.
  146. */
  147. describe('PrintModal cross-model mode on a multi-plate file', () => {
  148. const PLATES = [1, 2, 3].map((i) => ({
  149. index: i,
  150. name: `Plate ${i}`,
  151. objects: ['bracket'],
  152. filaments: [{ slot_id: 1, type: 'PETG', color: '#FFFFFF' }],
  153. has_thumbnail: false,
  154. thumbnail_url: null,
  155. bed_type: null,
  156. print_time_seconds: 3600,
  157. }));
  158. let posted: Record<string, unknown> | null;
  159. let reqUrls: string[];
  160. beforeEach(() => {
  161. posted = null;
  162. reqUrls = [];
  163. mockBackend();
  164. server.use(
  165. http.get('/api/v1/library/files/:id/plates', ({ params }) =>
  166. HttpResponse.json({ file_id: Number(params.id), filename: 'x', plates: PLATES, is_multi_plate: true }),
  167. ),
  168. http.get('/api/v1/library/files/:id/filament-requirements', ({ request }) => {
  169. reqUrls.push(request.url);
  170. return HttpResponse.json({
  171. filaments: [{ slot_id: 1, type: 'PETG', color: '#FFFFFF', used_grams: 15, used_meters: 5 }],
  172. });
  173. }),
  174. http.post('/api/v1/queue/', async ({ request }) => {
  175. posted = (await request.json()) as Record<string, unknown>;
  176. return HttpResponse.json({ id: 1, status: 'pending', variants: [] });
  177. }),
  178. );
  179. });
  180. it('offers the global Quantity field, not the per-plate steppers', async () => {
  181. renderCrossModel();
  182. await screen.findByText('x1c.gcode.3mf');
  183. // The plate selector is gone: its choice never reached the request, and the
  184. // candidate list below is where a cross-model job picks its plates.
  185. expect(screen.queryByRole('button', { name: /Select All/i })).toBeNull();
  186. expect(await screen.findByLabelText('Quantity')).toBeInTheDocument();
  187. });
  188. it('queues the number of copies the user asked for', async () => {
  189. const user = userEvent.setup();
  190. renderCrossModel();
  191. await screen.findByText('x1c.gcode.3mf');
  192. fireEvent.change(await screen.findByLabelText('Quantity'), { target: { value: '19' } });
  193. await user.click(screen.getByRole('button', { name: /^Print$/i }));
  194. await waitFor(() => expect(posted).not.toBeNull());
  195. expect(posted!.quantity).toBe(19);
  196. // One job with both candidates, nineteen times over — not nineteen jobs
  197. // each pinned to a model, and not one job.
  198. expect((posted!.variants as Array<{ library_file_id: number }>).map((v) => v.library_file_id))
  199. .toEqual([11, 12]);
  200. });
  201. it('reads filament requirements for the plate the primary candidate will run', async () => {
  202. const user = userEvent.setup();
  203. renderCrossModel();
  204. await screen.findByText('x1c.gcode.3mf');
  205. await waitFor(() => expect(reqUrls.some((u) => u.includes('plate_id=1'))).toBe(true));
  206. await user.selectOptions(
  207. screen.getByLabelText('Plate for x1c.gcode.3mf'),
  208. '3',
  209. );
  210. // Without this the override panel would describe plate 1 while the job ran
  211. // plate 3 — the plate selector that used to key it is no longer on screen.
  212. await waitFor(() => expect(reqUrls.some((u) => u.includes('plate_id=3'))).toBe(true));
  213. });
  214. });