PrintersPageDryingStartModes.test.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  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('lets the temperature be retyped instead of snapping to the 45 °C minimum (#3182)', async () => {
  125. const user = userEvent.setup();
  126. render(<PrintersPage />);
  127. await openDryingPopover(user);
  128. const temp = screen
  129. .getAllByRole('spinbutton')
  130. .find((el) => el.getAttribute('min') === '45') as HTMLInputElement;
  131. await user.clear(temp);
  132. await user.keyboard('6');
  133. expect(temp.value).toBe('6');
  134. await user.keyboard('0');
  135. expect(temp.value).toBe('60');
  136. await user.tab();
  137. expect(temp.value).toBe('60');
  138. expect((screen.getAllByRole('slider')[0] as HTMLInputElement).value).toBe('60');
  139. });
  140. it('reveals the delay chips when After delay is selected, with 2h preselected', async () => {
  141. const user = userEvent.setup();
  142. render(<PrintersPage />);
  143. await openDryingPopover(user);
  144. await user.click(screen.getByRole('button', { name: 'After delay' }));
  145. for (const label of ['30m', '1h', '2h', '4h', '8h', '12h', '24h']) {
  146. expect(screen.getByRole('button', { name: label })).toBeInTheDocument();
  147. }
  148. expect(screen.getByRole('button', { name: '2h' })).toHaveAttribute('aria-pressed', 'true');
  149. await user.click(screen.getByRole('button', { name: '4h' }));
  150. expect(screen.getByRole('button', { name: '4h' })).toHaveAttribute('aria-pressed', 'true');
  151. expect(screen.getByRole('button', { name: '2h' })).toHaveAttribute('aria-pressed', 'false');
  152. });
  153. it('reveals the datetime input for At time and keeps Schedule disabled until a time is set', async () => {
  154. const user = userEvent.setup();
  155. render(<PrintersPage />);
  156. await openDryingPopover(user);
  157. await user.click(screen.getByRole('button', { name: 'At time' }));
  158. const input = await screen.findByTestId('drying-start-at');
  159. expect(screen.getByTestId('drying-start-confirm')).toBeDisabled();
  160. fireEvent.change(input, { target: { value: '2099-01-15T18:00' } });
  161. expect(screen.getByTestId('drying-start-confirm')).toBeEnabled();
  162. });
  163. it('drops the scheduled banner promptly once a drying cycle starts', async () => {
  164. // The banner polls every 30s; without a nudge from the live AMS status
  165. // it shows a dispatched schedule as still pending for up to that long.
  166. let calls = 0;
  167. server.use(
  168. http.get('/api/v1/printers/:id/status', () => HttpResponse.json(DRYING)),
  169. http.get('/api/v1/scheduled-dryings', () => {
  170. calls += 1;
  171. return HttpResponse.json(calls === 1 ? [PENDING_ROW] : []);
  172. }),
  173. );
  174. render(<PrintersPage />);
  175. await waitFor(() => expect(calls).toBeGreaterThanOrEqual(2));
  176. await waitFor(() => {
  177. expect(screen.queryByText(/Drying scheduled for/)).not.toBeInTheDocument();
  178. });
  179. });
  180. it('labels a transient cannot-dry reason accurately, not as a power problem', async () => {
  181. const blocked = { ...IDLE, ams: [{ ...IDLE.ams[0], dry_sf_reason: [2] }] };
  182. server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(blocked)));
  183. render(<PrintersPage />);
  184. await waitFor(() => {
  185. expect(screen.getAllByTitle("AMS can't start drying right now").length).toBeGreaterThan(0);
  186. });
  187. });
  188. it('keeps the power tooltip for the power-supply reason codes', async () => {
  189. const blocked = { ...IDLE, ams: [{ ...IDLE.ams[0], dry_sf_reason: [8] }] };
  190. server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(blocked)));
  191. render(<PrintersPage />);
  192. await waitFor(() => {
  193. expect(screen.getAllByTitle('Connect AMS power adapter to enable drying').length).toBeGreaterThan(0);
  194. });
  195. });
  196. it('resets the start mode when the popover is reopened', async () => {
  197. // Leaving the mode set carried a stale "At time" timestamp into the next
  198. // open, by then in the past, and the POST rejected it.
  199. const user = userEvent.setup();
  200. render(<PrintersPage />);
  201. await openDryingPopover(user);
  202. await user.click(screen.getByRole('button', { name: 'At time' }));
  203. const input = await screen.findByTestId('drying-start-at');
  204. fireEvent.change(input, { target: { value: '2026-07-25T23:24' } });
  205. expect(screen.getByTestId('drying-start-at')).toHaveValue('2026-07-25T23:24');
  206. // Close, then reopen on the same flame button.
  207. await user.click(screen.getByTestId('drying-popover-backdrop'));
  208. await waitFor(() => {
  209. expect(screen.queryByTestId('drying-start-confirm')).not.toBeInTheDocument();
  210. });
  211. await openDryingPopover(user);
  212. // Back on "Now": neither the delay chips nor the datetime input show.
  213. expect(screen.queryByTestId('drying-start-at')).not.toBeInTheDocument();
  214. await user.click(screen.getByRole('button', { name: 'At time' }));
  215. expect(screen.getByTestId('drying-start-at')).toHaveValue('');
  216. });
  217. it('fetches the scheduled list once for the fleet rather than per printer', async () => {
  218. const requests: string[] = [];
  219. server.use(
  220. http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter, { ...mockPrinter, id: 2, name: 'P1S-2' }])),
  221. http.get('/api/v1/scheduled-dryings', ({ request }) => {
  222. requests.push(new URL(request.url).search);
  223. return HttpResponse.json([]);
  224. }),
  225. );
  226. render(<PrintersPage />);
  227. await waitFor(() => expect(requests.length).toBeGreaterThan(0));
  228. // No printer_id filter: one shared query, filtered client-side.
  229. expect(requests.every(search => search === '')).toBe(true);
  230. });
  231. it('says why a due run has not started when the AMS needs the power adapter', async () => {
  232. server.use(
  233. http.get('/api/v1/scheduled-dryings', () =>
  234. HttpResponse.json([{ ...PENDING_ROW, start_after: null, waiting_reason: 'ams_power_required' }])
  235. ),
  236. );
  237. render(<PrintersPage />);
  238. expect(await screen.findByText('Connect AMS power adapter to enable drying')).toBeInTheDocument();
  239. });
  240. it('shows a failed run by its error code, translated, not the backend English text', async () => {
  241. server.use(
  242. http.get('/api/v1/scheduled-dryings', () =>
  243. HttpResponse.json([
  244. {
  245. ...PENDING_ROW,
  246. status: 'failed',
  247. error_code: 'did_not_start',
  248. // Deliberately different from the translation, so the test shows
  249. // which of the two was rendered.
  250. error_message: 'backend English text',
  251. completed_at: '2026-07-25T23:25:00',
  252. },
  253. ])
  254. ),
  255. );
  256. render(<PrintersPage />);
  257. expect(
  258. await screen.findByText(
  259. 'Scheduled drying failed: The printer accepted the command, but the AMS did not start drying'
  260. )
  261. ).toBeInTheDocument();
  262. expect(screen.queryByText(/backend English text/)).not.toBeInTheDocument();
  263. });
  264. it('falls back to the error message for an unknown error code', async () => {
  265. server.use(
  266. http.get('/api/v1/scheduled-dryings', () =>
  267. HttpResponse.json([
  268. {
  269. ...PENDING_ROW,
  270. status: 'failed',
  271. error_code: 'something_new',
  272. error_message: 'A reason this frontend has no key for',
  273. completed_at: '2026-07-25T23:25:00',
  274. },
  275. ])
  276. ),
  277. );
  278. render(<PrintersPage />);
  279. expect(
  280. await screen.findByText('Scheduled drying failed: A reason this frontend has no key for')
  281. ).toBeInTheDocument();
  282. });
  283. it('shows a run that failed at dispatch, with a dismiss that clears it', async () => {
  284. // Only dispatch can fail (firmware too old on a printer that was offline
  285. // at schedule time). Without this the run vanishes and only the backend
  286. // log says why.
  287. const failedRow = {
  288. ...PENDING_ROW,
  289. status: 'failed',
  290. error_message: 'Drying not supported for this printer model or firmware version',
  291. completed_at: '2026-07-25T23:25:00',
  292. };
  293. let listed = [failedRow];
  294. let deleted: number | null = null;
  295. server.use(
  296. http.get('/api/v1/scheduled-dryings', () => HttpResponse.json(listed)),
  297. http.delete('/api/v1/scheduled-dryings/:id', ({ params }) => {
  298. deleted = Number(params.id);
  299. listed = [];
  300. return HttpResponse.json({ status: 'dismissed', id: deleted });
  301. }),
  302. );
  303. const user = userEvent.setup();
  304. render(<PrintersPage />);
  305. expect(
  306. await screen.findByText(/Scheduled drying failed: Drying not supported for this printer model/)
  307. ).toBeInTheDocument();
  308. await user.click(screen.getByTitle('Dismiss'));
  309. await waitFor(() => expect(deleted).toBe(failedRow.id));
  310. await waitFor(() => {
  311. expect(screen.queryByText(/Scheduled drying failed/)).not.toBeInTheDocument();
  312. });
  313. });
  314. it.each([
  315. ['ams_retract_filament', 'Retract the filament at the AMS outlet to start drying'],
  316. ['ams_not_found', 'Waiting for the AMS to be detected'],
  317. ['printer_offline', 'Waiting for the printer to come online'],
  318. ['printer_busy', 'Waiting for the printer to be free'],
  319. ['already_drying', 'Waiting for the current drying cycle to finish'],
  320. ['interrupted', 'Interrupted, will restart when the printer is free'],
  321. ])('renders text for the %s waiting reason', async (reason, text) => {
  322. // An unmapped reason left the card showing a bare "Drying scheduled for"
  323. // with no hint why it had not started.
  324. server.use(
  325. http.get('/api/v1/scheduled-dryings', () =>
  326. HttpResponse.json([{ ...PENDING_ROW, start_after: null, waiting_reason: reason }])
  327. ),
  328. );
  329. render(<PrintersPage />);
  330. expect(await screen.findByText(text)).toBeInTheDocument();
  331. });
  332. it('renders the banner inside the card body, not below it', async () => {
  333. // Between </CardContent> and </Card> it went full-bleed and its corners
  334. // collided with the card's rounded bottom edge.
  335. server.use(
  336. http.get('/api/v1/scheduled-dryings', () => HttpResponse.json([PENDING_ROW])),
  337. );
  338. render(<PrintersPage />);
  339. const banner = await screen.findByTestId('scheduled-drying-pending');
  340. // Its wrapper's parent is CardContent (which carries the padding), not the
  341. // bare Card root it used to hang off.
  342. const parent = banner.parentElement?.parentElement;
  343. expect(parent?.className).toMatch(/\bp-\d/);
  344. });
  345. it('does not close the popover on the outside click that dismisses the native date picker', async () => {
  346. const user = userEvent.setup();
  347. render(<PrintersPage />);
  348. await openDryingPopover(user);
  349. await user.click(screen.getByRole('button', { name: 'At time' }));
  350. const input = await screen.findByTestId('drying-start-at');
  351. // With the native picker open the input keeps focus; the click that
  352. // dismisses the picker lands on the backdrop.
  353. input.focus();
  354. await user.click(screen.getByTestId('drying-popover-backdrop'));
  355. expect(screen.getByTestId('drying-start-confirm')).toBeInTheDocument();
  356. // A second outside click (input no longer focused) closes as before.
  357. await user.click(screen.getByTestId('drying-popover-backdrop'));
  358. expect(screen.queryByTestId('drying-start-confirm')).not.toBeInTheDocument();
  359. });
  360. // The flame button's tooltip is the only place an immediate drying attempt
  361. // explains itself, and it has to name the same code the scheduled path would
  362. // record — otherwise the same blocked AMS reads two different ways depending
  363. // on which button you pressed.
  364. it.each([
  365. [[1], 'Connect AMS power adapter to enable drying'],
  366. [[8], 'Connect AMS power adapter to enable drying'],
  367. [[3], 'Retract the filament at the AMS outlet to start drying'],
  368. [[2], "AMS can't start drying right now"],
  369. // Both set: the power problem outranks the retract, as it does server-side.
  370. [[3, 1], 'Connect AMS power adapter to enable drying'],
  371. // Transient alongside an actionable one: name the one the user can fix.
  372. [[2, 3], 'Retract the filament at the AMS outlet to start drying'],
  373. ])('explains dry_sf_reason %j on the drying button', async (reasons, expected) => {
  374. server.use(
  375. http.get('/api/v1/printers/:id/status', () =>
  376. HttpResponse.json({ ...IDLE, ams: [{ ...IDLE.ams[0], dry_sf_reason: reasons }] })
  377. ),
  378. );
  379. render(<PrintersPage />);
  380. await waitFor(() => {
  381. expect(screen.getAllByTitle(expected).length).toBeGreaterThan(0);
  382. });
  383. });
  384. });