PrintersPagePlateCheckWiring.test.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. /**
  2. * Build-plate check wiring on the printer card.
  3. *
  4. * Opening the check turns the chamber light on when it is off (the camera
  5. * needs it), then asks the backend for a check with the debug image. Closing
  6. * the result (X, Close or Escape) must turn the light back off -- but only if
  7. * the check was what turned it on. A failed check must restore the light too.
  8. * From the result modal: calibrate, delete a reference and save the ROI all
  9. * name THIS printer.
  10. */
  11. import { describe, it, expect, vi, beforeEach } from 'vitest';
  12. import { screen, waitFor, within, fireEvent } from '@testing-library/react';
  13. import userEvent from '@testing-library/user-event';
  14. import { http, HttpResponse } from 'msw';
  15. import { server } from '../mocks/server';
  16. const permissions = { denied: new Set<string>() };
  17. const mockUseAuth = {
  18. user: { id: 1, username: 'operator', permissions: [] as string[] },
  19. authEnabled: true,
  20. requiresSetup: false,
  21. loading: false,
  22. isAdmin: false,
  23. login: vi.fn(),
  24. loginWithToken: vi.fn(),
  25. logout: vi.fn(),
  26. refreshUser: vi.fn(),
  27. refreshAuth: vi.fn(),
  28. hasPermission: vi.fn((permission: string) => !permissions.denied.has(permission)),
  29. hasAnyPermission: vi.fn(() => true),
  30. hasAllPermissions: vi.fn(() => true),
  31. canModify: vi.fn(() => true),
  32. };
  33. vi.mock('../../contexts/AuthContext', async (importOriginal) => {
  34. const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
  35. return { ...actual, useAuth: () => mockUseAuth };
  36. });
  37. import { render } from '../utils';
  38. import { PrintersPage } from '../../pages/PrintersPage';
  39. const PRINTER_ID = 9;
  40. // Opening with the light off waits 2.5 s for the camera before checking.
  41. const LIGHT_WAIT = { timeout: 6000 };
  42. const mockPrinter = {
  43. id: PRINTER_ID,
  44. name: 'Plate X1C',
  45. ip_address: '192.0.2.109',
  46. serial_number: '00M09A350100009',
  47. access_code: '12345678',
  48. model: 'X1C',
  49. enabled: true,
  50. plate_detection_enabled: true,
  51. nozzle_diameter: 0.4,
  52. nozzle_type: 'hardened_steel',
  53. auto_archive: true,
  54. created_at: '2024-01-01T00:00:00Z',
  55. updated_at: '2024-01-01T00:00:00Z',
  56. };
  57. const statusWithLight = (on: boolean) => ({
  58. connected: true,
  59. state: 'IDLE',
  60. progress: 0,
  61. layer_num: 0,
  62. total_layers: 0,
  63. temperatures: { nozzle: 25, bed: 25, chamber: 25 },
  64. remaining_time: 0,
  65. filename: null,
  66. wifi_signal: -50,
  67. speed_level: 2,
  68. chamber_light: on,
  69. vt_tray: [],
  70. ams: [],
  71. });
  72. const emptyResult = {
  73. is_empty: true,
  74. confidence: 0.93,
  75. difference_percent: 1.2,
  76. message: 'Plate is empty',
  77. has_debug_image: false,
  78. needs_calibration: false,
  79. failure_reason: null,
  80. reference_count: 2,
  81. max_references: 5,
  82. roi: { x: 0.1, y: 0.2, w: 0.6, h: 0.5 },
  83. };
  84. type Sent = { method: string; path: string; params: Record<string, string>; body: unknown };
  85. interface SetupOptions {
  86. lightOn?: boolean;
  87. checkResult?: Record<string, unknown>;
  88. checkStatus?: number;
  89. }
  90. function setup({ lightOn = false, checkResult = emptyResult, checkStatus = 200 }: SetupOptions = {}) {
  91. const sent: Sent[] = [];
  92. const log = async (request: Request) => {
  93. const url = new URL(request.url);
  94. const text = request.method === 'GET' ? '' : await request.text();
  95. sent.push({
  96. method: request.method,
  97. path: url.pathname.replace(`/api/v1/printers/${PRINTER_ID}`, ''),
  98. params: Object.fromEntries(url.searchParams.entries()),
  99. body: text ? JSON.parse(text) : null,
  100. });
  101. };
  102. server.use(
  103. http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
  104. http.get('/api/v1/queue/', () => HttpResponse.json([])),
  105. http.get('/api/v1/printers/:id/status', () => HttpResponse.json(statusWithLight(lightOn))),
  106. http.post('/api/v1/printers/:id/chamber-light', async ({ request }) => {
  107. await log(request);
  108. return HttpResponse.json({ success: true, message: 'ok' });
  109. }),
  110. http.get('/api/v1/printers/:id/camera/check-plate', async ({ request }) => {
  111. await log(request);
  112. if (checkStatus !== 200) {
  113. return HttpResponse.json({ detail: 'Camera unreachable' }, { status: checkStatus });
  114. }
  115. return HttpResponse.json(checkResult);
  116. }),
  117. http.get('/api/v1/printers/:id/camera/plate-detection/references', () =>
  118. HttpResponse.json({
  119. references: [
  120. { index: 0, label: 'Textured', timestamp: '2024-01-01T00:00:00Z', has_image: true, thumbnail_url: '' },
  121. { index: 3, label: 'Smooth', timestamp: '2024-01-02T00:00:00Z', has_image: true, thumbnail_url: '' },
  122. ],
  123. max_references: 5,
  124. })
  125. ),
  126. http.get('/api/v1/printers/:id/camera/plate-detection/references/:index/thumbnail', () =>
  127. new HttpResponse(null, { status: 204 })
  128. ),
  129. http.post('/api/v1/printers/:id/camera/plate-detection/calibrate', async ({ request }) => {
  130. await log(request);
  131. return HttpResponse.json({ success: true, message: 'Calibrated', index: 2 });
  132. }),
  133. http.delete('/api/v1/printers/:id/camera/plate-detection/references/:index', async ({ request }) => {
  134. await log(request);
  135. return HttpResponse.json({ success: true, message: 'Deleted' });
  136. }),
  137. http.patch('/api/v1/printers/:id', async ({ request }) => {
  138. await log(request);
  139. return HttpResponse.json(mockPrinter);
  140. }),
  141. );
  142. render(<PrintersPage />);
  143. return sent;
  144. }
  145. const light = (on: boolean) => ({ method: 'POST', path: '/chamber-light', params: { on: String(on) }, body: null });
  146. const check = { method: 'GET', path: '/camera/check-plate', params: { include_debug_image: 'true' }, body: null };
  147. async function openCheck(user: ReturnType<typeof userEvent.setup>) {
  148. await user.click(await screen.findByTitle('Manage plate detection calibration'));
  149. }
  150. function modal() {
  151. return screen.getByText('Build Plate Check').closest('div.max-w-lg') as HTMLElement;
  152. }
  153. describe('PrintersPage plate check: chamber light handling', () => {
  154. beforeEach(() => {
  155. permissions.denied = new Set();
  156. });
  157. it('light off: turns it on, then checks with the debug image; X turns it back off', async () => {
  158. const user = userEvent.setup();
  159. const sent = setup({ lightOn: false });
  160. await openCheck(user);
  161. await screen.findByText('Build Plate Check', {}, LIGHT_WAIT);
  162. expect(sent).toEqual([light(true), check]);
  163. // The header X is the first button in the modal.
  164. await user.click(within(modal()).getAllByRole('button')[0]);
  165. await waitFor(() => expect(screen.queryByText('Build Plate Check')).toBeNull());
  166. await waitFor(() => expect(sent).toEqual([light(true), check, light(false)]));
  167. });
  168. it('light off: Escape closes the result and turns the light back off', async () => {
  169. const user = userEvent.setup();
  170. const sent = setup({ lightOn: false });
  171. await openCheck(user);
  172. await screen.findByText('Build Plate Check', {}, LIGHT_WAIT);
  173. fireEvent.keyDown(window, { key: 'Escape' });
  174. await waitFor(() => expect(screen.queryByText('Build Plate Check')).toBeNull());
  175. await waitFor(() => expect(sent).toEqual([light(true), check, light(false)]));
  176. });
  177. it('light already on: no chamber-light request on open or on close', async () => {
  178. const user = userEvent.setup();
  179. const sent = setup({ lightOn: true });
  180. await openCheck(user);
  181. await screen.findByText('Build Plate Check');
  182. await user.click(within(modal()).getByRole('button', { name: 'Close' }));
  183. await waitFor(() => expect(screen.queryByText('Build Plate Check')).toBeNull());
  184. await new Promise((r) => setTimeout(r, 50));
  185. expect(sent).toEqual([check]);
  186. });
  187. it('check failure (500): error toast, no modal, and the light it turned on goes back off', async () => {
  188. const user = userEvent.setup();
  189. const sent = setup({ lightOn: false, checkStatus: 500 });
  190. await openCheck(user);
  191. expect(await screen.findByText('Camera unreachable', {}, LIGHT_WAIT)).toBeInTheDocument();
  192. await waitFor(() => expect(sent).toEqual([light(true), check, light(false)]));
  193. expect(screen.queryByText('Build Plate Check')).toBeNull();
  194. });
  195. it('check failure with the light already on sends no chamber-light request', async () => {
  196. const user = userEvent.setup();
  197. const sent = setup({ lightOn: true, checkStatus: 500 });
  198. await openCheck(user);
  199. expect(await screen.findByText('Camera unreachable')).toBeInTheDocument();
  200. await new Promise((r) => setTimeout(r, 50));
  201. expect(sent).toEqual([check]);
  202. });
  203. it('without printers:update the check button is disabled and sends nothing', async () => {
  204. permissions.denied = new Set(['printers:update']);
  205. const user = userEvent.setup();
  206. const sent = setup({ lightOn: true });
  207. const buttons = (await screen.findAllByTitle('You do not have permission to update printers')).filter(
  208. (el) => el.tagName === 'BUTTON'
  209. );
  210. expect(buttons.length).toBeGreaterThanOrEqual(2);
  211. for (const b of buttons) {
  212. expect(b).toBeDisabled();
  213. await user.click(b);
  214. }
  215. await new Promise((r) => setTimeout(r, 50));
  216. expect(sent).toEqual([]);
  217. });
  218. });
  219. describe('PrintersPage plate check: result rendering', () => {
  220. beforeEach(() => {
  221. permissions.denied = new Set();
  222. });
  223. it('empty plate shows the empty verdict', async () => {
  224. const user = userEvent.setup();
  225. setup({ lightOn: true });
  226. await openCheck(user);
  227. expect(await screen.findByText('Plate appears empty')).toBeInTheDocument();
  228. expect(screen.queryByText('Objects detected on plate')).toBeNull();
  229. });
  230. it('occupied plate shows objects detected', async () => {
  231. const user = userEvent.setup();
  232. setup({ lightOn: true, checkResult: { ...emptyResult, is_empty: false } });
  233. await openCheck(user);
  234. expect(await screen.findByText('Objects detected on plate')).toBeInTheDocument();
  235. expect(screen.queryByText('Plate appears empty')).toBeNull();
  236. });
  237. it('failure_reason shows the could-not-run text instead of a verdict', async () => {
  238. const user = userEvent.setup();
  239. setup({ lightOn: true, checkResult: { ...emptyResult, failure_reason: 'no_camera_frame' } });
  240. await openCheck(user);
  241. expect(await screen.findByText('Plate check could not run')).toBeInTheDocument();
  242. expect(screen.getByText('no camera image')).toBeInTheDocument();
  243. expect(screen.queryByText('Plate appears empty')).toBeNull();
  244. });
  245. it('needs_calibration shows the calibration prompt and no ROI editor', async () => {
  246. const user = userEvent.setup();
  247. setup({ lightOn: true, checkResult: { ...emptyResult, needs_calibration: true } });
  248. await openCheck(user);
  249. expect(await screen.findByText('Calibration Required')).toBeInTheDocument();
  250. expect(screen.queryByText('Detection Area (ROI)')).toBeNull();
  251. expect(screen.queryByText('Plate appears empty')).toBeNull();
  252. });
  253. });
  254. describe('PrintersPage plate check: calibrate, references, ROI', () => {
  255. beforeEach(() => {
  256. permissions.denied = new Set();
  257. });
  258. it('Calibrate Empty Plate -> POST calibrate for this printer, then re-checks', async () => {
  259. const user = userEvent.setup();
  260. const sent = setup({ lightOn: true, checkResult: { ...emptyResult, needs_calibration: true } });
  261. await openCheck(user);
  262. await screen.findByText('Calibration Required');
  263. await user.click(screen.getByRole('button', { name: 'Calibrate Empty Plate' }));
  264. // The card never passes a label, so the request carries none.
  265. await waitFor(() =>
  266. expect(sent).toEqual([
  267. check,
  268. { method: 'POST', path: '/camera/plate-detection/calibrate', params: {}, body: null },
  269. check,
  270. ])
  271. );
  272. });
  273. it('Add Reference on a calibrated printer also POSTs calibrate', async () => {
  274. const user = userEvent.setup();
  275. const sent = setup({ lightOn: true });
  276. await openCheck(user);
  277. await user.click(await screen.findByRole('button', { name: /^Add Reference/ }));
  278. await waitFor(() =>
  279. expect(sent.filter((s) => s.path === '/camera/plate-detection/calibrate')).toHaveLength(1)
  280. );
  281. });
  282. it('deleting a reference DELETEs that reference index for this printer, then re-checks', async () => {
  283. const user = userEvent.setup();
  284. const sent = setup({ lightOn: true });
  285. await openCheck(user);
  286. // Second saved reference has index 3 (indices are not positions).
  287. const deletes = await screen.findAllByTitle('Delete reference');
  288. expect(deletes).toHaveLength(2);
  289. await user.click(deletes[1]);
  290. await waitFor(() =>
  291. expect(sent).toEqual([
  292. check,
  293. { method: 'DELETE', path: '/camera/plate-detection/references/3', params: {}, body: null },
  294. check,
  295. ])
  296. );
  297. });
  298. it('Save ROI -> PATCH /printers/{id} {plate_detection_roi}', async () => {
  299. const user = userEvent.setup();
  300. const sent = setup({ lightOn: true });
  301. await openCheck(user);
  302. await screen.findByText('Detection Area (ROI)');
  303. await user.click(within(modal()).getByRole('button', { name: 'Edit' }));
  304. const sliders = within(modal()).getAllByRole('slider');
  305. fireEvent.change(sliders[0], { target: { value: '0.25' } });
  306. await user.click(within(modal()).getByRole('button', { name: 'Save' }));
  307. await waitFor(() =>
  308. expect(sent).toContainEqual({
  309. method: 'PATCH',
  310. path: '',
  311. params: {},
  312. body: { plate_detection_roi: { x: 0.25, y: 0.2, w: 0.6, h: 0.5 } },
  313. })
  314. );
  315. // and then re-checks with the new area
  316. await waitFor(() => expect(sent.at(-1)).toEqual(check));
  317. });
  318. it('cancelling the ROI edit sends nothing', async () => {
  319. const user = userEvent.setup();
  320. const sent = setup({ lightOn: true });
  321. await openCheck(user);
  322. await screen.findByText('Detection Area (ROI)');
  323. await user.click(within(modal()).getByRole('button', { name: 'Edit' }));
  324. await user.click(within(modal()).getByRole('button', { name: 'Cancel' }));
  325. await new Promise((r) => setTimeout(r, 50));
  326. expect(sent).toEqual([check]);
  327. });
  328. });