test_3mf_fallback_matching.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. """A no-3MF archive must not borrow a stranger's 3MF (#2843).
  2. H2-series and P2S firmware keeps a slicer-sent file on internal eMMC, which
  3. Bambuddy cannot read, so the print becomes an archive with no file and its
  4. ``filename`` stays the path the printer is executing:
  5. ``/data/Metadata/plate_1.gcode``.
  6. Measured on the maintainer's H2D, 2026-08-17. The stem of that path is
  7. ``plate_1``, the old matcher searched ``filename ILIKE '%plate_1.%'``, and
  8. ``lid_plate_1.gcode.3mf`` matched — so a 1.6 g Cube was costed from a 207 g
  9. four-colour ABS print:
  10. [UsageTracker] 3MF fallback: found previous archive 287 file for archive 345
  11. [UsageTracker] 3MF: slot_id=2 -> global_tray=4 -> AMS1-T0 (used_g=204.9 ...)
  12. Every Bambu print has a ``plate_N``, so this was not a near-miss between similar
  13. names — it was a name that matches everything.
  14. """
  15. import pytest
  16. from backend.app.services.usage_tracker import (
  17. _like_escape,
  18. _stem_matches,
  19. _threemf_search_stem,
  20. )
  21. # Their rows refer to printer 1.
  22. pytestmark = pytest.mark.usefixtures("printer_1")
  23. class TestSearchStem:
  24. def test_a_real_filename_still_wins(self):
  25. """Unchanged for every archive that has a 3MF of its own."""
  26. assert _threemf_search_stem("Cube.gcode.3mf", "Cube") == "Cube"
  27. assert _threemf_search_stem("benchy.3mf", None) == "benchy"
  28. def test_the_plate_path_is_refused(self):
  29. """The reported case: fall through to the model name instead."""
  30. assert _threemf_search_stem("/data/Metadata/plate_1.gcode", "Cube") == "Cube"
  31. @pytest.mark.parametrize("plate", ["plate_1", "plate_12", "plate1", "PLATE_3"])
  32. def test_every_plate_spelling_is_refused(self, plate):
  33. assert _threemf_search_stem(f"/data/Metadata/{plate}.gcode", None) is None
  34. def test_no_usable_name_matches_nothing(self):
  35. """Better to skip tracking than to charge a print for another model."""
  36. assert _threemf_search_stem("/data/Metadata/plate_1.gcode", None) is None
  37. assert _threemf_search_stem(None, None) is None
  38. assert _threemf_search_stem("", "") is None
  39. def test_a_model_named_after_a_plate_survives(self):
  40. """`lid_plate_1` names a model — only a bare plate stem is generic."""
  41. assert _threemf_search_stem("lid_plate_1.gcode.3mf", None) == "lid_plate_1"
  42. def test_whitespace_before_the_extension_is_preserved(self):
  43. """A real archive on the maintainer's install is named
  44. "…Face Down .gcode.3mf". Trimming the stem to "…Face Down" would stop it
  45. matching the very file it was derived from."""
  46. assert _threemf_search_stem("Steelers 6 Color Face Down .gcode.3mf", None) == "Steelers 6 Color Face Down "
  47. def test_surrounding_whitespace_is_still_ignored(self):
  48. assert _threemf_search_stem(" Cube.3mf ", None) == "Cube"
  49. class TestLikeEscaping:
  50. def test_underscores_are_literal(self):
  51. """``_`` is a single-character LIKE wildcard, and model names are full
  52. of them — unescaped, `Cube_v1` also matches `CubeXv1`."""
  53. assert _like_escape("Cube_v1") == "Cube\\_v1"
  54. def test_percent_and_backslash(self):
  55. assert _like_escape("100%_scale") == "100\\%\\_scale"
  56. assert _like_escape("a\\b") == "a\\\\b"
  57. class TestStemMatchesAtABoundary:
  58. """The SQL the matcher builds, checked by rendering it."""
  59. @staticmethod
  60. def _patterns(stem):
  61. from backend.app.models.archive import PrintArchive
  62. clause = _stem_matches(PrintArchive.filename, stem)
  63. return str(clause.compile(compile_kwargs={"literal_binds": True}))
  64. def test_it_no_longer_matches_a_suffix_of_a_longer_name(self):
  65. """The whole bug in one assertion: `%plate_1.%` is gone."""
  66. assert "%plate_1.%" not in self._patterns("plate_1")
  67. def test_it_anchors_the_basename(self):
  68. sql = self._patterns("Cube")
  69. assert "Cube.%" in sql
  70. assert "%/Cube.%" in sql
  71. def test_it_escapes_the_stem(self):
  72. assert "Cube\\_v1.%" in self._patterns("Cube_v1")
  73. @pytest.mark.asyncio
  74. async def test_the_h2d_collision_no_longer_resolves(db_session, tmp_path):
  75. """End to end against the real rows: a Cube on eMMC must not resolve to
  76. `lid_plate_1.gcode.3mf`."""
  77. from backend.app.models.archive import PrintArchive
  78. from backend.app.services.usage_tracker import _resolve_3mf_fallback
  79. donor_file = tmp_path / "archive" / "1" / "lid_plate_1.gcode.3mf"
  80. donor_file.parent.mkdir(parents=True)
  81. donor_file.write_bytes(b"PK\x03\x04not-really-a-3mf")
  82. donor = PrintArchive(
  83. printer_id=1,
  84. print_name="lid_plate_1",
  85. filename="lid_plate_1.gcode.3mf",
  86. file_path="archive/1/lid_plate_1.gcode.3mf",
  87. file_size=1,
  88. status="completed",
  89. )
  90. # The eMMC print: no file of its own, filename is the plate path.
  91. orphan = PrintArchive(
  92. printer_id=1,
  93. print_name="Cube",
  94. filename="/data/Metadata/plate_1.gcode",
  95. file_path="",
  96. file_size=0,
  97. status="completed",
  98. )
  99. db_session.add_all([donor, orphan])
  100. await db_session.commit()
  101. assert await _resolve_3mf_fallback(orphan, db_session, tmp_path) is None
  102. @pytest.mark.asyncio
  103. async def test_a_genuine_same_model_reprint_still_resolves(db_session, tmp_path):
  104. """The fallback's actual purpose must survive the fix."""
  105. from backend.app.models.archive import PrintArchive
  106. from backend.app.services.usage_tracker import _resolve_3mf_fallback
  107. donor_file = tmp_path / "archive" / "1" / "Cube.gcode.3mf"
  108. donor_file.parent.mkdir(parents=True)
  109. donor_file.write_bytes(b"PK\x03\x04not-really-a-3mf")
  110. donor = PrintArchive(
  111. printer_id=1,
  112. print_name="Cube",
  113. filename="Cube.gcode.3mf",
  114. file_path="archive/1/Cube.gcode.3mf",
  115. file_size=1,
  116. status="completed",
  117. )
  118. orphan = PrintArchive(
  119. printer_id=1,
  120. print_name="Cube",
  121. filename="/data/Metadata/plate_1.gcode",
  122. file_path="",
  123. file_size=0,
  124. status="completed",
  125. )
  126. db_session.add_all([donor, orphan])
  127. await db_session.commit()
  128. assert await _resolve_3mf_fallback(orphan, db_session, tmp_path) == donor_file