test_donor_3mf_validation_2957.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. """A same-named 3MF is not automatically this print's 3MF (#2957).
  2. When a print's own 3MF could not be fetched, the usage tracker looks for one in
  3. the library or in a previous archive and matches on the filename stem. Bambu
  4. Studio writes the printer-side filename from the project's ``Title`` metadata,
  5. so every plate of a project reaches the printer under one name however the user
  6. renamed the file on disk -- filename equality says almost nothing.
  7. The reporter's archive 94 was handed archive 92's file that way. The real print
  8. used one filament; the donor plate declared three, and three spools were debited
  9. for material they never extruded. Nothing in the archive said the numbers were
  10. someone else's.
  11. The plate is the only sound discriminator available here, and these tests pin
  12. both halves of it: a donor holding a different plate is refused, and an
  13. all-plates export is refused unless it actually carries the plate that is
  14. running. The tempting second check -- comparing the donor's filament count
  15. against the slicer's ``ams_mapping`` -- is deliberately absent and has a test of
  16. its own saying why: that field is indexed by the *project's* filament slots, so
  17. a genuine single-filament print reports ``[0, -1, -1, -1]``.
  18. Where the plate cannot be known at all, which is the reporter's own firmware,
  19. the donor is still accepted on its name and a warning says so. That is the
  20. honest limit of what the data supports.
  21. """
  22. from __future__ import annotations
  23. import zipfile
  24. from pathlib import Path
  25. import pytest
  26. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  27. from backend.app.models.archive import PrintArchive
  28. from backend.app.models.library import LibraryFile
  29. from backend.app.services.usage_tracker import (
  30. _donor_3mf_conflicts,
  31. _expected_plate_for_print,
  32. _resolve_3mf_fallback,
  33. )
  34. def _write_3mf(path: Path, *, plate: int, filaments: int) -> Path:
  35. """A single-plate 3MF declaring *filaments* filaments on plate *plate*."""
  36. path.parent.mkdir(parents=True, exist_ok=True)
  37. rows = "".join(
  38. f"<filament id='{i + 1}' type='PLA' color='#00AE42' used_g='10' used_m='3.4'/>" for i in range(filaments)
  39. )
  40. with zipfile.ZipFile(path, "w") as zf:
  41. zf.writestr(
  42. "Metadata/slice_info.config",
  43. "<?xml version='1.0' encoding='UTF-8'?><config><plate>"
  44. f"<metadata key='index' value='{plate}'/>"
  45. f"<metadata key='prediction' value='3600'/>{rows}"
  46. "</plate></config>",
  47. )
  48. return path
  49. def _write_multiplate_3mf(path: Path, plates: dict[int, int]) -> Path:
  50. """An all-plates export: ``{plate index: filament count}``."""
  51. path.parent.mkdir(parents=True, exist_ok=True)
  52. body = ""
  53. for plate, filaments in plates.items():
  54. rows = "".join(
  55. f"<filament id='{i + 1}' type='PLA' color='#00AE42' used_g='10' used_m='3.4'/>" for i in range(filaments)
  56. )
  57. body += f"<plate><metadata key='index' value='{plate}'/><metadata key='prediction' value='60'/>{rows}</plate>"
  58. with zipfile.ZipFile(path, "w") as zf:
  59. zf.writestr("Metadata/slice_info.config", f"<?xml version='1.0' encoding='UTF-8'?><config>{body}</config>")
  60. return path
  61. class TestWhatRulesADonorOut:
  62. def test_a_donor_holding_a_different_plate(self, tmp_path):
  63. donor = _write_3mf(tmp_path / "donor.3mf", plate=2, filaments=1)
  64. conflict = _donor_3mf_conflicts(donor, expected_plate=1)
  65. assert conflict is not None
  66. assert "plate 2" in conflict
  67. def test_an_all_plates_export_without_the_running_plate(self, tmp_path):
  68. """Left alone this is the silent one: the plate is looked for
  69. downstream, found missing, and every filament in the file is summed onto
  70. a single plate's print."""
  71. donor = _write_multiplate_3mf(tmp_path / "donor.3mf", {1: 1, 2: 3})
  72. assert _donor_3mf_conflicts(donor, expected_plate=5) is not None
  73. def test_an_unreadable_donor_is_not_rejected_on_that_alone(self, tmp_path):
  74. """Refusing a file we merely could not parse would take the fallback
  75. away from every 3MF variant this parser does not understand -- and an
  76. unreadable file is not evidence about which plate it holds. "No plates
  77. found" must not be read as "not your plate". The parse failure surfaces
  78. downstream as "no filament usage data" instead.
  79. """
  80. donor = tmp_path / "broken.3mf"
  81. donor.write_bytes(b"PK\x03\x04not-really-a-3mf")
  82. assert _donor_3mf_conflicts(donor, expected_plate=None) is None
  83. assert _donor_3mf_conflicts(donor, expected_plate=2) is None
  84. class TestWhatMustStillBeAccepted:
  85. def test_the_matching_plate(self, tmp_path):
  86. donor = _write_3mf(tmp_path / "donor.3mf", plate=2, filaments=2)
  87. assert _donor_3mf_conflicts(donor, expected_plate=2) is None
  88. def test_an_all_plates_export_that_carries_the_running_plate(self, tmp_path):
  89. """``peek_plate_index_in_3mf`` returns None for a multi-plate file --
  90. "which plate is this" has no answer (#2522) -- so the file is judged on
  91. whether it holds the plate instead."""
  92. donor = _write_multiplate_3mf(tmp_path / "donor.3mf", {1: 3, 2: 1})
  93. assert _donor_3mf_conflicts(donor, expected_plate=2) is None
  94. def test_nothing_known_accepts_anything(self, tmp_path):
  95. """The reporter's firmware echoes only the 3MF filename and the print
  96. was not one Bambuddy dispatched, so the plate is unknowable. Accepting
  97. is the pre-existing behaviour and stays -- refusing here would retire
  98. the fallback recovery this same issue asked for -- but it is logged."""
  99. donor = _write_3mf(tmp_path / "donor.3mf", plate=7, filaments=4)
  100. assert _donor_3mf_conflicts(donor, expected_plate=None) is None
  101. class TestTheCheckThatIsDeliberatelyNotMade:
  102. def test_the_filament_count_is_not_compared(self, tmp_path):
  103. """A donor whose plate matches is accepted however many filaments it
  104. declares, and this is the load-bearing reason why.
  105. ``ams_mapping`` is indexed by the *project's* filament slots, not the
  106. plate's -- ``slot_to_tray[slot_id - 1]`` in the same module -- so a
  107. genuine single-filament print publishes ``[0, -1, -1, -1]``. Comparing
  108. its length against a plate's filament count would reject correct donors
  109. far more often than wrong ones, on every multi-filament project.
  110. """
  111. donor = _write_3mf(tmp_path / "donor.3mf", plate=2, filaments=3)
  112. assert _donor_3mf_conflicts(donor, expected_plate=2) is None
  113. class TestWhereTheExpectationsComeFrom:
  114. def test_the_plate_column_wins_when_it_is_set(self):
  115. assert _expected_plate_for_print(3, "Metadata/plate_1.gcode") == 3
  116. def test_otherwise_the_gcode_path_the_printer_echoed(self):
  117. assert _expected_plate_for_print(None, "Metadata/plate_2.gcode") == 2
  118. def test_a_p1s_that_echoes_only_the_3mf_name_knows_no_plate(self):
  119. """Verbatim from the report: ``PRINT START detected - file:
  120. Desktop_Goose.gcode.3mf``. There is no plate in that."""
  121. assert _expected_plate_for_print(None, "Desktop_Goose.gcode.3mf") is None
  122. @pytest.mark.asyncio
  123. class TestTheLookupItself:
  124. async def _seed(self, engine, tmp_path, *, donor_plate: int):
  125. maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
  126. donor_rel = "archive/1/donor.gcode.3mf"
  127. _write_3mf(tmp_path / donor_rel, plate=donor_plate, filaments=1)
  128. async with maker() as db:
  129. # A successfully archived print keeps the 3MF's name; the fallback
  130. # row keeps whatever the printer echoed, which here is the plate
  131. # path that tells us which plate is running.
  132. donor = PrintArchive(
  133. printer_id=1,
  134. filename="Trent.gcode.3mf",
  135. file_path=donor_rel,
  136. file_size=1,
  137. print_name="Trent",
  138. status="completed",
  139. )
  140. fallback = PrintArchive(
  141. printer_id=1,
  142. filename="Metadata/plate_2.gcode",
  143. file_path="",
  144. file_size=0,
  145. print_name="Trent",
  146. status="printing",
  147. extra_data={"no_3mf_available": True},
  148. )
  149. db.add_all([donor, fallback])
  150. await db.commit()
  151. await db.refresh(donor)
  152. await db.refresh(fallback)
  153. return maker, donor.id, fallback.id
  154. async def test_a_wrong_donor_is_refused(self, test_engine, tmp_path):
  155. maker, _, fallback_id = await self._seed(test_engine, tmp_path, donor_plate=1)
  156. async with maker() as db:
  157. archive = await db.get(PrintArchive, fallback_id)
  158. assert await _resolve_3mf_fallback(archive, db, tmp_path) is None
  159. async def test_a_matching_donor_is_still_used(self, test_engine, tmp_path):
  160. maker, _, fallback_id = await self._seed(test_engine, tmp_path, donor_plate=2)
  161. async with maker() as db:
  162. archive = await db.get(PrintArchive, fallback_id)
  163. resolved = await _resolve_3mf_fallback(archive, db, tmp_path)
  164. assert resolved is not None and resolved.name == "donor.gcode.3mf"
  165. async def test_the_library_branch_is_guarded_too(self, test_engine, tmp_path):
  166. """A library upload can be the wrong plate for exactly the same reason a
  167. previous archive can, and it is consulted first."""
  168. maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  169. _write_3mf(tmp_path / "library/Trent.3mf", plate=1, filaments=3)
  170. async with maker() as db:
  171. db.add(LibraryFile(filename="Trent.3mf", file_path="library/Trent.3mf", file_type="3mf", file_size=1))
  172. # The printer echoes the plate path, so the plate is knowable; the
  173. # search stem falls back to the print name, which is what finds the
  174. # library upload in the first place.
  175. archive = PrintArchive(
  176. printer_id=1,
  177. filename="Metadata/plate_2.gcode",
  178. file_path="",
  179. file_size=0,
  180. print_name="Trent",
  181. status="printing",
  182. )
  183. db.add(archive)
  184. await db.commit()
  185. await db.refresh(archive)
  186. assert await _resolve_3mf_fallback(archive, db, tmp_path) is None