| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990 |
- /**
- * 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 }),
- ]);
- });
- });
|