/** * Tests for the unified PrintModal component. * * The PrintModal supports two modes: * - 'create': Create a print queue item * - 'edit-queue-item': Edit existing queue item (single printer) */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type React from 'react'; import { screen, waitFor, render as rtlRender } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { BrowserRouter } from 'react-router-dom'; import { render } from '../utils'; import { PrintModal } from '../../components/PrintModal'; import { AuthProvider } from '../../contexts/AuthContext'; import { ThemeProvider } from '../../contexts/ThemeContext'; import { ToastProvider } from '../../contexts/ToastContext'; import { http, HttpResponse } from 'msw'; import { server } from '../mocks/server'; import type { PrintQueueItem } from '../../api/client'; const mockPrinters = [ { id: 1, name: 'X1 Carbon', model: 'X1C', ip_address: '192.168.1.100', enabled: true, is_active: true }, { id: 2, name: 'P1S', model: 'P1S', ip_address: '192.168.1.101', enabled: true, is_active: true }, { id: 3, name: 'A1 Mini', model: 'A1M', ip_address: '192.168.1.102', enabled: true, is_active: true }, ]; const createMockQueueItem = (overrides: Partial = {}): PrintQueueItem => ({ id: 1, printer_id: 1, archive_id: 1, position: 1, scheduled_time: null, require_previous_success: false, auto_off_after: false, gcode_injection: false, manual_start: false, ams_mapping: null, plate_id: null, bed_levelling: 'on', flow_cali: 'off', vibration_cali: true, layer_inspect: false, timelapse: false, use_ams: true, status: 'pending', started_at: null, completed_at: null, error_message: null, created_at: '2024-01-01T00:00:00Z', archive_name: 'Test Print', archive_thumbnail: null, printer_name: 'Test Printer', print_time_seconds: 3600, batch_id: null, batch_name: null, ...overrides, }); describe('PrintModal', () => { const mockOnClose = vi.fn(); const mockOnSuccess = vi.fn(); beforeEach(() => { vi.clearAllMocks(); server.use( http.get('/api/v1/printers/', () => { return HttpResponse.json(mockPrinters); }), http.get('/api/v1/archives/:id/plates', () => { return HttpResponse.json({ is_multi_plate: false, plates: [] }); }), http.get('/api/v1/archives/:id/filament-requirements', () => { return HttpResponse.json({ filaments: [] }); }), http.get('/api/v1/printers/:id/status', () => { return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] }); }), http.post('/api/v1/queue/', () => { return HttpResponse.json({ id: 1, status: 'pending' }); }), http.patch('/api/v1/queue/:id', () => { return HttpResponse.json({ id: 1, status: 'pending' }); }) ); }); describe('create mode', () => { it('renders the modal title', () => { render( ); expect(screen.getByRole('heading', { name: 'Print' })).toBeInTheDocument(); }); it('shows archive name', () => { render( ); expect(screen.getByText('Benchy')).toBeInTheDocument(); }); it('shows printer selection with checkboxes for multi-select', async () => { render( ); await waitFor(() => { expect(screen.getByText('X1 Carbon')).toBeInTheDocument(); expect(screen.getByText('P1S')).toBeInTheDocument(); }); }); it('has print button', () => { render( ); // Get the submit button specifically (not printer selection buttons) const submitButton = screen.getByRole('button', { name: /^print$/i }); expect(submitButton).toBeInTheDocument(); }); it('has cancel button', () => { render( ); expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); }); it('calls onClose when cancel is clicked', async () => { const user = userEvent.setup(); render( ); await user.click(screen.getByRole('button', { name: /cancel/i })); expect(mockOnClose).toHaveBeenCalled(); }); it('print button is disabled until printer is selected', () => { render( ); // Get the submit button specifically (not printer selection buttons) const printButton = screen.getByRole('button', { name: /^print$/i }); expect(printButton).toBeDisabled(); }); it('shows no printers message when none active', async () => { server.use( http.get('/api/v1/printers/', () => { return HttpResponse.json([]); }) ); render( ); await waitFor(() => { expect(screen.getByText('No active printers available')).toBeInTheDocument(); }); }); it('shows print options toggle', async () => { render( ); await waitFor(() => { expect(screen.getByText('Print Options')).toBeInTheDocument(); }); }); }); describe('create mode', () => { it('renders the modal title', () => { render( ); expect(screen.getByRole('heading', { name: 'Print' })).toBeInTheDocument(); }); it('shows archive name', () => { render( ); expect(screen.getByText('Test Print')).toBeInTheDocument(); }); it('shows add button', () => { render( ); expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument(); }); it('shows cancel button', () => { render( ); expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); }); it('shows Queue option', () => { render( ); expect(screen.getByRole('button', { name: /queue/i })).toBeInTheDocument(); }); it('shows power off option', () => { render( ); expect(screen.getByText(/power off/i)).toBeInTheDocument(); }); it('shows schedule options', () => { render( ); expect(screen.getByRole('button', { name: /asap/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /queue/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /schedule/i })).toBeInTheDocument(); }); it('orders schedule options by time', () => { render( ); const options = screen.getAllByRole('button', { name: /^(asap|queue|schedule)$/i }); expect(options.map(button => button.textContent?.trim())).toEqual(['ASAP', 'Queue', 'Schedule']); }); it('calls onClose when cancel is clicked', async () => { const user = userEvent.setup(); render( ); await user.click(screen.getByRole('button', { name: /cancel/i })); expect(mockOnClose).toHaveBeenCalled(); }); }); describe('edit-queue-item mode', () => { it('renders the modal title', () => { const item = createMockQueueItem(); render( ); expect(screen.getByText('Edit Queue Item')).toBeInTheDocument(); }); it('shows save button', () => { const item = createMockQueueItem(); render( ); expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); }); it('shows cancel button', () => { const item = createMockQueueItem(); render( ); expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); }); it('shows print options toggle', () => { const item = createMockQueueItem(); render( ); expect(screen.getByText('Print Options')).toBeInTheDocument(); }); it('shows Queue option', () => { const item = createMockQueueItem(); render( ); expect(screen.getByRole('button', { name: /queue/i })).toBeInTheDocument(); }); it('shows power off option', () => { const item = createMockQueueItem(); render( ); expect(screen.getByText(/power off/i)).toBeInTheDocument(); }); it('calls onClose when cancel button is clicked', async () => { const user = userEvent.setup(); const item = createMockQueueItem(); render( ); const cancelButton = screen.getByRole('button', { name: /cancel/i }); await user.click(cancelButton); expect(mockOnClose).toHaveBeenCalled(); }); it('shows printer selector for single selection', async () => { const item = createMockQueueItem(); render( ); // PrinterSelector shows printer names directly await waitFor(() => { expect(screen.getByText('P1S')).toBeInTheDocument(); }); }); }); describe('multi-printer selection', () => { it('shows select all button when multiple printers available', async () => { render( ); await waitFor(() => { expect(screen.getByText('Select all')).toBeInTheDocument(); }); }); it('shows selected count when multiple printers selected', async () => { const user = userEvent.setup(); render( ); await waitFor(() => { expect(screen.getByText('Select all')).toBeInTheDocument(); }); await user.click(screen.getByText('Select all')); await waitFor(() => { expect(screen.getByText(/3 printers selected/)).toBeInTheDocument(); }); }); it('updates button text when multiple printers selected', async () => { const user = userEvent.setup(); render( ); await waitFor(() => { expect(screen.getByText('Select all')).toBeInTheDocument(); }); await user.click(screen.getByText('Select all')); await waitFor(() => { expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument(); }); }); }); describe('busy printer handling (#622)', () => { beforeEach(() => { // Set up per-printer statuses: printer 1 RUNNING, printer 2 IDLE, printer 3 FINISH server.use( http.get('/api/v1/printers/:id/status', ({ params }) => { const id = Number(params.id); if (id === 1) { return HttpResponse.json({ connected: true, state: 'RUNNING', stg_cur_name: null, ams: [], vt_tray: [], nozzles: [], }); } if (id === 2) { return HttpResponse.json({ connected: true, state: 'IDLE', stg_cur_name: null, ams: [], vt_tray: [], nozzles: [], }); } // printer 3 return HttpResponse.json({ connected: true, state: 'FINISH', stg_cur_name: null, ams: [], vt_tray: [], nozzles: [], }); }) ); }); it('shows state badges on printers in create mode', async () => { render( ); await waitFor(() => { expect(screen.getByText('Printing')).toBeInTheDocument(); expect(screen.getByText('Idle')).toBeInTheDocument(); expect(screen.getByText('Finished')).toBeInTheDocument(); }); }); it('allows selecting a busy printer in create mode', async () => { const user = userEvent.setup(); render( ); await waitFor(() => { expect(screen.getByText('Printing')).toBeInTheDocument(); }); const busyButton = screen.getByText('X1 Carbon').closest('button'); expect(busyButton).not.toBeDisabled(); await user.click(busyButton!); await waitFor(() => { expect(screen.getByText('1 printer selected')).toBeInTheDocument(); }); }); it('select all includes busy printers in create mode', async () => { const user = userEvent.setup(); render( ); await waitFor(() => { expect(screen.getByText('Select all')).toBeInTheDocument(); expect(screen.getByText('Printing')).toBeInTheDocument(); }); await user.click(screen.getByText('Select all')); await waitFor(() => { expect(screen.getByText(/3 printers selected/)).toBeInTheDocument(); }); }); it('allows selecting busy printers in create mode', async () => { const user = userEvent.setup(); render( ); await waitFor(() => { expect(screen.getByText('Printing')).toBeInTheDocument(); }); // The busy printer button should NOT be disabled in queue mode const busyButton = screen.getByText('X1 Carbon').closest('button'); expect(busyButton).not.toBeDisabled(); await user.click(busyButton!); await waitFor(() => { expect(screen.getByText('1 printer selected')).toBeInTheDocument(); }); }); it('shows Offline badge for disconnected printers', async () => { server.use( http.get('/api/v1/printers/:id/status', () => { return HttpResponse.json({ connected: false, state: null, stg_cur_name: null, ams: [], vt_tray: [], nozzles: [], }); }) ); render( ); await waitFor(() => { const offlineBadges = screen.getAllByText('Offline'); expect(offlineBadges.length).toBeGreaterThanOrEqual(1); }); }); it('shows calibration stage name when printer is calibrating', async () => { server.use( http.get('/api/v1/printers/:id/status', () => { return HttpResponse.json({ connected: true, state: 'RUNNING', stg_cur_name: 'Auto bed leveling', ams: [], vt_tray: [], nozzles: [], }); }) ); render( ); await waitFor(() => { const badges = screen.getAllByText('Auto bed leveling'); expect(badges.length).toBeGreaterThanOrEqual(1); }); }); }); describe('stagger start', () => { it('does not show stagger option with single printer in queue mode', async () => { const user = userEvent.setup(); render( ); await waitFor(() => { expect(screen.getByText('X1 Carbon')).toBeInTheDocument(); }); // Select single printer await user.click(screen.getByText('X1 Carbon')); expect(screen.queryByText('Stagger printer starts')).not.toBeInTheDocument(); }); it('shows stagger option when multiple printers selected in queue mode', async () => { const user = userEvent.setup(); render( ); await waitFor(() => { expect(screen.getByText('Select all')).toBeInTheDocument(); }); await user.click(screen.getByText('Select all')); await waitFor(() => { expect(screen.getByText('Stagger printer starts')).toBeInTheDocument(); }); }); it('shows stagger option in create mode with multiple printers', async () => { const user = userEvent.setup(); render( ); await waitFor(() => { expect(screen.getByText('Select all')).toBeInTheDocument(); }); await user.click(screen.getByText('Select all')); await waitFor(() => { expect(screen.getByText(/2 printers selected|3 printers selected/)).toBeInTheDocument(); }); expect(screen.getByText('Stagger printer starts')).toBeInTheDocument(); }); it('shows stagger preview in create mode when enabled', async () => { const user = userEvent.setup(); render( ); await waitFor(() => { expect(screen.getByText('Select all')).toBeInTheDocument(); }); await user.click(screen.getByText('Select all')); await waitFor(() => { expect(screen.getByText('Stagger printer starts')).toBeInTheDocument(); }); await user.click(screen.getByLabelText('Stagger printer starts')); await waitFor(() => { // Default: 3 printers, group size 2 = 2 groups — preview text shown expect(screen.getByText(/3 printers.*2 groups/)).toBeInTheDocument(); }); }); it('does not show stagger option in create mode with single printer', async () => { const user = userEvent.setup(); render( ); await waitFor(() => { expect(screen.getByText('X1 Carbon')).toBeInTheDocument(); }); // Select only one printer await user.click(screen.getByText('X1 Carbon')); expect(screen.queryByText('Stagger printer starts')).not.toBeInTheDocument(); }); it('shows stagger inputs when stagger checkbox is enabled', async () => { const user = userEvent.setup(); render( ); await waitFor(() => { expect(screen.getByText('Select all')).toBeInTheDocument(); }); await user.click(screen.getByText('Select all')); await waitFor(() => { expect(screen.getByText('Stagger printer starts')).toBeInTheDocument(); }); await user.click(screen.getByLabelText('Stagger printer starts')); await waitFor(() => { expect(screen.getByText('Group size')).toBeInTheDocument(); expect(screen.getByText('Interval (min)')).toBeInTheDocument(); }); }); it('shows stagger preview with printer count', async () => { const user = userEvent.setup(); render( ); await waitFor(() => { expect(screen.getByText('Select all')).toBeInTheDocument(); }); await user.click(screen.getByText('Select all')); await waitFor(() => { expect(screen.getByText('Stagger printer starts')).toBeInTheDocument(); }); await user.click(screen.getByLabelText('Stagger printer starts')); await waitFor(() => { // Default: 3 printers, group size 2 = 2 groups — preview text includes printer count expect(screen.getByText(/3 printers.*2 groups/)).toBeInTheDocument(); }); }); }); describe('multi-plate selection', () => { const multiPlateResponse = { is_multi_plate: true, plates: [ { index: 1, name: 'Plate 1', has_thumbnail: false, thumbnail_url: null, objects: ['Part A'], filaments: [{ type: 'PLA', color: '#FF0000' }], print_time_seconds: 1800, filament_used_grams: 50 }, { index: 2, name: 'Plate 2', has_thumbnail: false, thumbnail_url: null, objects: ['Part B'], filaments: [{ type: 'PLA', color: '#00FF00' }], print_time_seconds: 2400, filament_used_grams: 60 }, { index: 3, name: 'Plate 3', has_thumbnail: false, thumbnail_url: null, objects: ['Part C'], filaments: [{ type: 'PETG', color: '#0000FF' }], print_time_seconds: 3000, filament_used_grams: 70 }, ], }; beforeEach(() => { server.use( http.get('/api/v1/archives/:id/plates', () => { return HttpResponse.json(multiPlateResponse); }), ); }); it('shows "Select All" button only in create mode', async () => { render( ); await waitFor(() => { expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument(); }); }); it('shows "Select All" button in create mode', async () => { render( ); await waitFor(() => { expect(screen.getByText('Plate 1')).toBeInTheDocument(); }); expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument(); }); it('selects all plates when "Select All" is clicked', async () => { const user = userEvent.setup(); render( ); await waitFor(() => { expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument(); }); await user.click(screen.getByText('Select All 3 Plates')); // All plates should be highlighted (green border) await waitFor(() => { const plateButtons = document.querySelectorAll('button[type="button"].border-bambu-green'); // 3 plate buttons + the "Deselect All" toggle button = 4 green-bordered buttons expect(plateButtons.length).toBeGreaterThanOrEqual(3); }); }); it('allows selecting a subset of plates to queue', async () => { const queueRequests: unknown[] = []; server.use( http.post('/api/v1/queue/', async ({ request }) => { const body = await request.json(); queueRequests.push(body); return HttpResponse.json({ id: queueRequests.length, status: 'pending' }); }), ); const user = userEvent.setup(); render( ); // Wait for plates and select a printer await waitFor(() => { expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument(); expect(screen.getByText('X1 Carbon')).toBeInTheDocument(); }); // Select printer await user.click(screen.getByText('X1 Carbon')); // Plate 1 is auto-selected. Click Plate 3 to add it (multi-select in create mode) await user.click(screen.getByText('Plate 3')); // Submit — should queue plates 1 and 3 const submitButton = document.querySelector('button[type="submit"]') as HTMLElement; await user.click(submitButton); await waitFor(() => { expect(queueRequests.length).toBe(2); }); expect((queueRequests[0] as { plate_id: number }).plate_id).toBe(1); expect((queueRequests[1] as { plate_id: number }).plate_id).toBe(3); }); it('creates one queue item per plate when submitting with select-all', async () => { const queueRequests: unknown[] = []; server.use( http.post('/api/v1/queue/', async ({ request }) => { const body = await request.json(); queueRequests.push(body); return HttpResponse.json({ id: queueRequests.length, status: 'pending' }); }), ); const user = userEvent.setup(); render( ); // Wait for plates and select a printer await waitFor(() => { expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument(); expect(screen.getByText('X1 Carbon')).toBeInTheDocument(); }); // Select printer await user.click(screen.getByText('X1 Carbon')); // Click select all await user.click(screen.getByText('Select All 3 Plates')); // Find the submit button (type="submit") — distinct from the toggle button (type="button") const submitButton = document.querySelector('button[type="submit"]') as HTMLElement; await user.click(submitButton); await waitFor(() => { expect(queueRequests.length).toBe(3); }); // Verify each request has the correct plate_id expect((queueRequests[0] as { plate_id: number }).plate_id).toBe(1); expect((queueRequests[1] as { plate_id: number }).plate_id).toBe(2); expect((queueRequests[2] as { plate_id: number }).plate_id).toBe(3); }); }); describe('batch quantity', () => { it('shows quantity input in create mode', () => { render( ); expect(screen.getByLabelText('Quantity')).toBeInTheDocument(); }); it('shows quantity input in create mode', () => { render( ); expect(screen.getByLabelText('Quantity')).toBeInTheDocument(); }); it('does not show quantity input in edit-queue-item mode', () => { render( ); expect(screen.queryByLabelText('Quantity')).not.toBeInTheDocument(); }); it('defaults quantity to 1', () => { render( ); const input = screen.getByLabelText('Quantity') as HTMLInputElement; expect(input.value).toBe('1'); }); it('quantity input has default value of 1 and accepts changes', async () => { const user = userEvent.setup(); render( ); const input = screen.getByLabelText('Quantity') as HTMLInputElement; expect(input.value).toBe('1'); await user.tripleClick(input); await user.keyboard('5'); expect(input.value).toBe('5'); }); }); describe('reprint G-code injection dispatch (#422 / auto-eject)', () => { // Guards the fix: when "Inject auto-print G-code" is ticked on a create with // quantity > 1, ALL copies must go through the queue so every one is injected by // the scheduler. The first copy must NOT be dispatched immediately via the direct // create path — that path bypasses injection and would leave the first copy stuck // on the plate for auto-eject setups. const withSnippets = () => http.get('/api/v1/settings/', () => HttpResponse.json({ gcode_snippets: { X1C: { start_gcode: '', end_gcode: 'M400' } } }), ); it('injection ON queues all copies and dispatches none immediately', async () => { const queueCalls: Record[] = []; server.use( withSnippets(), http.post('/api/v1/queue/', async ({ request }) => { queueCalls.push((await request.json()) as Record); return HttpResponse.json({ id: queueCalls.length, status: 'pending' }); }), ); const user = userEvent.setup(); render( ); // Quantity > 1 so the injection checkbox is offered const qty = (await screen.findByLabelText('Quantity')) as HTMLInputElement; await user.tripleClick(qty); await user.keyboard('3'); expect(qty.value).toBe('3'); // Checkbox only renders once snippets are loaded AND quantity > 1 await user.click(await screen.findByLabelText(/inject auto-print/i)); await user.click(document.querySelector('button[type="submit"]') as HTMLElement); await waitFor(() => expect(queueCalls.length).toBe(1)); // One queue item carrying all copies, and zero immediate reprint dispatches expect(queueCalls[0].quantity).toBe(3); }); it('quantity 1 + snippets configured: checkbox toggles cleanly (#1852)', async () => { // The previous reset effect in PrintModal/index.tsx silently flipped // gcodeInjection back to false whenever effectiveQuantity <= 1 in create // mode, so a single print with snippets configured couldn't tick the // checkbox — every click visibly un-ticked itself within the next render. // The scheduler reads item.gcode_injection per queue item regardless of // batch size, so quantity 1 must be allowed too. const queueCalls: Record[] = []; server.use( withSnippets(), http.post('/api/v1/queue/', async ({ request }) => { queueCalls.push((await request.json()) as Record); return HttpResponse.json({ id: queueCalls.length, status: 'pending' }); }), ); const user = userEvent.setup(); render( ); // Quantity stays at default (1) — this is the OP's exact scenario. const checkbox = (await screen.findByLabelText(/inject auto-print/i)) as HTMLInputElement; expect(checkbox.checked).toBe(false); await user.click(checkbox); // CRITICAL: after the click the checkbox must stay checked. Under the // pre-fix reset effect, the parent state would flip back to false within // a render and the displayed `checked` attribute would track it. await waitFor(() => expect(checkbox.checked).toBe(true)); await user.click(document.querySelector('button[type="submit"]') as HTMLElement); await waitFor(() => expect(queueCalls.length).toBe(1)); // The injection flag actually reaches the API — pre-fix the parent reset // would have stripped it before submit. expect(queueCalls[0].gcode_injection).toBe(true); }); it('injection OFF queues all copies through the scheduler path', async () => { const queueCalls: Record[] = []; server.use( withSnippets(), http.post('/api/v1/queue/', async ({ request }) => { queueCalls.push((await request.json()) as Record); return HttpResponse.json({ id: queueCalls.length, status: 'pending' }); }), ); const user = userEvent.setup(); render( ); const qty = (await screen.findByLabelText('Quantity')) as HTMLInputElement; await user.tripleClick(qty); await user.keyboard('3'); expect(qty.value).toBe('3'); // Leave injection unticked: unified dispatch still queues all copies. await user.click(document.querySelector('button[type="submit"]') as HTMLElement); await waitFor(() => expect(queueCalls.length).toBe(1)); expect(queueCalls[0].quantity).toBe(3); }); }); describe('project_id forwarding', () => { beforeEach(() => { // Additional handlers needed for library file mode server.use( http.get('/api/v1/library/files/:id', () => { return HttpResponse.json({ id: 5, filename: 'benchy.gcode.3mf', print_name: null, file_type: '3mf', folder_id: null, project_id: null, file_hash: null, file_size_bytes: 1024, thumbnail_path: null, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', }); }), http.get('/api/v1/library/files/:id/plates', () => { return HttpResponse.json({ is_multi_plate: false, plates: [] }); }), http.get('/api/v1/library/files/:id/filament-requirements', () => { return HttpResponse.json({ file_id: 5, filename: 'benchy.gcode.3mf', filaments: [] }); }), http.get('/api/v1/printers/:id/status', () => { return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] }); }), ); }); it('includes project_id in queue item when printing a library file with projectId set', async () => { let capturedBody: Record | null = null; server.use( http.post('/api/v1/queue/', async ({ request }) => { capturedBody = await request.json() as Record; return HttpResponse.json({ id: 1, status: 'pending' }); }) ); const user = userEvent.setup(); render( ); // Wait for the modal to load printer and file data await waitFor(() => { expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument(); }); await user.click(screen.getByRole('button', { name: /^print$/i })); await waitFor(() => { expect(capturedBody).not.toBeNull(); expect(capturedBody?.library_file_id).toBe(5); expect(capturedBody?.project_id).toBe(42); }); }); it('queues archive prints through the scheduler path', async () => { let capturedBody: Record | null = null; server.use( http.post('/api/v1/queue/', async ({ request }) => { capturedBody = await request.json() as Record; return HttpResponse.json({ id: 1, status: 'pending' }); }) ); const user = userEvent.setup(); render( ); await waitFor(() => { expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument(); }); await user.click(screen.getByRole('button', { name: /^print$/i })); await waitFor(() => { expect(capturedBody).not.toBeNull(); expect(capturedBody?.archive_id).toBe(1); expect(capturedBody?.project_id).toBe(42); }); }); it('adds ASAP prints to the top of the queue', async () => { let capturedBody: Record | null = null; server.use( http.post('/api/v1/queue/', async ({ request }) => { capturedBody = await request.json() as Record; return HttpResponse.json({ id: 1, status: 'pending' }); }) ); const user = userEvent.setup(); render( ); await waitFor(() => { expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument(); }); await user.click(screen.getByRole('button', { name: /^print$/i })); await waitFor(() => { expect(capturedBody).not.toBeNull(); expect(capturedBody?.insert_at_top).toBe(true); expect(capturedBody?.insert_position).toBe(1); expect(capturedBody?.manual_start).toBe(false); expect(capturedBody?.scheduled_time).toBeUndefined(); }); }); it('adds Queue prints to the back unless manual start is required', async () => { let capturedBody: Record | null = null; server.use( http.post('/api/v1/queue/', async ({ request }) => { capturedBody = await request.json() as Record; return HttpResponse.json({ id: 1, status: 'pending' }); }) ); const user = userEvent.setup(); render( ); await user.click(screen.getByRole('button', { name: /^queue$/i })); expect(screen.getByLabelText(/require manual start/i)).toBeInTheDocument(); await user.click(screen.getByLabelText(/require manual start/i)); await user.click(screen.getByRole('button', { name: /^print$/i })); await waitFor(() => { expect(capturedBody).not.toBeNull(); expect(capturedBody?.insert_at_top).toBeUndefined(); expect(capturedBody?.insert_position).toBeUndefined(); expect(capturedBody?.manual_start).toBe(true); }); }); }); describe('cleanup_library_after_dispatch forwarding (#730)', () => { // The Printers-page Direct-Print flow passes cleanupLibraryAfterDispatch so the // transient LibraryFile created by FileUploadModal is deleted once the archive // owns its own copy. File Manager / Project Detail flows leave the prop unset so // their deliberately-added library entries survive the print. beforeEach(() => { server.use( http.get('/api/v1/library/files/:id', () => { return HttpResponse.json({ id: 5, filename: 'benchy.gcode.3mf', file_type: '3mf', folder_id: null, project_id: null, file_hash: null, file_size_bytes: 1024, thumbnail_path: null, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', }); }), http.get('/api/v1/library/files/:id/plates', () => { return HttpResponse.json({ is_multi_plate: false, plates: [] }); }), http.get('/api/v1/library/files/:id/filament-requirements', () => { return HttpResponse.json({ file_id: 5, filename: 'benchy.gcode.3mf', filaments: [] }); }), http.get('/api/v1/printers/:id/status', () => { return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] }); }), ); }); it('forwards cleanup_library_after_dispatch=true when the Direct-Print prop is set', async () => { let capturedBody: Record | null = null; server.use( http.post('/api/v1/queue/', async ({ request }) => { capturedBody = (await request.json()) as Record; return HttpResponse.json({ id: 1, status: 'pending' }); }) ); const user = userEvent.setup(); render( ); await waitFor(() => { expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument(); }); await user.click(screen.getByRole('button', { name: /^print$/i })); await waitFor(() => { expect(capturedBody).not.toBeNull(); expect(capturedBody?.cleanup_library_after_dispatch).toBe(true); }); }); it('defaults to omitting cleanup_library_after_dispatch (File Manager / Project flows survive)', async () => { let capturedBody: Record | null = null; server.use( http.post('/api/v1/queue/', async ({ request }) => { capturedBody = (await request.json()) as Record; return HttpResponse.json({ id: 1, status: 'pending' }); }) ); const user = userEvent.setup(); render( ); await waitFor(() => { expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument(); }); await user.click(screen.getByRole('button', { name: /^print$/i })); await waitFor(() => { expect(capturedBody).not.toBeNull(); }); // Either omitted entirely or explicitly undefined — both interpret as "keep file" expect(capturedBody?.cleanup_library_after_dispatch).toBeUndefined(); }); }); }); describe('PrintModal — per-plate filament mapping (#2551 follow-up)', () => { const mockOnClose = vi.fn(); // These tests deliberately do NOT use the shared `render` from '../utils': its // QueryClient sets `gcTime: 0`, which evicts a query the instant it loses its // observer. The whole-file filament requirements are fetched on open and then // orphaned when a plate is auto-selected, so under gcTime:0 they vanish and the // modal simply sends no mapping — the bug cannot reproduce. A real browser keeps // that entry for 5 minutes and hands the union straight back when the user picks // a second plate, which is what got mapped onto every plate. Same providers, // production cache behaviour. const render = (ui: React.ReactElement) => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); return rtlRender( {ui} , ); }; // Two plates, each printing one red object, but on different slots of the same // file. The printer has exactly one red spool (tray 0) and one black (tray 1). // Matching the union of both plates makes slot 1 claim the red tray and drops // slot 2 onto the black one — so plate 2 would print in the wrong colour. const PLATES = { is_multi_plate: true, plates: [ { index: 1, name: 'Plate 1', has_thumbnail: false, thumbnail_url: null, objects: ['A'], filaments: [{ type: 'PLA', color: '#FF0000' }], print_time_seconds: 1800, filament_used_grams: 50 }, { index: 2, name: 'Plate 2', has_thumbnail: false, thumbnail_url: null, objects: ['B'], filaments: [{ type: 'PLA', color: '#FF0000' }], print_time_seconds: 1800, filament_used_grams: 50 }, ], }; const SLOT_1_RED = { slot_id: 1, type: 'PLA', color: '#FF0000', tray_info_idx: '', used_grams: 50 }; const SLOT_2_RED = { slot_id: 2, type: 'PLA', color: '#FF0000', tray_info_idx: '', used_grams: 50 }; beforeEach(() => { vi.clearAllMocks(); server.use( http.get('/api/v1/printers/', () => HttpResponse.json(mockPrinters)), http.get('/api/v1/archives/:id/plates', () => HttpResponse.json(PLATES)), // The endpoint is plate-aware: no plate_id yields the whole-file union. http.get('/api/v1/archives/:id/filament-requirements', ({ request }) => { const plateId = new URL(request.url).searchParams.get('plate_id'); if (plateId === '1') return HttpResponse.json({ filaments: [SLOT_1_RED] }); if (plateId === '2') return HttpResponse.json({ filaments: [SLOT_2_RED] }); return HttpResponse.json({ filaments: [SLOT_1_RED, SLOT_2_RED] }); }), http.get('/api/v1/printers/:id/status', () => HttpResponse.json({ connected: true, state: 'IDLE', ams: [{ id: 0, tray: [ { id: 0, tray_type: 'PLA', tray_color: 'FF0000' }, { id: 1, tray_type: 'PLA', tray_color: '000000' }, ] }], vt_tray: [], }), ), http.get('/api/v1/printers/:id/assignments', () => HttpResponse.json([])), http.post('/api/v1/queue/', () => HttpResponse.json({ id: 1, status: 'pending' })), ); }); const selectBothPlates = async (user: ReturnType) => { await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument()); await user.click(screen.getByText('X1 Carbon')); await waitFor(() => expect(screen.getByText('Plate 2')).toBeInTheDocument()); await user.click(screen.getByText('Plate 2')); // Plate 1 is auto-selected }; it('shows one mapping panel per selected plate, named after the plate', async () => { const user = userEvent.setup(); render(); await selectBothPlates(user); await waitFor(() => { expect(screen.getByText(/Filament Mapping — Plate 1/)).toBeInTheDocument(); expect(screen.getByText(/Filament Mapping — Plate 2/)).toBeInTheDocument(); }); }); it('sends each plate the mapping for its own slots, not the whole-file union', async () => { const queued: { plate_id: number; ams_mapping: number[] | null }[] = []; server.use( http.post('/api/v1/queue/', async ({ request }) => { queued.push((await request.json()) as { plate_id: number; ams_mapping: number[] | null }); return HttpResponse.json({ id: queued.length, status: 'pending' }); }), ); const user = userEvent.setup(); render(); await selectBothPlates(user); await waitFor(() => expect(screen.getByText(/Filament Mapping — Plate 2/)).toBeInTheDocument()); await user.click(document.querySelector('button[type="submit"]') as HTMLElement); await waitFor(() => expect(queued.length).toBe(2)); const plate1 = queued.find((q) => q.plate_id === 1); const plate2 = queued.find((q) => q.plate_id === 2); // Plate 1 prints slot 1 from the red tray. expect(plate1?.ams_mapping).toEqual([0]); // Plate 2 prints slot 2 from the SAME red tray — slot 1 is not its business. // The union mapping would have been [0, 1], sending this plate to the black tray. expect(plate2?.ams_mapping).toEqual([-1, 0]); }); it('sends no mapping when several plates fan out across several printers', async () => { // A tray id is meaningless on a different printer, so the first printer's // mapping must not be handed to the second. The scheduler maps each plate // against the printer it actually dispatches to. const queued: { printer_id: number; ams_mapping?: number[] | null }[] = []; server.use( http.post('/api/v1/queue/', async ({ request }) => { queued.push((await request.json()) as { printer_id: number; ams_mapping?: number[] | null }); return HttpResponse.json({ id: queued.length, status: 'pending' }); }), ); const user = userEvent.setup(); render(); await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument()); await user.click(screen.getByText('X1 Carbon')); await user.click(screen.getByText('P1S')); await waitFor(() => expect(screen.getByText('Plate 2')).toBeInTheDocument()); await user.click(screen.getByText('Plate 2')); await user.click(document.querySelector('button[type="submit"]') as HTMLElement); await waitFor(() => expect(queued.length).toBe(4)); // 2 plates x 2 printers queued.forEach((q) => expect(q.ams_mapping ?? null).toBeNull()); }); it('sends no mapping for a model-assigned multi-plate job, leaving it to the scheduler', async () => { const queued: { ams_mapping?: number[] | null }[] = []; server.use( http.post('/api/v1/queue/', async ({ request }) => { queued.push((await request.json()) as { ams_mapping?: number[] | null }); return HttpResponse.json({ id: queued.length, status: 'pending' }); }), ); const user = userEvent.setup(); render(); // "Any model" mode: no printer is chosen, so there is no AMS to map onto and // the scheduler computes the mapping per plate once it picks one. await waitFor(() => expect(screen.getByText('Plate 2')).toBeInTheDocument()); await user.click(screen.getByText('Plate 2')); await user.click(screen.getByRole('button', { name: /any model/i })); await waitFor(() => expect(screen.getByRole('combobox')).toBeInTheDocument()); await user.selectOptions(screen.getByRole('combobox'), 'X1C'); await user.click(document.querySelector('button[type="submit"]') as HTMLElement); await waitFor(() => expect(queued.length).toBe(2)); queued.forEach((q) => expect(q.ams_mapping ?? null).toBeNull()); }); it('drops a plate\'s manual tray overrides when the printer changes', async () => { // A manual override holds a global tray id, which names a different spool on a // different printer. Carrying plate 1's "slot 1 -> tray 1" from the X1C over to // the P1S would dispatch a tray the user never picked. const queued: { plate_id: number; ams_mapping: number[] | null }[] = []; server.use( http.post('/api/v1/queue/', async ({ request }) => { queued.push((await request.json()) as { plate_id: number; ams_mapping: number[] | null }); return HttpResponse.json({ id: queued.length, status: 'pending' }); }), ); const user = userEvent.setup(); render(); await selectBothPlates(user); await waitFor(() => expect(screen.getByText(/Filament Mapping — Plate 1/)).toBeInTheDocument()); // Force plate 1's slot 1 onto the black tray (1) instead of the auto-matched red (0). await user.click(screen.getByText(/Filament Mapping — Plate 1/)); const traySelects = await waitFor(() => { const found = screen.getAllByRole('combobox').filter((el) => Array.from((el as HTMLSelectElement).options).some((o) => o.value === '1'), ); if (found.length === 0) throw new Error('no tray select rendered'); return found; }); await user.selectOptions(traySelects[0], '1'); // Now move the job to the other printer. await user.click(screen.getByText('X1 Carbon')); // deselect await user.click(screen.getByText('P1S')); await user.click(document.querySelector('button[type="submit"]') as HTMLElement); await waitFor(() => expect(queued.length).toBe(2)); // Plate 1 auto-matches red (tray 0) on the new printer. Tray 1 is the stale pick. const plate1 = queued.find((q) => q.plate_id === 1); expect(plate1?.ams_mapping).toEqual([0]); }); it('sums what the plates draw from one spool before warning about it', async () => { // Both plates map to the same red tray. 60 g left covers either plate on its // own (40 g), but not both — the check has to add them up, not test them // one at a time. server.use( http.get('/api/v1/archives/:id/filament-requirements', ({ request }) => { const plateId = new URL(request.url).searchParams.get('plate_id'); if (plateId === '1') return HttpResponse.json({ filaments: [{ ...SLOT_1_RED, used_grams: 40 }] }); if (plateId === '2') return HttpResponse.json({ filaments: [{ ...SLOT_2_RED, used_grams: 40 }] }); return HttpResponse.json({ filaments: [SLOT_1_RED, SLOT_2_RED] }); }), http.get('/api/v1/inventory/assignments', () => HttpResponse.json([ { id: 1, spool_id: 1, printer_id: 1, printer_name: 'X1 Carbon', ams_id: 0, tray_id: 0, fingerprint_color: null, fingerprint_type: null, configured: true, spool: { id: 1, label_weight: 1000, weight_used: 940 }, }, ]), ), ); const user = userEvent.setup(); render(); await selectBothPlates(user); await waitFor(() => expect(screen.getByText(/Filament Mapping — Plate 2/)).toBeInTheDocument()); await user.click(document.querySelector('button[type="submit"]') as HTMLElement); // 80 g needed from a spool with 60 g left → the acknowledgement dialog, not a // silent queue. await waitFor(() => expect(screen.getByText(/Not enough filament/i)).toBeInTheDocument()); expect(screen.getByText(/needs 80g, remaining 60g/i)).toBeInTheDocument(); }); it('holds the Print button until every selected plate has answered', async () => { // A plate whose filaments cannot be read has no mapping and no forced colours; // queueing it anyway prints it in whatever happens to be loaded. server.use( http.get('/api/v1/archives/:id/filament-requirements', ({ request }) => { const plateId = new URL(request.url).searchParams.get('plate_id'); if (plateId === '1') return HttpResponse.json({ filaments: [SLOT_1_RED] }); if (plateId === '2') return new HttpResponse(null, { status: 500 }); return HttpResponse.json({ filaments: [SLOT_1_RED, SLOT_2_RED] }); }), ); const user = userEvent.setup(); render(); await selectBothPlates(user); await waitFor(() => expect(document.querySelector('button[type="submit"]')).toBeDisabled(), ); expect(screen.getByText(/could not be read/i)).toBeInTheDocument(); // Deselecting the unreadable plate frees the rest of the job. await user.click(screen.getByText('Plate 2')); await waitFor(() => expect(document.querySelector('button[type="submit"]')).not.toBeDisabled(), ); }); }); describe('PrintModal — per-plate filament override in model mode (#2552)', () => { const mockOnClose = vi.fn(); // The reporter's trigger — "only after the file was previously queued or printed" // — is really "only once the plates query is warm in the cache". On a cold cache // the plates data is undefined for the first render, so the whole-file filament // requirements are fetched and left behind; the override panel then rendered from // that union. On a warm cache the modal knows it is multi-plate from the first // render, the whole-file query never runs, and the panel disappeared entirely. // Both states are exercised here, and neither may depend on the cache. const renderWithCache = (ui: React.ReactElement, prewarm?: (qc: QueryClient) => void) => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); prewarm?.(queryClient); return rtlRender( {ui} , ); }; // Plate 1 prints slot 1 (red), plate 2 prints slot 2 (blue). Nothing is shared. const PLATES = { is_multi_plate: true, plates: [ { index: 1, name: 'Plate 1', has_thumbnail: false, thumbnail_url: null, objects: ['A'], filaments: [{ type: 'PLA', color: '#FF0000' }], print_time_seconds: 1800, filament_used_grams: 50 }, { index: 2, name: 'Plate 2', has_thumbnail: false, thumbnail_url: null, objects: ['B'], filaments: [{ type: 'PLA', color: '#0000FF' }], print_time_seconds: 1800, filament_used_grams: 50 }, ], }; const SLOT_1_RED = { slot_id: 1, type: 'PLA', color: '#FF0000', tray_info_idx: '', used_grams: 50 }; const SLOT_2_BLUE = { slot_id: 2, type: 'PLA', color: '#0000FF', tray_info_idx: '', used_grams: 50 }; beforeEach(() => { vi.clearAllMocks(); server.use( http.get('/api/v1/printers/', () => HttpResponse.json(mockPrinters)), http.get('/api/v1/archives/:id/plates', () => HttpResponse.json(PLATES)), http.get('/api/v1/archives/:id/filament-requirements', ({ request }) => { const plateId = new URL(request.url).searchParams.get('plate_id'); if (plateId === '1') return HttpResponse.json({ filaments: [SLOT_1_RED] }); if (plateId === '2') return HttpResponse.json({ filaments: [SLOT_2_BLUE] }); return HttpResponse.json({ filaments: [SLOT_1_RED, SLOT_2_BLUE] }); }), http.get('/api/v1/printers/available-filaments', () => HttpResponse.json([ { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: null }, { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: null }, ]), ), http.post('/api/v1/queue/', () => HttpResponse.json({ id: 1, status: 'pending' })), ); }); const selectAnyX1CAndBothPlates = async (user: ReturnType) => { await waitFor(() => expect(screen.getByText('Plate 2')).toBeInTheDocument()); await user.click(screen.getByText('Plate 2')); // Plate 1 is auto-selected await user.click(screen.getByRole('button', { name: /any model/i })); // Switching to model mode pre-selects the first model, so the override panels // are already up; the target-model select is the one offering the models. const modelSelect = await waitFor(() => { const select = screen .getAllByRole('combobox') .find((el) => (el as HTMLSelectElement).options[0]?.value === '' && (el as HTMLSelectElement).options[0]?.text === 'Select a model...'); if (!select) throw new Error('target model select not rendered'); return select as HTMLSelectElement; }); await user.selectOptions(modelSelect, 'X1C'); }; it('shows one override panel per selected plate even when the plates query is already cached', async () => { const user = userEvent.setup(); renderWithCache( , // The file has been opened before: the plates are known on the first render, // so the whole-file requirements query never runs. This is the state in which // the override section used to vanish for a multi-plate selection. (qc) => qc.setQueryData(['archive-plates', 1], PLATES), ); await selectAnyX1CAndBothPlates(user); await waitFor(() => { expect(screen.getByText(/Filament Override — Plate 1/)).toBeInTheDocument(); expect(screen.getByText(/Filament Override — Plate 2/)).toBeInTheDocument(); }); }); it('shows the same per-plate panels on a cold cache', async () => { const user = userEvent.setup(); renderWithCache(); await selectAnyX1CAndBothPlates(user); await waitFor(() => { expect(screen.getByText(/Filament Override — Plate 1/)).toBeInTheDocument(); expect(screen.getByText(/Filament Override — Plate 2/)).toBeInTheDocument(); }); // Not the whole-file union in a single unnamed panel. expect(screen.queryByText('Filament Override')).not.toBeInTheDocument(); }); it('sends each plate only the overrides for the slots it prints', async () => { type Queued = { plate_id: number; filament_overrides?: Array<{ slot_id: number; force_color_match: boolean }> | null; }; const queued: Queued[] = []; server.use( http.post('/api/v1/queue/', async ({ request }) => { queued.push((await request.json()) as Queued); return HttpResponse.json({ id: queued.length, status: 'pending' }); }), ); const user = userEvent.setup(); renderWithCache( , (qc) => qc.setQueryData(['archive-plates', 1], PLATES), ); await selectAnyX1CAndBothPlates(user); await waitFor(() => expect(screen.getByText(/Filament Override — Plate 2/)).toBeInTheDocument()); // Force the colour on plate 2's only slot. Plate 1 does not print slot 2 and // must not be held back waiting for a blue spool it never uses (#2551). const forceBoxes = screen.getAllByRole('checkbox', { name: /force color match/i }); expect(forceBoxes).toHaveLength(2); // one slot per plate await user.click(forceBoxes[1]); await user.click(document.querySelector('button[type="submit"]') as HTMLElement); await waitFor(() => expect(queued.length).toBe(2)); const plate1 = queued.find((q) => q.plate_id === 1); const plate2 = queued.find((q) => q.plate_id === 2); expect(plate1?.filament_overrides ?? null).toBeNull(); expect(plate2?.filament_overrides).toEqual([ expect.objectContaining({ slot_id: 2, force_color_match: true }), ]); }); });