| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311 |
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
- import { screen, waitFor, within } from '@testing-library/react';
- import userEvent from '@testing-library/user-event';
- import { http, HttpResponse } from 'msw';
- import { server } from '../mocks/server';
- import { render as rtlRender } from '@testing-library/react';
- import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
- import { MemoryRouter } from 'react-router-dom';
- import { render } from '../utils';
- import { ToastProvider } from '../../contexts/ToastContext';
- import { api } from '../../api/client';
- import { CamWallPage } from '../../pages/CamWallPage';
- import { CameraTile } from '../../components/CameraTile';
- import { CameraWall, type CameraWallStatus } from '../../components/CameraWall';
- // The camera wall tiles carry what a printer card shows at a glance: the
- // heaters and the finish time in the full overlay, and pause/resume, stop and
- // the chamber light for a user allowed to control printers (#3479).
- let canControl = true;
- let signedInUser: { id: number; username: string } | null = null;
- vi.mock('../../contexts/AuthContext', async (importOriginal) => {
- const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
- return {
- ...actual,
- useAuth: () => ({
- hasPermission: (p: string) => (p === 'printers:control' ? canControl : true),
- hasAnyPermission: () => true,
- authEnabled: true,
- user: signedInUser,
- loading: false,
- }),
- };
- });
- const PRINTER = { id: 5, name: 'Farm-5' };
- const PRINTING: CameraWallStatus = {
- connected: true,
- state: 'RUNNING',
- progress: 40,
- remaining_time: 90,
- temperatures: { nozzle: 219.6, bed: 60, chamber: 34.4 },
- chamber_light: false,
- };
- let posted: string[];
- beforeEach(() => {
- canControl = true;
- signedInUser = null;
- posted = [];
- vi.spyOn(global, 'fetch');
- server.use(
- http.post('/api/v1/printers/:id/print/:action', ({ request, params }) => {
- posted.push(`${params.action} ${new URL(request.url).pathname}`);
- return HttpResponse.json({ success: true, message: 'ok' });
- }),
- http.post('/api/v1/printers/:id/chamber-light', ({ request }) => {
- posted.push(`light ${new URL(request.url).searchParams.get('on')}`);
- return HttpResponse.json({ success: true, message: 'ok' });
- }),
- http.post('/api/v1/printers/:id/camera/stop', () => HttpResponse.json({ success: true })),
- );
- });
- afterEach(() => {
- vi.restoreAllMocks();
- });
- function renderWall(
- status: CameraWallStatus,
- props: Partial<Parameters<typeof CameraWall>[0]> = { showControls: true },
- ) {
- return render(
- <CameraWall
- printers={[PRINTER]}
- maxLive={0}
- snapshotIntervalSec={8}
- statusMode="full"
- statuses={new Map([[PRINTER.id, status]])}
- onChangeMaxLive={() => {}}
- onChangeSnapshotIntervalSec={() => {}}
- onChangeStatusMode={() => {}}
- onTileClick={() => {}}
- timeFormat="24h"
- {...props}
- />,
- );
- }
- describe('tile information', () => {
- function tile(props: Partial<Parameters<typeof CameraTile>[0]>) {
- return render(
- <CameraTile
- printerId={5}
- printerName="Farm-5"
- mode="paused"
- snapshotIntervalMs={8000}
- connected
- statusMode="full"
- printerState="RUNNING"
- remainingMin={90}
- temperatures={{ nozzle: 219.6, nozzle_2: 180, bed: 60, chamber: 34.4 }}
- timeFormat="24h"
- {...props}
- />,
- );
- }
- it('shows every heater, both nozzles of a dual-nozzle printer, rounded', () => {
- tile({});
- expect(screen.getByTitle('Nozzle')).toHaveTextContent('220° / 180°');
- expect(screen.getByTitle('Bed')).toHaveTextContent('60°');
- expect(screen.getByTitle('Chamber')).toHaveTextContent('34°');
- });
- it('leaves out a heater the printer does not report', () => {
- tile({ temperatures: { nozzle: 200, bed: 55 } });
- expect(screen.queryByTitle('Chamber')).not.toBeInTheDocument();
- });
- it('shows the heaters on an idle printer too', () => {
- tile({ printerState: 'IDLE', remainingMin: null });
- expect(screen.getByTitle('Bed')).toHaveTextContent('60°');
- });
- it('shows the finish time while printing', () => {
- tile({});
- expect(screen.getByText(/^ETA \d{2}:\d{2}$|^ETA Tomorrow \d{2}:\d{2}$/)).toBeInTheDocument();
- });
- it('shows no finish time for a paused print, whose end moves with the pause', () => {
- tile({ printerState: 'PAUSE' });
- expect(screen.getByText(/left$/)).toBeInTheDocument();
- expect(screen.queryByText(/^ETA/)).not.toBeInTheDocument();
- });
- it('keeps the compact overlay to the state badge', () => {
- tile({ statusMode: 'compact' });
- expect(screen.queryByTitle('Bed')).not.toBeInTheDocument();
- expect(screen.queryByText(/^ETA/)).not.toBeInTheDocument();
- });
- it('shows no heaters for an offline printer', () => {
- tile({ connected: false });
- expect(screen.queryByTitle('Bed')).not.toBeInTheDocument();
- });
- });
- describe('tile controls', () => {
- it('pauses a print after the same confirmation as the card', async () => {
- const user = userEvent.setup();
- renderWall(PRINTING);
- await user.click(await screen.findByRole('button', { name: 'Pause' }));
- expect(posted).toEqual([]);
- await screen.findByText(/pause the current print on "Farm-5"/);
- await user.click(screen.getByRole('button', { name: 'Pause Print' }));
- await waitFor(() => expect(posted).toEqual(['pause /api/v1/printers/5/print/pause']));
- });
- it('resumes a paused print', async () => {
- const user = userEvent.setup();
- renderWall({ ...PRINTING, state: 'PAUSE' });
- await user.click(await screen.findByRole('button', { name: 'Resume' }));
- await screen.findByText(/resume the print on "Farm-5"/);
- await user.click(screen.getByRole('button', { name: 'Resume Print' }));
- await waitFor(() => expect(posted).toEqual(['resume /api/v1/printers/5/print/resume']));
- });
- it('stops a print only after it is confirmed', async () => {
- const user = userEvent.setup();
- renderWall(PRINTING);
- await user.click(await screen.findByRole('button', { name: 'Stop' }));
- await screen.findByText(/stop the current print on "Farm-5"/);
- await user.click(screen.getByRole('button', { name: 'Cancel' }));
- expect(posted).toEqual([]);
- await user.click(screen.getByRole('button', { name: 'Stop' }));
- await screen.findByText(/stop the current print on "Farm-5"/);
- await user.click(screen.getByRole('button', { name: 'Stop Print' }));
- await waitFor(() => expect(posted).toEqual(['stop /api/v1/printers/5/print/stop']));
- });
- it('switches the chamber light', async () => {
- const user = userEvent.setup();
- renderWall(PRINTING);
- await user.click(await screen.findByRole('button', { name: 'Turn on chamber light' }));
- await waitFor(() => expect(posted).toEqual(['light true']));
- });
- it('offers only the light on a printer that is not printing', async () => {
- renderWall({ ...PRINTING, state: 'IDLE' });
- expect(await screen.findByRole('button', { name: 'Turn on chamber light' })).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Pause' })).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Stop' })).not.toBeInTheDocument();
- });
- it('gives a user without printers:control no controls', async () => {
- canControl = false;
- renderWall(PRINTING);
- // The tile itself is there; only its controls are not.
- expect(await screen.findByTitle('Bed')).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Pause' })).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Stop' })).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: /chamber light/ })).not.toBeInTheDocument();
- });
- it('gives an offline printer no controls', async () => {
- renderWall({ ...PRINTING, connected: false });
- expect(screen.queryByRole('button', { name: /chamber light/ })).not.toBeInTheDocument();
- });
- it('gives a wall without controls (the kiosk) none', async () => {
- renderWall(PRINTING, { showControls: false });
- expect(screen.queryByRole('button', { name: 'Pause' })).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: /chamber light/ })).not.toBeInTheDocument();
- });
- });
- describe('kiosk wall', () => {
- it('has no controls, also where the browser could control printers', async () => {
- vi.spyOn(api, 'getCamWallPrinters').mockResolvedValue([
- { 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: [] },
- ]);
- const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
- rtlRender(
- <QueryClientProvider client={queryClient}>
- <MemoryRouter initialEntries={['/camwall?token=bblt_abcdefgh_secretsecretsecret']}>
- <ToastProvider>
- <CamWallPage />
- </ToastProvider>
- </MemoryRouter>
- </QueryClientProvider>,
- );
- expect(await screen.findByText('X1C-Lab')).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: /chamber light/ })).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Pause' })).not.toBeInTheDocument();
- });
- });
- describe('when the tile controls show', () => {
- const controls = () => screen.getByTestId('camwall-tile-controls');
- it('waits for hover by default', async () => {
- renderWall(PRINTING);
- await screen.findByRole('button', { name: 'Pause' });
- expect(controls().className).toContain('opacity-0');
- expect(controls().className).toContain('group-hover/tile:opacity-100');
- // No hover on a touch screen: shown there all the same.
- expect(controls().className).toContain('[@media(hover:none)]:opacity-100');
- });
- it('shows them all the time when set to always', async () => {
- renderWall(PRINTING, { showControls: true, controlsMode: 'always', onChangeControlsMode: () => {} });
- await screen.findByRole('button', { name: 'Pause' });
- expect(controls().className).not.toContain('opacity-0');
- });
- it('is chosen in the wall settings', async () => {
- const onChange = vi.fn();
- const user = userEvent.setup();
- renderWall(PRINTING, { showControls: true, controlsMode: 'hover', onChangeControlsMode: onChange });
- await user.click(screen.getByRole('button', { name: 'Cam wall settings' }));
- const group = screen.getByRole('radiogroup', { name: 'Tile controls' });
- expect(within(group).getByRole('radio', { name: 'On hover' })).toHaveAttribute('aria-checked', 'true');
- await user.click(within(group).getByRole('radio', { name: 'Always' }));
- expect(onChange).toHaveBeenCalledWith('always');
- });
- it('is not offered on a wall without controls', async () => {
- const user = userEvent.setup();
- renderWall(PRINTING, { showControls: false, onChangeControlsMode: () => {} });
- await user.click(screen.getByRole('button', { name: 'Cam wall settings' }));
- expect(screen.queryByRole('radiogroup', { name: 'Tile controls' })).not.toBeInTheDocument();
- });
- });
- describe('the /camwall page, signed in', () => {
- it('follows the choice saved in this browser', async () => {
- signedInUser = { id: 1, username: 'farm' };
- vi.mocked(localStorage.getItem).mockImplementation((key: string) => (key === 'camWallControls' ? 'always' : null));
- vi.spyOn(api, 'getPrinters').mockResolvedValue([{ id: 7, name: 'X1C-Lab', camera_rotation: 0 }] as never);
- vi.spyOn(api, 'getPrinterStatus').mockResolvedValue({ ...PRINTING, hms_errors: [] } as never);
- const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
- rtlRender(
- <QueryClientProvider client={queryClient}>
- <MemoryRouter initialEntries={['/camwall']}>
- <ToastProvider>
- <CamWallPage />
- </ToastProvider>
- </MemoryRouter>
- </QueryClientProvider>,
- );
- await screen.findByRole('button', { name: 'Pause' });
- expect(screen.getByTestId('camwall-tile-controls').className).not.toContain('opacity-0');
- });
- });
|