PrintersPageDryingStartModes.test.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. /**
  2. * UX for the scheduled-drying start modes.
  3. *
  4. * "After delay" and "At time" reveal an extra control at the bottom of the
  5. * drying popover. These used to render below the fold of a height-capped,
  6. * scrollable body with no affordance; the delay options are now inline
  7. * chips and an above-placed popover grows upward from its anchored bottom
  8. * edge. The click that dismisses the native date picker must not tear down
  9. * the popover.
  10. */
  11. import { describe, it, expect, beforeEach } from 'vitest';
  12. import { screen, fireEvent, waitFor } from '@testing-library/react';
  13. import userEvent from '@testing-library/user-event';
  14. import { render } from '../utils';
  15. import { PrintersPage } from '../../pages/PrintersPage';
  16. import { http, HttpResponse } from 'msw';
  17. import { server } from '../mocks/server';
  18. const mockPrinter = {
  19. id: 1,
  20. name: 'X1C',
  21. ip_address: '192.168.1.100',
  22. serial_number: '01P00A000000001',
  23. access_code: '12345678',
  24. model: 'X1C',
  25. enabled: true,
  26. nozzle_diameter: 0.4,
  27. nozzle_type: 'stainless_steel',
  28. location: 'Workshop',
  29. auto_archive: true,
  30. created_at: '2024-01-01T00:00:00Z',
  31. updated_at: '2024-01-01T00:00:00Z',
  32. };
  33. const baseTray = {
  34. tray_color: 'FF0000FF',
  35. tray_type: 'PLA',
  36. tray_sub_brands: 'PLA Basic',
  37. tray_id_name: 'A00-R0',
  38. tray_info_idx: 'GFA00',
  39. remain: 80,
  40. k: 0.02,
  41. cali_idx: null,
  42. tag_uid: null,
  43. tray_uuid: null,
  44. nozzle_temp_min: 190,
  45. nozzle_temp_max: 230,
  46. drying_temp: null,
  47. drying_time: null,
  48. state: 3,
  49. };
  50. /** AMS 2 Pro (n3f) on an idle printer that accepts remote drying commands. */
  51. const IDLE = {
  52. connected: true,
  53. state: 'IDLE',
  54. progress: 0,
  55. layer_num: 0,
  56. total_layers: 0,
  57. temperatures: { nozzle: 25, bed: 25, chamber: 25 },
  58. remaining_time: 0,
  59. filename: null,
  60. wifi_signal: -29,
  61. speed_level: 2,
  62. supports_drying: true,
  63. drying_screen_only: false,
  64. vt_tray: [],
  65. ams: [
  66. {
  67. id: 0,
  68. humidity: 30,
  69. temp: 33,
  70. is_ams_ht: false,
  71. serial_number: 'AMS00',
  72. sw_ver: '03.00.21.29',
  73. dry_sub_status: 0,
  74. dry_sf_reason: [],
  75. module_type: 'n3f',
  76. dry_time: 0,
  77. dry_status: 0,
  78. tray: [
  79. { id: 0, ...baseTray },
  80. { id: 1, ...baseTray },
  81. { id: 2, ...baseTray },
  82. { id: 3, ...baseTray },
  83. ],
  84. },
  85. ],
  86. };
  87. /** Same AMS mid-cycle: 12h remaining on the dryer. */
  88. const DRYING = {
  89. ...IDLE,
  90. ams: [{ ...IDLE.ams[0], dry_time: 720, dry_status: 2 }],
  91. };
  92. const PENDING_ROW = {
  93. id: 1,
  94. printer_id: 1,
  95. ams_id: 0,
  96. temp: 45,
  97. duration_hours: 12,
  98. filament: 'PLA',
  99. rotate_tray: false,
  100. start_after: '2026-07-25T23:24:00',
  101. status: 'pending',
  102. waiting_reason: null,
  103. error_message: null,
  104. created_at: '2026-07-25T20:00:00',
  105. started_at: null,
  106. completed_at: null,
  107. };
  108. async function openDryingPopover(user: ReturnType<typeof userEvent.setup>) {
  109. await waitFor(() => {
  110. expect(screen.getAllByTitle('Start Drying').length).toBeGreaterThan(0);
  111. });
  112. await user.click(screen.getAllByTitle('Start Drying')[0]);
  113. await screen.findByTestId('drying-start-confirm');
  114. }
  115. describe('PrintersPage - drying start modes', () => {
  116. beforeEach(() => {
  117. server.use(
  118. http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
  119. http.get('/api/v1/printers/:id/status', () => HttpResponse.json(IDLE)),
  120. http.get('/api/v1/queue/', () => HttpResponse.json([])),
  121. http.get('/api/v1/scheduled-dryings', () => HttpResponse.json([])),
  122. );
  123. });
  124. it('does not offer rotation while the printer reports a slot of this AMS as loaded', async () => {
  125. // The tray state code for "loaded" differs by firmware; tray_now does not.
  126. server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json({ ...IDLE, tray_now: 2 })));
  127. const user = userEvent.setup();
  128. render(<PrintersPage />);
  129. await openDryingPopover(user);
  130. const rotate = screen.getByRole('button', { name: 'Rotate spool during drying' });
  131. expect((rotate as HTMLButtonElement).disabled).toBe(true);
  132. });
  133. it('lets the temperature be retyped instead of snapping to the 45 °C minimum (#3182)', async () => {
  134. const user = userEvent.setup();
  135. render(<PrintersPage />);
  136. await openDryingPopover(user);
  137. const temp = screen
  138. .getAllByRole('spinbutton')
  139. .find((el) => el.getAttribute('min') === '45') as HTMLInputElement;
  140. await user.clear(temp);
  141. await user.keyboard('6');
  142. expect(temp.value).toBe('6');
  143. await user.keyboard('0');
  144. expect(temp.value).toBe('60');
  145. await user.tab();
  146. expect(temp.value).toBe('60');
  147. expect((screen.getAllByRole('slider')[0] as HTMLInputElement).value).toBe('60');
  148. });
  149. it('reveals the delay chips when After delay is selected, with 2h preselected', async () => {
  150. const user = userEvent.setup();
  151. render(<PrintersPage />);
  152. await openDryingPopover(user);
  153. await user.click(screen.getByRole('button', { name: 'After delay' }));
  154. for (const label of ['30m', '1h', '2h', '4h', '8h', '12h', '24h']) {
  155. expect(screen.getByRole('button', { name: label })).toBeInTheDocument();
  156. }
  157. expect(screen.getByRole('button', { name: '2h' })).toHaveAttribute('aria-pressed', 'true');
  158. await user.click(screen.getByRole('button', { name: '4h' }));
  159. expect(screen.getByRole('button', { name: '4h' })).toHaveAttribute('aria-pressed', 'true');
  160. expect(screen.getByRole('button', { name: '2h' })).toHaveAttribute('aria-pressed', 'false');
  161. });
  162. it('reveals the datetime input for At time and keeps Schedule disabled until a time is set', async () => {
  163. const user = userEvent.setup();
  164. render(<PrintersPage />);
  165. await openDryingPopover(user);
  166. await user.click(screen.getByRole('button', { name: 'At time' }));
  167. const input = await screen.findByTestId('drying-start-at');
  168. expect(screen.getByTestId('drying-start-confirm')).toBeDisabled();
  169. fireEvent.change(input, { target: { value: '2099-01-15T18:00' } });
  170. expect(screen.getByTestId('drying-start-confirm')).toBeEnabled();
  171. });
  172. it('drops the scheduled banner promptly once a drying cycle starts', async () => {
  173. // The banner polls every 30s; without a nudge from the live AMS status
  174. // it shows a dispatched schedule as still pending for up to that long.
  175. let calls = 0;
  176. server.use(
  177. http.get('/api/v1/printers/:id/status', () => HttpResponse.json(DRYING)),
  178. http.get('/api/v1/scheduled-dryings', () => {
  179. calls += 1;
  180. return HttpResponse.json(calls === 1 ? [PENDING_ROW] : []);
  181. }),
  182. );
  183. render(<PrintersPage />);
  184. await waitFor(() => expect(calls).toBeGreaterThanOrEqual(2));
  185. await waitFor(() => {
  186. expect(screen.queryByText(/Drying scheduled for/)).not.toBeInTheDocument();
  187. });
  188. });
  189. it('labels a transient cannot-dry reason accurately, not as a power problem', async () => {
  190. const blocked = { ...IDLE, ams: [{ ...IDLE.ams[0], dry_sf_reason: [2] }] };
  191. server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(blocked)));
  192. render(<PrintersPage />);
  193. await waitFor(() => {
  194. expect(screen.getAllByTitle("AMS can't start drying right now").length).toBeGreaterThan(0);
  195. });
  196. });
  197. it('keeps the power tooltip for the power-supply reason codes', async () => {
  198. const blocked = { ...IDLE, ams: [{ ...IDLE.ams[0], dry_sf_reason: [8] }] };
  199. server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(blocked)));
  200. render(<PrintersPage />);
  201. await waitFor(() => {
  202. expect(screen.getAllByTitle('Connect AMS power adapter to enable drying').length).toBeGreaterThan(0);
  203. });
  204. });
  205. it('resets the start mode when the popover is reopened', async () => {
  206. // Leaving the mode set carried a stale "At time" timestamp into the next
  207. // open, by then in the past, and the POST rejected it.
  208. const user = userEvent.setup();
  209. render(<PrintersPage />);
  210. await openDryingPopover(user);
  211. await user.click(screen.getByRole('button', { name: 'At time' }));
  212. const input = await screen.findByTestId('drying-start-at');
  213. fireEvent.change(input, { target: { value: '2026-07-25T23:24' } });
  214. expect(screen.getByTestId('drying-start-at')).toHaveValue('2026-07-25T23:24');
  215. // Close, then reopen on the same flame button.
  216. await user.click(screen.getByTestId('drying-popover-backdrop'));
  217. await waitFor(() => {
  218. expect(screen.queryByTestId('drying-start-confirm')).not.toBeInTheDocument();
  219. });
  220. await openDryingPopover(user);
  221. // Back on "Now": neither the delay chips nor the datetime input show.
  222. expect(screen.queryByTestId('drying-start-at')).not.toBeInTheDocument();
  223. await user.click(screen.getByRole('button', { name: 'At time' }));
  224. expect(screen.getByTestId('drying-start-at')).toHaveValue('');
  225. });
  226. it('fetches the scheduled list once for the fleet rather than per printer', async () => {
  227. const requests: string[] = [];
  228. server.use(
  229. http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter, { ...mockPrinter, id: 2, name: 'P1S-2' }])),
  230. http.get('/api/v1/scheduled-dryings', ({ request }) => {
  231. requests.push(new URL(request.url).search);
  232. return HttpResponse.json([]);
  233. }),
  234. );
  235. render(<PrintersPage />);
  236. await waitFor(() => expect(requests.length).toBeGreaterThan(0));
  237. // No printer_id filter: one shared query, filtered client-side.
  238. expect(requests.every(search => search === '')).toBe(true);
  239. });
  240. it('says why a due run has not started when the AMS needs the power adapter', async () => {
  241. server.use(
  242. http.get('/api/v1/scheduled-dryings', () =>
  243. HttpResponse.json([{ ...PENDING_ROW, start_after: null, waiting_reason: 'ams_power_required' }])
  244. ),
  245. );
  246. render(<PrintersPage />);
  247. expect(await screen.findByText('Connect AMS power adapter to enable drying')).toBeInTheDocument();
  248. });
  249. it('shows a failed run by its error code, translated, not the backend English text', async () => {
  250. server.use(
  251. http.get('/api/v1/scheduled-dryings', () =>
  252. HttpResponse.json([
  253. {
  254. ...PENDING_ROW,
  255. status: 'failed',
  256. error_code: 'did_not_start',
  257. // Deliberately different from the translation, so the test shows
  258. // which of the two was rendered.
  259. error_message: 'backend English text',
  260. completed_at: '2026-07-25T23:25:00',
  261. },
  262. ])
  263. ),
  264. );
  265. render(<PrintersPage />);
  266. expect(
  267. await screen.findByText(
  268. 'Scheduled drying failed: The printer accepted the command, but the AMS did not start drying'
  269. )
  270. ).toBeInTheDocument();
  271. expect(screen.queryByText(/backend English text/)).not.toBeInTheDocument();
  272. });
  273. it('falls back to the error message for an unknown error code', async () => {
  274. server.use(
  275. http.get('/api/v1/scheduled-dryings', () =>
  276. HttpResponse.json([
  277. {
  278. ...PENDING_ROW,
  279. status: 'failed',
  280. error_code: 'something_new',
  281. error_message: 'A reason this frontend has no key for',
  282. completed_at: '2026-07-25T23:25:00',
  283. },
  284. ])
  285. ),
  286. );
  287. render(<PrintersPage />);
  288. expect(
  289. await screen.findByText('Scheduled drying failed: A reason this frontend has no key for')
  290. ).toBeInTheDocument();
  291. });
  292. it('shows a run that failed at dispatch, with a dismiss that clears it', async () => {
  293. // Only dispatch can fail (firmware too old on a printer that was offline
  294. // at schedule time). Without this the run vanishes and only the backend
  295. // log says why.
  296. const failedRow = {
  297. ...PENDING_ROW,
  298. status: 'failed',
  299. error_message: 'Drying not supported for this printer model or firmware version',
  300. completed_at: '2026-07-25T23:25:00',
  301. };
  302. let listed = [failedRow];
  303. let deleted: number | null = null;
  304. server.use(
  305. http.get('/api/v1/scheduled-dryings', () => HttpResponse.json(listed)),
  306. http.delete('/api/v1/scheduled-dryings/:id', ({ params }) => {
  307. deleted = Number(params.id);
  308. listed = [];
  309. return HttpResponse.json({ status: 'dismissed', id: deleted });
  310. }),
  311. );
  312. const user = userEvent.setup();
  313. render(<PrintersPage />);
  314. expect(
  315. await screen.findByText(/Scheduled drying failed: Drying not supported for this printer model/)
  316. ).toBeInTheDocument();
  317. await user.click(screen.getByTitle('Dismiss'));
  318. await waitFor(() => expect(deleted).toBe(failedRow.id));
  319. await waitFor(() => {
  320. expect(screen.queryByText(/Scheduled drying failed/)).not.toBeInTheDocument();
  321. });
  322. });
  323. it.each([
  324. ['ams_retract_filament', 'Retract the filament at the AMS outlet to start drying'],
  325. ['ams_not_found', 'Waiting for the AMS to be detected'],
  326. ['printer_offline', 'Waiting for the printer to come online'],
  327. ['printer_busy', 'Waiting for the printer to be free'],
  328. ['already_drying', 'Waiting for the current drying cycle to finish'],
  329. ['interrupted', 'Interrupted, will restart when the printer is free'],
  330. ])('renders text for the %s waiting reason', async (reason, text) => {
  331. // An unmapped reason left the card showing a bare "Drying scheduled for"
  332. // with no hint why it had not started.
  333. server.use(
  334. http.get('/api/v1/scheduled-dryings', () =>
  335. HttpResponse.json([{ ...PENDING_ROW, start_after: null, waiting_reason: reason }])
  336. ),
  337. );
  338. render(<PrintersPage />);
  339. expect(await screen.findByText(text)).toBeInTheDocument();
  340. });
  341. it('renders the banner inside the card body, not below it', async () => {
  342. // Between </CardContent> and </Card> it went full-bleed and its corners
  343. // collided with the card's rounded bottom edge.
  344. server.use(
  345. http.get('/api/v1/scheduled-dryings', () => HttpResponse.json([PENDING_ROW])),
  346. );
  347. render(<PrintersPage />);
  348. const banner = await screen.findByTestId('scheduled-drying-pending');
  349. // Its wrapper's parent is CardContent (which carries the padding), not the
  350. // bare Card root it used to hang off.
  351. const parent = banner.parentElement?.parentElement;
  352. expect(parent?.className).toMatch(/\bp-\d/);
  353. });
  354. it('does not close the popover on the outside click that dismisses the native date picker', async () => {
  355. const user = userEvent.setup();
  356. render(<PrintersPage />);
  357. await openDryingPopover(user);
  358. await user.click(screen.getByRole('button', { name: 'At time' }));
  359. const input = await screen.findByTestId('drying-start-at');
  360. // With the native picker open the input keeps focus; the click that
  361. // dismisses the picker lands on the backdrop.
  362. input.focus();
  363. await user.click(screen.getByTestId('drying-popover-backdrop'));
  364. expect(screen.getByTestId('drying-start-confirm')).toBeInTheDocument();
  365. // A second outside click (input no longer focused) closes as before.
  366. await user.click(screen.getByTestId('drying-popover-backdrop'));
  367. expect(screen.queryByTestId('drying-start-confirm')).not.toBeInTheDocument();
  368. });
  369. // The flame button's tooltip is the only place an immediate drying attempt
  370. // explains itself, and it has to name the same code the scheduled path would
  371. // record — otherwise the same blocked AMS reads two different ways depending
  372. // on which button you pressed.
  373. it.each([
  374. [[1], 'Connect AMS power adapter to enable drying'],
  375. [[8], 'Connect AMS power adapter to enable drying'],
  376. [[3], 'Retract the filament at the AMS outlet to start drying'],
  377. [[2], "AMS can't start drying right now"],
  378. // Both set: the power problem outranks the retract, as it does server-side.
  379. [[3, 1], 'Connect AMS power adapter to enable drying'],
  380. // Transient alongside an actionable one: name the one the user can fix.
  381. [[2, 3], 'Retract the filament at the AMS outlet to start drying'],
  382. ])('explains dry_sf_reason %j on the drying button', async (reasons, expected) => {
  383. server.use(
  384. http.get('/api/v1/printers/:id/status', () =>
  385. HttpResponse.json({ ...IDLE, ams: [{ ...IDLE.ams[0], dry_sf_reason: reasons }] })
  386. ),
  387. );
  388. render(<PrintersPage />);
  389. await waitFor(() => {
  390. expect(screen.getAllByTitle(expected).length).toBeGreaterThan(0);
  391. });
  392. });
  393. });