test_run_filament_plate_scope_2614.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. """Backfill for whole-file filament mis-copied onto per-plate print-log rows (#2614).
  2. A plate dispatched from a multi-plate 3MF, when the AMS tracker measured nothing,
  3. logged the archive's whole-file filament (the sum over every plate) into
  4. PrintLogEntry.filament_used_grams — inflating stats by the plate count. The
  5. forward fix scopes new rows; _migrate_scope_run_filament_to_plate repairs the
  6. rows already written, touching only the exact whole-file mis-copies.
  7. """
  8. from types import SimpleNamespace
  9. import pytest
  10. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  11. import backend.app.models # noqa: F401 - populate Base.metadata
  12. import backend.app.utils.threemf_tools as threemf_tools
  13. from backend.app.core import database as database_module
  14. from backend.app.core.database import Base, _migrate_scope_run_filament_to_plate
  15. from backend.app.models.archive import PrintArchive
  16. from backend.app.models.print_log import PrintLogEntry
  17. from backend.app.models.printer import Printer
  18. WHOLE = 12006.49 # 22-plate file total
  19. PLATE = 350.0 # the printed plate's own estimate
  20. COST = 240.13 # whole-file cost
  21. @pytest.fixture
  22. async def engine(tmp_path):
  23. eng = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/t.db")
  24. async with eng.begin() as conn:
  25. await conn.run_sync(Base.metadata.create_all)
  26. try:
  27. yield eng
  28. finally:
  29. await eng.dispose()
  30. @pytest.fixture
  31. def stub_3mf(tmp_path, monkeypatch):
  32. """A stub file on disk + a patched extractor returning the plate estimate."""
  33. monkeypatch.setattr(database_module.settings, "base_dir", tmp_path)
  34. fp = tmp_path / "archive" / "1" / "heart.gcode.3mf"
  35. fp.parent.mkdir(parents=True)
  36. fp.write_bytes(b"stub")
  37. monkeypatch.setattr(
  38. threemf_tools,
  39. "extract_plate_metadata_from_3mf",
  40. lambda path, plate_id: SimpleNamespace(filament_used_grams=PLATE),
  41. )
  42. return "archive/1/heart.gcode.3mf"
  43. async def _archive(db, file_path, *, plate_id=3, whole=WHOLE, cost=COST):
  44. p = Printer(name="P", serial_number="S", ip_address="1.1.1.1", access_code="c", model="X1C")
  45. db.add(p)
  46. await db.flush()
  47. a = PrintArchive(
  48. filename="heart.gcode.3mf",
  49. file_path=file_path,
  50. file_size=1,
  51. status="completed",
  52. plate_id=plate_id,
  53. filament_used_grams=whole,
  54. cost=cost,
  55. )
  56. db.add(a)
  57. await db.flush()
  58. return a
  59. @pytest.mark.asyncio
  60. async def test_rescopes_miscopied_row_and_scales_cost(engine, stub_3mf):
  61. sm = async_sessionmaker(engine, expire_on_commit=False)
  62. async with sm() as db:
  63. a = await _archive(db, stub_3mf)
  64. mis = PrintLogEntry(archive_id=a.id, status="completed", filament_used_grams=WHOLE, cost=COST)
  65. db.add(mis)
  66. await db.commit()
  67. mis_id = mis.id
  68. async with engine.begin() as conn:
  69. await _migrate_scope_run_filament_to_plate(conn)
  70. async with sm() as db:
  71. fixed = await db.get(PrintLogEntry, mis_id)
  72. assert fixed.filament_used_grams == PLATE
  73. assert fixed.cost == round(COST * (PLATE / WHOLE), 2)
  74. @pytest.mark.asyncio
  75. async def test_leaves_tracker_measured_and_partial_rows_alone(engine, stub_3mf):
  76. sm = async_sessionmaker(engine, expire_on_commit=False)
  77. async with sm() as db:
  78. a = await _archive(db, stub_3mf)
  79. # Measured spool delta (rounded), != whole-file → must be untouched.
  80. tracked = PrintLogEntry(archive_id=a.id, status="completed", filament_used_grams=96.5, cost=2.0)
  81. # A partial (failed) run scaled to progress, != whole-file → untouched.
  82. partial = PrintLogEntry(archive_id=a.id, status="failed", filament_used_grams=1200.6, cost=24.0)
  83. db.add_all([tracked, partial])
  84. await db.commit()
  85. tracked_id, partial_id = tracked.id, partial.id
  86. async with engine.begin() as conn:
  87. await _migrate_scope_run_filament_to_plate(conn)
  88. async with sm() as db:
  89. assert (await db.get(PrintLogEntry, tracked_id)).filament_used_grams == 96.5
  90. assert (await db.get(PrintLogEntry, partial_id)).filament_used_grams == 1200.6
  91. @pytest.mark.asyncio
  92. async def test_idempotent_second_run_is_a_noop(engine, stub_3mf):
  93. sm = async_sessionmaker(engine, expire_on_commit=False)
  94. async with sm() as db:
  95. a = await _archive(db, stub_3mf)
  96. mis = PrintLogEntry(archive_id=a.id, status="completed", filament_used_grams=WHOLE, cost=COST)
  97. db.add(mis)
  98. await db.commit()
  99. mis_id = mis.id
  100. async with engine.begin() as conn:
  101. await _migrate_scope_run_filament_to_plate(conn)
  102. async with engine.begin() as conn:
  103. await _migrate_scope_run_filament_to_plate(conn)
  104. async with sm() as db:
  105. assert (await db.get(PrintLogEntry, mis_id)).filament_used_grams == PLATE
  106. @pytest.mark.asyncio
  107. async def test_one_shot_gate_prevents_rescan_on_later_boots(engine, stub_3mf):
  108. """After the first pass writes its settings flag, a later boot does no work —
  109. the migration must never re-scan the print log every startup (single-plate rows
  110. legitimately match the whole-file==plate signature forever, so an ungated
  111. version would re-parse every single-plate 3MF on each boot)."""
  112. sm = async_sessionmaker(engine, expire_on_commit=False)
  113. async with sm() as db:
  114. a = await _archive(db, stub_3mf)
  115. first = PrintLogEntry(archive_id=a.id, status="completed", filament_used_grams=WHOLE, cost=COST)
  116. db.add(first)
  117. await db.commit()
  118. first_id, archive_id = first.id, a.id
  119. async with engine.begin() as conn:
  120. await _migrate_scope_run_filament_to_plate(conn) # fixes `first`, writes the flag
  121. # A fresh mis-copy appears after the one-shot already ran.
  122. async with sm() as db:
  123. later = PrintLogEntry(archive_id=archive_id, status="completed", filament_used_grams=WHOLE, cost=COST)
  124. db.add(later)
  125. await db.commit()
  126. later_id = later.id
  127. async with engine.begin() as conn:
  128. await _migrate_scope_run_filament_to_plate(conn) # gate short-circuits; no scan
  129. async with sm() as db:
  130. assert (await db.get(PrintLogEntry, first_id)).filament_used_grams == PLATE
  131. # Deliberately untouched: the gate skipped the whole pass. New mis-copies
  132. # can't occur anyway — the forward fix scopes every row at write time.
  133. assert (await db.get(PrintLogEntry, later_id)).filament_used_grams == WHOLE
  134. @pytest.mark.asyncio
  135. async def test_skips_row_when_3mf_missing(engine, tmp_path, monkeypatch):
  136. # base_dir set, but the archive's file was never on disk → row is left alone
  137. # (can't compute a plate value; don't guess).
  138. monkeypatch.setattr(database_module.settings, "base_dir", tmp_path)
  139. monkeypatch.setattr(
  140. threemf_tools,
  141. "extract_plate_metadata_from_3mf",
  142. lambda path, plate_id: SimpleNamespace(filament_used_grams=PLATE),
  143. )
  144. sm = async_sessionmaker(engine, expire_on_commit=False)
  145. async with sm() as db:
  146. a = await _archive(db, "archive/1/gone.gcode.3mf")
  147. mis = PrintLogEntry(archive_id=a.id, status="completed", filament_used_grams=WHOLE, cost=COST)
  148. db.add(mis)
  149. await db.commit()
  150. mis_id = mis.id
  151. async with engine.begin() as conn:
  152. await _migrate_scope_run_filament_to_plate(conn)
  153. async with sm() as db:
  154. assert (await db.get(PrintLogEntry, mis_id)).filament_used_grams == WHOLE
  155. @pytest.mark.asyncio
  156. async def test_single_plate_archive_not_relabelled(engine, tmp_path, monkeypatch):
  157. # A genuine single-plate archive whose plate estimate equals the whole-file
  158. # value must not be rewritten (no-op guard on unchanged grams).
  159. monkeypatch.setattr(database_module.settings, "base_dir", tmp_path)
  160. fp = tmp_path / "archive" / "1" / "heart.gcode.3mf"
  161. fp.parent.mkdir(parents=True)
  162. fp.write_bytes(b"stub")
  163. monkeypatch.setattr(
  164. threemf_tools,
  165. "extract_plate_metadata_from_3mf",
  166. lambda path, plate_id: SimpleNamespace(filament_used_grams=WHOLE),
  167. )
  168. sm = async_sessionmaker(engine, expire_on_commit=False)
  169. async with sm() as db:
  170. a = await _archive(db, "archive/1/heart.gcode.3mf", plate_id=1)
  171. row = PrintLogEntry(archive_id=a.id, status="completed", filament_used_grams=WHOLE, cost=COST)
  172. db.add(row)
  173. await db.commit()
  174. row_id = row.id
  175. async with engine.begin() as conn:
  176. await _migrate_scope_run_filament_to_plate(conn)
  177. async with sm() as db:
  178. fixed = await db.get(PrintLogEntry, row_id)
  179. assert fixed.filament_used_grams == WHOLE
  180. assert fixed.cost == COST