CamWallPage.test.tsx 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. /**
  2. * Standalone Cam Wall page (#2531).
  3. *
  4. * The assertions that carry weight are the kiosk ones: a wall on a TV must not
  5. * offer controls it cannot honour, must not name the file on the bed, and must
  6. * carry its token into the <img> URLs — an MJPEG tag has no Authorization
  7. * header, so a missing token means a wall of broken images.
  8. */
  9. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
  10. import { act, screen, waitFor } from '@testing-library/react';
  11. import { MemoryRouter } from 'react-router-dom';
  12. import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
  13. import { render as rtlRender } from '@testing-library/react';
  14. import { ThemeProvider } from '../../contexts/ThemeContext';
  15. import { ToastProvider } from '../../contexts/ToastContext';
  16. import { AuthProvider } from '../../contexts/AuthContext';
  17. import { CamWallPage } from '../../pages/CamWallPage';
  18. import { api, getStreamToken } from '../../api/client';
  19. const KIOSK_TOKEN = 'bblt_abcdefgh_secretsecretsecret';
  20. const FEED = [
  21. {
  22. id: 7,
  23. name: 'X1C-Lab',
  24. camera_rotation: 0,
  25. connected: true,
  26. state: 'RUNNING',
  27. progress: 42,
  28. remaining_time: 33,
  29. layer_num: 120,
  30. total_layers: 300,
  31. hms_errors: [],
  32. },
  33. ];
  34. // The page renders outside the app layout, so it supplies its own route context.
  35. // The shared render() util hard-codes BrowserRouter with no way to seed a query
  36. // string, and the query string is the whole point here.
  37. function renderAt(search: string) {
  38. const queryClient = new QueryClient({
  39. defaultOptions: { queries: { retry: false, gcTime: 0 } },
  40. });
  41. return rtlRender(
  42. <QueryClientProvider client={queryClient}>
  43. <MemoryRouter initialEntries={[`/camwall${search}`]}>
  44. <AuthProvider>
  45. <ThemeProvider>
  46. <ToastProvider>
  47. <CamWallPage />
  48. </ToastProvider>
  49. </ThemeProvider>
  50. </AuthProvider>
  51. </MemoryRouter>
  52. </QueryClientProvider>,
  53. );
  54. }
  55. describe('CamWallPage — kiosk mode', () => {
  56. beforeEach(() => {
  57. vi.spyOn(api, 'getCamWallPrinters').mockResolvedValue(FEED);
  58. // AuthProvider probes /auth/me on mount; a kiosk browser has no session.
  59. vi.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 401 }));
  60. localStorage.clear();
  61. });
  62. afterEach(() => {
  63. vi.restoreAllMocks();
  64. });
  65. it('fetches the wall with the URL token and renders a tile per printer', async () => {
  66. renderAt(`?token=${KIOSK_TOKEN}`);
  67. await waitFor(() => expect(screen.getByText('X1C-Lab')).toBeInTheDocument());
  68. expect(api.getCamWallPrinters).toHaveBeenCalledWith(KIOSK_TOKEN);
  69. });
  70. it('carries the token into the stream URL', async () => {
  71. renderAt(`?token=${KIOSK_TOKEN}`);
  72. await waitFor(() => expect(screen.getByText('X1C-Lab')).toBeInTheDocument());
  73. // Tiles start paused (jsdom's IntersectionObserver never reports a tile as
  74. // on-screen), and a paused tile renders no <img> at all. So assert on the
  75. // module-level token those URLs are built from, which is what CameraTile
  76. // reads through withStreamToken().
  77. expect(getStreamToken()).toBe(KIOSK_TOKEN);
  78. });
  79. it('offers no settings popover — a TV has nobody standing at it', async () => {
  80. renderAt(`?token=${KIOSK_TOKEN}`);
  81. await waitFor(() => expect(screen.getByText('X1C-Lab')).toBeInTheDocument());
  82. expect(screen.queryByTitle('Cam wall settings')).not.toBeInTheDocument();
  83. });
  84. it('renders tiles inert — click-through would need a session the token has not got', async () => {
  85. renderAt(`?token=${KIOSK_TOKEN}`);
  86. await waitFor(() => expect(screen.getByText('X1C-Lab')).toBeInTheDocument());
  87. expect(screen.queryByRole('button', { name: /X1C-Lab/ })).not.toBeInTheDocument();
  88. });
  89. it('refuses to honour ?status=full — a kiosk wall never names the part on the bed', async () => {
  90. renderAt(`?token=${KIOSK_TOKEN}&status=full`);
  91. await waitFor(() => expect(screen.getByText('X1C-Lab')).toBeInTheDocument());
  92. // 'full' is what adds the progress/layer strip. Capped to 'compact', so the
  93. // strip must be absent even though the feed says the printer is at 42%.
  94. expect(screen.queryByText('42%')).not.toBeInTheDocument();
  95. expect(screen.queryByText(/Layer 120/)).not.toBeInTheDocument();
  96. });
  97. it('says so plainly when the token has expired or been revoked', async () => {
  98. vi.spyOn(api, 'getCamWallPrinters').mockRejectedValue(new Error('401'));
  99. renderAt(`?token=${KIOSK_TOKEN}`);
  100. await waitFor(() =>
  101. expect(screen.getByText(/no longer valid/i)).toBeInTheDocument(),
  102. );
  103. });
  104. });
  105. describe('CamWallPage — signed out, no token', () => {
  106. beforeEach(() => {
  107. vi.spyOn(api, 'getCamWallPrinters').mockResolvedValue(FEED);
  108. localStorage.clear();
  109. });
  110. afterEach(() => {
  111. vi.restoreAllMocks();
  112. });
  113. it('does not reach for the kiosk feed without a token', async () => {
  114. vi.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 401 }));
  115. renderAt('');
  116. await act(async () => {
  117. await Promise.resolve();
  118. });
  119. // No token means this is an ordinary app page; it must fall back to the
  120. // session-authenticated printers API (or bounce to /login), never to the
  121. // kiosk endpoint.
  122. expect(api.getCamWallPrinters).not.toHaveBeenCalled();
  123. });
  124. });