|
@@ -0,0 +1,217 @@
|
|
|
|
|
+"""Backfill for whole-file filament mis-copied onto per-plate print-log rows (#2614).
|
|
|
|
|
+
|
|
|
|
|
+A plate dispatched from a multi-plate 3MF, when the AMS tracker measured nothing,
|
|
|
|
|
+logged the archive's whole-file filament (the sum over every plate) into
|
|
|
|
|
+PrintLogEntry.filament_used_grams — inflating stats by the plate count. The
|
|
|
|
|
+forward fix scopes new rows; _migrate_scope_run_filament_to_plate repairs the
|
|
|
|
|
+rows already written, touching only the exact whole-file mis-copies.
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+from types import SimpleNamespace
|
|
|
|
|
+
|
|
|
|
|
+import pytest
|
|
|
|
|
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
|
|
|
+
|
|
|
|
|
+import backend.app.models # noqa: F401 - populate Base.metadata
|
|
|
|
|
+import backend.app.utils.threemf_tools as threemf_tools
|
|
|
|
|
+from backend.app.core import database as database_module
|
|
|
|
|
+from backend.app.core.database import Base, _migrate_scope_run_filament_to_plate
|
|
|
|
|
+from backend.app.models.archive import PrintArchive
|
|
|
|
|
+from backend.app.models.print_log import PrintLogEntry
|
|
|
|
|
+from backend.app.models.printer import Printer
|
|
|
|
|
+
|
|
|
|
|
+WHOLE = 12006.49 # 22-plate file total
|
|
|
|
|
+PLATE = 350.0 # the printed plate's own estimate
|
|
|
|
|
+COST = 240.13 # whole-file cost
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.fixture
|
|
|
|
|
+async def engine(tmp_path):
|
|
|
|
|
+ eng = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/t.db")
|
|
|
|
|
+ async with eng.begin() as conn:
|
|
|
|
|
+ await conn.run_sync(Base.metadata.create_all)
|
|
|
|
|
+ try:
|
|
|
|
|
+ yield eng
|
|
|
|
|
+ finally:
|
|
|
|
|
+ await eng.dispose()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.fixture
|
|
|
|
|
+def stub_3mf(tmp_path, monkeypatch):
|
|
|
|
|
+ """A stub file on disk + a patched extractor returning the plate estimate."""
|
|
|
|
|
+ monkeypatch.setattr(database_module.settings, "base_dir", tmp_path)
|
|
|
|
|
+ fp = tmp_path / "archive" / "1" / "heart.gcode.3mf"
|
|
|
|
|
+ fp.parent.mkdir(parents=True)
|
|
|
|
|
+ fp.write_bytes(b"stub")
|
|
|
|
|
+ monkeypatch.setattr(
|
|
|
|
|
+ threemf_tools,
|
|
|
|
|
+ "extract_plate_metadata_from_3mf",
|
|
|
|
|
+ lambda path, plate_id: SimpleNamespace(filament_used_grams=PLATE),
|
|
|
|
|
+ )
|
|
|
|
|
+ return "archive/1/heart.gcode.3mf"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _archive(db, file_path, *, plate_id=3, whole=WHOLE, cost=COST):
|
|
|
|
|
+ p = Printer(name="P", serial_number="S", ip_address="1.1.1.1", access_code="c", model="X1C")
|
|
|
|
|
+ db.add(p)
|
|
|
|
|
+ await db.flush()
|
|
|
|
|
+ a = PrintArchive(
|
|
|
|
|
+ filename="heart.gcode.3mf",
|
|
|
|
|
+ file_path=file_path,
|
|
|
|
|
+ file_size=1,
|
|
|
|
|
+ status="completed",
|
|
|
|
|
+ plate_id=plate_id,
|
|
|
|
|
+ filament_used_grams=whole,
|
|
|
|
|
+ cost=cost,
|
|
|
|
|
+ )
|
|
|
|
|
+ db.add(a)
|
|
|
|
|
+ await db.flush()
|
|
|
|
|
+ return a
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+async def test_rescopes_miscopied_row_and_scales_cost(engine, stub_3mf):
|
|
|
|
|
+ sm = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
|
|
+ async with sm() as db:
|
|
|
|
|
+ a = await _archive(db, stub_3mf)
|
|
|
|
|
+ mis = PrintLogEntry(archive_id=a.id, status="completed", filament_used_grams=WHOLE, cost=COST)
|
|
|
|
|
+ db.add(mis)
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+ mis_id = mis.id
|
|
|
|
|
+
|
|
|
|
|
+ async with engine.begin() as conn:
|
|
|
|
|
+ await _migrate_scope_run_filament_to_plate(conn)
|
|
|
|
|
+
|
|
|
|
|
+ async with sm() as db:
|
|
|
|
|
+ fixed = await db.get(PrintLogEntry, mis_id)
|
|
|
|
|
+ assert fixed.filament_used_grams == PLATE
|
|
|
|
|
+ assert fixed.cost == round(COST * (PLATE / WHOLE), 2)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+async def test_leaves_tracker_measured_and_partial_rows_alone(engine, stub_3mf):
|
|
|
|
|
+ sm = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
|
|
+ async with sm() as db:
|
|
|
|
|
+ a = await _archive(db, stub_3mf)
|
|
|
|
|
+ # Measured spool delta (rounded), != whole-file → must be untouched.
|
|
|
|
|
+ tracked = PrintLogEntry(archive_id=a.id, status="completed", filament_used_grams=96.5, cost=2.0)
|
|
|
|
|
+ # A partial (failed) run scaled to progress, != whole-file → untouched.
|
|
|
|
|
+ partial = PrintLogEntry(archive_id=a.id, status="failed", filament_used_grams=1200.6, cost=24.0)
|
|
|
|
|
+ db.add_all([tracked, partial])
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+ tracked_id, partial_id = tracked.id, partial.id
|
|
|
|
|
+
|
|
|
|
|
+ async with engine.begin() as conn:
|
|
|
|
|
+ await _migrate_scope_run_filament_to_plate(conn)
|
|
|
|
|
+
|
|
|
|
|
+ async with sm() as db:
|
|
|
|
|
+ assert (await db.get(PrintLogEntry, tracked_id)).filament_used_grams == 96.5
|
|
|
|
|
+ assert (await db.get(PrintLogEntry, partial_id)).filament_used_grams == 1200.6
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+async def test_idempotent_second_run_is_a_noop(engine, stub_3mf):
|
|
|
|
|
+ sm = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
|
|
+ async with sm() as db:
|
|
|
|
|
+ a = await _archive(db, stub_3mf)
|
|
|
|
|
+ mis = PrintLogEntry(archive_id=a.id, status="completed", filament_used_grams=WHOLE, cost=COST)
|
|
|
|
|
+ db.add(mis)
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+ mis_id = mis.id
|
|
|
|
|
+
|
|
|
|
|
+ async with engine.begin() as conn:
|
|
|
|
|
+ await _migrate_scope_run_filament_to_plate(conn)
|
|
|
|
|
+ async with engine.begin() as conn:
|
|
|
|
|
+ await _migrate_scope_run_filament_to_plate(conn)
|
|
|
|
|
+
|
|
|
|
|
+ async with sm() as db:
|
|
|
|
|
+ assert (await db.get(PrintLogEntry, mis_id)).filament_used_grams == PLATE
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+async def test_one_shot_gate_prevents_rescan_on_later_boots(engine, stub_3mf):
|
|
|
|
|
+ """After the first pass writes its settings flag, a later boot does no work —
|
|
|
|
|
+ the migration must never re-scan the print log every startup (single-plate rows
|
|
|
|
|
+ legitimately match the whole-file==plate signature forever, so an ungated
|
|
|
|
|
+ version would re-parse every single-plate 3MF on each boot)."""
|
|
|
|
|
+ sm = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
|
|
+ async with sm() as db:
|
|
|
|
|
+ a = await _archive(db, stub_3mf)
|
|
|
|
|
+ first = PrintLogEntry(archive_id=a.id, status="completed", filament_used_grams=WHOLE, cost=COST)
|
|
|
|
|
+ db.add(first)
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+ first_id, archive_id = first.id, a.id
|
|
|
|
|
+
|
|
|
|
|
+ async with engine.begin() as conn:
|
|
|
|
|
+ await _migrate_scope_run_filament_to_plate(conn) # fixes `first`, writes the flag
|
|
|
|
|
+
|
|
|
|
|
+ # A fresh mis-copy appears after the one-shot already ran.
|
|
|
|
|
+ async with sm() as db:
|
|
|
|
|
+ later = PrintLogEntry(archive_id=archive_id, status="completed", filament_used_grams=WHOLE, cost=COST)
|
|
|
|
|
+ db.add(later)
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+ later_id = later.id
|
|
|
|
|
+
|
|
|
|
|
+ async with engine.begin() as conn:
|
|
|
|
|
+ await _migrate_scope_run_filament_to_plate(conn) # gate short-circuits; no scan
|
|
|
|
|
+
|
|
|
|
|
+ async with sm() as db:
|
|
|
|
|
+ assert (await db.get(PrintLogEntry, first_id)).filament_used_grams == PLATE
|
|
|
|
|
+ # Deliberately untouched: the gate skipped the whole pass. New mis-copies
|
|
|
|
|
+ # can't occur anyway — the forward fix scopes every row at write time.
|
|
|
|
|
+ assert (await db.get(PrintLogEntry, later_id)).filament_used_grams == WHOLE
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+async def test_skips_row_when_3mf_missing(engine, tmp_path, monkeypatch):
|
|
|
|
|
+ # base_dir set, but the archive's file was never on disk → row is left alone
|
|
|
|
|
+ # (can't compute a plate value; don't guess).
|
|
|
|
|
+ monkeypatch.setattr(database_module.settings, "base_dir", tmp_path)
|
|
|
|
|
+ monkeypatch.setattr(
|
|
|
|
|
+ threemf_tools,
|
|
|
|
|
+ "extract_plate_metadata_from_3mf",
|
|
|
|
|
+ lambda path, plate_id: SimpleNamespace(filament_used_grams=PLATE),
|
|
|
|
|
+ )
|
|
|
|
|
+ sm = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
|
|
+ async with sm() as db:
|
|
|
|
|
+ a = await _archive(db, "archive/1/gone.gcode.3mf")
|
|
|
|
|
+ mis = PrintLogEntry(archive_id=a.id, status="completed", filament_used_grams=WHOLE, cost=COST)
|
|
|
|
|
+ db.add(mis)
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+ mis_id = mis.id
|
|
|
|
|
+
|
|
|
|
|
+ async with engine.begin() as conn:
|
|
|
|
|
+ await _migrate_scope_run_filament_to_plate(conn)
|
|
|
|
|
+
|
|
|
|
|
+ async with sm() as db:
|
|
|
|
|
+ assert (await db.get(PrintLogEntry, mis_id)).filament_used_grams == WHOLE
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+async def test_single_plate_archive_not_relabelled(engine, tmp_path, monkeypatch):
|
|
|
|
|
+ # A genuine single-plate archive whose plate estimate equals the whole-file
|
|
|
|
|
+ # value must not be rewritten (no-op guard on unchanged grams).
|
|
|
|
|
+ monkeypatch.setattr(database_module.settings, "base_dir", tmp_path)
|
|
|
|
|
+ fp = tmp_path / "archive" / "1" / "heart.gcode.3mf"
|
|
|
|
|
+ fp.parent.mkdir(parents=True)
|
|
|
|
|
+ fp.write_bytes(b"stub")
|
|
|
|
|
+ monkeypatch.setattr(
|
|
|
|
|
+ threemf_tools,
|
|
|
|
|
+ "extract_plate_metadata_from_3mf",
|
|
|
|
|
+ lambda path, plate_id: SimpleNamespace(filament_used_grams=WHOLE),
|
|
|
|
|
+ )
|
|
|
|
|
+ sm = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
|
|
+ async with sm() as db:
|
|
|
|
|
+ a = await _archive(db, "archive/1/heart.gcode.3mf", plate_id=1)
|
|
|
|
|
+ row = PrintLogEntry(archive_id=a.id, status="completed", filament_used_grams=WHOLE, cost=COST)
|
|
|
|
|
+ db.add(row)
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+ row_id = row.id
|
|
|
|
|
+
|
|
|
|
|
+ async with engine.begin() as conn:
|
|
|
|
|
+ await _migrate_scope_run_filament_to_plate(conn)
|
|
|
|
|
+
|
|
|
|
|
+ async with sm() as db:
|
|
|
|
|
+ fixed = await db.get(PrintLogEntry, row_id)
|
|
|
|
|
+ assert fixed.filament_used_grams == WHOLE
|
|
|
|
|
+ assert fixed.cost == COST
|