Files
bambuddy/frontend/src/__tests__/components/PrintModal.test.tsx
T
maziggy 2e45893dd5 feat(print-options): add "Auto" state to bed levelling, flow & nozzle-offset calibration
Bed levelling, flow calibration, and nozzle-offset calibration were on/off
only, so the sole way to run bed levelling was to force a full level before
every print. Bambu Studio has always offered a third "Auto" state that lets
the printer skip the calibration when it was done recently -- the state most
users actually want. Make these three options tri-state (off/on/auto),
defaulting to auto, and leave vibration/layer-inspect/timelapse as on/off
(Bambu Studio exposes no auto for those).

Wire encoding follows Bambu Studio's source exactly: each option sends a JSON
bool (true only for "on") plus a companion int -- off=0, on=1, auto=2. The
bool fields stay booleans (the #1478 H2S regression); only the companion int
widened from {0,1} to {0,1,2}. #1721's observation that stage 8/39 stays
queued when sending 2 is the auto contract (queued, skipped at runtime if
recent), not a broken "off".

- schemas: TriState = Literal[off/on/auto] with a BeforeValidator coercing
  legacy bool / 0-1 / true-false so old clients and un-migrated rows validate
- model + migration: boolean columns -> String; SQLite via column affinity +
  data backfill, PostgreSQL via ALTER COLUMN TYPE guarded on information_schema
  (verified on both dialects); settings rows normalised true/false -> on/off
- MQTT: start_print takes the tri-state strings and emits the paired bool+int
- Virtual Printer: reconstructs the slicer's auto/on/off from the int companion
  (auto_bed_leveling / extrude_cali_flag) in both capture paths
- frontend: CalibrationMode type; off/auto/on segmented controls in the print
  dialog, queue bulk-edit, and Settings -> Workflow; calibrationMode_* strings
  in all 11 locales
2026-07-20 17:55:56 +02:00

1991 lines
66 KiB
TypeScript

/**
* 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> = {}): 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(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
initialSelectedPrinterIds={[1]}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
expect(screen.getByRole('heading', { name: 'Print' })).toBeInTheDocument();
});
it('shows archive name', () => {
render(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
expect(screen.getByText('Benchy')).toBeInTheDocument();
});
it('shows printer selection with checkboxes for multi-select', async () => {
render(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
await waitFor(() => {
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
expect(screen.getByText('P1S')).toBeInTheDocument();
});
});
it('has print button', () => {
render(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
// 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(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
it('calls onClose when cancel is clicked', async () => {
const user = userEvent.setup();
render(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
await user.click(screen.getByRole('button', { name: /cancel/i }));
expect(mockOnClose).toHaveBeenCalled();
});
it('print button is disabled until printer is selected', () => {
render(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
// 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(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
await waitFor(() => {
expect(screen.getByText('No active printers available')).toBeInTheDocument();
});
});
it('shows print options toggle', async () => {
render(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
initialSelectedPrinterIds={[1]}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
await waitFor(() => {
expect(screen.getByText('Print Options')).toBeInTheDocument();
});
});
});
describe('create mode', () => {
it('renders the modal title', () => {
render(
<PrintModal
mode="create"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByRole('heading', { name: 'Print' })).toBeInTheDocument();
});
it('shows archive name', () => {
render(
<PrintModal
mode="create"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByText('Test Print')).toBeInTheDocument();
});
it('shows add button', () => {
render(
<PrintModal
mode="create"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
});
it('shows cancel button', () => {
render(
<PrintModal
mode="create"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
it('shows Queue option', () => {
render(
<PrintModal
mode="create"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByRole('button', { name: /queue/i })).toBeInTheDocument();
});
it('shows power off option', () => {
render(
<PrintModal
mode="create"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByText(/power off/i)).toBeInTheDocument();
});
it('shows schedule options', () => {
render(
<PrintModal
mode="create"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
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(
<PrintModal
mode="create"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
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(
<PrintModal
mode="create"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
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(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Test Print"
queueItem={item}
onClose={mockOnClose}
/>
);
expect(screen.getByText('Edit Queue Item')).toBeInTheDocument();
});
it('shows save button', () => {
const item = createMockQueueItem();
render(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Test Print"
queueItem={item}
onClose={mockOnClose}
/>
);
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
it('shows cancel button', () => {
const item = createMockQueueItem();
render(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Test Print"
queueItem={item}
onClose={mockOnClose}
/>
);
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
it('shows print options toggle', () => {
const item = createMockQueueItem();
render(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Test Print"
queueItem={item}
onClose={mockOnClose}
/>
);
expect(screen.getByText('Print Options')).toBeInTheDocument();
});
it('shows Queue option', () => {
const item = createMockQueueItem();
render(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Test Print"
queueItem={item}
onClose={mockOnClose}
/>
);
expect(screen.getByRole('button', { name: /queue/i })).toBeInTheDocument();
});
it('shows power off option', () => {
const item = createMockQueueItem();
render(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Test Print"
queueItem={item}
onClose={mockOnClose}
/>
);
expect(screen.getByText(/power off/i)).toBeInTheDocument();
});
it('calls onClose when cancel button is clicked', async () => {
const user = userEvent.setup();
const item = createMockQueueItem();
render(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Test Print"
queueItem={item}
onClose={mockOnClose}
/>
);
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(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Test Print"
queueItem={item}
onClose={mockOnClose}
/>
);
// 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(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
/>
);
await waitFor(() => {
expect(screen.getByText('Select all')).toBeInTheDocument();
});
});
it('shows selected count when multiple printers selected', async () => {
const user = userEvent.setup();
render(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
/>
);
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(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
/>
);
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(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
/>
);
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(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
/>
);
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(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
/>
);
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(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
/>
);
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(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
/>
);
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(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
/>
);
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(
<PrintModal
mode="create"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
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(
<PrintModal
mode="create"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
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(
<PrintModal
mode="create"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
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(
<PrintModal
mode="create"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
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(
<PrintModal
mode="create"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
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(
<PrintModal
mode="create"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
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(
<PrintModal
mode="create"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
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(
<PrintModal
mode="create"
archiveId={1}
archiveName="MultiPlate.3mf"
onClose={mockOnClose}
/>
);
await waitFor(() => {
expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();
});
});
it('shows "Select All" button in create mode', async () => {
render(
<PrintModal
mode="create"
archiveId={1}
archiveName="MultiPlate.3mf"
initialSelectedPrinterIds={[1]}
onClose={mockOnClose}
/>
);
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(
<PrintModal
mode="create"
archiveId={1}
archiveName="MultiPlate.3mf"
onClose={mockOnClose}
/>
);
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(
<PrintModal
mode="create"
archiveId={1}
archiveName="MultiPlate.3mf"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
// 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(
<PrintModal
mode="create"
archiveId={1}
archiveName="MultiPlate.3mf"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
// 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(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
expect(screen.getByLabelText('Quantity')).toBeInTheDocument();
});
it('shows quantity input in create mode', () => {
render(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
expect(screen.getByLabelText('Quantity')).toBeInTheDocument();
});
it('does not show quantity input in edit-queue-item mode', () => {
render(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Benchy"
queueItem={createMockQueueItem()}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
expect(screen.queryByLabelText('Quantity')).not.toBeInTheDocument();
});
it('defaults quantity to 1', () => {
render(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
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(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
initialSelectedPrinterIds={[1]}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
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<string, unknown>[] = [];
server.use(
withSnippets(),
http.post('/api/v1/queue/', async ({ request }) => {
queueCalls.push((await request.json()) as Record<string, unknown>);
return HttpResponse.json({ id: queueCalls.length, status: 'pending' });
}),
);
const user = userEvent.setup();
render(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
initialSelectedPrinterIds={[1]}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
// 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<string, unknown>[] = [];
server.use(
withSnippets(),
http.post('/api/v1/queue/', async ({ request }) => {
queueCalls.push((await request.json()) as Record<string, unknown>);
return HttpResponse.json({ id: queueCalls.length, status: 'pending' });
}),
);
const user = userEvent.setup();
render(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
initialSelectedPrinterIds={[1]}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
// 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<string, unknown>[] = [];
server.use(
withSnippets(),
http.post('/api/v1/queue/', async ({ request }) => {
queueCalls.push((await request.json()) as Record<string, unknown>);
return HttpResponse.json({ id: queueCalls.length, status: 'pending' });
}),
);
const user = userEvent.setup();
render(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
initialSelectedPrinterIds={[1]}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
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<string, unknown> | null = null;
server.use(
http.post('/api/v1/queue/', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ id: 1, status: 'pending' });
})
);
const user = userEvent.setup();
render(
<PrintModal
mode="create"
libraryFileId={5}
archiveName="Benchy"
projectId={42}
initialSelectedPrinterIds={[1]}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
// 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<string, unknown> | null = null;
server.use(
http.post('/api/v1/queue/', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ id: 1, status: 'pending' });
})
);
const user = userEvent.setup();
render(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
projectId={42}
initialSelectedPrinterIds={[1]}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
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<string, unknown> | null = null;
server.use(
http.post('/api/v1/queue/', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ id: 1, status: 'pending' });
})
);
const user = userEvent.setup();
render(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
initialSelectedPrinterIds={[1]}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
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<string, unknown> | null = null;
server.use(
http.post('/api/v1/queue/', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ id: 1, status: 'pending' });
})
);
const user = userEvent.setup();
render(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
initialSelectedPrinterIds={[1]}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
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<string, unknown> | null = null;
server.use(
http.post('/api/v1/queue/', async ({ request }) => {
capturedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ id: 1, status: 'pending' });
})
);
const user = userEvent.setup();
render(
<PrintModal
mode="create"
libraryFileId={5}
archiveName="Benchy"
cleanupLibraryAfterDispatch
initialSelectedPrinterIds={[1]}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
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<string, unknown> | null = null;
server.use(
http.post('/api/v1/queue/', async ({ request }) => {
capturedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ id: 1, status: 'pending' });
})
);
const user = userEvent.setup();
render(
<PrintModal
mode="create"
libraryFileId={5}
archiveName="Benchy"
initialSelectedPrinterIds={[1]}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
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(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<ThemeProvider>
<ToastProvider>{ui}</ToastProvider>
</ThemeProvider>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>,
);
};
// 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<typeof userEvent.setup>) => {
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(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
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(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
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(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
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(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
// "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(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
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(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
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(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
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(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<ThemeProvider>
<ToastProvider>{ui}</ToastProvider>
</ThemeProvider>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>,
);
};
// 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<typeof userEvent.setup>) => {
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(
<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />,
// 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(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
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(
<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />,
(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 }),
]);
});
});