diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py index 001663bcb..2c606f569 100644 --- a/backend/app/api/routes/print_queue.py +++ b/backend/app/api/routes/print_queue.py @@ -971,6 +971,15 @@ async def add_to_queue( batch_name_base = library_file.file_metadata.get("print_name") or library_file.filename else: batch_name_base = library_file.filename + elif variant_specs: + # A cross-model job carries neither archive_id nor library_file_id -- + # the candidates are the files (#671) -- so both branches above miss + # and every such batch was named "Batch". Unreachable until the print + # dialog could ask for more than one copy of one (#3101). Name it + # after the first candidate, which is what the dialog names the job + # after and what the resolver prefers when both printers are free. + first_file = variant_specs[0][1] + batch_name_base = (first_file.file_metadata or {}).get("print_name") or first_file.filename or "Batch" batch_name_base = batch_name_base.replace(".gcode.3mf", "").replace(".3mf", "") batch = PrintBatch( diff --git a/backend/tests/integration/test_queue_variants_api.py b/backend/tests/integration/test_queue_variants_api.py index 706665f02..b870521df 100644 --- a/backend/tests/integration/test_queue_variants_api.py +++ b/backend/tests/integration/test_queue_variants_api.py @@ -295,3 +295,28 @@ class TestQueueWithVariants: assert len(item_ids) == 3 total = (await db_session.execute(select(PrintQueueVariant))).scalars().all() assert len(total) == 6 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_quantity_batch_is_named_after_the_first_candidate( + self, async_client, db_session, sliced_file_factory, printer_factory + ): + """A cross-model job has no archive_id and no library_file_id -- the + candidates are the files -- so the batch name has to come from one of + them or every such order reads "Batch" in the Batches tab (#3101).""" + from backend.app.models.print_batch import PrintBatch + + await printer_factory(model="H2S") + await printer_factory(model="H2C") + h2s = await sliced_file_factory("H2S", filename="bloom.gcode.3mf") + h2c = await sliced_file_factory("H2C") + + r = await _queue_variants(async_client, h2s.id, h2c.id, quantity=4) + assert r.status_code == 200 + + batch = (await db_session.execute(select(PrintBatch))).scalars().one() + assert batch.name == "bloom ×4" + # Both stay null: the row cannot name one source without disowning the + # others, and every consumer derives progress from the items instead. + assert batch.archive_id is None + assert batch.library_file_id is None diff --git a/frontend/src/__tests__/components/PrintModalCrossModel.test.tsx b/frontend/src/__tests__/components/PrintModalCrossModel.test.tsx index 0ea6d5bc7..00ae8e62d 100644 --- a/frontend/src/__tests__/components/PrintModalCrossModel.test.tsx +++ b/frontend/src/__tests__/components/PrintModalCrossModel.test.tsx @@ -9,7 +9,8 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { screen, waitFor } from '@testing-library/react'; +import { screen, waitFor, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { render } from '../utils'; import { server } from '../mocks/server'; @@ -141,3 +142,91 @@ describe('PrintModal cross-model mode', () => { expect(screen.queryByText('Filament Mapping')).not.toBeInTheDocument(); }); }); + +/** + * Cross-model on a multi-plate file (#3101). + * + * The two features met badly. A multi-plate file moves quantity onto the + * per-plate steppers and hides the global field (#342), but the cross-model + * submit posts the global one — which in that configuration nothing can + * change. The reporter asked for 19 runs across an X1C and a P2S, watched the + * modal say "19 runs in total", and got exactly one print. + */ +describe('PrintModal cross-model mode on a multi-plate file', () => { + const PLATES = [1, 2, 3].map((i) => ({ + index: i, + name: `Plate ${i}`, + objects: ['bracket'], + filaments: [{ slot_id: 1, type: 'PETG', color: '#FFFFFF' }], + has_thumbnail: false, + thumbnail_url: null, + bed_type: null, + print_time_seconds: 3600, + })); + + let posted: Record | null; + let reqUrls: string[]; + + beforeEach(() => { + posted = null; + reqUrls = []; + mockBackend(); + server.use( + http.get('/api/v1/library/files/:id/plates', ({ params }) => + HttpResponse.json({ file_id: Number(params.id), filename: 'x', plates: PLATES, is_multi_plate: true }), + ), + http.get('/api/v1/library/files/:id/filament-requirements', ({ request }) => { + reqUrls.push(request.url); + return HttpResponse.json({ + filaments: [{ slot_id: 1, type: 'PETG', color: '#FFFFFF', used_grams: 15, used_meters: 5 }], + }); + }), + http.post('/api/v1/queue/', async ({ request }) => { + posted = (await request.json()) as Record; + return HttpResponse.json({ id: 1, status: 'pending', variants: [] }); + }), + ); + }); + + it('offers the global Quantity field, not the per-plate steppers', async () => { + renderCrossModel(); + await screen.findByText('x1c.gcode.3mf'); + + // The plate selector is gone: its choice never reached the request, and the + // candidate list below is where a cross-model job picks its plates. + expect(screen.queryByRole('button', { name: /Select All/i })).toBeNull(); + expect(await screen.findByLabelText('Quantity')).toBeInTheDocument(); + }); + + it('queues the number of copies the user asked for', async () => { + const user = userEvent.setup(); + renderCrossModel(); + await screen.findByText('x1c.gcode.3mf'); + + fireEvent.change(await screen.findByLabelText('Quantity'), { target: { value: '19' } }); + await user.click(screen.getByRole('button', { name: /^Print$/i })); + + await waitFor(() => expect(posted).not.toBeNull()); + expect(posted!.quantity).toBe(19); + // One job with both candidates, nineteen times over — not nineteen jobs + // each pinned to a model, and not one job. + expect((posted!.variants as Array<{ library_file_id: number }>).map((v) => v.library_file_id)) + .toEqual([11, 12]); + }); + + it('reads filament requirements for the plate the primary candidate will run', async () => { + const user = userEvent.setup(); + renderCrossModel(); + await screen.findByText('x1c.gcode.3mf'); + await waitFor(() => expect(reqUrls.some((u) => u.includes('plate_id=1'))).toBe(true)); + + await user.selectOptions( + screen.getByLabelText('Plate for x1c.gcode.3mf'), + '3', + ); + + // Without this the override panel would describe plate 1 while the job ran + // plate 3 — the plate selector that used to key it is no longer on screen. + await waitFor(() => expect(reqUrls.some((u) => u.includes('plate_id=3'))).toBe(true)); + }); +}); diff --git a/frontend/src/components/PrintModal/index.tsx b/frontend/src/components/PrintModal/index.tsx index ba65f551d..b1e7f7522 100644 --- a/frontend/src/components/PrintModal/index.tsx +++ b/frontend/src/components/PrintModal/index.tsx @@ -673,6 +673,18 @@ export function PrintModal({ } }, [platesData, selectedPlates.size]); + // Cross-model: the candidate list owns plate choice, and `platesData` is the + // primary file's. `selectedPlate` still keys the filament-requirements query, + // so it has to follow that file's dropdown — otherwise the override panel + // describes plate 1 while the job runs plate 3 (#3101). An untouched dropdown + // renders its first plate, which is what the auto-select above already set. + useEffect(() => { + if (!isCrossModel || !libraryFileId) return; + const chosen = candidatePlates[libraryFileId]; + if (chosen == null) return; + setSelectedPlates((prev) => (prev.size === 1 && prev.has(chosen) ? prev : new Set([chosen]))); + }, [isCrossModel, libraryFileId, candidatePlates]); + // Auto-select first printer when only one available useEffect(() => { // Skip auto-select for edit mode (already initialized from queueItem) @@ -1414,7 +1426,11 @@ export function PrintModal({ // global field is hidden (#342) — the reporter's case is "plate 1 once, // plate 2 twice", which one shared number cannot express. Single-plate // files, and edit mode, keep the single field exactly as before. - const usePerPlateQuantities = mode === 'create' && isMultiPlate && plates.length > 1; + // Cross-model is excluded: its plate choice is per candidate and lives in + // VariantCandidates, so there are no per-plate steppers to own the number + // and the global Quantity field below is the only one there is (#3101). + const usePerPlateQuantities = + mode === 'create' && !isCrossModel && isMultiPlate && plates.length > 1; /** Runs to queue for one plate. `null` = the single-plate / whole-file case. */ const quantityForPlate = (plateIndex: number | null): number => { @@ -1572,37 +1588,43 @@ export function PrintModal({ ); })()} - {/* Plate selection - first so users know filament requirements before selecting printers */} - { - setSelectedPlates(prev => { - const next = new Set(prev); - if (!isEditing) { - // Multi-select: toggle the plate - if (next.has(plateIndex)) { - next.delete(plateIndex); + {/* Plate selection - first so users know filament requirements before + selecting printers. Cross-model has no use for it: the plate is + chosen per candidate in the list below, and this selector's own + choice never reached the request — it only decided which plate + the filament panel described (#3101). */} + {!isCrossModel && ( + { + setSelectedPlates(prev => { + const next = new Set(prev); + if (!isEditing) { + // Multi-select: toggle the plate + if (next.has(plateIndex)) { + next.delete(plateIndex); + } else { + next.add(plateIndex); + } } else { + // Single-select: replace selection + next.clear(); next.add(plateIndex); } - } else { - // Single-select: replace selection - next.clear(); - next.add(plateIndex); - } - return next; - }); - }} - onSelectAll={!isEditing ? () => setSelectedPlates(new Set(plates.map(p => p.index))) : undefined} - onDeselectAll={!isEditing ? () => setSelectedPlates(new Set()) : undefined} - multiSelect={!isEditing} - quantities={usePerPlateQuantities ? plateQuantities : undefined} - onQuantityChange={usePerPlateQuantities - ? (plateIndex, value) => setPlateQuantities(prev => ({ ...prev, [plateIndex]: value })) - : undefined} - /> + return next; + }); + }} + onSelectAll={!isEditing ? () => setSelectedPlates(new Set(plates.map(p => p.index))) : undefined} + onDeselectAll={!isEditing ? () => setSelectedPlates(new Set()) : undefined} + multiSelect={!isEditing} + quantities={usePerPlateQuantities ? plateQuantities : undefined} + onQuantityChange={usePerPlateQuantities + ? (plateIndex, value) => setPlateQuantities(prev => ({ ...prev, [plateIndex]: value })) + : undefined} + /> + )} {/* Cross-model alternatives (#671) replace the printer picker entirely: the user already answered "which printer" by choosing these files,