Browse Source

fix(archives): cross-model re-slice no longer carries source printer_id

  When the user re-sliced an H2D archive for X1C, the new archive card and
  reprint modal both showed the source printer's name (e.g. "Workshop H2C")
  even though sliced_for_model on the same row correctly read "X1C".

  slice_and_persist_as_archive copied source_archive.printer_id verbatim
  onto the new PrintArchive row. Both the archive card
  (ArchivesPage.tsx:3571) and the reprint modal read printer_id first and
  only fall back to sliced_for_model when it's None. With printer_id set
  to the source's H2D printer, neither could see that the new file is for
  a different model.

  Same shape as the sliced_for_model fix already in the same function — a
  cross-model re-slice means the source's physical printer isn't where
  this output prints anymore. When the slicer-baked target model differs
  from source_archive.sliced_for_model, drop printer_id so both surfaces
  fall back to the sliced_for_model badge ("X1C"). Same-model re-slices
  keep printer_id so the reprint modal still pre-selects the source
  printer.

  Edge case: when source_archive.sliced_for_model is None (older archives
  that predate that column being populated), we can't tell whether this
  is a cross-model re-slice. Fail open and preserve printer_id rather
  than spuriously nulling it.

  slice_and_persist (the library-file path) doesn't have this bug —
  LibraryFile has no printer_id column.

  Tests in TestSliceArchiveResliceModel cover all three branches: cross-
  model nulls printer_id; same-model keeps it; unknown source model
  preserves it.

  Surfaced via the bambuddy demo doing H2D -> X1C re-slices after the
  .bbscfg System-tier slicing fix landed.
maziggy 5 days ago
parent
commit
c0c07bc509
2 changed files with 194 additions and 1 deletions
  1. 16 1
      backend/app/api/routes/library.py
  2. 178 0
      backend/tests/integration/test_library_slice_api.py

+ 16 - 1
backend/app/api/routes/library.py

@@ -3624,8 +3624,23 @@ async def slice_and_persist_as_archive(
     new_filament_type = parsed_metadata.get("filament_type") or source_archive.filament_type
     new_filament_color = parsed_metadata.get("filament_color") or source_archive.filament_color
 
+    # When the user re-slices for a different printer model than the source,
+    # the source's printer_id (e.g. an H2D's "Workshop H2C") no longer
+    # represents where the new archive can be reprinted. The archive card
+    # and reprint modal both read printer_id first and only fall back to
+    # sliced_for_model when it's None, so leaving the inherited id makes
+    # the X1C-sliced card display the source H2D's printer name.
+    # Same pitfall as the sliced_for_model copy a few lines below.
+    new_target_model = parsed_metadata.get("sliced_for_model") or source_archive.sliced_for_model
+    is_cross_model_reslice = (
+        new_target_model is not None
+        and source_archive.sliced_for_model is not None
+        and new_target_model != source_archive.sliced_for_model
+    )
+    new_printer_id = None if is_cross_model_reslice else source_archive.printer_id
+
     new_archive = PrintArchive(
-        printer_id=source_archive.printer_id,
+        printer_id=new_printer_id,
         project_id=source_archive.project_id,
         filename=out_filename,
         file_path=str(out_path.relative_to(app_settings.base_dir)),

+ 178 - 0
backend/tests/integration/test_library_slice_api.py

@@ -1039,6 +1039,184 @@ class TestSliceArchiveResliceModel:
         source_reloaded = await db_session.get(PrintArchive, source_id)
         assert source_reloaded.sliced_for_model == "X1C"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cross_model_reslice_drops_source_printer_id(
+        self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
+    ):
+        """A cross-model re-slice (source's X1C → target's H2D) must not carry
+        over ``source.printer_id``. The archive card and reprint modal both
+        read ``printer_id`` first and only fall back to ``sliced_for_model``
+        when it's None, so leaving the inherited id makes the H2D-sliced card
+        display the source's X1C printer name (the "Workshop H2C" bug)."""
+        from backend.app.models.archive import PrintArchive
+
+        tmp_path = slice_test_setup["tmp_path"]
+        monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
+
+        src_dir = tmp_path / "archives" / "src"
+        src_dir.mkdir(parents=True, exist_ok=True)
+        src_3mf = src_dir / "cube.3mf"
+        src_3mf.write_bytes(_make_3mf_with_settings())
+        source_printer = await printer_factory()
+        source = await archive_factory(
+            source_printer.id,
+            filename="cube.3mf",
+            file_path=str(src_3mf.relative_to(tmp_path)),
+            sliced_for_model="X1C",
+            with_run=False,
+        )
+        source_id = source.id
+        source_printer_id = source_printer.id
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(
+                status_code=200,
+                content=_make_sliced_3mf("O1D"),  # H2D
+                headers={
+                    "x-print-time-seconds": "600",
+                    "x-filament-used-g": "5.0",
+                    "x-filament-used-mm": "1600.0",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+
+        resp = await async_client.post(
+            f"/api/v1/archives/{source_id}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+            },
+        )
+        assert resp.status_code == 202, resp.text
+        final = await _wait_for_job(async_client, resp.json()["job_id"])
+        assert final["status"] == "completed", final
+
+        new_archive = await db_session.get(PrintArchive, final["result"]["archive_id"])
+        assert new_archive.sliced_for_model == "H2D"
+        # Card / reprint modal will now fall back to the sliced_for_model
+        # badge instead of showing the source printer's name.
+        assert new_archive.printer_id is None
+
+        # Source untouched: still bound to its original printer.
+        source_reloaded = await db_session.get(PrintArchive, source_id)
+        assert source_reloaded.printer_id == source_printer_id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_same_model_reslice_preserves_source_printer_id(
+        self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
+    ):
+        """Same-model re-slice (X1C → X1C, e.g. just swapped a process preset)
+        keeps ``printer_id`` so the reprint modal pre-selects the original
+        printer. Only cross-model re-slices null it out."""
+        from backend.app.models.archive import PrintArchive
+
+        tmp_path = slice_test_setup["tmp_path"]
+        monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
+
+        src_dir = tmp_path / "archives" / "src"
+        src_dir.mkdir(parents=True, exist_ok=True)
+        src_3mf = src_dir / "cube.3mf"
+        src_3mf.write_bytes(_make_3mf_with_settings())
+        source_printer = await printer_factory()
+        source = await archive_factory(
+            source_printer.id,
+            filename="cube.3mf",
+            file_path=str(src_3mf.relative_to(tmp_path)),
+            sliced_for_model="X1C",
+            with_run=False,
+        )
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(
+                status_code=200,
+                content=_make_sliced_3mf("C11"),  # X1C — same model as source
+                headers={
+                    "x-print-time-seconds": "600",
+                    "x-filament-used-g": "5.0",
+                    "x-filament-used-mm": "1600.0",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+
+        resp = await async_client.post(
+            f"/api/v1/archives/{source.id}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+            },
+        )
+        assert resp.status_code == 202, resp.text
+        final = await _wait_for_job(async_client, resp.json()["job_id"])
+        assert final["status"] == "completed", final
+
+        new_archive = await db_session.get(PrintArchive, final["result"]["archive_id"])
+        assert new_archive.sliced_for_model == "X1C"
+        # Same-model: keep the source's printer assignment so reprint pre-selects it.
+        assert new_archive.printer_id == source_printer.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reslice_with_unknown_source_model_preserves_printer_id(
+        self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
+    ):
+        """When ``source.sliced_for_model`` is None (older archive that
+        predates that column being populated), the backend can't tell whether
+        this is a cross-model re-slice. Fail open and preserve ``printer_id``
+        rather than spuriously nulling it — current pre-fix behaviour, kept
+        as a deliberate edge case."""
+        from backend.app.models.archive import PrintArchive
+
+        tmp_path = slice_test_setup["tmp_path"]
+        monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
+
+        src_dir = tmp_path / "archives" / "src"
+        src_dir.mkdir(parents=True, exist_ok=True)
+        src_3mf = src_dir / "cube.3mf"
+        src_3mf.write_bytes(_make_3mf_with_settings())
+        source_printer = await printer_factory()
+        source = await archive_factory(
+            source_printer.id,
+            filename="cube.3mf",
+            file_path=str(src_3mf.relative_to(tmp_path)),
+            sliced_for_model=None,
+            with_run=False,
+        )
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(
+                status_code=200,
+                content=_make_sliced_3mf("O1D"),
+                headers={
+                    "x-print-time-seconds": "600",
+                    "x-filament-used-g": "5.0",
+                    "x-filament-used-mm": "1600.0",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+
+        resp = await async_client.post(
+            f"/api/v1/archives/{source.id}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+            },
+        )
+        assert resp.status_code == 202, resp.text
+        final = await _wait_for_job(async_client, resp.json()["job_id"])
+        assert final["status"] == "completed", final
+
+        new_archive = await db_session.get(PrintArchive, final["result"]["archive_id"])
+        # Insufficient info to decide cross-model → preserve printer_id.
+        assert new_archive.printer_id == source_printer.id
+
 
 class TestSliceArchiveReslicedThumbnail:
     """#1493 follow-up: the re-sliced archive's cover image preference order is