HMSErrorModal.test.tsx 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  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 { HMSErrorModal, filterKnownHMSErrors } from '../../components/HMSErrorModal';
  9. import { http, HttpResponse } from 'msw';
  10. import { server } from '../mocks/server';
  11. import type { HMSError } from '../../api/client';
  12. // Error code 0300_400C = "The task was canceled." (known code in the database)
  13. const knownError: HMSError = {
  14. attr: 0x0300,
  15. code: '0x400C',
  16. severity: 2,
  17. };
  18. // Error code FFFF_FFFF = unknown (not in the database)
  19. const unknownError: HMSError = {
  20. attr: 0xFFFF,
  21. code: '0xFFFF',
  22. severity: 1,
  23. };
  24. // Error code 0700_8011 = AMS filament runout (#2587).
  25. const runoutError: HMSError = {
  26. attr: 0x0700,
  27. code: '0x8011',
  28. severity: 2,
  29. };
  30. describe('HMSErrorModal', () => {
  31. const defaultProps = {
  32. printerName: 'Test Printer',
  33. errors: [knownError],
  34. onClose: vi.fn(),
  35. printerId: 1,
  36. hasPermission: vi.fn().mockReturnValue(true) as unknown as (permission: 'printers:control') => boolean,
  37. };
  38. afterEach(() => {
  39. cleanup();
  40. vi.clearAllMocks();
  41. });
  42. describe('rendering', () => {
  43. it('renders the modal title with printer name', () => {
  44. render(<HMSErrorModal {...defaultProps} />);
  45. expect(screen.getByText('Errors - Test Printer')).toBeInTheDocument();
  46. });
  47. it('shows error description for known error codes', () => {
  48. render(<HMSErrorModal {...defaultProps} />);
  49. expect(screen.getByText('The task was canceled.')).toBeInTheDocument();
  50. });
  51. it('shows no errors message when all errors are unknown', () => {
  52. render(<HMSErrorModal {...defaultProps} errors={[unknownError]} />);
  53. expect(screen.getByText('No errors')).toBeInTheDocument();
  54. });
  55. it('shows no errors message when errors array is empty', () => {
  56. render(<HMSErrorModal {...defaultProps} errors={[]} />);
  57. expect(screen.getByText('No errors')).toBeInTheDocument();
  58. });
  59. });
  60. describe('clear errors button', () => {
  61. it('shows clear button when there are known errors', () => {
  62. render(<HMSErrorModal {...defaultProps} />);
  63. expect(screen.getByText('Clear Errors')).toBeInTheDocument();
  64. });
  65. it('hides clear button when there are no known errors', () => {
  66. render(<HMSErrorModal {...defaultProps} errors={[]} />);
  67. expect(screen.queryByText('Clear Errors')).not.toBeInTheDocument();
  68. });
  69. it('hides clear button when all errors are unknown codes', () => {
  70. render(<HMSErrorModal {...defaultProps} errors={[unknownError]} />);
  71. expect(screen.queryByText('Clear Errors')).not.toBeInTheDocument();
  72. });
  73. it('disables clear button when user lacks permission', () => {
  74. const noPermission = vi.fn().mockReturnValue(false) as unknown as (permission: 'printers:control') => boolean;
  75. render(<HMSErrorModal {...defaultProps} hasPermission={noPermission} />);
  76. expect(screen.getByText('Clear Errors').closest('button')).toBeDisabled();
  77. });
  78. it('calls API and closes modal on successful clear', async () => {
  79. const user = userEvent.setup();
  80. const onClose = vi.fn();
  81. server.use(
  82. http.post('/api/v1/printers/1/hms/clear', () => {
  83. return HttpResponse.json({ success: true, message: 'HMS errors cleared' });
  84. })
  85. );
  86. render(<HMSErrorModal {...defaultProps} onClose={onClose} />);
  87. await user.click(screen.getByText('Clear Errors'));
  88. await waitFor(() => {
  89. expect(onClose).toHaveBeenCalledTimes(1);
  90. });
  91. });
  92. it('shows error toast on failed 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({ detail: 'Failed' }, { status: 500 });
  98. })
  99. );
  100. render(<HMSErrorModal {...defaultProps} onClose={onClose} />);
  101. await user.click(screen.getByText('Clear Errors'));
  102. await waitFor(() => {
  103. expect(onClose).not.toHaveBeenCalled();
  104. });
  105. });
  106. });
  107. describe('runout guidance (#2587)', () => {
  108. it('shows the generic runout text when no guidance is provided', () => {
  109. render(<HMSErrorModal {...defaultProps} errors={[runoutError]} />);
  110. expect(
  111. screen.getByText('AMS filament ran out. Please insert a new filament into the same AMS slot.')
  112. ).toBeInTheDocument();
  113. });
  114. it('names both the expected and ran-out slot when both are resolved', () => {
  115. render(
  116. <HMSErrorModal
  117. {...defaultProps}
  118. errors={[runoutError]}
  119. runoutGuidance={{ expectedSlotLabel: 'AMS-A · Slot 3', ranOutSlotLabel: 'AMS-A · Slot 2' }}
  120. />
  121. );
  122. const p = screen.getByText(/waiting for compatible filament/i);
  123. expect(p.textContent).toContain('AMS-A · Slot 3');
  124. expect(p.textContent).toContain('AMS-A · Slot 2');
  125. // The misleading "same slot" text must be gone.
  126. expect(screen.queryByText(/into the same AMS slot/i)).not.toBeInTheDocument();
  127. });
  128. it('names only the expected slot when the ran-out slot is unknown', () => {
  129. render(
  130. <HMSErrorModal
  131. {...defaultProps}
  132. errors={[runoutError]}
  133. runoutGuidance={{ expectedSlotLabel: 'AMS-A · Slot 3', ranOutSlotLabel: null }}
  134. />
  135. );
  136. const p = screen.getByText(/waiting for compatible filament/i);
  137. expect(p.textContent).toContain('AMS-A · Slot 3');
  138. });
  139. it('shows an honest fallback when the slot cannot be resolved', () => {
  140. render(
  141. <HMSErrorModal
  142. {...defaultProps}
  143. errors={[runoutError]}
  144. runoutGuidance={{ expectedSlotLabel: null, ranOutSlotLabel: null }}
  145. />
  146. );
  147. expect(screen.getByText(/could not determine which slot/i)).toBeInTheDocument();
  148. });
  149. it('does not apply runout guidance to non-runout errors', () => {
  150. render(
  151. <HMSErrorModal
  152. {...defaultProps}
  153. errors={[knownError]}
  154. runoutGuidance={{ expectedSlotLabel: 'AMS-A · Slot 3', ranOutSlotLabel: 'AMS-A · Slot 2' }}
  155. />
  156. );
  157. // 0300_400C keeps its own description; no slot injection.
  158. expect(screen.getByText('The task was canceled.')).toBeInTheDocument();
  159. expect(screen.queryByText(/waiting for compatible filament/i)).not.toBeInTheDocument();
  160. });
  161. });
  162. describe('MQTT command verification failed (#2732)', () => {
  163. // attr 0x05000500, code 0x00010007 — a real P1S on firmware 01.10.00.00.
  164. // getShortCode() collapses this to "0500_0007", which matches nothing, so
  165. // before #2732 filterKnownHMSErrors dropped the one error that explained
  166. // why the printer accepted every job and started none of them.
  167. const verifyFailedError: HMSError = {
  168. attr: 0x05000500,
  169. code: '0x10007',
  170. severity: 1,
  171. full_code: '0500050000010007',
  172. };
  173. it('surfaces the error instead of filtering it out', () => {
  174. render(<HMSErrorModal {...defaultProps} errors={[verifyFailedError]} />);
  175. expect(screen.queryByText('No errors')).not.toBeInTheDocument();
  176. expect(screen.getByText(/could not verify it/i)).toBeInTheDocument();
  177. });
  178. it('counts towards the known-error filter', () => {
  179. expect(filterKnownHMSErrors([verifyFailedError])).toHaveLength(1);
  180. });
  181. it('shows the remedy, not just the fault', () => {
  182. render(<HMSErrorModal {...defaultProps} errors={[verifyFailedError]} />);
  183. expect(screen.getByText(/Enable Developer Mode on the printer/i)).toBeInTheDocument();
  184. });
  185. it('displays the code the printer screen shows, not the truncated form', () => {
  186. render(<HMSErrorModal {...defaultProps} errors={[verifyFailedError]} />);
  187. expect(screen.getByText('[0500-0500-0001-0007]')).toBeInTheDocument();
  188. expect(screen.queryByText('[0500-0007]')).not.toBeInTheDocument();
  189. });
  190. it('leaves short-code errors on the two-group display', () => {
  191. render(<HMSErrorModal {...defaultProps} errors={[knownError]} />);
  192. expect(screen.getByText('[0300-400C]')).toBeInTheDocument();
  193. });
  194. it('does not add the remedy line to other errors', () => {
  195. render(<HMSErrorModal {...defaultProps} errors={[knownError]} />);
  196. expect(screen.queryByText(/Enable Developer Mode/i)).not.toBeInTheDocument();
  197. });
  198. });
  199. describe('interactions', () => {
  200. it('calls onClose when X button is clicked', async () => {
  201. const user = userEvent.setup();
  202. const onClose = vi.fn();
  203. render(<HMSErrorModal {...defaultProps} onClose={onClose} />);
  204. // The X button is the button with the X icon in the header
  205. const closeButtons = screen.getAllByRole('button');
  206. // First button is the X close button in the header
  207. await user.click(closeButtons[0]);
  208. expect(onClose).toHaveBeenCalledTimes(1);
  209. });
  210. it('calls onClose when Escape key is pressed', () => {
  211. const onClose = vi.fn();
  212. render(<HMSErrorModal {...defaultProps} onClose={onClose} />);
  213. fireEvent.keyDown(window, { key: 'Escape' });
  214. expect(onClose).toHaveBeenCalledTimes(1);
  215. });
  216. });
  217. });