| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425 |
- /**
- * UX for the scheduled-drying start modes.
- *
- * "After delay" and "At time" reveal an extra control at the bottom of the
- * drying popover. These used to render below the fold of a height-capped,
- * scrollable body with no affordance; the delay options are now inline
- * chips and an above-placed popover grows upward from its anchored bottom
- * edge. The click that dismisses the native date picker must not tear down
- * the popover.
- */
- import { describe, it, expect, beforeEach } from 'vitest';
- import { screen, fireEvent, waitFor } from '@testing-library/react';
- import userEvent from '@testing-library/user-event';
- import { render } from '../utils';
- import { PrintersPage } from '../../pages/PrintersPage';
- import { http, HttpResponse } from 'msw';
- import { server } from '../mocks/server';
- const mockPrinter = {
- id: 1,
- name: 'X1C',
- ip_address: '192.168.1.100',
- serial_number: '01P00A000000001',
- access_code: '12345678',
- model: 'X1C',
- enabled: true,
- nozzle_diameter: 0.4,
- nozzle_type: 'stainless_steel',
- location: 'Workshop',
- auto_archive: true,
- created_at: '2024-01-01T00:00:00Z',
- updated_at: '2024-01-01T00:00:00Z',
- };
- const baseTray = {
- tray_color: 'FF0000FF',
- tray_type: 'PLA',
- tray_sub_brands: 'PLA Basic',
- tray_id_name: 'A00-R0',
- tray_info_idx: 'GFA00',
- remain: 80,
- k: 0.02,
- cali_idx: null,
- tag_uid: null,
- tray_uuid: null,
- nozzle_temp_min: 190,
- nozzle_temp_max: 230,
- drying_temp: null,
- drying_time: null,
- state: 3,
- };
- /** AMS 2 Pro (n3f) on an idle printer that accepts remote drying commands. */
- const IDLE = {
- connected: true,
- state: 'IDLE',
- progress: 0,
- layer_num: 0,
- total_layers: 0,
- temperatures: { nozzle: 25, bed: 25, chamber: 25 },
- remaining_time: 0,
- filename: null,
- wifi_signal: -29,
- speed_level: 2,
- supports_drying: true,
- drying_screen_only: false,
- vt_tray: [],
- ams: [
- {
- id: 0,
- humidity: 30,
- temp: 33,
- is_ams_ht: false,
- serial_number: 'AMS00',
- sw_ver: '03.00.21.29',
- dry_sub_status: 0,
- dry_sf_reason: [],
- module_type: 'n3f',
- dry_time: 0,
- dry_status: 0,
- tray: [
- { id: 0, ...baseTray },
- { id: 1, ...baseTray },
- { id: 2, ...baseTray },
- { id: 3, ...baseTray },
- ],
- },
- ],
- };
- /** Same AMS mid-cycle: 12h remaining on the dryer. */
- const DRYING = {
- ...IDLE,
- ams: [{ ...IDLE.ams[0], dry_time: 720, dry_status: 2 }],
- };
- const PENDING_ROW = {
- id: 1,
- printer_id: 1,
- ams_id: 0,
- temp: 45,
- duration_hours: 12,
- filament: 'PLA',
- rotate_tray: false,
- start_after: '2026-07-25T23:24:00',
- status: 'pending',
- waiting_reason: null,
- error_message: null,
- created_at: '2026-07-25T20:00:00',
- started_at: null,
- completed_at: null,
- };
- async function openDryingPopover(user: ReturnType<typeof userEvent.setup>) {
- await waitFor(() => {
- expect(screen.getAllByTitle('Start Drying').length).toBeGreaterThan(0);
- });
- await user.click(screen.getAllByTitle('Start Drying')[0]);
- await screen.findByTestId('drying-start-confirm');
- }
- describe('PrintersPage - drying start modes', () => {
- beforeEach(() => {
- server.use(
- http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
- http.get('/api/v1/printers/:id/status', () => HttpResponse.json(IDLE)),
- http.get('/api/v1/queue/', () => HttpResponse.json([])),
- http.get('/api/v1/scheduled-dryings', () => HttpResponse.json([])),
- );
- });
- it('lets the temperature be retyped instead of snapping to the 45 °C minimum (#3182)', async () => {
- const user = userEvent.setup();
- render(<PrintersPage />);
- await openDryingPopover(user);
- const temp = screen
- .getAllByRole('spinbutton')
- .find((el) => el.getAttribute('min') === '45') as HTMLInputElement;
- await user.clear(temp);
- await user.keyboard('6');
- expect(temp.value).toBe('6');
- await user.keyboard('0');
- expect(temp.value).toBe('60');
- await user.tab();
- expect(temp.value).toBe('60');
- expect((screen.getAllByRole('slider')[0] as HTMLInputElement).value).toBe('60');
- });
- it('reveals the delay chips when After delay is selected, with 2h preselected', async () => {
- const user = userEvent.setup();
- render(<PrintersPage />);
- await openDryingPopover(user);
- await user.click(screen.getByRole('button', { name: 'After delay' }));
- for (const label of ['30m', '1h', '2h', '4h', '8h', '12h', '24h']) {
- expect(screen.getByRole('button', { name: label })).toBeInTheDocument();
- }
- expect(screen.getByRole('button', { name: '2h' })).toHaveAttribute('aria-pressed', 'true');
- await user.click(screen.getByRole('button', { name: '4h' }));
- expect(screen.getByRole('button', { name: '4h' })).toHaveAttribute('aria-pressed', 'true');
- expect(screen.getByRole('button', { name: '2h' })).toHaveAttribute('aria-pressed', 'false');
- });
- it('reveals the datetime input for At time and keeps Schedule disabled until a time is set', async () => {
- const user = userEvent.setup();
- render(<PrintersPage />);
- await openDryingPopover(user);
- await user.click(screen.getByRole('button', { name: 'At time' }));
- const input = await screen.findByTestId('drying-start-at');
- expect(screen.getByTestId('drying-start-confirm')).toBeDisabled();
- fireEvent.change(input, { target: { value: '2099-01-15T18:00' } });
- expect(screen.getByTestId('drying-start-confirm')).toBeEnabled();
- });
- it('drops the scheduled banner promptly once a drying cycle starts', async () => {
- // The banner polls every 30s; without a nudge from the live AMS status
- // it shows a dispatched schedule as still pending for up to that long.
- let calls = 0;
- server.use(
- http.get('/api/v1/printers/:id/status', () => HttpResponse.json(DRYING)),
- http.get('/api/v1/scheduled-dryings', () => {
- calls += 1;
- return HttpResponse.json(calls === 1 ? [PENDING_ROW] : []);
- }),
- );
- render(<PrintersPage />);
- await waitFor(() => expect(calls).toBeGreaterThanOrEqual(2));
- await waitFor(() => {
- expect(screen.queryByText(/Drying scheduled for/)).not.toBeInTheDocument();
- });
- });
- it('labels a transient cannot-dry reason accurately, not as a power problem', async () => {
- const blocked = { ...IDLE, ams: [{ ...IDLE.ams[0], dry_sf_reason: [2] }] };
- server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(blocked)));
- render(<PrintersPage />);
- await waitFor(() => {
- expect(screen.getAllByTitle("AMS can't start drying right now").length).toBeGreaterThan(0);
- });
- });
- it('keeps the power tooltip for the power-supply reason codes', async () => {
- const blocked = { ...IDLE, ams: [{ ...IDLE.ams[0], dry_sf_reason: [8] }] };
- server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(blocked)));
- render(<PrintersPage />);
- await waitFor(() => {
- expect(screen.getAllByTitle('Connect AMS power adapter to enable drying').length).toBeGreaterThan(0);
- });
- });
- it('resets the start mode when the popover is reopened', async () => {
- // Leaving the mode set carried a stale "At time" timestamp into the next
- // open, by then in the past, and the POST rejected it.
- const user = userEvent.setup();
- render(<PrintersPage />);
- await openDryingPopover(user);
- await user.click(screen.getByRole('button', { name: 'At time' }));
- const input = await screen.findByTestId('drying-start-at');
- fireEvent.change(input, { target: { value: '2026-07-25T23:24' } });
- expect(screen.getByTestId('drying-start-at')).toHaveValue('2026-07-25T23:24');
- // Close, then reopen on the same flame button.
- await user.click(screen.getByTestId('drying-popover-backdrop'));
- await waitFor(() => {
- expect(screen.queryByTestId('drying-start-confirm')).not.toBeInTheDocument();
- });
- await openDryingPopover(user);
- // Back on "Now": neither the delay chips nor the datetime input show.
- expect(screen.queryByTestId('drying-start-at')).not.toBeInTheDocument();
- await user.click(screen.getByRole('button', { name: 'At time' }));
- expect(screen.getByTestId('drying-start-at')).toHaveValue('');
- });
- it('fetches the scheduled list once for the fleet rather than per printer', async () => {
- const requests: string[] = [];
- server.use(
- http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter, { ...mockPrinter, id: 2, name: 'P1S-2' }])),
- http.get('/api/v1/scheduled-dryings', ({ request }) => {
- requests.push(new URL(request.url).search);
- return HttpResponse.json([]);
- }),
- );
- render(<PrintersPage />);
- await waitFor(() => expect(requests.length).toBeGreaterThan(0));
- // No printer_id filter: one shared query, filtered client-side.
- expect(requests.every(search => search === '')).toBe(true);
- });
- it('says why a due run has not started when the AMS needs the power adapter', async () => {
- server.use(
- http.get('/api/v1/scheduled-dryings', () =>
- HttpResponse.json([{ ...PENDING_ROW, start_after: null, waiting_reason: 'ams_power_required' }])
- ),
- );
- render(<PrintersPage />);
- expect(await screen.findByText('Connect AMS power adapter to enable drying')).toBeInTheDocument();
- });
- it('shows a failed run by its error code, translated, not the backend English text', async () => {
- server.use(
- http.get('/api/v1/scheduled-dryings', () =>
- HttpResponse.json([
- {
- ...PENDING_ROW,
- status: 'failed',
- error_code: 'did_not_start',
- // Deliberately different from the translation, so the test shows
- // which of the two was rendered.
- error_message: 'backend English text',
- completed_at: '2026-07-25T23:25:00',
- },
- ])
- ),
- );
- render(<PrintersPage />);
- expect(
- await screen.findByText(
- 'Scheduled drying failed: The printer accepted the command, but the AMS did not start drying'
- )
- ).toBeInTheDocument();
- expect(screen.queryByText(/backend English text/)).not.toBeInTheDocument();
- });
- it('falls back to the error message for an unknown error code', async () => {
- server.use(
- http.get('/api/v1/scheduled-dryings', () =>
- HttpResponse.json([
- {
- ...PENDING_ROW,
- status: 'failed',
- error_code: 'something_new',
- error_message: 'A reason this frontend has no key for',
- completed_at: '2026-07-25T23:25:00',
- },
- ])
- ),
- );
- render(<PrintersPage />);
- expect(
- await screen.findByText('Scheduled drying failed: A reason this frontend has no key for')
- ).toBeInTheDocument();
- });
- it('shows a run that failed at dispatch, with a dismiss that clears it', async () => {
- // Only dispatch can fail (firmware too old on a printer that was offline
- // at schedule time). Without this the run vanishes and only the backend
- // log says why.
- const failedRow = {
- ...PENDING_ROW,
- status: 'failed',
- error_message: 'Drying not supported for this printer model or firmware version',
- completed_at: '2026-07-25T23:25:00',
- };
- let listed = [failedRow];
- let deleted: number | null = null;
- server.use(
- http.get('/api/v1/scheduled-dryings', () => HttpResponse.json(listed)),
- http.delete('/api/v1/scheduled-dryings/:id', ({ params }) => {
- deleted = Number(params.id);
- listed = [];
- return HttpResponse.json({ status: 'dismissed', id: deleted });
- }),
- );
- const user = userEvent.setup();
- render(<PrintersPage />);
- expect(
- await screen.findByText(/Scheduled drying failed: Drying not supported for this printer model/)
- ).toBeInTheDocument();
- await user.click(screen.getByTitle('Dismiss'));
- await waitFor(() => expect(deleted).toBe(failedRow.id));
- await waitFor(() => {
- expect(screen.queryByText(/Scheduled drying failed/)).not.toBeInTheDocument();
- });
- });
- it.each([
- ['ams_retract_filament', 'Retract the filament at the AMS outlet to start drying'],
- ['ams_not_found', 'Waiting for the AMS to be detected'],
- ['printer_offline', 'Waiting for the printer to come online'],
- ['printer_busy', 'Waiting for the printer to be free'],
- ['already_drying', 'Waiting for the current drying cycle to finish'],
- ['interrupted', 'Interrupted, will restart when the printer is free'],
- ])('renders text for the %s waiting reason', async (reason, text) => {
- // An unmapped reason left the card showing a bare "Drying scheduled for"
- // with no hint why it had not started.
- server.use(
- http.get('/api/v1/scheduled-dryings', () =>
- HttpResponse.json([{ ...PENDING_ROW, start_after: null, waiting_reason: reason }])
- ),
- );
- render(<PrintersPage />);
- expect(await screen.findByText(text)).toBeInTheDocument();
- });
- it('renders the banner inside the card body, not below it', async () => {
- // Between </CardContent> and </Card> it went full-bleed and its corners
- // collided with the card's rounded bottom edge.
- server.use(
- http.get('/api/v1/scheduled-dryings', () => HttpResponse.json([PENDING_ROW])),
- );
- render(<PrintersPage />);
- const banner = await screen.findByTestId('scheduled-drying-pending');
- // Its wrapper's parent is CardContent (which carries the padding), not the
- // bare Card root it used to hang off.
- const parent = banner.parentElement?.parentElement;
- expect(parent?.className).toMatch(/\bp-\d/);
- });
- it('does not close the popover on the outside click that dismisses the native date picker', async () => {
- const user = userEvent.setup();
- render(<PrintersPage />);
- await openDryingPopover(user);
- await user.click(screen.getByRole('button', { name: 'At time' }));
- const input = await screen.findByTestId('drying-start-at');
- // With the native picker open the input keeps focus; the click that
- // dismisses the picker lands on the backdrop.
- input.focus();
- await user.click(screen.getByTestId('drying-popover-backdrop'));
- expect(screen.getByTestId('drying-start-confirm')).toBeInTheDocument();
- // A second outside click (input no longer focused) closes as before.
- await user.click(screen.getByTestId('drying-popover-backdrop'));
- expect(screen.queryByTestId('drying-start-confirm')).not.toBeInTheDocument();
- });
- // The flame button's tooltip is the only place an immediate drying attempt
- // explains itself, and it has to name the same code the scheduled path would
- // record — otherwise the same blocked AMS reads two different ways depending
- // on which button you pressed.
- it.each([
- [[1], 'Connect AMS power adapter to enable drying'],
- [[8], 'Connect AMS power adapter to enable drying'],
- [[3], 'Retract the filament at the AMS outlet to start drying'],
- [[2], "AMS can't start drying right now"],
- // Both set: the power problem outranks the retract, as it does server-side.
- [[3, 1], 'Connect AMS power adapter to enable drying'],
- // Transient alongside an actionable one: name the one the user can fix.
- [[2, 3], 'Retract the filament at the AMS outlet to start drying'],
- ])('explains dry_sf_reason %j on the drying button', async (reasons, expected) => {
- server.use(
- http.get('/api/v1/printers/:id/status', () =>
- HttpResponse.json({ ...IDLE, ams: [{ ...IDLE.ams[0], dry_sf_reason: reasons }] })
- ),
- );
- render(<PrintersPage />);
- await waitFor(() => {
- expect(screen.getAllByTitle(expected).length).toBeGreaterThan(0);
- });
- });
- });
|