cameraWallCardInfo3479.test.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
  2. import { screen, waitFor, within } from '@testing-library/react';
  3. import userEvent from '@testing-library/user-event';
  4. import { http, HttpResponse } from 'msw';
  5. import { server } from '../mocks/server';
  6. import { render as rtlRender } from '@testing-library/react';
  7. import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
  8. import { MemoryRouter } from 'react-router-dom';
  9. import { render } from '../utils';
  10. import { ToastProvider } from '../../contexts/ToastContext';
  11. import { api } from '../../api/client';
  12. import { CamWallPage } from '../../pages/CamWallPage';
  13. import { CameraTile } from '../../components/CameraTile';
  14. import { CameraWall, type CameraWallStatus } from '../../components/CameraWall';
  15. // The camera wall tiles carry what a printer card shows at a glance: the
  16. // heaters and the finish time in the full overlay, and pause/resume, stop and
  17. // the chamber light for a user allowed to control printers (#3479).
  18. let canControl = true;
  19. let signedInUser: { id: number; username: string } | null = null;
  20. vi.mock('../../contexts/AuthContext', async (importOriginal) => {
  21. const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
  22. return {
  23. ...actual,
  24. useAuth: () => ({
  25. hasPermission: (p: string) => (p === 'printers:control' ? canControl : true),
  26. hasAnyPermission: () => true,
  27. authEnabled: true,
  28. user: signedInUser,
  29. loading: false,
  30. }),
  31. };
  32. });
  33. const PRINTER = { id: 5, name: 'Farm-5' };
  34. const PRINTING: CameraWallStatus = {
  35. connected: true,
  36. state: 'RUNNING',
  37. progress: 40,
  38. remaining_time: 90,
  39. temperatures: { nozzle: 219.6, bed: 60, chamber: 34.4 },
  40. chamber_light: false,
  41. };
  42. let posted: string[];
  43. beforeEach(() => {
  44. canControl = true;
  45. signedInUser = null;
  46. posted = [];
  47. vi.spyOn(global, 'fetch');
  48. server.use(
  49. http.post('/api/v1/printers/:id/print/:action', ({ request, params }) => {
  50. posted.push(`${params.action} ${new URL(request.url).pathname}`);
  51. return HttpResponse.json({ success: true, message: 'ok' });
  52. }),
  53. http.post('/api/v1/printers/:id/chamber-light', ({ request }) => {
  54. posted.push(`light ${new URL(request.url).searchParams.get('on')}`);
  55. return HttpResponse.json({ success: true, message: 'ok' });
  56. }),
  57. http.post('/api/v1/printers/:id/camera/stop', () => HttpResponse.json({ success: true })),
  58. );
  59. });
  60. afterEach(() => {
  61. vi.restoreAllMocks();
  62. });
  63. function renderWall(
  64. status: CameraWallStatus,
  65. props: Partial<Parameters<typeof CameraWall>[0]> = { showControls: true },
  66. ) {
  67. return render(
  68. <CameraWall
  69. printers={[PRINTER]}
  70. maxLive={0}
  71. snapshotIntervalSec={8}
  72. statusMode="full"
  73. statuses={new Map([[PRINTER.id, status]])}
  74. onChangeMaxLive={() => {}}
  75. onChangeSnapshotIntervalSec={() => {}}
  76. onChangeStatusMode={() => {}}
  77. onTileClick={() => {}}
  78. timeFormat="24h"
  79. {...props}
  80. />,
  81. );
  82. }
  83. describe('tile information', () => {
  84. function tile(props: Partial<Parameters<typeof CameraTile>[0]>) {
  85. return render(
  86. <CameraTile
  87. printerId={5}
  88. printerName="Farm-5"
  89. mode="paused"
  90. snapshotIntervalMs={8000}
  91. connected
  92. statusMode="full"
  93. printerState="RUNNING"
  94. remainingMin={90}
  95. temperatures={{ nozzle: 219.6, nozzle_2: 180, bed: 60, chamber: 34.4 }}
  96. timeFormat="24h"
  97. {...props}
  98. />,
  99. );
  100. }
  101. it('shows every heater, both nozzles of a dual-nozzle printer, rounded', () => {
  102. tile({});
  103. expect(screen.getByTitle('Nozzle')).toHaveTextContent('220° / 180°');
  104. expect(screen.getByTitle('Bed')).toHaveTextContent('60°');
  105. expect(screen.getByTitle('Chamber')).toHaveTextContent('34°');
  106. });
  107. it('leaves out a heater the printer does not report', () => {
  108. tile({ temperatures: { nozzle: 200, bed: 55 } });
  109. expect(screen.queryByTitle('Chamber')).not.toBeInTheDocument();
  110. });
  111. it('shows the heaters on an idle printer too', () => {
  112. tile({ printerState: 'IDLE', remainingMin: null });
  113. expect(screen.getByTitle('Bed')).toHaveTextContent('60°');
  114. });
  115. it('shows the finish time while printing', () => {
  116. tile({});
  117. expect(screen.getByText(/^ETA \d{2}:\d{2}$|^ETA Tomorrow \d{2}:\d{2}$/)).toBeInTheDocument();
  118. });
  119. it('shows no finish time for a paused print, whose end moves with the pause', () => {
  120. tile({ printerState: 'PAUSE' });
  121. expect(screen.getByText(/left$/)).toBeInTheDocument();
  122. expect(screen.queryByText(/^ETA/)).not.toBeInTheDocument();
  123. });
  124. it('keeps the compact overlay to the state badge', () => {
  125. tile({ statusMode: 'compact' });
  126. expect(screen.queryByTitle('Bed')).not.toBeInTheDocument();
  127. expect(screen.queryByText(/^ETA/)).not.toBeInTheDocument();
  128. });
  129. it('shows no heaters for an offline printer', () => {
  130. tile({ connected: false });
  131. expect(screen.queryByTitle('Bed')).not.toBeInTheDocument();
  132. });
  133. });
  134. describe('tile controls', () => {
  135. it('pauses a print after the same confirmation as the card', async () => {
  136. const user = userEvent.setup();
  137. renderWall(PRINTING);
  138. await user.click(await screen.findByRole('button', { name: 'Pause' }));
  139. expect(posted).toEqual([]);
  140. await screen.findByText(/pause the current print on "Farm-5"/);
  141. await user.click(screen.getByRole('button', { name: 'Pause Print' }));
  142. await waitFor(() => expect(posted).toEqual(['pause /api/v1/printers/5/print/pause']));
  143. });
  144. it('resumes a paused print', async () => {
  145. const user = userEvent.setup();
  146. renderWall({ ...PRINTING, state: 'PAUSE' });
  147. await user.click(await screen.findByRole('button', { name: 'Resume' }));
  148. await screen.findByText(/resume the print on "Farm-5"/);
  149. await user.click(screen.getByRole('button', { name: 'Resume Print' }));
  150. await waitFor(() => expect(posted).toEqual(['resume /api/v1/printers/5/print/resume']));
  151. });
  152. it('stops a print only after it is confirmed', async () => {
  153. const user = userEvent.setup();
  154. renderWall(PRINTING);
  155. await user.click(await screen.findByRole('button', { name: 'Stop' }));
  156. await screen.findByText(/stop the current print on "Farm-5"/);
  157. await user.click(screen.getByRole('button', { name: 'Cancel' }));
  158. expect(posted).toEqual([]);
  159. await user.click(screen.getByRole('button', { name: 'Stop' }));
  160. await screen.findByText(/stop the current print on "Farm-5"/);
  161. await user.click(screen.getByRole('button', { name: 'Stop Print' }));
  162. await waitFor(() => expect(posted).toEqual(['stop /api/v1/printers/5/print/stop']));
  163. });
  164. it('switches the chamber light', async () => {
  165. const user = userEvent.setup();
  166. renderWall(PRINTING);
  167. await user.click(await screen.findByRole('button', { name: 'Turn on chamber light' }));
  168. await waitFor(() => expect(posted).toEqual(['light true']));
  169. });
  170. it('offers only the light on a printer that is not printing', async () => {
  171. renderWall({ ...PRINTING, state: 'IDLE' });
  172. expect(await screen.findByRole('button', { name: 'Turn on chamber light' })).toBeInTheDocument();
  173. expect(screen.queryByRole('button', { name: 'Pause' })).not.toBeInTheDocument();
  174. expect(screen.queryByRole('button', { name: 'Stop' })).not.toBeInTheDocument();
  175. });
  176. it('gives a user without printers:control no controls', async () => {
  177. canControl = false;
  178. renderWall(PRINTING);
  179. // The tile itself is there; only its controls are not.
  180. expect(await screen.findByTitle('Bed')).toBeInTheDocument();
  181. expect(screen.queryByRole('button', { name: 'Pause' })).not.toBeInTheDocument();
  182. expect(screen.queryByRole('button', { name: 'Stop' })).not.toBeInTheDocument();
  183. expect(screen.queryByRole('button', { name: /chamber light/ })).not.toBeInTheDocument();
  184. });
  185. it('gives an offline printer no controls', async () => {
  186. renderWall({ ...PRINTING, connected: false });
  187. expect(screen.queryByRole('button', { name: /chamber light/ })).not.toBeInTheDocument();
  188. });
  189. it('gives a wall without controls (the kiosk) none', async () => {
  190. renderWall(PRINTING, { showControls: false });
  191. expect(screen.queryByRole('button', { name: 'Pause' })).not.toBeInTheDocument();
  192. expect(screen.queryByRole('button', { name: /chamber light/ })).not.toBeInTheDocument();
  193. });
  194. });
  195. describe('kiosk wall', () => {
  196. it('has no controls, also where the browser could control printers', async () => {
  197. vi.spyOn(api, 'getCamWallPrinters').mockResolvedValue([
  198. { id: 7, name: 'X1C-Lab', camera_rotation: 0, connected: true, state: 'RUNNING', progress: 42, remaining_time: 33, layer_num: 1, total_layers: 2, hms_errors: [] },
  199. ]);
  200. const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
  201. rtlRender(
  202. <QueryClientProvider client={queryClient}>
  203. <MemoryRouter initialEntries={['/camwall?token=bblt_abcdefgh_secretsecretsecret']}>
  204. <ToastProvider>
  205. <CamWallPage />
  206. </ToastProvider>
  207. </MemoryRouter>
  208. </QueryClientProvider>,
  209. );
  210. expect(await screen.findByText('X1C-Lab')).toBeInTheDocument();
  211. expect(screen.queryByRole('button', { name: /chamber light/ })).not.toBeInTheDocument();
  212. expect(screen.queryByRole('button', { name: 'Pause' })).not.toBeInTheDocument();
  213. });
  214. });
  215. describe('when the tile controls show', () => {
  216. const controls = () => screen.getByTestId('camwall-tile-controls');
  217. it('waits for hover by default', async () => {
  218. renderWall(PRINTING);
  219. await screen.findByRole('button', { name: 'Pause' });
  220. expect(controls().className).toContain('opacity-0');
  221. expect(controls().className).toContain('group-hover/tile:opacity-100');
  222. // No hover on a touch screen: shown there all the same.
  223. expect(controls().className).toContain('[@media(hover:none)]:opacity-100');
  224. });
  225. it('shows them all the time when set to always', async () => {
  226. renderWall(PRINTING, { showControls: true, controlsMode: 'always', onChangeControlsMode: () => {} });
  227. await screen.findByRole('button', { name: 'Pause' });
  228. expect(controls().className).not.toContain('opacity-0');
  229. });
  230. it('is chosen in the wall settings', async () => {
  231. const onChange = vi.fn();
  232. const user = userEvent.setup();
  233. renderWall(PRINTING, { showControls: true, controlsMode: 'hover', onChangeControlsMode: onChange });
  234. await user.click(screen.getByRole('button', { name: 'Cam wall settings' }));
  235. const group = screen.getByRole('radiogroup', { name: 'Tile controls' });
  236. expect(within(group).getByRole('radio', { name: 'On hover' })).toHaveAttribute('aria-checked', 'true');
  237. await user.click(within(group).getByRole('radio', { name: 'Always' }));
  238. expect(onChange).toHaveBeenCalledWith('always');
  239. });
  240. it('is not offered on a wall without controls', async () => {
  241. const user = userEvent.setup();
  242. renderWall(PRINTING, { showControls: false, onChangeControlsMode: () => {} });
  243. await user.click(screen.getByRole('button', { name: 'Cam wall settings' }));
  244. expect(screen.queryByRole('radiogroup', { name: 'Tile controls' })).not.toBeInTheDocument();
  245. });
  246. });
  247. describe('the /camwall page, signed in', () => {
  248. it('follows the choice saved in this browser', async () => {
  249. signedInUser = { id: 1, username: 'farm' };
  250. vi.mocked(localStorage.getItem).mockImplementation((key: string) => (key === 'camWallControls' ? 'always' : null));
  251. vi.spyOn(api, 'getPrinters').mockResolvedValue([{ id: 7, name: 'X1C-Lab', camera_rotation: 0 }] as never);
  252. vi.spyOn(api, 'getPrinterStatus').mockResolvedValue({ ...PRINTING, hms_errors: [] } as never);
  253. const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
  254. rtlRender(
  255. <QueryClientProvider client={queryClient}>
  256. <MemoryRouter initialEntries={['/camwall']}>
  257. <ToastProvider>
  258. <CamWallPage />
  259. </ToastProvider>
  260. </MemoryRouter>
  261. </QueryClientProvider>,
  262. );
  263. await screen.findByRole('button', { name: 'Pause' });
  264. expect(screen.getByTestId('camwall-tile-controls').className).not.toContain('opacity-0');
  265. });
  266. });