PrintersPageDryingStartModes.test.tsx 13 KB

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