HMSErrorModal.test.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. /**
  2. * Tests for the HMSErrorModal component.
  3. */
  4. import { describe, it, expect, vi, afterEach } from 'vitest';
  5. import { screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
  6. import userEvent from '@testing-library/user-event';
  7. import { render } from '../utils';
  8. import {
  9. HMSErrorModal,
  10. filterKnownHMSErrors,
  11. filterUncountedHMSErrors,
  12. isSevereHMSError,
  13. } from '../../components/HMSErrorModal';
  14. import { http, HttpResponse } from 'msw';
  15. import { server } from '../mocks/server';
  16. import type { HMSError } from '../../api/client';
  17. // The backend resolves each fault's text from the catalogue generated out of
  18. // Bambu Studio and sends it as `description` (#2728); the fixtures carry it the
  19. // way the status response does. The frontend has no table of its own.
  20. // 0300_400C, "The task was canceled."
  21. const knownError: HMSError = {
  22. attr: 0x0300,
  23. code: '0x400C',
  24. severity: 1,
  25. description: 'The task was canceled.',
  26. };
  27. // A code Bambu publishes no text for: description null, no actions.
  28. const unknownError: HMSError = {
  29. attr: 0xFFFF,
  30. code: '0xFFFF',
  31. severity: 1,
  32. description: null,
  33. };
  34. // 0700_8011, AMS filament runout (#2587).
  35. const runoutError: HMSError = {
  36. attr: 0x0700,
  37. code: '0x8011',
  38. severity: 2,
  39. description: 'AMS filament ran out. Please insert a new filament into the same AMS slot.',
  40. };
  41. describe('HMSErrorModal', () => {
  42. const defaultProps = {
  43. printerName: 'Test Printer',
  44. errors: [knownError],
  45. onClose: vi.fn(),
  46. printerId: 1,
  47. hasPermission: vi.fn().mockReturnValue(true) as unknown as (permission: 'printers:control') => boolean,
  48. };
  49. afterEach(() => {
  50. cleanup();
  51. vi.clearAllMocks();
  52. });
  53. describe('rendering', () => {
  54. it('renders the modal title with printer name', () => {
  55. render(<HMSErrorModal {...defaultProps} />);
  56. expect(screen.getByText('Errors - Test Printer')).toBeInTheDocument();
  57. });
  58. it('shows error description for known error codes', () => {
  59. render(<HMSErrorModal {...defaultProps} />);
  60. expect(screen.getByText('The task was canceled.')).toBeInTheDocument();
  61. });
  62. it('lists a fault without text collapsed instead of hiding it (#2728)', () => {
  63. render(<HMSErrorModal {...defaultProps} errors={[unknownError]} />);
  64. expect(screen.queryByText('No errors')).not.toBeInTheDocument();
  65. expect(screen.getByText('Also reported, not counted (1)')).toBeInTheDocument();
  66. expect(screen.getByText('[FFFF-FFFF]')).toBeInTheDocument();
  67. });
  68. it('shows no errors message when errors array is empty', () => {
  69. render(<HMSErrorModal {...defaultProps} errors={[]} />);
  70. expect(screen.getByText('No errors')).toBeInTheDocument();
  71. });
  72. });
  73. describe('clear errors button', () => {
  74. it('shows clear button when there are known errors', () => {
  75. render(<HMSErrorModal {...defaultProps} />);
  76. expect(screen.getByText('Clear Errors')).toBeInTheDocument();
  77. });
  78. it('hides clear button when there are no known errors', () => {
  79. render(<HMSErrorModal {...defaultProps} errors={[]} />);
  80. expect(screen.queryByText('Clear Errors')).not.toBeInTheDocument();
  81. });
  82. it('offers clear when the printer holds only faults without text', () => {
  83. // The printer is still holding them, and clearing is how they go away.
  84. render(<HMSErrorModal {...defaultProps} errors={[unknownError]} />);
  85. expect(screen.getByText('Clear Errors')).toBeInTheDocument();
  86. });
  87. it('disables clear button when user lacks permission', () => {
  88. const noPermission = vi.fn().mockReturnValue(false) as unknown as (permission: 'printers:control') => boolean;
  89. render(<HMSErrorModal {...defaultProps} hasPermission={noPermission} />);
  90. expect(screen.getByText('Clear Errors').closest('button')).toBeDisabled();
  91. });
  92. it('calls API and closes modal on successful clear', async () => {
  93. const user = userEvent.setup();
  94. const onClose = vi.fn();
  95. server.use(
  96. http.post('/api/v1/printers/1/hms/clear', () => {
  97. return HttpResponse.json({ success: true, message: 'HMS errors cleared' });
  98. })
  99. );
  100. render(<HMSErrorModal {...defaultProps} onClose={onClose} />);
  101. await user.click(screen.getByText('Clear Errors'));
  102. await waitFor(() => {
  103. expect(onClose).toHaveBeenCalledTimes(1);
  104. });
  105. });
  106. it('shows error toast on failed clear', async () => {
  107. const user = userEvent.setup();
  108. const onClose = vi.fn();
  109. server.use(
  110. http.post('/api/v1/printers/1/hms/clear', () => {
  111. return HttpResponse.json({ detail: 'Failed' }, { status: 500 });
  112. })
  113. );
  114. render(<HMSErrorModal {...defaultProps} onClose={onClose} />);
  115. await user.click(screen.getByText('Clear Errors'));
  116. await waitFor(() => {
  117. expect(onClose).not.toHaveBeenCalled();
  118. });
  119. });
  120. });
  121. describe('runout guidance (#2587)', () => {
  122. it('shows the generic runout text when no guidance is provided', () => {
  123. render(<HMSErrorModal {...defaultProps} errors={[runoutError]} />);
  124. expect(
  125. screen.getByText('AMS filament ran out. Please insert a new filament into the same AMS slot.')
  126. ).toBeInTheDocument();
  127. });
  128. it('names both the expected and ran-out slot when both are resolved', () => {
  129. render(
  130. <HMSErrorModal
  131. {...defaultProps}
  132. errors={[runoutError]}
  133. runoutGuidance={{ expectedSlotLabel: 'AMS-A · Slot 3', ranOutSlotLabel: 'AMS-A · Slot 2' }}
  134. />
  135. );
  136. const p = screen.getByText(/waiting for compatible filament/i);
  137. expect(p.textContent).toContain('AMS-A · Slot 3');
  138. expect(p.textContent).toContain('AMS-A · Slot 2');
  139. // The misleading "same slot" text must be gone.
  140. expect(screen.queryByText(/into the same AMS slot/i)).not.toBeInTheDocument();
  141. });
  142. it('names only the expected slot when the ran-out slot is unknown', () => {
  143. render(
  144. <HMSErrorModal
  145. {...defaultProps}
  146. errors={[runoutError]}
  147. runoutGuidance={{ expectedSlotLabel: 'AMS-A · Slot 3', ranOutSlotLabel: null }}
  148. />
  149. );
  150. const p = screen.getByText(/waiting for compatible filament/i);
  151. expect(p.textContent).toContain('AMS-A · Slot 3');
  152. });
  153. it('shows an honest fallback when the slot cannot be resolved', () => {
  154. render(
  155. <HMSErrorModal
  156. {...defaultProps}
  157. errors={[runoutError]}
  158. runoutGuidance={{ expectedSlotLabel: null, ranOutSlotLabel: null }}
  159. />
  160. );
  161. expect(screen.getByText(/could not determine which slot/i)).toBeInTheDocument();
  162. });
  163. it('does not apply runout guidance to non-runout errors', () => {
  164. render(
  165. <HMSErrorModal
  166. {...defaultProps}
  167. errors={[knownError]}
  168. runoutGuidance={{ expectedSlotLabel: 'AMS-A · Slot 3', ranOutSlotLabel: 'AMS-A · Slot 2' }}
  169. />
  170. );
  171. // 0300_400C keeps its own description; no slot injection.
  172. expect(screen.getByText('The task was canceled.')).toBeInTheDocument();
  173. expect(screen.queryByText(/waiting for compatible filament/i)).not.toBeInTheDocument();
  174. });
  175. });
  176. describe('MQTT command verification failed (#2732)', () => {
  177. // attr 0x05000500, code 0x00010007 — a real P1S on firmware 01.10.00.00.
  178. // getShortCode() collapses this to "0500_0007", which matches nothing, so
  179. // before #2732 filterKnownHMSErrors dropped the one error that explained
  180. // why the printer accepted every job and started none of them.
  181. const verifyFailedError: HMSError = {
  182. attr: 0x05000500,
  183. code: '0x10007',
  184. severity: 1,
  185. full_code: '0500050000010007',
  186. // Bambu's own text, which says to update Studio or Handy.
  187. description: 'MQTT Command verification failed. Please update Studio (including the network plugin) or Handy.',
  188. };
  189. it('surfaces the error in Bambuddy\'s words, not Bambu\'s', () => {
  190. render(<HMSErrorModal {...defaultProps} errors={[verifyFailedError]} />);
  191. expect(screen.queryByText('No errors')).not.toBeInTheDocument();
  192. expect(screen.getByText(/could not verify it/i)).toBeInTheDocument();
  193. expect(screen.queryByText(/update Studio/i)).not.toBeInTheDocument();
  194. });
  195. it('counts towards the known-error filter', () => {
  196. expect(filterKnownHMSErrors([verifyFailedError])).toHaveLength(1);
  197. });
  198. it('shows the remedy, not just the fault', () => {
  199. render(<HMSErrorModal {...defaultProps} errors={[verifyFailedError]} />);
  200. expect(screen.getByText(/Enable Developer Mode on the printer/i)).toBeInTheDocument();
  201. });
  202. it('displays the code the printer screen shows, not the truncated form', () => {
  203. render(<HMSErrorModal {...defaultProps} errors={[verifyFailedError]} />);
  204. expect(screen.getByText('[0500-0500-0001-0007]')).toBeInTheDocument();
  205. expect(screen.queryByText('[0500-0007]')).not.toBeInTheDocument();
  206. });
  207. it('leaves short-code errors on the two-group display', () => {
  208. render(<HMSErrorModal {...defaultProps} errors={[knownError]} />);
  209. expect(screen.getByText('[0300-400C]')).toBeInTheDocument();
  210. });
  211. it('does not add the remedy line to other errors', () => {
  212. render(<HMSErrorModal {...defaultProps} errors={[knownError]} />);
  213. expect(screen.queryByText(/Enable Developer Mode/i)).not.toBeInTheDocument();
  214. });
  215. });
  216. describe('severity is Bambu\'s alert level (#2728)', () => {
  217. it.each([
  218. [1, 'Error'],
  219. [2, 'Warning'],
  220. [3, 'Notice'],
  221. [0, 'Unknown level'],
  222. [6, 'Unknown level'],
  223. ])('labels level %i as %s', (severity, label) => {
  224. render(<HMSErrorModal {...defaultProps} errors={[{ ...knownError, severity }]} />);
  225. expect(screen.getByText(label)).toBeInTheDocument();
  226. });
  227. it('treats stopping and pausing faults as severe, and nothing else', () => {
  228. expect([0, 1, 2, 3, 4, 6].map((severity) => isSevereHMSError({ ...knownError, severity }))).toEqual([
  229. false, true, true, false, false, false,
  230. ]);
  231. });
  232. });
  233. describe('what counts (#2728)', () => {
  234. // The reporter's three P2S faults as the backend now sends them. Bambu
  235. // describes the first; it lists the other two with no text.
  236. const p2s: HMSError[] = [
  237. {
  238. attr: 83886848,
  239. code: '0x2000e',
  240. severity: 2,
  241. full_code: '050003000002000E',
  242. description: "Some modules are incompatible with the printer's firmware version.",
  243. },
  244. { attr: 83887616, code: '0x20070', severity: 2, full_code: '0500060000020070', description: null },
  245. { attr: 83886592, code: '0x3000a', severity: 3, full_code: '050002000003000A', description: null },
  246. ];
  247. it('counts a fault Bambu describes and leaves the others out of the count', () => {
  248. expect(filterKnownHMSErrors(p2s).map((e) => e.full_code)).toEqual(['050003000002000E']);
  249. expect(filterUncountedHMSErrors(p2s).map((e) => e.full_code)).toEqual([
  250. '0500060000020070',
  251. '050002000003000A',
  252. ]);
  253. });
  254. it('still counts an actionable fault without text, so its buttons render', () => {
  255. const actionable: HMSError = { ...unknownError, actions: ['IGNORE_RESUME'] };
  256. expect(filterKnownHMSErrors([actionable])).toHaveLength(1);
  257. expect(filterUncountedHMSErrors([actionable])).toHaveLength(0);
  258. });
  259. it('shows every fault the printer holds, counted ones first', () => {
  260. render(<HMSErrorModal {...defaultProps} errors={p2s} />);
  261. expect(screen.getByText('[0500-0300-0002-000E]')).toBeInTheDocument();
  262. expect(screen.getByText(/Some modules are incompatible/)).toBeInTheDocument();
  263. expect(screen.getByText('Also reported, not counted (2)')).toBeInTheDocument();
  264. expect(screen.getByText('[0500-0600-0002-0070]')).toBeInTheDocument();
  265. expect(screen.getByText('[0500-0200-0003-000A]')).toBeInTheDocument();
  266. });
  267. it('does not count an hms[] notice without actions, but lists it with its text', () => {
  268. // 0300-9700-0003-0001: a printer can hold this through a whole print.
  269. const topCover: HMSError = {
  270. attr: 0x03009700,
  271. code: '0x30001',
  272. severity: 3,
  273. full_code: '0300970000030001',
  274. description: 'The top cover is open.',
  275. };
  276. expect(filterKnownHMSErrors([topCover])).toHaveLength(0);
  277. render(<HMSErrorModal {...defaultProps} errors={[topCover]} />);
  278. expect(screen.getByText('Also reported, not counted (1)')).toBeInTheDocument();
  279. expect(screen.getByText('The top cover is open.')).toBeInTheDocument();
  280. });
  281. it('counts an hms[] notice that offers actions', () => {
  282. const notice: HMSError = {
  283. attr: 0x03009700,
  284. code: '0x30001',
  285. severity: 3,
  286. full_code: '0300970000030001',
  287. description: 'The top cover is open.',
  288. actions: ['OK_BUTTON'],
  289. };
  290. expect(filterKnownHMSErrors([notice])).toHaveLength(1);
  291. });
  292. it('still counts a print_error prompt at the notice level', () => {
  293. const prompt: HMSError = {
  294. attr: 0x1880c003,
  295. code: '0xc003',
  296. severity: 3,
  297. full_code: '1880C003',
  298. description: 'Unable to start drying.',
  299. };
  300. expect(filterKnownHMSErrors([prompt])).toHaveLength(1);
  301. });
  302. it('never counts level 0', () => {
  303. expect(filterKnownHMSErrors([{ ...knownError, severity: 0 }])).toHaveLength(0);
  304. });
  305. it('has no collapsed group when everything is described', () => {
  306. render(<HMSErrorModal {...defaultProps} errors={[knownError]} />);
  307. expect(screen.queryByTestId('hms-uncounted')).not.toBeInTheDocument();
  308. });
  309. });
  310. describe('interactions', () => {
  311. it('calls onClose when X button is clicked', async () => {
  312. const user = userEvent.setup();
  313. const onClose = vi.fn();
  314. render(<HMSErrorModal {...defaultProps} onClose={onClose} />);
  315. // The X button is the button with the X icon in the header
  316. const closeButtons = screen.getAllByRole('button');
  317. // First button is the X close button in the header
  318. await user.click(closeButtons[0]);
  319. expect(onClose).toHaveBeenCalledTimes(1);
  320. });
  321. it('calls onClose when Escape key is pressed', () => {
  322. const onClose = vi.fn();
  323. render(<HMSErrorModal {...defaultProps} onClose={onClose} />);
  324. fireEvent.keyDown(window, { key: 'Escape' });
  325. expect(onClose).toHaveBeenCalledTimes(1);
  326. });
  327. });
  328. });