PrintersPageDryingFeedback.test.tsx 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. /**
  2. * Feedback for the AMS drying start/stop buttons (#2533).
  3. *
  4. * The reporter's P1S accepted `ams_filament_drying` with result=success and
  5. * then never dried. Two gaps: the Start button gave no confirmation at all,
  6. * and Bambuddy treated the MQTT ack as proof the cycle had begun. These tests
  7. * cover the toast and the post-ack watcher that catches a printer which takes
  8. * the command and drops it.
  9. *
  10. * The reporter's own printer is now handled further up: Bambu's P1 manual says
  11. * P1-series AMS drying is screen-only, so the card no longer offers to command
  12. * it (last describe block). The watcher stays for any other firmware that acks
  13. * and declines — on the models we *can* command, the ack is still all we have,
  14. * because the `dry_sf_reason` refusal array only exists on some of them.
  15. */
  16. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
  17. import { screen, waitFor } from '@testing-library/react';
  18. import userEvent from '@testing-library/user-event';
  19. import { render } from '../utils';
  20. import { PrintersPage } from '../../pages/PrintersPage';
  21. import { http, HttpResponse } from 'msw';
  22. import { server } from '../mocks/server';
  23. const mockShowToast = vi.fn();
  24. vi.mock('../../contexts/ToastContext', async (importOriginal) => {
  25. const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
  26. return { ...actual, useToast: () => ({ showToast: mockShowToast }) };
  27. });
  28. const mockPrinter = {
  29. id: 1,
  30. name: 'X1C',
  31. ip_address: '192.168.1.100',
  32. serial_number: '01P00A000000001',
  33. access_code: '12345678',
  34. model: 'X1C',
  35. enabled: true,
  36. nozzle_diameter: 0.4,
  37. nozzle_type: 'stainless_steel',
  38. location: 'Workshop',
  39. auto_archive: true,
  40. created_at: '2024-01-01T00:00:00Z',
  41. updated_at: '2024-01-01T00:00:00Z',
  42. };
  43. const baseTray = {
  44. tray_color: 'FF0000FF',
  45. tray_type: 'PLA',
  46. tray_sub_brands: 'PLA Basic',
  47. tray_id_name: 'A00-R0',
  48. tray_info_idx: 'GFA00',
  49. remain: 80,
  50. k: 0.02,
  51. cali_idx: null,
  52. tag_uid: null,
  53. tray_uuid: null,
  54. nozzle_temp_min: 190,
  55. nozzle_temp_max: 230,
  56. drying_temp: null,
  57. drying_time: null,
  58. state: 3,
  59. };
  60. /** AMS 2 Pro (n3f) on an idle printer that accepts remote drying commands. */
  61. function makeStatus(
  62. dry: { dry_time: number; dry_status: number },
  63. caps: { supports_drying?: boolean; drying_screen_only?: boolean } = {},
  64. ) {
  65. return {
  66. connected: true,
  67. state: 'IDLE',
  68. progress: 0,
  69. layer_num: 0,
  70. total_layers: 0,
  71. temperatures: { nozzle: 25, bed: 25, chamber: 25 },
  72. remaining_time: 0,
  73. filename: null,
  74. wifi_signal: -29,
  75. speed_level: 2,
  76. supports_drying: caps.supports_drying ?? true,
  77. drying_screen_only: caps.drying_screen_only ?? false,
  78. vt_tray: [],
  79. ams: [
  80. {
  81. id: 0,
  82. humidity: 30,
  83. temp: 33,
  84. is_ams_ht: false,
  85. serial_number: 'AMS00',
  86. sw_ver: '03.00.21.29',
  87. dry_sub_status: 0,
  88. dry_sf_reason: [],
  89. module_type: 'n3f',
  90. ...dry,
  91. tray: [
  92. { id: 0, ...baseTray },
  93. { id: 1, ...baseTray },
  94. { id: 2, ...baseTray },
  95. { id: 3, ...baseTray },
  96. ],
  97. },
  98. ],
  99. };
  100. }
  101. const IDLE = makeStatus({ dry_time: 0, dry_status: 0 });
  102. const DRYING = makeStatus({ dry_time: 720, dry_status: 2 });
  103. /** A P1: the AMS dries, but only from the printer's own screen. */
  104. const SCREEN_ONLY = makeStatus(
  105. { dry_time: 0, dry_status: 0 },
  106. { supports_drying: false, drying_screen_only: true },
  107. );
  108. const SCREEN_ONLY_DRYING = makeStatus(
  109. { dry_time: 720, dry_status: 2 },
  110. { supports_drying: false, drying_screen_only: true },
  111. );
  112. const SCREEN_ONLY_TITLE =
  113. "AMS drying on this printer can only be controlled from the printer's own screen (Bambu limitation)";
  114. /**
  115. * Open the drying popover and press Start. The card renders the AMS in two
  116. * layouts, so the flame icon appears more than once — either opens the same
  117. * popover.
  118. */
  119. async function startDrying(user: ReturnType<typeof userEvent.setup>) {
  120. await waitFor(() => {
  121. expect(screen.getAllByTitle('Start Drying').length).toBeGreaterThan(0);
  122. });
  123. await user.click(screen.getAllByTitle('Start Drying')[0]);
  124. await user.click(await screen.findByTestId('drying-start-confirm'));
  125. }
  126. describe('PrintersPage - AMS drying feedback (#2533)', () => {
  127. beforeEach(() => {
  128. mockShowToast.mockClear();
  129. server.use(
  130. http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
  131. http.get('/api/v1/queue/', () => HttpResponse.json([])),
  132. http.post('/api/v1/printers/:id/drying/start', () =>
  133. HttpResponse.json({ status: 'drying_started', ams_id: 0, temp: 45, duration: 12 }),
  134. ),
  135. http.post('/api/v1/printers/:id/drying/stop', () =>
  136. HttpResponse.json({ status: 'drying_stopped', ams_id: 0 }),
  137. ),
  138. );
  139. });
  140. afterEach(() => {
  141. vi.useRealTimers();
  142. });
  143. it('acknowledges the start command with a toast that claims only that it was sent', async () => {
  144. const user = userEvent.setup();
  145. server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(IDLE)));
  146. render(<PrintersPage />);
  147. await startDrying(user);
  148. await waitFor(() => {
  149. expect(mockShowToast).toHaveBeenCalledWith('Drying command sent', 'success');
  150. });
  151. });
  152. it('confirms the stop command with a toast', async () => {
  153. const user = userEvent.setup();
  154. server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(DRYING)));
  155. render(<PrintersPage />);
  156. // While a cycle is live the same button becomes Stop Drying.
  157. await waitFor(() => {
  158. expect(screen.getAllByTitle('Stop Drying').length).toBeGreaterThan(0);
  159. });
  160. await user.click(screen.getAllByTitle('Stop Drying')[0]);
  161. await waitFor(() => {
  162. expect(mockShowToast).toHaveBeenCalledWith('Drying stopped', 'success');
  163. });
  164. });
  165. it('warns when the printer acks the command but the AMS never starts drying', async () => {
  166. vi.useFakeTimers({ shouldAdvanceTime: true });
  167. const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
  168. // Status keeps reporting dry_status 0 — the reporter's exact symptom.
  169. server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(IDLE)));
  170. render(<PrintersPage />);
  171. await startDrying(user);
  172. await waitFor(() => {
  173. expect(mockShowToast).toHaveBeenCalledWith('Drying command sent', 'success');
  174. });
  175. await vi.advanceTimersByTimeAsync(31_000);
  176. expect(mockShowToast).toHaveBeenCalledWith(
  177. 'The printer accepted the command but the AMS never started drying. Check that the AMS power adapter is connected and that the printer is idle.',
  178. 'error',
  179. );
  180. });
  181. it('stays quiet when the AMS does enter a drying cycle', async () => {
  182. vi.useFakeTimers({ shouldAdvanceTime: true });
  183. const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
  184. // Idle until the command lands, then the AMS reports a live cycle — which is
  185. // what the mutation's cache invalidation refetches.
  186. let started = false;
  187. server.use(
  188. http.get('/api/v1/printers/:id/status', () => HttpResponse.json(started ? DRYING : IDLE)),
  189. http.post('/api/v1/printers/:id/drying/start', () => {
  190. started = true;
  191. return HttpResponse.json({ status: 'drying_started', ams_id: 0, temp: 45, duration: 12 });
  192. }),
  193. );
  194. render(<PrintersPage />);
  195. await startDrying(user);
  196. await waitFor(() => {
  197. expect(mockShowToast).toHaveBeenCalledWith('Drying command sent', 'success');
  198. });
  199. await vi.advanceTimersByTimeAsync(31_000);
  200. expect(mockShowToast).not.toHaveBeenCalledWith(expect.stringContaining('never started drying'), 'error');
  201. });
  202. });
  203. describe('PrintersPage - screen-only AMS drying (#2533)', () => {
  204. beforeEach(() => {
  205. mockShowToast.mockClear();
  206. server.use(
  207. http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
  208. http.get('/api/v1/queue/', () => HttpResponse.json([])),
  209. );
  210. });
  211. it('keeps the drying control visible but disabled, and says why', async () => {
  212. const user = userEvent.setup();
  213. server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(SCREEN_ONLY)));
  214. render(<PrintersPage />);
  215. // Present, so the user learns the AMS *can* dry and where to do it — silently
  216. // dropping the button would just look like the feature vanished.
  217. const buttons = await screen.findAllByTitle(SCREEN_ONLY_TITLE);
  218. expect(buttons[0]).toBeDisabled();
  219. await user.click(buttons[0]);
  220. expect(screen.queryByTestId('drying-start-confirm')).not.toBeInTheDocument();
  221. });
  222. it('shows a cycle started at the printer, without offering to stop it', async () => {
  223. server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(SCREEN_ONLY_DRYING)));
  224. render(<PrintersPage />);
  225. // The countdown is pure observation and still works.
  226. expect((await screen.findAllByText(/12h 0m/)).length).toBeGreaterThan(0);
  227. // Stop is a command, and a P1 ignores it exactly as it ignores start.
  228. expect(screen.queryByTitle('Stop Drying')).not.toBeInTheDocument();
  229. });
  230. });