"""Each print against the estimate of the plate it printed (#3375). A multi-plate 3MF's archive adds its plates up (#1593): one plate printed read as +200 % on the card and skewed the statistics. The archive keeps each plate's estimate, read from its 3MF, and a print is measured against its plate's. """ from __future__ import annotations import zipfile from datetime import datetime, timedelta from types import SimpleNamespace import pytest from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from backend.app.core.config import settings as app_settings from backend.app.models.archive import PrintArchive from backend.app.models.print_log import PrintLogEntry from backend.app.services.print_cost_estimate import ( fill_plate_estimates, refresh_plate_estimates, run_estimate, ) from backend.app.utils.threemf_tools import extract_plate_estimates_from_3mf T1 = datetime(2026, 10, 30, 9) DIR = "archives/plates" SLICE_INFO = """ """ def _three_mf(path, slice_info: str | None = SLICE_INFO): path.parent.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(path, "w") as zf: zf.writestr("3D/3dmodel.model", "") if slice_info is not None: zf.writestr("Metadata/slice_info.config", slice_info) return path @pytest.fixture def files(tmp_path, monkeypatch): monkeypatch.setattr(app_settings, "base_dir", tmp_path) monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive") _three_mf(tmp_path / DIR / "two.gcode.3mf") return tmp_path def _file(**overrides): values = {"plate_estimates": None, "print_time_seconds": 5400, "filament_used_grams": 60.0} return SimpleNamespace(**{**values, **overrides}) PLATES = {"1": {"seconds": 3600, "grams": 40.0}, "2": {"seconds": 1800, "grams": 20.0}} class TestReadingPlates: def test_each_plate_has_its_time_and_grams(self, tmp_path): assert extract_plate_estimates_from_3mf(_three_mf(tmp_path / "f.3mf")) == PLATES def test_a_file_not_sliced_has_none(self, tmp_path): assert extract_plate_estimates_from_3mf(_three_mf(tmp_path / "f.3mf", slice_info=None)) == {} def test_an_unreadable_file_is_left_to_read_again(self, tmp_path): bad = tmp_path / "f.3mf" bad.write_bytes(b"not a zip") assert extract_plate_estimates_from_3mf(bad) is None class TestRunEstimate: def test_a_print_of_a_plate_is_that_plates(self): estimate = run_estimate(_file(plate_estimates=PLATES), 2) assert (estimate.seconds, estimate.grams, estimate.per_plate) == (1800, 20.0, True) def test_a_print_of_a_single_plate_file_is_its_plate_whatever_its_number(self): estimate = run_estimate(_file(plate_estimates={"2": PLATES["2"]}), 1) assert (estimate.seconds, estimate.per_plate) == (1800, True) def test_a_print_of_an_unknown_plate_of_several_has_none(self): assert run_estimate(_file(plate_estimates=PLATES), None) is None def test_plates_not_known_give_the_files_figures(self): for plates in (None, {}): estimate = run_estimate(_file(plate_estimates=plates), 2) assert (estimate.seconds, estimate.grams, estimate.per_plate) == (5400, 60.0, False) class TestKeepingPlates: def test_an_archive_reads_its_files_plates(self, files): archive = PrintArchive(filename="two.gcode.3mf", file_path=f"{DIR}/two.gcode.3mf", file_size=1) refresh_plate_estimates(archive) assert archive.plate_estimates == PLATES def test_a_gcode_file_has_no_plates_to_read(self, files): (files / DIR / "cube.gcode").write_text("; gcode\n") archive = PrintArchive(filename="cube.gcode", file_path=f"{DIR}/cube.gcode", file_size=1) refresh_plate_estimates(archive) assert archive.plate_estimates == {} def test_an_archive_without_its_file_stays_unread(self, files): archive = PrintArchive(filename="gone.3mf", file_path=f"{DIR}/gone.3mf", file_size=1) refresh_plate_estimates(archive) assert archive.plate_estimates is None async def test_an_archived_print_keeps_its_files_plates(self, files, db_session, printer_factory): from backend.app.services.archive import ArchiveService printer = await printer_factory() source = _three_mf(files / "upload" / "two.gcode.3mf") archive = await ArchiveService(db_session).archive_print(printer.id, source) assert archive.plate_estimates == PLATES async def test_a_print_given_its_3mf_later_reads_its_plates(self, files, db_session, printer_factory): from backend.app.services.archive import ArchiveService printer = await printer_factory() fallback = PrintArchive(printer_id=printer.id, filename="two.3mf", file_path="", file_size=0, status="printing") db_session.add(fallback) await db_session.commit() source = _three_mf(files / "upload" / "two.gcode.3mf") archive = await ArchiveService(db_session).archive_print(printer.id, source, update_archive_id=fallback.id) assert (archive.id, archive.plate_estimates) == (fallback.id, PLATES) async def test_archives_made_before_get_theirs_once(self, files, db_session, test_engine): older = PrintArchive(filename="two.gcode.3mf", file_path=f"{DIR}/two.gcode.3mf", file_size=1) gone = PrintArchive(filename="gone.3mf", file_path=f"{DIR}/gone.3mf", file_size=1) read = PrintArchive(filename="r.3mf", file_path=f"{DIR}/two.gcode.3mf", file_size=1, plate_estimates={}) db_session.add_all([older, gone, read]) await db_session.commit() sessions = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) assert await fill_plate_estimates(sessions, batch=1) == 2 assert await fill_plate_estimates(sessions) == 0 for row in (older, gone, read): await db_session.refresh(row) assert (older.plate_estimates, gone.plate_estimates, read.plate_estimates) == (PLATES, {}, {}) async def test_an_archive_read_meanwhile_keeps_what_was_read(self, files, db_session, test_engine, monkeypatch): from backend.app.services import print_cost_estimate racing = PrintArchive(filename="two.gcode.3mf", file_path="", file_size=0) deleted = PrintArchive(filename="gone.3mf", file_path=f"{DIR}/gone.3mf", file_size=1) db_session.add_all([racing, deleted]) await db_session.commit() sessions = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) async def meanwhile(fn, row): # Its 3MF recovered and read, the other one deleted, while the # fill reads the first. if row.id == racing.id: async with sessions() as other: (await other.get(PrintArchive, racing.id)).plate_estimates = PLATES await other.delete(await other.get(PrintArchive, deleted.id)) await other.commit() return fn(row) monkeypatch.setattr(print_cost_estimate, "asyncio", SimpleNamespace(to_thread=meanwhile)) assert await fill_plate_estimates(sessions) == 0 await db_session.refresh(racing) assert racing.plate_estimates == PLATES async def test_an_archive_set_back_to_unread_is_read_again(self, files, db_session, test_engine): archive = PrintArchive(filename="two.gcode.3mf", file_path=f"{DIR}/two.gcode.3mf", file_size=1) archive.plate_estimates = {} db_session.add(archive) await db_session.commit() # As the print start's rollback of an adoption restores its columns. archive.plate_estimates = None await db_session.commit() sessions = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) assert await fill_plate_estimates(sessions) == 1 @pytest.fixture async def printed(db_session, printer_factory, files): """Plate 1 of the file printed in its estimate, then plate 2 in 2000 s of its 1800.""" printer = await printer_factory() archive = PrintArchive( printer_id=printer.id, filename="two.gcode.3mf", file_path=f"{DIR}/two.gcode.3mf", file_size=1, print_name="two", print_time_seconds=5400, filament_used_grams=60.0, status="completed", plate_estimates=PLATES, ) db_session.add(archive) await db_session.flush() first = PrintLogEntry( archive_id=archive.id, printer_id=printer.id, plate_id=1, status="completed", started_at=T1, completed_at=T1 + timedelta(seconds=3600), duration_seconds=3600, filament_used_grams=40.0, ) second = PrintLogEntry( archive_id=archive.id, printer_id=printer.id, plate_id=2, status="completed", started_at=T1 + timedelta(hours=2), completed_at=T1 + timedelta(hours=2, seconds=2000), duration_seconds=2000, filament_used_grams=21.0, ) db_session.add_all([first, second]) await db_session.commit() return SimpleNamespace(archive=archive, printer=printer, first=first.id, second=second.id) async def _card(async_client, archive_id): return next(a for a in (await async_client.get("/api/v1/archives/")).json() if a["id"] == archive_id) @pytest.mark.asyncio @pytest.mark.integration class TestReading: async def test_the_card_measures_the_latest_print_against_its_plate(self, async_client, printed): card = await _card(async_client, printed.archive.id) assert (card["time_estimate_seconds"], card["actual_time_seconds"], card["time_accuracy"]) == (1800, 2000, 90.0) async def test_a_file_whose_plates_are_unknown_shows_no_badge_once_printed_twice( self, async_client, db_session, printed ): printed.archive.plate_estimates = None await db_session.commit() card = await _card(async_client, printed.archive.id) assert (card["time_estimate_seconds"], card["time_accuracy"]) == (None, None) async def test_a_print_not_completed_shows_its_estimate_and_no_badge(self, async_client, db_session, printed): second = await db_session.get(PrintLogEntry, printed.second) second.status = "failed" await db_session.commit() card = await _card(async_client, printed.archive.id) assert (card["time_estimate_seconds"], card["actual_time_seconds"], card["time_accuracy"]) == (1800, None, None) async def test_a_print_of_an_unknown_plate_shows_its_time_and_no_badge(self, async_client, db_session, printed): second = await db_session.get(PrintLogEntry, printed.second) second.plate_id = None await db_session.commit() card = await _card(async_client, printed.archive.id) assert (card["time_estimate_seconds"], card["actual_time_seconds"], card["time_accuracy"]) == (None, 2000, None) async def test_an_archive_shown_as_another_print_has_no_badge_of_the_latest(self, async_client, printed): shown = (await async_client.get(f"/api/v1/archives/{printed.archive.id}?run_id={printed.first}")).json() assert (shown["plate_id"], shown["time_estimate_seconds"], shown["time_accuracy"]) == (1, None, None) async def test_each_print_event_has_its_plates_estimate(self, async_client, printed): events = (await async_client.get("/api/v1/archives/slim")).json() assert sorted(e["print_time_seconds"] for e in events) == [1800, 3600] async def test_each_print_carries_its_plates_estimate(self, async_client, db_session, printed): third = PrintLogEntry(archive_id=printed.archive.id, printer_id=printed.printer.id, status="failed") db_session.add(third) await db_session.commit() runs = (await async_client.get(f"/api/v1/archives/{printed.archive.id}/runs")).json()["items"] by_id = {r["id"]: (r["estimate_seconds"], r["estimate_grams"]) for r in runs} assert by_id == {printed.first: (3600, 40.0), printed.second: (1800, 20.0), third.id: (None, None)} async def test_prints_of_a_file_whose_plates_are_unknown_get_no_estimate(self, async_client, db_session, printed): printed.archive.plate_estimates = None await db_session.commit() runs = (await async_client.get(f"/api/v1/archives/{printed.archive.id}/runs")).json()["items"] # The file's 5400 s describes neither plate. assert [r["estimate_seconds"] for r in runs] == [None, None] async def test_the_statistics_measure_each_print_against_its_plate(self, async_client, printed): body = (await async_client.get("/api/v1/archives/stats")).json() # 100 % and 90 %: against the file's 5400 s they were 150 % and 270 %. assert body["average_time_accuracy"] == pytest.approx(95.0) @pytest.mark.asyncio @pytest.mark.integration async def test_a_queued_job_moved_to_another_plate_takes_its_time(async_client, db_session, printed): from backend.app.models.print_queue import PrintQueueItem item = PrintQueueItem( printer_id=printed.printer.id, archive_id=printed.archive.id, position=1, plate_id=1, print_time_seconds=3600 ) db_session.add(item) await db_session.commit() response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"plate_id": 2}) assert response.status_code == 200, response.text await db_session.refresh(item) assert item.print_time_seconds == 1800 def test_the_start_notification_gives_the_plates_time(monkeypatch): from backend.app import main archive = SimpleNamespace(id=7, plate_id=1, plate_estimates=PLATES, print_time_seconds=5400, filament_used_grams=60) monkeypatch.setattr(main, "_print_plate_ids", {(3, 7): 2}) assert main._run_print_time_seconds(archive, 3) == 1800 # Another printer's dispatch of it: the archive's plate. assert main._run_print_time_seconds(archive, 4) == 3600 # Plates not known: the file's. assert main._run_print_time_seconds(SimpleNamespace(**{**vars(archive), "plate_estimates": None}), 3) == 5400 # A plate not known of several: none, the printer's remaining time is used. assert main._run_print_time_seconds(SimpleNamespace(**{**vars(archive), "plate_id": None}), 4) is None