Kaynağa Gözat

fix(queue): send the copy count for a cross-model print (issue #3101)

Selecting sliced files for two printer models and asking for 25 copies
queued one item. The queue emptied as soon as it dispatched and the
Batches tab stayed empty, because no batch is created at quantity 1.

A multi-plate file moves the run count off the modal's Quantity field
onto a stepper beside each plate (#342), hiding the field. The
cross-model submit (#671) posts that field, which in this combination
nothing can set, so it stayed at its initial 1. The modal read "19 runs
in total" above a button that queued one.

Per-plate steppers do not fit a cross-model job: its plate is chosen per
candidate, in the alternatives list, so there is one number to give.
Exclude cross-model from the per-plate mode and the global field comes
back.

Drop the plate selector in that mode too. Its choice never reached the
request; it only keyed the filament-requirements query, so picking plate
3 for a candidate while plate 1 stayed ticked above produced overrides
computed from a plate the job would not print. That query now follows
the primary file's own dropdown.

Dispatch needed nothing -- it already gives each copy its own candidate
rows -- but naming did. A cross-model job carries neither archive_id nor
library_file_id, because the candidates are the files, so both branches
that name a batch missed and every such order would have read "Batch" in
the tab the reporter went looking in. Name it after the first candidate.

The existing cross-model tests all mock a single-plate file, which is
why the pair was never covered; the multi-plate case is added.
maziggy 3 gün önce
ebeveyn
işleme
f98381f3d1

Dosya farkı çok büyük olduğundan ihmal edildi
+ 1 - 0
CHANGELOG.md


+ 9 - 0
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(

+ 25 - 0
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

+ 90 - 1
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<string, unknown> | 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<string, unknown>;
+        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));
+  });
+});

+ 51 - 29
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 */}
-            <PlateSelector
-              plates={plates}
-              isMultiPlate={isMultiPlate}
-              selectedPlates={selectedPlates}
-              onToggle={(plateIndex) => {
-                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 && (
+              <PlateSelector
+                plates={plates}
+                isMultiPlate={isMultiPlate}
+                selectedPlates={selectedPlates}
+                onToggle={(plateIndex) => {
+                  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,

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor