/** * Tests for the PrinterQueueWidget clear plate behavior. * * When the printer is in FINISH or FAILED state and has pending queue items, * the widget shows a "Clear Plate & Start Next" button instead of the * passive queue link. After clicking, it shows a confirmation state. */ import { describe, it, expect, beforeEach } from 'vitest'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { render } from '../utils'; import { PrinterQueueWidget } from '../../components/PrinterQueueWidget'; import { http, HttpResponse } from 'msw'; import { server } from '../mocks/server'; const mockQueueItems = [ { id: 1, printer_id: 1, archive_id: 1, position: 1, status: 'pending', archive_name: 'First Print', printer_name: 'X1 Carbon', print_time_seconds: 3600, scheduled_time: null, }, { id: 2, printer_id: 1, archive_id: 2, position: 2, status: 'pending', archive_name: 'Second Print', printer_name: 'X1 Carbon', print_time_seconds: 7200, scheduled_time: null, }, ]; describe('PrinterQueueWidget - Clear Plate', () => { beforeEach(() => { server.use( http.get('/api/v1/queue/', ({ request }) => { const url = new URL(request.url); const printerId = url.searchParams.get('printer_id'); if (printerId === '1') { return HttpResponse.json(mockQueueItems); } return HttpResponse.json([]); }), http.post('/api/v1/printers/:id/clear-plate', () => { return HttpResponse.json({ success: true, message: 'Plate cleared' }); }) ); }); describe('clear plate button visibility', () => { it('shows clear plate button when printer state is FINISH', async () => { render(); await waitFor(() => { expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument(); }); }); it('shows clear plate button when printer state is FAILED', async () => { render(); await waitFor(() => { expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument(); }); }); it('shows passive link when printer state is IDLE', async () => { render(); await waitFor(() => { const link = screen.getByRole('link'); expect(link).toHaveAttribute('href', '/queue'); }); expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument(); }); it('shows passive link when printer state is RUNNING', async () => { render(); await waitFor(() => { const link = screen.getByRole('link'); expect(link).toHaveAttribute('href', '/queue'); }); }); it('shows passive link when printerState is not provided', async () => { render(); await waitFor(() => { const link = screen.getByRole('link'); expect(link).toHaveAttribute('href', '/queue'); }); }); it('shows passive link when FINISH but plateCleared is true', async () => { render(); await waitFor(() => { const link = screen.getByRole('link'); expect(link).toHaveAttribute('href', '/queue'); }); expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument(); }); it('shows passive link when FAILED but plateCleared is true', async () => { render(); await waitFor(() => { const link = screen.getByRole('link'); expect(link).toHaveAttribute('href', '/queue'); }); expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument(); }); // Regression for #961: after Auto Off cycles the printer it boots into IDLE while // still awaiting plate-clear ack. The prompt must still show — the ack state, not // the reported printer state, is the authoritative signal. it('shows clear plate button in IDLE state when awaitingPlateClear is true (#961)', async () => { render(); await waitFor(() => { expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument(); }); }); it('shows clear plate button with no printerState when awaitingPlateClear is true', async () => { // State may be null briefly after a reconnect; the widget must still gate on the flag. render(); await waitFor(() => { expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument(); }); }); }); describe('clear plate button shows queue info', () => { it('shows next item name in clear plate mode', async () => { render(); await waitFor(() => { expect(screen.getByText('First Print')).toBeInTheDocument(); }); }); it('shows additional items badge in clear plate mode', async () => { render(); await waitFor(() => { expect(screen.getByText('+1')).toBeInTheDocument(); }); }); }); describe('clear plate action', () => { it('shows confirmation state after clicking clear plate', async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument(); }); await user.click(screen.getByText('Clear Plate & Start Next')); await waitFor(() => { // Both the widget confirmation and the toast show this text const elements = screen.getAllByText('Plate cleared — ready for next print'); expect(elements.length).toBeGreaterThanOrEqual(1); }); }); it('shows error toast on API failure', async () => { server.use( http.post('/api/v1/printers/:id/clear-plate', () => { return HttpResponse.json( { detail: 'Printer not connected' }, { status: 400 } ); }) ); const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument(); }); await user.click(screen.getByText('Clear Plate & Start Next')); // Button should remain visible (not transition to success state) await waitFor(() => { expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument(); }); }); }); describe('empty queue', () => { it('renders nothing in FINISH state with no queue items', async () => { const { container } = render(); await waitFor(() => { expect(container.querySelector('button')).not.toBeInTheDocument(); }); }); }); describe('filament compatibility filtering', () => { const petgQueueItems = [ { id: 10, printer_id: 1, archive_id: 10, position: 1, status: 'pending', archive_name: 'PETG Print', printer_name: 'H2S', print_time_seconds: 3600, scheduled_time: null, required_filament_types: ['PETG'], }, ]; it('hides widget when queue item requires filament not loaded on printer', async () => { server.use( http.get('/api/v1/queue/', () => HttpResponse.json(petgQueueItems)) ); const { container } = render( ); // Wait for query to settle, then confirm widget is not rendered await waitFor(() => { expect(container.querySelector('button')).not.toBeInTheDocument(); }); expect(screen.queryByText('PETG Print')).not.toBeInTheDocument(); }); it('shows widget when queue item required filaments match loaded', async () => { server.use( http.get('/api/v1/queue/', () => HttpResponse.json(petgQueueItems)) ); render( ); await waitFor(() => { expect(screen.getByText('PETG Print')).toBeInTheDocument(); expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument(); }); }); it('shows widget when queue item has no required_filament_types', async () => { // Default mockQueueItems have no required_filament_types render( ); await waitFor(() => { expect(screen.getByText('First Print')).toBeInTheDocument(); expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument(); }); }); it('shows widget when loadedFilamentTypes prop is not provided', async () => { server.use( http.get('/api/v1/queue/', () => HttpResponse.json(petgQueueItems)) ); render( ); await waitFor(() => { expect(screen.getByText('PETG Print')).toBeInTheDocument(); expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument(); }); }); it('skips incompatible first item and shows compatible second item', async () => { const mixedQueue = [ { id: 10, printer_id: 1, archive_id: 10, position: 1, status: 'pending', archive_name: 'PETG Print', printer_name: 'H2S', print_time_seconds: 3600, scheduled_time: null, required_filament_types: ['PETG'], }, { id: 11, printer_id: 1, archive_id: 11, position: 2, status: 'pending', archive_name: 'PLA Print', printer_name: 'H2S', print_time_seconds: 1800, scheduled_time: null, required_filament_types: ['PLA'], }, ]; server.use( http.get('/api/v1/queue/', () => HttpResponse.json(mixedQueue)) ); render( ); await waitFor(() => { expect(screen.getByText('PLA Print')).toBeInTheDocument(); }); expect(screen.queryByText('PETG Print')).not.toBeInTheDocument(); }); it('matches filament types case-insensitively', async () => { const lowercaseQueue = [ { id: 10, printer_id: 1, archive_id: 10, position: 1, status: 'pending', archive_name: 'Petg Print', printer_name: 'H2S', print_time_seconds: 3600, scheduled_time: null, required_filament_types: ['petg'], }, ]; server.use( http.get('/api/v1/queue/', () => HttpResponse.json(lowercaseQueue)) ); render( ); await waitFor(() => { expect(screen.getByText('Petg Print')).toBeInTheDocument(); expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument(); }); }); }); describe('filament override color filtering', () => { const whitePetgOverrideItem = [ { id: 20, printer_id: null, archive_id: 20, position: 1, status: 'pending', archive_name: 'White PETG Print', printer_name: null, print_time_seconds: 3600, scheduled_time: null, required_filament_types: ['PETG'], filament_overrides: [{ slot_id: 1, type: 'PETG', color: '#FFFFFF' }], }, ]; it('hides widget when override color does not match loaded filaments', async () => { server.use( http.get('/api/v1/queue/', () => HttpResponse.json(whitePetgOverrideItem)) ); const { container } = render( ); await waitFor(() => { expect(container.querySelector('button')).not.toBeInTheDocument(); }); expect(screen.queryByText('White PETG Print')).not.toBeInTheDocument(); }); it('shows widget when override color matches loaded filaments', async () => { server.use( http.get('/api/v1/queue/', () => HttpResponse.json(whitePetgOverrideItem)) ); render( ); await waitFor(() => { expect(screen.getByText('White PETG Print')).toBeInTheDocument(); expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument(); }); }); it('normalizes override color format (strips # and lowercases)', async () => { const upperCaseColorItem = [ { id: 21, printer_id: null, archive_id: 21, position: 1, status: 'pending', archive_name: 'Red PLA Print', printer_name: null, print_time_seconds: 3600, scheduled_time: null, required_filament_types: ['PLA'], filament_overrides: [{ slot_id: 1, type: 'PLA', color: '#FF0000' }], }, ]; server.use( http.get('/api/v1/queue/', () => HttpResponse.json(upperCaseColorItem)) ); render( ); await waitFor(() => { expect(screen.getByText('Red PLA Print')).toBeInTheDocument(); }); }); it('shows widget when no loadedFilaments prop is provided (no color filtering)', async () => { server.use( http.get('/api/v1/queue/', () => HttpResponse.json(whitePetgOverrideItem)) ); render( ); await waitFor(() => { expect(screen.getByText('White PETG Print')).toBeInTheDocument(); }); }); it('shows widget when queue item has no filament overrides', async () => { // Default mockQueueItems have no filament_overrides render( ); await waitFor(() => { expect(screen.getByText('First Print')).toBeInTheDocument(); }); }); it('matches any override when multiple overrides exist', async () => { const multiOverrideItem = [ { id: 22, printer_id: null, archive_id: 22, position: 1, status: 'pending', archive_name: 'Multi Color Print', printer_name: null, print_time_seconds: 3600, scheduled_time: null, required_filament_types: ['PLA'], filament_overrides: [ { slot_id: 1, type: 'PLA', color: '#FF0000' }, { slot_id: 2, type: 'PLA', color: '#00FF00' }, ], }, ]; server.use( http.get('/api/v1/queue/', () => HttpResponse.json(multiOverrideItem)) ); // Printer has green PLA but not red — should still match (at least one override) render( ); await waitFor(() => { expect(screen.getByText('Multi Color Print')).toBeInTheDocument(); }); }); }); describe('requirePlateClear setting', () => { it('shows passive link when requirePlateClear is false even in FINISH state', async () => { render(); await waitFor(() => { const link = screen.getByRole('link'); expect(link).toHaveAttribute('href', '/queue'); }); expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument(); }); it('shows passive link when requirePlateClear is false even in FAILED state', async () => { render(); await waitFor(() => { const link = screen.getByRole('link'); expect(link).toHaveAttribute('href', '/queue'); }); expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument(); }); it('shows clear plate button when requirePlateClear is true (explicit)', async () => { render(); await waitFor(() => { expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument(); }); }); it('shows clear plate button when requirePlateClear is not provided (defaults to true)', async () => { render(); await waitFor(() => { expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument(); }); }); it('still shows next item info in passive link when requirePlateClear is false', async () => { render(); await waitFor(() => { expect(screen.getByText('First Print')).toBeInTheDocument(); }); }); }); describe('staged (manual_start) items', () => { const stagedItems = [ { id: 10, printer_id: 1, archive_id: 1, position: 1, status: 'pending', archive_name: 'Staged Print 1', manual_start: true, scheduled_time: null }, { id: 11, printer_id: 1, archive_id: 2, position: 2, status: 'pending', archive_name: 'Staged Print 2', manual_start: true, scheduled_time: null }, ]; it('does not show clear plate button when all items are staged', async () => { server.use( http.get('/api/v1/queue/', () => HttpResponse.json(stagedItems)), ); render(); // Should show the passive link (not the clear plate button) await waitFor(() => { expect(screen.getByText('Staged Print 1')).toBeInTheDocument(); }); expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument(); }); it('shows clear plate button when mix of staged and auto-dispatch items', async () => { const mixedItems = [ { id: 10, printer_id: 1, archive_id: 1, position: 1, status: 'pending', archive_name: 'Staged Print', manual_start: true, scheduled_time: null }, { id: 11, printer_id: 1, archive_id: 2, position: 2, status: 'pending', archive_name: 'Auto Print', manual_start: false, scheduled_time: null }, ]; server.use( http.get('/api/v1/queue/', () => HttpResponse.json(mixedItems)), ); render(); await waitFor(() => { expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument(); }); }); }); });