PrintersPageCameraSplitButton.test.tsx 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. /**
  2. * The printer card's camera button chooses its own view mode.
  3. *
  4. * Window-vs-overlay used to be one switch in Settings > General > Camera, so
  5. * watching one printer in an overlay and another in its own window meant a trip
  6. * to another page and back. The button is now a split control: the icon opens
  7. * whichever mode was used last, the caret picks between the two. The stored
  8. * setting survives only as the default a browser that has never chosen starts
  9. * from -- the local choice wins after that, because a user without
  10. * settings:update cannot write theirs back and would otherwise never keep one.
  11. */
  12. import React from 'react';
  13. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
  14. import { screen, waitFor, fireEvent } from '@testing-library/react';
  15. import { http, HttpResponse } from 'msw';
  16. import { server } from '../mocks/server';
  17. const permissions = { granted: ['printers:read', 'camera:view', 'settings:update'] as string[] };
  18. const mockUseAuth = {
  19. user: { id: 1, username: 'operator', permissions: [] as string[] },
  20. authEnabled: true,
  21. loading: false,
  22. isAdmin: false,
  23. login: vi.fn(),
  24. loginWithToken: vi.fn(),
  25. logout: vi.fn(),
  26. refreshAuth: vi.fn(),
  27. hasPermission: vi.fn((permission: string) => permissions.granted.includes(permission)),
  28. hasAnyPermission: vi.fn(() => true),
  29. canModify: vi.fn(() => true),
  30. };
  31. vi.mock('../../contexts/AuthContext', async (importOriginal) => {
  32. const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
  33. return { ...actual, useAuth: () => mockUseAuth };
  34. });
  35. import { render } from '../utils';
  36. import { PrintersPage } from '../../pages/PrintersPage';
  37. const mockPrinter = {
  38. id: 1,
  39. name: 'X1C',
  40. ip_address: '192.168.1.100',
  41. serial_number: '01P00A000000001',
  42. access_code: '12345678',
  43. model: 'X1C',
  44. enabled: true,
  45. nozzle_diameter: 0.4,
  46. nozzle_type: 'stainless_steel',
  47. location: 'Workshop',
  48. auto_archive: true,
  49. created_at: '2024-01-01T00:00:00Z',
  50. updated_at: '2024-01-01T00:00:00Z',
  51. };
  52. /** Bodies of every PUT /settings the page made, so the write-back is checkable. */
  53. const settingsWrites: Record<string, unknown>[] = [];
  54. function renderPage(storedMode: 'window' | 'embedded' = 'window') {
  55. server.use(
  56. http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
  57. http.get('/api/v1/printers/:id/status', () =>
  58. HttpResponse.json({
  59. connected: true,
  60. state: 'IDLE',
  61. progress: 0,
  62. layer_num: 0,
  63. total_layers: 0,
  64. temperatures: { nozzle: 25, bed: 25, chamber: 25 },
  65. remaining_time: 0,
  66. filename: null,
  67. wifi_signal: -29,
  68. speed_level: 2,
  69. vt_tray: [],
  70. ams: [],
  71. })
  72. ),
  73. http.get('/api/v1/queue/', () => HttpResponse.json([])),
  74. http.get('/api/v1/settings/ui-preferences', () =>
  75. HttpResponse.json({ camera_view_mode: storedMode })
  76. ),
  77. http.put('/api/v1/settings/', async ({ request }) => {
  78. const body = (await request.json()) as Record<string, unknown>;
  79. settingsWrites.push(body);
  80. return HttpResponse.json(body);
  81. }),
  82. );
  83. return render(<PrintersPage />);
  84. }
  85. /** The camera icon, whichever of the two modes it currently promises. */
  86. async function cameraIcon(): Promise<HTMLElement> {
  87. await waitFor(() => expect(document.getElementById('printer-card-1')).not.toBeNull());
  88. // The camera controls stay disabled until the printer's status says it is
  89. // connected; status arrives a moment after the card.
  90. await waitFor(() => expect(screen.getByLabelText('Camera View Mode')).not.toBeDisabled());
  91. const el =
  92. screen.queryByTitle('Open camera in new window') ?? screen.queryByTitle('Open camera overlay');
  93. expect(el).not.toBeNull();
  94. return el as HTMLElement;
  95. }
  96. async function openModeMenu(): Promise<void> {
  97. await cameraIcon();
  98. fireEvent.click(screen.getByLabelText('Camera View Mode'));
  99. }
  100. let openSpy: ReturnType<typeof vi.spyOn>;
  101. /**
  102. * A real store behind the suite-wide localStorage mock, which is otherwise a
  103. * set of no-op vi.fn()s -- so setItem would be forgotten and getItem would hand
  104. * back undefined, and "remembers the choice" could not be tested at all.
  105. */
  106. const storage = new Map<string, string>();
  107. describe('PrintersPage — camera split button', () => {
  108. beforeEach(() => {
  109. settingsWrites.length = 0;
  110. permissions.granted = ['printers:read', 'camera:view', 'settings:update'];
  111. storage.clear();
  112. vi.mocked(localStorage.getItem).mockImplementation((key: string) => storage.get(key) ?? null);
  113. vi.mocked(localStorage.setItem).mockImplementation((key: string, value: string) => {
  114. storage.set(key, value);
  115. });
  116. vi.mocked(localStorage.removeItem).mockImplementation((key: string) => {
  117. storage.delete(key);
  118. });
  119. openSpy = vi.spyOn(window, 'open').mockReturnValue(null);
  120. });
  121. afterEach(() => {
  122. openSpy.mockRestore();
  123. });
  124. it('opens a separate window when that is the mode in effect', async () => {
  125. renderPage('window');
  126. fireEvent.click(await cameraIcon());
  127. expect(openSpy).toHaveBeenCalledWith(
  128. '/camera/1',
  129. 'camera-1',
  130. expect.stringContaining('width=640')
  131. );
  132. });
  133. it('offers both modes from the caret', async () => {
  134. renderPage('window');
  135. await openModeMenu();
  136. expect(await screen.findByText(/New Window/)).toBeInTheDocument();
  137. expect(await screen.findByText(/Embedded Overlay/)).toBeInTheDocument();
  138. });
  139. it('opens the overlay when the overlay is picked, instead of a window', async () => {
  140. renderPage('window');
  141. await openModeMenu();
  142. fireEvent.click(await screen.findByText(/Embedded Overlay/));
  143. // "Refresh stream" is the overlay's own control; the card has no such button.
  144. expect(await screen.findByTitle('Refresh stream')).toBeInTheDocument();
  145. expect(openSpy).not.toHaveBeenCalled();
  146. });
  147. it('remembers the picked mode for the next visit', async () => {
  148. renderPage('window');
  149. await openModeMenu();
  150. fireEvent.click(await screen.findByText(/Embedded Overlay/));
  151. await waitFor(() => expect(storage.get('cameraViewMode')).toBe('embedded'));
  152. });
  153. it('uses a remembered choice over the stored setting', async () => {
  154. // The case that makes the local choice authoritative: the install-wide
  155. // default still says window, but this browser has asked for the overlay.
  156. storage.set('cameraViewMode', 'embedded');
  157. renderPage('window');
  158. fireEvent.click(await cameraIcon());
  159. expect(await screen.findByTitle('Refresh stream')).toBeInTheDocument();
  160. expect(openSpy).not.toHaveBeenCalled();
  161. });
  162. it('falls back to the stored setting in a browser that has never chosen', async () => {
  163. renderPage('embedded');
  164. await waitFor(() => expect(screen.queryByTitle('Open camera overlay')).not.toBeNull());
  165. fireEvent.click(await cameraIcon());
  166. expect(await screen.findByTitle('Refresh stream')).toBeInTheDocument();
  167. expect(openSpy).not.toHaveBeenCalled();
  168. });
  169. it('marks which mode the plain icon will use', async () => {
  170. storage.set('cameraViewMode', 'embedded');
  171. renderPage('window');
  172. await openModeMenu();
  173. expect(await screen.findByText('Embedded Overlay ✓')).toBeInTheDocument();
  174. expect(await screen.findByText('New Window')).toBeInTheDocument();
  175. });
  176. it('saves the pick as the install-wide default when allowed to', async () => {
  177. renderPage('window');
  178. await openModeMenu();
  179. fireEvent.click(await screen.findByText(/Embedded Overlay/));
  180. await waitFor(() => expect(settingsWrites).toEqual([{ camera_view_mode: 'embedded' }]));
  181. });
  182. it('still applies the pick for a user who cannot write settings', async () => {
  183. // A viewer has camera:view but not settings:update. Their choice has to
  184. // stick locally, or the menu would appear to do nothing on the next click.
  185. permissions.granted = ['printers:read', 'camera:view'];
  186. renderPage('window');
  187. await openModeMenu();
  188. fireEvent.click(await screen.findByText(/Embedded Overlay/));
  189. expect(await screen.findByTitle('Refresh stream')).toBeInTheDocument();
  190. await waitFor(() => expect(storage.get('cameraViewMode')).toBe('embedded'));
  191. expect(settingsWrites).toEqual([]);
  192. });
  193. });