StreamOverlayPage.test.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620
  1. /**
  2. * Tests for the StreamOverlayPage component.
  3. */
  4. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
  5. import { screen, waitFor, render as rtlRender } from '@testing-library/react';
  6. import { StreamOverlayPage } from '../../pages/StreamOverlayPage';
  7. import { http, HttpResponse } from 'msw';
  8. import { server } from '../mocks/server';
  9. import { MemoryRouter, Route, Routes } from 'react-router-dom';
  10. import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
  11. import { ThemeProvider } from '../../contexts/ThemeContext';
  12. import { ToastProvider } from '../../contexts/ToastContext';
  13. import { AuthProvider } from '../../contexts/AuthContext';
  14. const mockPrinter = {
  15. id: 1,
  16. name: 'X1 Carbon',
  17. ip_address: '192.168.1.100',
  18. serial_number: '00M09A350100001',
  19. access_code: '12345678',
  20. model: 'X1C',
  21. enabled: true,
  22. };
  23. const mockStatusIdle = {
  24. id: 1,
  25. name: 'X1 Carbon',
  26. connected: true,
  27. state: 'IDLE',
  28. progress: 0,
  29. current_print: null,
  30. remaining_time: null,
  31. layer_num: null,
  32. total_layers: null,
  33. stg_cur_name: null,
  34. };
  35. const mockStatusPrinting = {
  36. id: 1,
  37. name: 'X1 Carbon',
  38. connected: true,
  39. state: 'RUNNING',
  40. progress: 45,
  41. current_print: 'Benchy.gcode.3mf',
  42. remaining_time: 82,
  43. layer_num: 150,
  44. total_layers: 300,
  45. stg_cur_name: null,
  46. };
  47. // Custom render for StreamOverlayPage
  48. function renderOverlayPage(printerId: number, queryParams = '') {
  49. const queryClient = new QueryClient({
  50. defaultOptions: {
  51. queries: { retry: false, gcTime: 0 },
  52. mutations: { retry: false },
  53. },
  54. });
  55. return rtlRender(
  56. <QueryClientProvider client={queryClient}>
  57. <MemoryRouter initialEntries={[`/overlay/${printerId}${queryParams}`]}>
  58. <AuthProvider>
  59. <ThemeProvider>
  60. <ToastProvider>
  61. <Routes>
  62. <Route path="/overlay/:printerId" element={<StreamOverlayPage />} />
  63. </Routes>
  64. </ToastProvider>
  65. </ThemeProvider>
  66. </AuthProvider>
  67. </MemoryRouter>
  68. </QueryClientProvider>
  69. );
  70. }
  71. describe('StreamOverlayPage', () => {
  72. const originalTitle = document.title;
  73. beforeEach(() => {
  74. // Mock WebSocket. vitest 4 dropped support for arrow-function constructor
  75. // mocks (`new (() => ...)` throws "is not a constructor"); use a plain
  76. // function so `new WebSocket(...)` resolves correctly.
  77. vi.stubGlobal(
  78. 'WebSocket',
  79. vi.fn().mockImplementation(function (this: { close: () => void; onmessage: null; onerror: null }) {
  80. this.close = vi.fn();
  81. this.onmessage = null;
  82. this.onerror = null;
  83. }),
  84. );
  85. server.use(
  86. http.get('/api/v1/printers/:id', () => {
  87. return HttpResponse.json(mockPrinter);
  88. }),
  89. http.get('/api/v1/printers/:id/status', () => {
  90. return HttpResponse.json(mockStatusIdle);
  91. })
  92. );
  93. });
  94. afterEach(() => {
  95. document.title = originalTitle;
  96. vi.unstubAllGlobals();
  97. });
  98. describe('rendering', () => {
  99. it('renders overlay page for printer', async () => {
  100. renderOverlayPage(1);
  101. await waitFor(() => {
  102. expect(screen.getByText('Printer is idle')).toBeInTheDocument();
  103. });
  104. });
  105. it('shows Bambuddy logo', async () => {
  106. renderOverlayPage(1);
  107. await waitFor(() => {
  108. expect(screen.getByAltText('Bambuddy')).toBeInTheDocument();
  109. });
  110. });
  111. it('logo links to GitHub', async () => {
  112. renderOverlayPage(1);
  113. await waitFor(() => {
  114. const logo = screen.getByAltText('Bambuddy');
  115. const link = logo.closest('a');
  116. expect(link).toHaveAttribute('href', 'https://github.com/maziggy/bambuddy');
  117. });
  118. });
  119. });
  120. describe('printer model', () => {
  121. it.each(['', '?show=printer', '?show='])('hides the model unless selected (%s)', async (query) => {
  122. renderOverlayPage(1, query);
  123. await screen.findByText('Printer is idle');
  124. expect(screen.queryByText(/X1C/)).not.toBeInTheDocument();
  125. });
  126. it.each([
  127. ['?show=model', 'X1C'],
  128. ['?show=printer,model', 'X1 Carbon · X1C'],
  129. ])('shows the selected printer identity (%s)', async (query, identity) => {
  130. renderOverlayPage(1, query);
  131. expect(await screen.findByText(identity)).toBeInTheDocument();
  132. if (query === '?show=model') expect(screen.queryByText(/X1 Carbon/)).not.toBeInTheDocument();
  133. });
  134. it.each([null, ''])('omits a missing model without adding a separator (%s)', async (model) => {
  135. server.use(http.get('/api/v1/printers/:id', () => HttpResponse.json({ ...mockPrinter, model })));
  136. renderOverlayPage(1, '?show=printer,model');
  137. expect(await screen.findByText('X1 Carbon')).toBeInTheDocument();
  138. expect(screen.queryByText(/·/)).not.toBeInTheDocument();
  139. });
  140. it.each([['H2D', 'Workshop · H2D'], [null, 'Workshop'], ['', 'Workshop']] as const)('reads the model (%s) from the OBS token feed without requesting printer details', async (model, identity) => {
  141. let printerHit = false;
  142. server.use(
  143. http.get('/api/v1/printers/:id/overlay-status', () => HttpResponse.json({
  144. ...mockStatusIdle, name: 'Workshop', model, camera_rotation: 0,
  145. gcode_file: null, temperatures: {}, time_format: 'system',
  146. })),
  147. http.get('/api/v1/printers/:id', () => {
  148. printerHit = true;
  149. return new HttpResponse(null, { status: 401 });
  150. }),
  151. );
  152. renderOverlayPage(1, '?token=obs-tok&show=printer,model');
  153. expect(await screen.findByText(identity)).toBeInTheDocument();
  154. expect(printerHit).toBe(false);
  155. if (!model) expect(screen.queryByText(/·/)).not.toBeInTheDocument();
  156. });
  157. });
  158. describe('printing state', () => {
  159. beforeEach(() => {
  160. server.use(
  161. http.get('/api/v1/printers/:id/status', () => {
  162. return HttpResponse.json(mockStatusPrinting);
  163. })
  164. );
  165. });
  166. it('shows filename when printing', async () => {
  167. renderOverlayPage(1);
  168. await waitFor(() => {
  169. expect(screen.getByText('Benchy')).toBeInTheDocument();
  170. });
  171. });
  172. it('shows progress percentage', async () => {
  173. renderOverlayPage(1);
  174. await waitFor(() => {
  175. expect(screen.getByText('45%')).toBeInTheDocument();
  176. });
  177. });
  178. it('shows layer count', async () => {
  179. renderOverlayPage(1);
  180. await waitFor(() => {
  181. expect(screen.getByText('150')).toBeInTheDocument();
  182. expect(screen.getByText('300')).toBeInTheDocument();
  183. });
  184. });
  185. it('shows status text', async () => {
  186. renderOverlayPage(1);
  187. await waitFor(() => {
  188. expect(screen.getByText('Printing')).toBeInTheDocument();
  189. });
  190. });
  191. });
  192. describe('invalid printer', () => {
  193. it('shows invalid printer message for ID 0', async () => {
  194. renderOverlayPage(0);
  195. await waitFor(() => {
  196. expect(screen.getByText('Invalid printer ID')).toBeInTheDocument();
  197. });
  198. });
  199. });
  200. describe('query parameters', () => {
  201. it('respects size parameter', async () => {
  202. renderOverlayPage(1, '?size=large');
  203. await waitFor(() => {
  204. // Just verify it renders without error
  205. expect(screen.getByAltText('Bambuddy')).toBeInTheDocument();
  206. });
  207. });
  208. it('respects show parameter to hide elements', async () => {
  209. server.use(
  210. http.get('/api/v1/printers/:id/status', () => {
  211. return HttpResponse.json(mockStatusPrinting);
  212. })
  213. );
  214. renderOverlayPage(1, '?show=progress');
  215. await waitFor(() => {
  216. // Progress should be visible
  217. expect(screen.getByText('45%')).toBeInTheDocument();
  218. // Status text should be hidden when not in show list
  219. expect(screen.queryByText('Printing')).not.toBeInTheDocument();
  220. });
  221. });
  222. });
  223. describe('FPS configuration', () => {
  224. it('uses default FPS of 15 when not specified', async () => {
  225. renderOverlayPage(1);
  226. await waitFor(() => {
  227. const img = screen.getByAltText('Camera stream') as HTMLImageElement;
  228. expect(img.src).toContain('fps=15');
  229. });
  230. });
  231. it('uses custom FPS when specified in query params', async () => {
  232. renderOverlayPage(1, '?fps=30');
  233. await waitFor(() => {
  234. const img = screen.getByAltText('Camera stream') as HTMLImageElement;
  235. expect(img.src).toContain('fps=30');
  236. });
  237. });
  238. it('clamps FPS to maximum of 30', async () => {
  239. renderOverlayPage(1, '?fps=60');
  240. await waitFor(() => {
  241. const img = screen.getByAltText('Camera stream') as HTMLImageElement;
  242. expect(img.src).toContain('fps=30');
  243. });
  244. });
  245. it('clamps FPS to minimum of 1', async () => {
  246. renderOverlayPage(1, '?fps=0');
  247. await waitFor(() => {
  248. const img = screen.getByAltText('Camera stream') as HTMLImageElement;
  249. expect(img.src).toContain('fps=1');
  250. });
  251. });
  252. it('handles invalid FPS value gracefully', async () => {
  253. renderOverlayPage(1, '?fps=invalid');
  254. await waitFor(() => {
  255. const img = screen.getByAltText('Camera stream') as HTMLImageElement;
  256. // Should fall back to default of 15
  257. expect(img.src).toContain('fps=15');
  258. });
  259. });
  260. });
  261. describe('camera toggle (status-only mode)', () => {
  262. it('shows camera by default', async () => {
  263. renderOverlayPage(1);
  264. await waitFor(() => {
  265. expect(screen.getByAltText('Camera stream')).toBeInTheDocument();
  266. });
  267. });
  268. it('hides camera when camera=false', async () => {
  269. renderOverlayPage(1, '?camera=false');
  270. await waitFor(() => {
  271. // Status should still be visible
  272. expect(screen.getByText('Printer is idle')).toBeInTheDocument();
  273. });
  274. // Camera should not be rendered
  275. expect(screen.queryByAltText('Camera stream')).not.toBeInTheDocument();
  276. });
  277. it('hides camera when camera=0', async () => {
  278. renderOverlayPage(1, '?camera=0');
  279. await waitFor(() => {
  280. expect(screen.getByText('Printer is idle')).toBeInTheDocument();
  281. });
  282. expect(screen.queryByAltText('Camera stream')).not.toBeInTheDocument();
  283. });
  284. it('shows camera when camera=true', async () => {
  285. renderOverlayPage(1, '?camera=true');
  286. await waitFor(() => {
  287. expect(screen.getByAltText('Camera stream')).toBeInTheDocument();
  288. });
  289. });
  290. it('shows camera when camera=1', async () => {
  291. renderOverlayPage(1, '?camera=1');
  292. await waitFor(() => {
  293. expect(screen.getByAltText('Camera stream')).toBeInTheDocument();
  294. });
  295. });
  296. });
  297. describe('combined parameters', () => {
  298. it('supports fps and camera together', async () => {
  299. renderOverlayPage(1, '?fps=25&camera=true');
  300. await waitFor(() => {
  301. const img = screen.getByAltText('Camera stream') as HTMLImageElement;
  302. expect(img.src).toContain('fps=25');
  303. });
  304. });
  305. it('supports status-only with custom size', async () => {
  306. renderOverlayPage(1, '?camera=false&size=large');
  307. await waitFor(() => {
  308. expect(screen.getByText('Printer is idle')).toBeInTheDocument();
  309. });
  310. expect(screen.queryByAltText('Camera stream')).not.toBeInTheDocument();
  311. });
  312. it('supports show parameter with fps', async () => {
  313. server.use(
  314. http.get('/api/v1/printers/:id/status', () => {
  315. return HttpResponse.json(mockStatusPrinting);
  316. })
  317. );
  318. renderOverlayPage(1, '?fps=20&show=progress');
  319. await waitFor(() => {
  320. const img = screen.getByAltText('Camera stream') as HTMLImageElement;
  321. expect(img.src).toContain('fps=20');
  322. expect(screen.getByText('45%')).toBeInTheDocument();
  323. });
  324. });
  325. });
  326. describe('offline state', () => {
  327. beforeEach(() => {
  328. server.use(
  329. http.get('/api/v1/printers/:id/status', () => {
  330. return HttpResponse.json({
  331. ...mockStatusIdle,
  332. connected: false,
  333. });
  334. })
  335. );
  336. });
  337. it('shows offline message when printer disconnected', async () => {
  338. renderOverlayPage(1);
  339. await waitFor(() => {
  340. expect(screen.getByText('Printer offline')).toBeInTheDocument();
  341. });
  342. });
  343. });
  344. describe('kiosk token mode (#2613)', () => {
  345. const mockOverlayPrinting = {
  346. id: 1,
  347. name: 'X1 Carbon',
  348. camera_rotation: 0,
  349. connected: true,
  350. state: 'RUNNING',
  351. current_print: 'KioskBenchy.gcode.3mf',
  352. gcode_file: 'plate_1.gcode',
  353. progress: 67,
  354. remaining_time: 40,
  355. layer_num: 10,
  356. total_layers: 20,
  357. stg_cur_name: null,
  358. time_format: 'system',
  359. };
  360. it('reads the token-authed overlay-status feed and carries the token to the camera', async () => {
  361. let overlayHit = false;
  362. server.use(
  363. http.get('/api/v1/printers/:id/overlay-status', () => {
  364. overlayHit = true;
  365. return HttpResponse.json(mockOverlayPrinting);
  366. })
  367. );
  368. renderOverlayPage(1, '?token=obs-tok');
  369. await waitFor(() => {
  370. expect(screen.getByText('KioskBenchy')).toBeInTheDocument();
  371. });
  372. expect(overlayHit).toBe(true);
  373. expect(screen.getByText('67%')).toBeInTheDocument();
  374. // The camera <img> must carry the same kiosk token — a fresh OBS browser
  375. // has no session to mint a camera stream token from.
  376. const img = screen.getByAltText('Camera stream') as HTMLImageElement;
  377. expect(img.src).toContain('token=obs-tok');
  378. });
  379. it('does not touch the JWT-only status endpoint or a WebSocket in kiosk mode', async () => {
  380. let statusHit = false;
  381. server.use(
  382. http.get('/api/v1/printers/:id/overlay-status', () => HttpResponse.json(mockOverlayPrinting)),
  383. http.get('/api/v1/printers/:id/status', () => {
  384. statusHit = true;
  385. return HttpResponse.json(mockStatusIdle);
  386. })
  387. );
  388. renderOverlayPage(1, '?token=obs-tok');
  389. await waitFor(() => {
  390. expect(screen.getByText('KioskBenchy')).toBeInTheDocument();
  391. });
  392. // The logged-in status query is disabled when a token is present, so an
  393. // unauthenticated OBS browser never fires a doomed 401 (or opens a socket).
  394. expect(statusHit).toBe(false);
  395. expect(WebSocket).not.toHaveBeenCalled();
  396. });
  397. });
  398. describe('temperatures (#1422)', () => {
  399. const withTemps = {
  400. ...mockStatusPrinting,
  401. temperatures: {
  402. nozzle: 219.6,
  403. nozzle_target: 220,
  404. bed: 60,
  405. bed_target: 60,
  406. chamber: 38.4,
  407. },
  408. };
  409. beforeEach(() => {
  410. server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(withTemps)));
  411. });
  412. it('draws no temperatures unless the URL asks for them', async () => {
  413. renderOverlayPage(1);
  414. await waitFor(() => {
  415. expect(screen.getByText('45%')).toBeInTheDocument();
  416. });
  417. // Default ?show= is unchanged by #1422, so overlays already running in an
  418. // OBS scene look identical after the upgrade.
  419. expect(screen.queryByText('Nozzle')).not.toBeInTheDocument();
  420. expect(screen.queryByText('Bed')).not.toBeInTheDocument();
  421. });
  422. it('draws only the readings named in ?show=', async () => {
  423. renderOverlayPage(1, '?show=progress,nozzle');
  424. await waitFor(() => {
  425. expect(screen.getByText('Nozzle')).toBeInTheDocument();
  426. });
  427. expect(screen.queryByText('Bed')).not.toBeInTheDocument();
  428. expect(screen.queryByText('Chamber')).not.toBeInTheDocument();
  429. });
  430. it('rounds the reading and hides a target it has already reached', async () => {
  431. renderOverlayPage(1, '?show=nozzle,bed');
  432. await waitFor(() => {
  433. expect(screen.getByText('220°C')).toBeInTheDocument();
  434. });
  435. // Nozzle is 219.6 against a target of 220: both round to 220, so the
  436. // "/ 220°C" half is dropped rather than reading "220 / 220°C" all print.
  437. expect(screen.queryByText('/')).not.toBeInTheDocument();
  438. expect(screen.getByText('60°C')).toBeInTheDocument();
  439. });
  440. it('shows the target while the heater is still climbing', async () => {
  441. server.use(
  442. http.get('/api/v1/printers/:id/status', () =>
  443. HttpResponse.json({ ...withTemps, temperatures: { nozzle: 140, nozzle_target: 220 } }),
  444. ),
  445. );
  446. renderOverlayPage(1, '?show=nozzle');
  447. await waitFor(() => {
  448. expect(screen.getByText('140°C')).toBeInTheDocument();
  449. });
  450. expect(screen.getByText('220°C')).toBeInTheDocument();
  451. });
  452. it('skips a reading the printer does not report', async () => {
  453. server.use(
  454. http.get('/api/v1/printers/:id/status', () =>
  455. // A P1S: the backend drops chamber for models without a real sensor,
  456. // so asking for it in ?show= must not produce an empty row.
  457. HttpResponse.json({ ...withTemps, temperatures: { nozzle: 200, bed: 55 } }),
  458. ),
  459. );
  460. renderOverlayPage(1, '?show=nozzle,bed,chamber');
  461. await waitFor(() => {
  462. expect(screen.getByText('Nozzle')).toBeInTheDocument();
  463. });
  464. expect(screen.queryByText('Chamber')).not.toBeInTheDocument();
  465. });
  466. it('draws both nozzles on a dual-nozzle printer', async () => {
  467. server.use(
  468. http.get('/api/v1/printers/:id/status', () =>
  469. HttpResponse.json({
  470. ...withTemps,
  471. temperatures: { nozzle: 220, nozzle_2: 250, nozzle_2_target: 250 },
  472. }),
  473. ),
  474. );
  475. renderOverlayPage(1, '?show=nozzle');
  476. await waitFor(() => {
  477. expect(screen.getByText('Nozzle')).toBeInTheDocument();
  478. });
  479. expect(screen.getByText('Nozzle 2')).toBeInTheDocument();
  480. expect(screen.getByText('250°C')).toBeInTheDocument();
  481. });
  482. it('draws temperatures while the printer is idle', async () => {
  483. server.use(
  484. http.get('/api/v1/printers/:id/status', () =>
  485. HttpResponse.json({ ...mockStatusIdle, temperatures: { bed: 45, bed_target: 60 } }),
  486. ),
  487. );
  488. renderOverlayPage(1, '?show=bed');
  489. await waitFor(() => {
  490. expect(screen.getByText('Printer is idle')).toBeInTheDocument();
  491. });
  492. // A preheating printer is exactly when the readings are worth watching,
  493. // so they are not gated behind a running print.
  494. expect(screen.getByText('45°C')).toBeInTheDocument();
  495. expect(screen.getByText('60°C')).toBeInTheDocument();
  496. });
  497. it('reads temperatures from the token-authed feed in kiosk mode', async () => {
  498. server.use(
  499. http.get('/api/v1/printers/:id/overlay-status', () =>
  500. HttpResponse.json({
  501. id: 1,
  502. name: 'X1 Carbon',
  503. camera_rotation: 0,
  504. connected: true,
  505. state: 'RUNNING',
  506. current_print: 'KioskBenchy.gcode.3mf',
  507. gcode_file: 'plate_1.gcode',
  508. progress: 67,
  509. remaining_time: 40,
  510. layer_num: 10,
  511. total_layers: 20,
  512. stg_cur_name: null,
  513. temperatures: { chamber: 38, chamber_target: 40 },
  514. time_format: 'system',
  515. }),
  516. ),
  517. );
  518. renderOverlayPage(1, '?token=obs-tok&show=chamber');
  519. await waitFor(() => {
  520. expect(screen.getByText('Chamber')).toBeInTheDocument();
  521. });
  522. expect(screen.getByText('38°C')).toBeInTheDocument();
  523. });
  524. });
  525. });