Explorar o código

Queue: a print the printer resumes finishes its job (#3485)

- A printer that picks a print back up (A1 mini after power-loss
  recovery, or a print Bambuddy reconnects to after a restart) reports
  the subtask as the file name with ".3mf"; the completion guard
  stripped extensions from the archive side only, so the completion
  was ignored and the queue item stayed "printing", blocking the queue
- New _dispatched_name_matches_echo() tries the echo as it came and
  with its extensions stripped; used by the completion guard and the
  external-print outcome check, which had the same gap
- Tests for the resumed-echo names from the report, a model named
  "Foo.3mf", and a different file still being rejected
maziggy hai 2 días
pai
achega
a071344f72
Modificáronse 2 ficheiros con 74 adicións e 3 borrados
  1. 17 2
      backend/app/main.py
  2. 57 1
      backend/tests/unit/test_completion_queue_item_match.py

+ 17 - 2
backend/app/main.py

@@ -4637,7 +4637,7 @@ async def _decide_outcome_for_external_print(db, printer_id: int, observed_name:
 
     expected = await _queue_item_dispatched_name(db, dispatched_here)
     observed = (observed_name or "").strip()
-    if not expected or not observed or _subtask_names_match(expected, observed):
+    if not expected or not observed or _dispatched_name_matches_echo(expected, observed):
         logger.info(
             "[CALLBACK] Not asking for the outcome on printer %s: queue item %s is still printing, so "
             "Bambuddy dispatched this run and the item's own ask-for-outcome flag decides.",
@@ -8949,6 +8949,21 @@ def _subtask_names_match(expected: str, observed: str) -> bool:
     return False
 
 
+def _dispatched_name_matches_echo(expected: str, observed: str) -> bool:
+    """Whether the printer's subtask echo is the name a print was dispatched as.
+
+    A dispatched print is echoed under its bare name at start, but a printer
+    that picks a print back up -- Bambuddy reconnecting to a print already
+    running, or an A1 mini resuming after power-loss recovery -- can report the
+    file name with its extension instead (#3485). The echo is tried both as it
+    came and with the extensions stripped; the raw form still has to count, so
+    a model named ``Foo.3mf`` (file ``Foo.3mf.gcode.3mf``) keeps matching.
+    """
+    return _subtask_names_match(expected, observed) or _subtask_names_match(
+        expected, _subtask_name_from_filename(observed)
+    )
+
+
 async def _queue_item_dispatched_name(db, item) -> str:
     """The subtask name *item* was dispatched under, or "" when unknowable.
 
@@ -8993,7 +9008,7 @@ async def _completion_belongs_to_queue_item(db, item, data: dict) -> bool:
         return True
 
     expected = _subtask_name_from_filename(archive.filename)
-    if not expected or _subtask_names_match(expected, observed):
+    if not expected or _dispatched_name_matches_echo(expected, observed):
         return True
 
     logging.getLogger(__name__).warning(

+ 57 - 1
backend/tests/unit/test_completion_queue_item_match.py

@@ -14,10 +14,15 @@ the unverifiable cases through rather than strand an item in ``printing``.
 
 import pytest
 
-from backend.app.main import _completion_belongs_to_queue_item, _subtask_name_from_filename
+from backend.app.main import (
+    _ask_outcome_for_external_print,
+    _completion_belongs_to_queue_item,
+    _subtask_name_from_filename,
+)
 from backend.app.models.archive import PrintArchive
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
+from backend.app.models.settings import Settings
 
 
 class TestSubtaskNameFromFilename:
@@ -89,6 +94,37 @@ class TestCompletionBelongsToQueueItem:
 
         assert await _completion_belongs_to_queue_item(db_session, item, {"subtask_name": "ams_rack"}) is True
 
+    @pytest.mark.parametrize(
+        ("archive_filename", "subtask"),
+        [
+            # A1 mini after power-loss recovery: dispatched as the bare name,
+            # resumed and completed under the file name (#3485).
+            (
+                "Bottom-heavy; stands upright without saucer.3mf",
+                "Bottom-heavy;_stands_upright_without_saucer.3mf",
+            ),
+            ("mini-bench_plate_1.3mf", "mini-bench_plate_1.3mf"),
+            ("new bench.gcode.3mf", "new_bench.3mf"),
+            ("new bench.gcode.3mf", "new_bench.gcode.3mf"),
+        ],
+    )
+    async def test_accepts_its_own_print_echoed_with_the_extension(self, db_session, archive_filename, subtask):
+        item = await _seed(db_session, archive_filename=archive_filename)
+
+        assert await _completion_belongs_to_queue_item(db_session, item, {"subtask_name": subtask}) is True
+
+    async def test_a_model_named_like_a_file_still_matches(self, db_session):
+        # "Foo.3mf" is the model's own name here; stripping the echo to "Foo"
+        # must not be the only form tried.
+        item = await _seed(db_session, archive_filename="Foo.3mf.gcode.3mf")
+
+        assert await _completion_belongs_to_queue_item(db_session, item, {"subtask_name": "Foo.3mf"}) is True
+
+    async def test_an_extension_does_not_make_another_print_match(self, db_session):
+        item = await _seed(db_session, archive_filename="AMS_Rack.gcode.3mf")
+
+        assert await _completion_belongs_to_queue_item(db_session, item, {"subtask_name": "Test.3mf"}) is False
+
     @pytest.mark.parametrize("subtask", [None, "", "   "])
     async def test_lets_an_unidentified_completion_through(self, db_session, subtask):
         # No subtask name to compare means unverifiable, not wrong. Refusing here
@@ -119,6 +155,26 @@ class TestCompletionBelongsToQueueItem:
         assert await _completion_belongs_to_queue_item(db_session, item, {"subtask_name": "Anything"}) is True
 
 
+@pytest.mark.asyncio
+class TestOutcomePromptForAResumedQueuePrint:
+    """The outcome prompt holds a printing queue row against the observed name
+    with the same rule, so a resumed queue print must not read as external."""
+
+    async def test_a_resumed_queue_print_is_not_asked_about(self, db_session):
+        item = await _seed(db_session, archive_filename="mini-bench_plate_1.3mf")
+        db_session.add(Settings(key="confirm_outcome_external_prints", value="true"))
+        await db_session.flush()
+
+        assert await _ask_outcome_for_external_print(db_session, item.printer_id, "mini-bench_plate_1.3mf") is False
+
+    async def test_another_file_is_still_asked_about(self, db_session):
+        item = await _seed(db_session, archive_filename="mini-bench_plate_1.3mf")
+        db_session.add(Settings(key="confirm_outcome_external_prints", value="true"))
+        await db_session.flush()
+
+        assert await _ask_outcome_for_external_print(db_session, item.printer_id, "Other.3mf") is True
+
+
 @pytest.mark.sqlite_only  # on PostgreSQL the guard checks the throwaway server's databases instead
 class TestDisposableDatabaseGuard:
     """The suite must never be able to open a session against a real database.