StreamOverlayPage.test.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720
  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('updated artwork', () => {
  99. it('maps legacy model codes in version 2', async () => {
  100. server.use(http.get('/api/v1/printers/:id', () => HttpResponse.json({ ...mockPrinter, model: 'BL-P001' })));
  101. renderOverlayPage(1, '?artwork=2&show=model');
  102. expect(await screen.findByText('X1C')).toBeInTheDocument();
  103. expect(screen.queryByText('BL-P001')).not.toBeInTheDocument();
  104. });
  105. it.each(['', '?artwork=other', '?artwork=updated'])('retains the original renderer for %s', async (query) => {
  106. const { container } = renderOverlayPage(1, query);
  107. await screen.findByAltText('Bambuddy');
  108. expect(container.querySelector('.updated-overlay')).not.toBeInTheDocument();
  109. expect(screen.getByAltText('Camera stream')).toHaveClass('object-contain');
  110. });
  111. it('uses token data, rotation and both nozzles without inventing missing temperatures', async () => {
  112. server.use(http.get('/api/v1/printers/:id/overlay-status', () => HttpResponse.json({
  113. ...mockStatusPrinting, name: 'Token printer', model: 'O1D', camera_rotation: 90,
  114. time_format: '24h', temperatures: { nozzle: 210, nozzle_target: 220, nozzle_2: 215, bed: 45 },
  115. })));
  116. renderOverlayPage(1, '?artwork=2&token=bblt_test&show=model,nozzle,bed,chamber&size=large');
  117. expect(await screen.findByText('H2D')).toBeInTheDocument();
  118. expect(screen.queryByText('Token printer')).not.toBeInTheDocument();
  119. expect(screen.getByText('Nozzle 2')).toBeInTheDocument();
  120. expect(screen.queryByText('Chamber')).not.toBeInTheDocument();
  121. const camera = screen.getByAltText('Camera stream');
  122. expect(camera).toHaveAttribute('src', expect.stringContaining('token=bblt_test'));
  123. expect(camera).toHaveStyle({ transform: 'translate(-50%, -50%) rotate(90deg)' });
  124. expect(camera.closest('.updated-overlay')).toHaveAttribute('data-size', 'large');
  125. expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
  126. });
  127. it.each([
  128. [90, { width: '640px', height: '360px' }],
  129. [0, null],
  130. ])('sizes a camera turned %i degrees from its slot, not the viewport', async (rotation, expected) => {
  131. // A portrait source: the camera fills a 360 x 640 middle row, so the
  132. // viewport (the old vw/vh fallback) would be the wrong box to swap.
  133. const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
  134. return this.classList.contains('updated-overlay__camera')
  135. ? ({ width: 360, height: 640, top: 0, left: 0, right: 360, bottom: 640, x: 0, y: 0, toJSON: () => ({}) } as DOMRect)
  136. : ({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}) } as DOMRect);
  137. });
  138. try {
  139. server.use(http.get('/api/v1/printers/:id', () => HttpResponse.json({ ...mockPrinter, camera_rotation: rotation })));
  140. renderOverlayPage(1, '?artwork=2');
  141. const camera = await screen.findByAltText('Camera stream');
  142. await waitFor(() => expect(camera).toHaveStyle({ transform: `translate(-50%, -50%) rotate(${rotation}deg)` }));
  143. if (expected) {
  144. await waitFor(() => expect(camera).toHaveStyle(expected));
  145. } else {
  146. expect(camera.style.width).toBe('');
  147. expect(camera.style.height).toBe('');
  148. }
  149. } finally {
  150. rect.mockRestore();
  151. }
  152. });
  153. it('renders selected fields and accessible progress', async () => {
  154. server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockStatusPrinting)));
  155. renderOverlayPage(1, '?artwork=2&show=printer,model,filename,status,progress,layers,eta');
  156. expect(await screen.findByRole('progressbar')).toHaveAttribute('aria-valuenow', '45');
  157. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  158. expect(screen.getByText('X1C')).toBeInTheDocument();
  159. expect(screen.getByText('Benchy')).toBeInTheDocument();
  160. expect(screen.getByText('150 / 300')).toBeInTheDocument();
  161. });
  162. it('hides all unselected information, including idle status', async () => {
  163. const { container } = renderOverlayPage(1, '?artwork=2&show=&camera=false');
  164. await screen.findByAltText('Bambuddy');
  165. expect(screen.queryByText('Printer is idle')).not.toBeInTheDocument();
  166. expect(screen.queryByAltText('Camera stream')).not.toBeInTheDocument();
  167. expect(container.querySelector('.updated-overlay__panel')).not.toBeInTheDocument();
  168. });
  169. it.each(['FINISH', 'FAILED'])('shows %s without contradictory idle text', async (state) => {
  170. server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json({ ...mockStatusPrinting, state })));
  171. renderOverlayPage(1, '?artwork=2&show=status,progress');
  172. expect(await screen.findByText(state === 'FINISH' ? 'Finished' : 'Failed')).toBeInTheDocument();
  173. expect(screen.queryByText('Printer is idle')).not.toBeInTheDocument();
  174. expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
  175. });
  176. it('hides stale progress when disconnected', async () => {
  177. server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json({ ...mockStatusPrinting, connected: false })));
  178. renderOverlayPage(1, '?artwork=2&show=status,progress,layers,eta');
  179. expect(await screen.findByText('Printer offline')).toBeInTheDocument();
  180. expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
  181. });
  182. });
  183. describe('rendering', () => {
  184. it('renders overlay page for printer', async () => {
  185. renderOverlayPage(1);
  186. await waitFor(() => {
  187. expect(screen.getByText('Printer is idle')).toBeInTheDocument();
  188. });
  189. });
  190. it('shows Bambuddy logo', async () => {
  191. renderOverlayPage(1);
  192. await waitFor(() => {
  193. expect(screen.getByAltText('Bambuddy')).toBeInTheDocument();
  194. });
  195. });
  196. it('logo links to GitHub', async () => {
  197. renderOverlayPage(1);
  198. await waitFor(() => {
  199. const logo = screen.getByAltText('Bambuddy');
  200. const link = logo.closest('a');
  201. expect(link).toHaveAttribute('href', 'https://github.com/maziggy/bambuddy');
  202. });
  203. });
  204. });
  205. describe('printer model', () => {
  206. it.each(['', '?show=printer', '?show='])('hides the model unless selected (%s)', async (query) => {
  207. renderOverlayPage(1, query);
  208. await screen.findByText('Printer is idle');
  209. expect(screen.queryByText(/X1C/)).not.toBeInTheDocument();
  210. });
  211. it.each([
  212. ['?show=model', 'X1C'],
  213. ['?show=printer,model', 'X1 Carbon · X1C'],
  214. ])('shows the selected printer identity (%s)', async (query, identity) => {
  215. renderOverlayPage(1, query);
  216. expect(await screen.findByText(identity)).toBeInTheDocument();
  217. if (query === '?show=model') expect(screen.queryByText(/X1 Carbon/)).not.toBeInTheDocument();
  218. });
  219. it.each([
  220. ['BL-P001', 'X1C'],
  221. ['O1D', 'H2D'],
  222. ['H2D', 'H2D'],
  223. ['Future printer', 'Future printer'],
  224. ])('displays the saved model %s as %s', async (model, displayName) => {
  225. server.use(http.get('/api/v1/printers/:id', () => HttpResponse.json({ ...mockPrinter, model })));
  226. renderOverlayPage(1, '?show=model');
  227. expect(await screen.findByText(displayName)).toBeInTheDocument();
  228. });
  229. it.each([null, ''])('omits a missing model without adding a separator (%s)', async (model) => {
  230. server.use(http.get('/api/v1/printers/:id', () => HttpResponse.json({ ...mockPrinter, model })));
  231. renderOverlayPage(1, '?show=printer,model');
  232. expect(await screen.findByText('X1 Carbon')).toBeInTheDocument();
  233. expect(screen.queryByText(/·/)).not.toBeInTheDocument();
  234. });
  235. it.each([['H2D', 'H2D'], ['BL-P001', 'X1C'], ['O1D', 'H2D'], [null, ''], ['', '']] as const)('reads the model %s from the OBS token feed without requesting printer details', async (model, displayName) => {
  236. let printerHit = false;
  237. server.use(
  238. http.get('/api/v1/printers/:id/overlay-status', () => HttpResponse.json({
  239. ...mockStatusIdle, name: 'Workshop', model, camera_rotation: 0,
  240. gcode_file: null, temperatures: {}, time_format: 'system',
  241. })),
  242. http.get('/api/v1/printers/:id', () => {
  243. printerHit = true;
  244. return new HttpResponse(null, { status: 401 });
  245. }),
  246. );
  247. renderOverlayPage(1, '?token=obs-tok&show=printer,model');
  248. expect(await screen.findByText(displayName ? `Workshop · ${displayName}` : 'Workshop')).toBeInTheDocument();
  249. expect(printerHit).toBe(false);
  250. if (!model) expect(screen.queryByText(/·/)).not.toBeInTheDocument();
  251. });
  252. });
  253. describe('printing state', () => {
  254. beforeEach(() => {
  255. server.use(
  256. http.get('/api/v1/printers/:id/status', () => {
  257. return HttpResponse.json(mockStatusPrinting);
  258. })
  259. );
  260. });
  261. it('shows filename when printing', async () => {
  262. renderOverlayPage(1);
  263. await waitFor(() => {
  264. expect(screen.getByText('Benchy')).toBeInTheDocument();
  265. });
  266. });
  267. it('shows progress percentage', async () => {
  268. renderOverlayPage(1);
  269. await waitFor(() => {
  270. expect(screen.getByText('45%')).toBeInTheDocument();
  271. });
  272. });
  273. it('shows layer count', async () => {
  274. renderOverlayPage(1);
  275. await waitFor(() => {
  276. expect(screen.getByText('150')).toBeInTheDocument();
  277. expect(screen.getByText('300')).toBeInTheDocument();
  278. });
  279. });
  280. it('shows status text', async () => {
  281. renderOverlayPage(1);
  282. await waitFor(() => {
  283. expect(screen.getByText('Printing')).toBeInTheDocument();
  284. });
  285. });
  286. });
  287. describe('invalid printer', () => {
  288. it('shows invalid printer message for ID 0', async () => {
  289. renderOverlayPage(0);
  290. await waitFor(() => {
  291. expect(screen.getByText('Invalid printer ID')).toBeInTheDocument();
  292. });
  293. });
  294. });
  295. describe('query parameters', () => {
  296. it('respects size parameter', async () => {
  297. renderOverlayPage(1, '?size=large');
  298. await waitFor(() => {
  299. // Just verify it renders without error
  300. expect(screen.getByAltText('Bambuddy')).toBeInTheDocument();
  301. });
  302. });
  303. it('respects show parameter to hide elements', async () => {
  304. server.use(
  305. http.get('/api/v1/printers/:id/status', () => {
  306. return HttpResponse.json(mockStatusPrinting);
  307. })
  308. );
  309. renderOverlayPage(1, '?show=progress');
  310. await waitFor(() => {
  311. // Progress should be visible
  312. expect(screen.getByText('45%')).toBeInTheDocument();
  313. // Status text should be hidden when not in show list
  314. expect(screen.queryByText('Printing')).not.toBeInTheDocument();
  315. });
  316. });
  317. });
  318. describe('FPS configuration', () => {
  319. it('uses default FPS of 15 when not specified', async () => {
  320. renderOverlayPage(1);
  321. await waitFor(() => {
  322. const img = screen.getByAltText('Camera stream') as HTMLImageElement;
  323. expect(img.src).toContain('fps=15');
  324. });
  325. });
  326. it('uses custom FPS when specified in query params', async () => {
  327. renderOverlayPage(1, '?fps=30');
  328. await waitFor(() => {
  329. const img = screen.getByAltText('Camera stream') as HTMLImageElement;
  330. expect(img.src).toContain('fps=30');
  331. });
  332. });
  333. it('clamps FPS to maximum of 30', async () => {
  334. renderOverlayPage(1, '?fps=60');
  335. await waitFor(() => {
  336. const img = screen.getByAltText('Camera stream') as HTMLImageElement;
  337. expect(img.src).toContain('fps=30');
  338. });
  339. });
  340. it('clamps FPS to minimum of 1', async () => {
  341. renderOverlayPage(1, '?fps=0');
  342. await waitFor(() => {
  343. const img = screen.getByAltText('Camera stream') as HTMLImageElement;
  344. expect(img.src).toContain('fps=1');
  345. });
  346. });
  347. it('handles invalid FPS value gracefully', async () => {
  348. renderOverlayPage(1, '?fps=invalid');
  349. await waitFor(() => {
  350. const img = screen.getByAltText('Camera stream') as HTMLImageElement;
  351. // Should fall back to default of 15
  352. expect(img.src).toContain('fps=15');
  353. });
  354. });
  355. });
  356. describe('camera toggle (status-only mode)', () => {
  357. it('shows camera by default', async () => {
  358. renderOverlayPage(1);
  359. await waitFor(() => {
  360. expect(screen.getByAltText('Camera stream')).toBeInTheDocument();
  361. });
  362. });
  363. it('hides camera when camera=false', async () => {
  364. renderOverlayPage(1, '?camera=false');
  365. await waitFor(() => {
  366. // Status should still be visible
  367. expect(screen.getByText('Printer is idle')).toBeInTheDocument();
  368. });
  369. // Camera should not be rendered
  370. expect(screen.queryByAltText('Camera stream')).not.toBeInTheDocument();
  371. });
  372. it('hides camera when camera=0', async () => {
  373. renderOverlayPage(1, '?camera=0');
  374. await waitFor(() => {
  375. expect(screen.getByText('Printer is idle')).toBeInTheDocument();
  376. });
  377. expect(screen.queryByAltText('Camera stream')).not.toBeInTheDocument();
  378. });
  379. it('shows camera when camera=true', async () => {
  380. renderOverlayPage(1, '?camera=true');
  381. await waitFor(() => {
  382. expect(screen.getByAltText('Camera stream')).toBeInTheDocument();
  383. });
  384. });
  385. it('shows camera when camera=1', async () => {
  386. renderOverlayPage(1, '?camera=1');
  387. await waitFor(() => {
  388. expect(screen.getByAltText('Camera stream')).toBeInTheDocument();
  389. });
  390. });
  391. });
  392. describe('combined parameters', () => {
  393. it('supports fps and camera together', async () => {
  394. renderOverlayPage(1, '?fps=25&camera=true');
  395. await waitFor(() => {
  396. const img = screen.getByAltText('Camera stream') as HTMLImageElement;
  397. expect(img.src).toContain('fps=25');
  398. });
  399. });
  400. it('supports status-only with custom size', async () => {
  401. renderOverlayPage(1, '?camera=false&size=large');
  402. await waitFor(() => {
  403. expect(screen.getByText('Printer is idle')).toBeInTheDocument();
  404. });
  405. expect(screen.queryByAltText('Camera stream')).not.toBeInTheDocument();
  406. });
  407. it('supports show parameter with fps', async () => {
  408. server.use(
  409. http.get('/api/v1/printers/:id/status', () => {
  410. return HttpResponse.json(mockStatusPrinting);
  411. })
  412. );
  413. renderOverlayPage(1, '?fps=20&show=progress');
  414. await waitFor(() => {
  415. const img = screen.getByAltText('Camera stream') as HTMLImageElement;
  416. expect(img.src).toContain('fps=20');
  417. expect(screen.getByText('45%')).toBeInTheDocument();
  418. });
  419. });
  420. });
  421. describe('offline state', () => {
  422. beforeEach(() => {
  423. server.use(
  424. http.get('/api/v1/printers/:id/status', () => {
  425. return HttpResponse.json({
  426. ...mockStatusIdle,
  427. connected: false,
  428. });
  429. })
  430. );
  431. });
  432. it('shows offline message when printer disconnected', async () => {
  433. renderOverlayPage(1);
  434. await waitFor(() => {
  435. expect(screen.getByText('Printer offline')).toBeInTheDocument();
  436. });
  437. });
  438. });
  439. describe('kiosk token mode (#2613)', () => {
  440. const mockOverlayPrinting = {
  441. id: 1,
  442. name: 'X1 Carbon',
  443. camera_rotation: 0,
  444. connected: true,
  445. state: 'RUNNING',
  446. current_print: 'KioskBenchy.gcode.3mf',
  447. gcode_file: 'plate_1.gcode',
  448. progress: 67,
  449. remaining_time: 40,
  450. layer_num: 10,
  451. total_layers: 20,
  452. stg_cur_name: null,
  453. time_format: 'system',
  454. };
  455. it('reads the token-authed overlay-status feed and carries the token to the camera', async () => {
  456. let overlayHit = false;
  457. server.use(
  458. http.get('/api/v1/printers/:id/overlay-status', () => {
  459. overlayHit = true;
  460. return HttpResponse.json(mockOverlayPrinting);
  461. })
  462. );
  463. renderOverlayPage(1, '?token=obs-tok');
  464. await waitFor(() => {
  465. expect(screen.getByText('KioskBenchy')).toBeInTheDocument();
  466. });
  467. expect(overlayHit).toBe(true);
  468. expect(screen.getByText('67%')).toBeInTheDocument();
  469. // The camera <img> must carry the same kiosk token — a fresh OBS browser
  470. // has no session to mint a camera stream token from.
  471. const img = screen.getByAltText('Camera stream') as HTMLImageElement;
  472. expect(img.src).toContain('token=obs-tok');
  473. });
  474. it('does not touch the JWT-only status endpoint or a WebSocket in kiosk mode', async () => {
  475. let statusHit = false;
  476. server.use(
  477. http.get('/api/v1/printers/:id/overlay-status', () => HttpResponse.json(mockOverlayPrinting)),
  478. http.get('/api/v1/printers/:id/status', () => {
  479. statusHit = true;
  480. return HttpResponse.json(mockStatusIdle);
  481. })
  482. );
  483. renderOverlayPage(1, '?token=obs-tok');
  484. await waitFor(() => {
  485. expect(screen.getByText('KioskBenchy')).toBeInTheDocument();
  486. });
  487. // The logged-in status query is disabled when a token is present, so an
  488. // unauthenticated OBS browser never fires a doomed 401 (or opens a socket).
  489. expect(statusHit).toBe(false);
  490. expect(WebSocket).not.toHaveBeenCalled();
  491. });
  492. });
  493. describe('temperatures (#1422)', () => {
  494. const withTemps = {
  495. ...mockStatusPrinting,
  496. temperatures: {
  497. nozzle: 219.6,
  498. nozzle_target: 220,
  499. bed: 60,
  500. bed_target: 60,
  501. chamber: 38.4,
  502. },
  503. };
  504. beforeEach(() => {
  505. server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(withTemps)));
  506. });
  507. it('draws no temperatures unless the URL asks for them', async () => {
  508. renderOverlayPage(1);
  509. await waitFor(() => {
  510. expect(screen.getByText('45%')).toBeInTheDocument();
  511. });
  512. // Default ?show= is unchanged by #1422, so overlays already running in an
  513. // OBS scene look identical after the upgrade.
  514. expect(screen.queryByText('Nozzle')).not.toBeInTheDocument();
  515. expect(screen.queryByText('Bed')).not.toBeInTheDocument();
  516. });
  517. it('draws only the readings named in ?show=', async () => {
  518. renderOverlayPage(1, '?show=progress,nozzle');
  519. await waitFor(() => {
  520. expect(screen.getByText('Nozzle')).toBeInTheDocument();
  521. });
  522. expect(screen.queryByText('Bed')).not.toBeInTheDocument();
  523. expect(screen.queryByText('Chamber')).not.toBeInTheDocument();
  524. });
  525. it('rounds the reading and hides a target it has already reached', async () => {
  526. renderOverlayPage(1, '?show=nozzle,bed');
  527. await waitFor(() => {
  528. expect(screen.getByText('220°C')).toBeInTheDocument();
  529. });
  530. // Nozzle is 219.6 against a target of 220: both round to 220, so the
  531. // "/ 220°C" half is dropped rather than reading "220 / 220°C" all print.
  532. expect(screen.queryByText('/')).not.toBeInTheDocument();
  533. expect(screen.getByText('60°C')).toBeInTheDocument();
  534. });
  535. it('shows the target while the heater is still climbing', async () => {
  536. server.use(
  537. http.get('/api/v1/printers/:id/status', () =>
  538. HttpResponse.json({ ...withTemps, temperatures: { nozzle: 140, nozzle_target: 220 } }),
  539. ),
  540. );
  541. renderOverlayPage(1, '?show=nozzle');
  542. await waitFor(() => {
  543. expect(screen.getByText('140°C')).toBeInTheDocument();
  544. });
  545. expect(screen.getByText('220°C')).toBeInTheDocument();
  546. });
  547. it('skips a reading the printer does not report', async () => {
  548. server.use(
  549. http.get('/api/v1/printers/:id/status', () =>
  550. // A P1S: the backend drops chamber for models without a real sensor,
  551. // so asking for it in ?show= must not produce an empty row.
  552. HttpResponse.json({ ...withTemps, temperatures: { nozzle: 200, bed: 55 } }),
  553. ),
  554. );
  555. renderOverlayPage(1, '?show=nozzle,bed,chamber');
  556. await waitFor(() => {
  557. expect(screen.getByText('Nozzle')).toBeInTheDocument();
  558. });
  559. expect(screen.queryByText('Chamber')).not.toBeInTheDocument();
  560. });
  561. it('draws both nozzles on a dual-nozzle printer', async () => {
  562. server.use(
  563. http.get('/api/v1/printers/:id/status', () =>
  564. HttpResponse.json({
  565. ...withTemps,
  566. temperatures: { nozzle: 220, nozzle_2: 250, nozzle_2_target: 250 },
  567. }),
  568. ),
  569. );
  570. renderOverlayPage(1, '?show=nozzle');
  571. await waitFor(() => {
  572. expect(screen.getByText('Nozzle')).toBeInTheDocument();
  573. });
  574. expect(screen.getByText('Nozzle 2')).toBeInTheDocument();
  575. expect(screen.getByText('250°C')).toBeInTheDocument();
  576. });
  577. it('draws temperatures while the printer is idle', async () => {
  578. server.use(
  579. http.get('/api/v1/printers/:id/status', () =>
  580. HttpResponse.json({ ...mockStatusIdle, temperatures: { bed: 45, bed_target: 60 } }),
  581. ),
  582. );
  583. renderOverlayPage(1, '?show=bed');
  584. await waitFor(() => {
  585. expect(screen.getByText('Printer is idle')).toBeInTheDocument();
  586. });
  587. // A preheating printer is exactly when the readings are worth watching,
  588. // so they are not gated behind a running print.
  589. expect(screen.getByText('45°C')).toBeInTheDocument();
  590. expect(screen.getByText('60°C')).toBeInTheDocument();
  591. });
  592. it('reads temperatures from the token-authed feed in kiosk mode', async () => {
  593. server.use(
  594. http.get('/api/v1/printers/:id/overlay-status', () =>
  595. HttpResponse.json({
  596. id: 1,
  597. name: 'X1 Carbon',
  598. camera_rotation: 0,
  599. connected: true,
  600. state: 'RUNNING',
  601. current_print: 'KioskBenchy.gcode.3mf',
  602. gcode_file: 'plate_1.gcode',
  603. progress: 67,
  604. remaining_time: 40,
  605. layer_num: 10,
  606. total_layers: 20,
  607. stg_cur_name: null,
  608. temperatures: { chamber: 38, chamber_target: 40 },
  609. time_format: 'system',
  610. }),
  611. ),
  612. );
  613. renderOverlayPage(1, '?token=obs-tok&show=chamber');
  614. await waitFor(() => {
  615. expect(screen.getByText('Chamber')).toBeInTheDocument();
  616. });
  617. expect(screen.getByText('38°C')).toBeInTheDocument();
  618. });
  619. });
  620. });