Browse Source

Close the queue run of a file whose name ends in a space (#3241)

maziggy 1 day ago
parent
commit
71a98800e1

+ 1 - 0
CHANGELOG.md

@@ -138,6 +138,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **A queue job stayed "printing" for five minutes when its file name ended in a space (#3241, reported by @Thomansky)** — A file saved as `Part .gcode.3mf` is sent to the printer as `Part `, and the printer reports it back as `Part_`, turning that space into an underscore like every other. Bambuddy dropped the space from its own side before comparing, so the names differed, the finish was ignored as belonging to another print, and the job showed "printing" until the stale-job cleanup closed it about five minutes later, holding up the printer's next job. The finish photo's plate restore skipped these prints for the same reason. A space at the start of the name had the same effect. Both are now matched.
 - **The finish photo never raised the plate back into view (#3240, reported by @Thomansky)** — Since 1.2.5.2, Bambuddy is meant to raise the build plate back to just above the finished print before the finish photo, then lower it again, because the printer's end G-code drops the plate about 100 mm. It never did, on any printer: the lookup for the print's height failed every time and logged nothing above debug level. It read the archived file from a setting that does not exist, and it matched the archive by exact name, while the printer reports a name with its spaces turned into underscores. Both are fixed, so the plate now really moves after a print when **Restore plate for finish photo** is on, which it is by default. Turn it off under **Settings** > **General** to keep the photo where the plate stops. Because the plate now really moves, the print's height is taken only from the archive Bambuddy linked to that print when it started, and from the plate that was sent, never from another archive that merely has the same name: every plate of a multi-plate file shares one name, and support bundles showed the old lookup picking another plate's archive, stopped only by its layer count. That archive must also match the print's name exactly, never part of it or a shortened version; its layer count must agree with the printer's; and the plate stays put when another job is queued for the printer or the print was not linked to an archive.
 - **An "Any model" job was held for too little filament on one printer while another idle printer of that model had plenty (#3137, reported by @RambachTJ)** — The queue gave a job queued for **Any H2D Pro** (or any model) to the first idle printer with the right filament type, and only then checked whether its spools held enough. When they did not, the job waited on that printer for a manual start, even though another idle printer of the same model could run it; the only way out was to move it by hand. The queue now passes over a printer that would run short and gives the job to one that has enough. If every idle printer would run short, the job still waits on the first, where **Print Anyway** works as before, and moves by itself once another idle printer of its model has enough filament, on record in Inventory or Spoolman.
 - **Shutting Bambuddy down logged an error with a traceback when nothing was wrong (#3243, reported by @Thomansky)** — Stopping Bambuddy cancels the printer updates still waiting to be handled, and each cancelled one was logged as `ERROR ... Exception in scheduled callback` with a traceback, as the last line before the restart. In a log or a support bundle it looked like a crash. A cancelled update now ends quietly; one that actually fails is still logged as an error.

+ 12 - 2
backend/app/main.py

@@ -7077,8 +7077,12 @@ def _normalise_subtask_name(name: str) -> str:
     directory search normalises both sides before comparing. This exists so
     the completion check reads the same rule from the same place instead of
     growing its own, which is exactly how it came to disagree (#2829).
+
+    A space at either end is substituted too: ``Part .gcode.3mf`` is
+    dispatched as ``Part `` and echoed as ``Part_``. Underscores at the ends
+    are dropped after the substitution so both sides agree (#3241).
     """
-    return name.strip().replace(" ", "_").casefold()
+    return name.strip().replace(" ", "_").strip("_").casefold()
 
 
 def _subtask_names_match(expected: str, observed: str) -> bool:
@@ -7097,7 +7101,13 @@ def _subtask_names_match(expected: str, observed: str) -> bool:
     # echoes, and an archive whose own filename was recorded from a previous
     # truncated echo carries the marker too.
     for full, cut in ((expected_n, observed_n), (observed_n, expected_n)):
-        if cut.endswith(_SUBTASK_TRUNCATION_MARKER) and full.startswith(cut[: -len(_SUBTASK_TRUNCATION_MARKER)]):
+        if not cut.endswith(_SUBTASK_TRUNCATION_MARKER):
+            continue
+        # The full side has lost its edge underscores; a cut that lands right
+        # after one keeps it, so drop it here too (#3241). Nothing left before
+        # the marker is no evidence of anything, and must not match every name.
+        kept = cut[: -len(_SUBTASK_TRUNCATION_MARKER)].rstrip("_")
+        if kept and full.startswith(kept):
             return True
     return False
 

+ 33 - 0
backend/tests/unit/test_completion_subtask_match_2829.py

@@ -38,6 +38,36 @@ class TestTheReportedCase:
         assert _subtask_names_match(expected, "H2D_Carbon_Filter_(V2)_Body_&_Solid_Lid")
 
 
+class TestASpaceAtEitherEnd:
+    """#3241: a file saved with a space before its extension. The printer turns
+    that space into an underscore like every other, so stripping whitespace
+    before the substitution left the underscore on the echo alone."""
+
+    def test_a_trailing_space_matches_its_trailing_underscore(self):
+        """Queue item 1460, verbatim from the warning it logged."""
+        expected = _subtask_name_from_filename("Unterteil H2S mit Logo V24 .gcode.3mf")
+
+        assert _subtask_names_match(expected, "Unterteil_H2S_mit_Logo_V24_")
+
+    def test_a_leading_space_matches_its_leading_underscore(self):
+        assert _subtask_names_match(" Deckel", "_Deckel")
+
+    def test_a_different_print_still_does_not_match(self):
+        assert not _subtask_names_match("Unterteil H2S mit Logo V24 ", "Unterteil_H2S_mit_Logo_V25_")
+
+    def test_a_truncated_echo_of_it_still_matches(self):
+        assert _subtask_names_match("Unterteil H2S mit Logo V24 ", "Unterteil_H2S_mit...")
+
+    def test_a_cut_right_after_the_last_underscore_still_matches(self):
+        """The full side loses its edge underscore; the cut must too."""
+        assert _subtask_names_match("Part_", "Part_...")
+        assert _subtask_names_match("Unterteil H2S mit Logo V24 ", "Unterteil_H2S_mit_Logo_V24_...")
+
+    def test_a_cut_with_nothing_before_the_marker_matches_nothing(self):
+        assert not _subtask_names_match("Benchy", "_...")
+        assert not _subtask_names_match("Benchy", "...")
+
+
 class TestTruncation:
     """The printer cuts long names and marks the cut with '...'.
 
@@ -101,6 +131,9 @@ class TestNormalisation:
             ("A_B", "a_b"),
             (" A  B ", "a__b"),
             ("Mundstück", "mundstück"),
+            ("V24 ", "v24"),
+            ("V24_", "v24"),
+            (" V24", "v24"),
         ],
     )
     def test_canonical_form(self, raw, expected):

+ 5 - 0
backend/tests/unit/test_finish_photo_moment_sync.py

@@ -910,6 +910,11 @@ class TestMaxZResolution:
         assert await self._resolve("Bracket_Left.gcode", archive_id) == 16.0
         assert await self._resolve("Bracket Left.gcode.3mf", archive_id) == 16.0
 
+    async def test_matches_a_file_name_that_ends_in_a_space(self, resolver_env):
+        """#3241: "Part .gcode.3mf" is echoed as "Part_"."""
+        archive_id = await self._archive(resolver_env, print_name="Part ", filename="Part .gcode.3mf")
+        assert await self._resolve("Part_", archive_id) == 16.0
+
     async def test_refuses_a_bound_archive_with_another_name(self, resolver_env):
         archive_id = await self._archive(resolver_env)
         assert await self._resolve("other", archive_id) is None