test_3mf_fallback_matching.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  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. class TestSearchStem:
  22. def test_a_real_filename_still_wins(self):
  23. """Unchanged for every archive that has a 3MF of its own."""
  24. assert _threemf_search_stem("Cube.gcode.3mf", "Cube") == "Cube"
  25. assert _threemf_search_stem("benchy.3mf", None) == "benchy"
  26. def test_the_plate_path_is_refused(self):
  27. """The reported case: fall through to the model name instead."""
  28. assert _threemf_search_stem("/data/Metadata/plate_1.gcode", "Cube") == "Cube"
  29. @pytest.mark.parametrize("plate", ["plate_1", "plate_12", "plate1", "PLATE_3"])
  30. def test_every_plate_spelling_is_refused(self, plate):
  31. assert _threemf_search_stem(f"/data/Metadata/{plate}.gcode", None) is None
  32. def test_no_usable_name_matches_nothing(self):
  33. """Better to skip tracking than to charge a print for another model."""
  34. assert _threemf_search_stem("/data/Metadata/plate_1.gcode", None) is None
  35. assert _threemf_search_stem(None, None) is None
  36. assert _threemf_search_stem("", "") is None
  37. def test_a_model_named_after_a_plate_survives(self):
  38. """`lid_plate_1` names a model — only a bare plate stem is generic."""
  39. assert _threemf_search_stem("lid_plate_1.gcode.3mf", None) == "lid_plate_1"
  40. def test_whitespace_before_the_extension_is_preserved(self):
  41. """A real archive on the maintainer's install is named
  42. "…Face Down .gcode.3mf". Trimming the stem to "…Face Down" would stop it
  43. matching the very file it was derived from."""
  44. assert _threemf_search_stem("Steelers 6 Color Face Down .gcode.3mf", None) == "Steelers 6 Color Face Down "
  45. def test_surrounding_whitespace_is_still_ignored(self):
  46. assert _threemf_search_stem(" Cube.3mf ", None) == "Cube"
  47. class TestLikeEscaping:
  48. def test_underscores_are_literal(self):
  49. """``_`` is a single-character LIKE wildcard, and model names are full
  50. of them — unescaped, `Cube_v1` also matches `CubeXv1`."""
  51. assert _like_escape("Cube_v1") == "Cube\\_v1"
  52. def test_percent_and_backslash(self):
  53. assert _like_escape("100%_scale") == "100\\%\\_scale"
  54. assert _like_escape("a\\b") == "a\\\\b"
  55. class TestStemMatchesAtABoundary:
  56. """The SQL the matcher builds, checked by rendering it."""
  57. @staticmethod
  58. def _patterns(stem):
  59. from backend.app.models.archive import PrintArchive
  60. clause = _stem_matches(PrintArchive.filename, stem)
  61. return str(clause.compile(compile_kwargs={"literal_binds": True}))
  62. def test_it_no_longer_matches_a_suffix_of_a_longer_name(self):
  63. """The whole bug in one assertion: `%plate_1.%` is gone."""
  64. assert "%plate_1.%" not in self._patterns("plate_1")
  65. def test_it_anchors_the_basename(self):
  66. sql = self._patterns("Cube")
  67. assert "Cube.%" in sql
  68. assert "%/Cube.%" in sql
  69. def test_it_escapes_the_stem(self):
  70. assert "Cube\\_v1.%" in self._patterns("Cube_v1")
  71. @pytest.mark.asyncio
  72. async def test_the_h2d_collision_no_longer_resolves(db_session, tmp_path):
  73. """End to end against the real rows: a Cube on eMMC must not resolve to
  74. `lid_plate_1.gcode.3mf`."""
  75. from backend.app.models.archive import PrintArchive
  76. from backend.app.services.usage_tracker import _resolve_3mf_fallback
  77. donor_file = tmp_path / "archive" / "1" / "lid_plate_1.gcode.3mf"
  78. donor_file.parent.mkdir(parents=True)
  79. donor_file.write_bytes(b"PK\x03\x04not-really-a-3mf")
  80. donor = PrintArchive(
  81. printer_id=1,
  82. print_name="lid_plate_1",
  83. filename="lid_plate_1.gcode.3mf",
  84. file_path="archive/1/lid_plate_1.gcode.3mf",
  85. file_size=1,
  86. status="completed",
  87. )
  88. # The eMMC print: no file of its own, filename is the plate path.
  89. orphan = PrintArchive(
  90. printer_id=1,
  91. print_name="Cube",
  92. filename="/data/Metadata/plate_1.gcode",
  93. file_path="",
  94. file_size=0,
  95. status="completed",
  96. )
  97. db_session.add_all([donor, orphan])
  98. await db_session.commit()
  99. assert await _resolve_3mf_fallback(orphan, db_session, tmp_path) is None
  100. @pytest.mark.asyncio
  101. async def test_a_genuine_same_model_reprint_still_resolves(db_session, tmp_path):
  102. """The fallback's actual purpose must survive the fix."""
  103. from backend.app.models.archive import PrintArchive
  104. from backend.app.services.usage_tracker import _resolve_3mf_fallback
  105. donor_file = tmp_path / "archive" / "1" / "Cube.gcode.3mf"
  106. donor_file.parent.mkdir(parents=True)
  107. donor_file.write_bytes(b"PK\x03\x04not-really-a-3mf")
  108. donor = PrintArchive(
  109. printer_id=1,
  110. print_name="Cube",
  111. filename="Cube.gcode.3mf",
  112. file_path="archive/1/Cube.gcode.3mf",
  113. file_size=1,
  114. status="completed",
  115. )
  116. orphan = PrintArchive(
  117. printer_id=1,
  118. print_name="Cube",
  119. filename="/data/Metadata/plate_1.gcode",
  120. file_path="",
  121. file_size=0,
  122. status="completed",
  123. )
  124. db_session.add_all([donor, orphan])
  125. await db_session.commit()
  126. assert await _resolve_3mf_fallback(orphan, db_session, tmp_path) == donor_file