From c0c07bc509a9661f2f9956905cf58b2f8ab560e6 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sat, 23 May 2026 14:25:12 +0200 Subject: [PATCH] fix(archives): cross-model re-slice no longer carries source printer_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/app/api/routes/library.py | 17 +- .../integration/test_library_slice_api.py | 178 ++++++++++++++++++ 2 files changed, 194 insertions(+), 1 deletion(-) diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index a4e2359a9..6ccf961f3 100644 --- a/backend/app/api/routes/library.py +++ b/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)), diff --git a/backend/tests/integration/test_library_slice_api.py b/backend/tests/integration/test_library_slice_api.py index c57e7f786..f3f1ad2a4 100644 --- a/backend/tests/integration/test_library_slice_api.py +++ b/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