|
@@ -7,9 +7,27 @@ matched by the old broad heuristic (`module == 0x0C → Layer shift`).
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
+import re
|
|
|
|
|
+from pathlib import Path
|
|
|
|
|
+
|
|
|
import pytest
|
|
import pytest
|
|
|
|
|
|
|
|
-from backend.app.main import derive_failure_reason
|
|
|
|
|
|
|
+from backend.app.main import _HMS_FAILURE_REASONS, derive_failure_reason
|
|
|
|
|
+
|
|
|
|
|
+REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
|
|
|
+_EN_TS = REPO_ROOT / "frontend" / "src" / "i18n" / "locales" / "en.ts"
|
|
|
|
|
+_EDIT_ARCHIVE_MODAL = REPO_ROOT / "frontend" / "src" / "components" / "EditArchiveModal.tsx"
|
|
|
|
|
+
|
|
|
|
|
+# Dockerfile.test copies backend/, pyproject.toml and the requirements files and
|
|
|
|
|
+# nothing else, so frontend/ does not exist inside the test image and the two
|
|
|
|
|
+# tests below that read it have nothing to check. A source checkout always has
|
|
|
|
|
+# it and keeps those guards live on every test_backend.sh run.
|
|
|
|
|
+# frontend/package.json is present in every checkout and never in the image,
|
|
|
|
|
+# which is what the launcher config tests use to tell the two apart.
|
|
|
|
|
+_needs_the_frontend_tree = pytest.mark.skipif(
|
|
|
|
|
+ not (REPO_ROOT / "frontend" / "package.json").is_file(),
|
|
|
|
|
+ reason="frontend/ isn't shipped in the Docker test image; the guards run in native runs",
|
|
|
|
|
+)
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ---------------------------------------------------------------------------
|
|
|
# Status-based reasons (no HMS lookup needed)
|
|
# Status-based reasons (no HMS lookup needed)
|
|
@@ -99,6 +117,72 @@ def test_int_code_field_accepted() -> None:
|
|
|
assert derive_failure_reason("failed", hms) == "layerShift"
|
|
assert derive_failure_reason("failed", hms) == "layerShift"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# AI print monitoring (issue #2946)
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_ai_spaghetti_detection_is_classified() -> None:
|
|
|
|
|
+ """0300_8003 is what the onboard AI raises when it halts a print for spaghetti.
|
|
|
|
|
+
|
|
|
|
|
+ Taken from the archive that reported this: the printer sent
|
|
|
|
|
+ ``attr=50364419, code='0x8003'``, which is 0x0300_8003, and the archive was
|
|
|
|
|
+ written with failure_reason=None because the map had no row for it. The text
|
|
|
|
|
+ for the code was already in the tree twice — hms_errors.py and
|
|
|
|
|
+ HMSErrorModal.tsx — so this was a missing key, not a missing meaning.
|
|
|
|
|
+
|
|
|
|
|
+ The dict is the one bambu_mqtt.py builds for it: attr holding the whole
|
|
|
|
|
+ word is the print_error branch, which is also where severity=3 comes from.
|
|
|
|
|
+ That 3 is a constant for every print_error entry, not a level the printer
|
|
|
|
|
+ sent, and nothing here depends on it.
|
|
|
|
|
+ """
|
|
|
|
|
+ hms = [{"code": "0x8003", "attr": 50364419, "module": 0x03, "severity": 3}]
|
|
|
|
|
+ assert derive_failure_reason("failed", hms) == "spaghettiDetached"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_the_ai_monitors_other_code_is_classified_too() -> None:
|
|
|
|
|
+ """0C00_8042 is the same event reported from the motion-controller module.
|
|
|
|
|
+
|
|
|
|
|
+ hms_errors.py documents it as "The AI print monitor has detected a spaghetti
|
|
|
|
|
+ defect", so it is a full short code with a published meaning rather than the
|
|
|
|
|
+ module-0x0C guessing the map header rules out.
|
|
|
|
|
+ """
|
|
|
|
|
+ hms = [{"code": "0x8042", "attr": 0x0C00_0000, "module": 0x0C}]
|
|
|
|
|
+ assert derive_failure_reason("failed", hms) == "spaghettiDetached"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.parametrize(
|
|
|
|
|
+ ("short_code", "attr", "code"),
|
|
|
|
|
+ [
|
|
|
|
|
+ # "Possible spaghetti failure was detected." — a warning about a print
|
|
|
|
|
+ # that is still running, not a print that stopped.
|
|
|
|
|
+ ("0C00_C004", 0x0C00_0000, "0xC004"),
|
|
|
|
|
+ # AI monitoring, but a filament pile-up in the waste chute.
|
|
|
|
|
+ ("0300_800A", 0x0300_0000, "0x800A"),
|
|
|
|
|
+ ],
|
|
|
|
|
+)
|
|
|
|
|
+def test_the_ai_monitors_warnings_are_left_unclassified(short_code: str, attr: int, code: str) -> None:
|
|
|
|
|
+ """Being AI monitoring is not the criterion — halting the print is.
|
|
|
|
|
+
|
|
|
|
|
+ Both of these are in hms_errors.py and both would be easy to sweep in with
|
|
|
|
|
+ the two that are mapped. Neither means the print failed, and a wrong reason
|
|
|
|
|
+ on an archive is worse than none, so they stay out and this says so.
|
|
|
|
|
+ """
|
|
|
|
|
+ assert short_code not in _HMS_FAILURE_REASONS
|
|
|
|
|
+ hms = [{"code": code, "attr": attr, "module": attr >> 24}]
|
|
|
|
|
+ assert derive_failure_reason("failed", hms) is None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_ai_detection_and_its_runout_neighbour_are_distinct() -> None:
|
|
|
|
|
+ """0300_8003 and 0300_8004 are one hex digit apart and arrive by the same
|
|
|
|
|
+ path. The runout side was already mapped; this keeps them from drifting into
|
|
|
|
|
+ each other."""
|
|
|
|
|
+ ai = [{"code": "0x8003", "attr": 0x0300_0000, "module": 0x03}]
|
|
|
|
|
+ runout = [{"code": "0x8004", "attr": 0x0300_0000, "module": 0x03}]
|
|
|
|
|
+ assert derive_failure_reason("failed", ai) == "spaghettiDetached"
|
|
|
|
|
+ assert derive_failure_reason("failed", runout) == "filamentRunout"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
# ---------------------------------------------------------------------------
|
|
# ---------------------------------------------------------------------------
|
|
|
# One vocabulary in storage (issue #2974)
|
|
# One vocabulary in storage (issue #2974)
|
|
|
# ---------------------------------------------------------------------------
|
|
# ---------------------------------------------------------------------------
|
|
@@ -151,3 +235,70 @@ def test_the_stale_paths_write_a_key_the_editor_will_not_discard() -> None:
|
|
|
assert text.count('failure_reason = "noStatusUpdate"') == 2
|
|
assert text.count('failure_reason = "noStatusUpdate"') == 2
|
|
|
assert "Stale - print likely cancelled" not in text
|
|
assert "Stale - print likely cancelled" not in text
|
|
|
assert "Stale - reconciled after reconnect" not in text
|
|
assert "Stale - reconciled after reconnect" not in text
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# The vocabulary spans two languages, and only a comment says so
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _keys_the_dropdown_offers() -> set[str]:
|
|
|
|
|
+ """The `FAILURE_REASON_KEYS` array exported from EditArchiveModal.tsx."""
|
|
|
|
|
+ source = _EDIT_ARCHIVE_MODAL.read_text(encoding="utf-8")
|
|
|
|
|
+ block = re.search(r"export const FAILURE_REASON_KEYS = \[(.*?)\] as const;", source, re.S)
|
|
|
|
|
+ assert block is not None, f"no FAILURE_REASON_KEYS array in {_EDIT_ARCHIVE_MODAL}"
|
|
|
|
|
+ return set(re.findall(r"'([^']+)'", block.group(1)))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _keys_the_frontend_can_translate() -> set[str]:
|
|
|
|
|
+ """Every key in the `editArchive.failureReasons` block of en.ts."""
|
|
|
|
|
+ source = _EN_TS.read_text(encoding="utf-8")
|
|
|
|
|
+ # Up to the brace that closes the block on its own line, so a `}` inside a
|
|
|
|
|
+ # label (an ICU placeholder, say) does not cut the block short.
|
|
|
|
|
+ block = re.search(r"failureReasons:\s*\{(.*?)^\s*\}", source, re.S | re.M)
|
|
|
|
|
+ assert block is not None, f"no failureReasons block in {_EN_TS}"
|
|
|
|
|
+ # Either quote: a label with an apostrophe is written double-quoted in TS.
|
|
|
|
|
+ return set(re.findall(r"^\s*(\w+):\s*['\"]", block.group(1), re.M))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@_needs_the_frontend_tree
|
|
|
|
|
+def test_the_backend_vocabulary_matches_the_one_the_frontend_offers() -> None:
|
|
|
|
|
+ """The only thing holding the two lists together is a comment asking nicely.
|
|
|
|
|
+
|
|
|
|
|
+ ``_FAILURE_REASON_KEYS`` in api/routes/print_log.py gates every write, and
|
|
|
|
|
+ its own comment says "Keep these two lists in sync if the EditArchiveModal
|
|
|
|
|
+ options ever change". Nothing enforces it, and the drift is silent in both
|
|
|
|
|
+ directions: a key the frontend offers but the backend rejects turns a save
|
|
|
|
|
+ into a 400 the modal has no surface for, and a key the backend accepts but
|
|
|
|
|
+ the dropdown omits is a value the editor discards the next time anyone
|
|
|
|
|
+ opens that archive.
|
|
|
|
|
+
|
|
|
|
|
+ Asserting one Python literal against another cannot see either — the other
|
|
|
|
|
+ end lives in TypeScript, so the check has to read it.
|
|
|
|
|
+ """
|
|
|
|
|
+ from backend.app.api.routes.print_log import _FAILURE_REASON_KEYS
|
|
|
|
|
+
|
|
|
|
|
+ offered = _keys_the_dropdown_offers()
|
|
|
|
|
+ assert offered, "the FAILURE_REASON_KEYS array parsed as empty; the regex has gone stale"
|
|
|
|
|
+
|
|
|
|
|
+ # "" is the backend's "clear the classification" value; the dropdown spells
|
|
|
|
|
+ # that as its own placeholder option rather than a key, so it is not drift.
|
|
|
|
|
+ backend_keys = set(_FAILURE_REASON_KEYS) - {""}
|
|
|
|
|
+
|
|
|
|
|
+ assert backend_keys == offered, (
|
|
|
|
|
+ f"backend-only: {sorted(backend_keys - offered)}, frontend-only: {sorted(offered - backend_keys)}"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@_needs_the_frontend_tree
|
|
|
|
|
+def test_every_offered_key_has_english_text() -> None:
|
|
|
|
|
+ """A key with no en.ts entry renders as the raw key in the dropdown.
|
|
|
|
|
+
|
|
|
|
|
+ The parity script covers the other 13 locales against en.ts, so en.ts is the
|
|
|
|
|
+ one end of this that nothing else checks.
|
|
|
|
|
+ """
|
|
|
|
|
+ translatable = _keys_the_frontend_can_translate()
|
|
|
|
|
+ assert translatable, "the failureReasons block parsed as empty; the regex has gone stale"
|
|
|
|
|
+
|
|
|
|
|
+ untranslated = sorted(_keys_the_dropdown_offers() - translatable)
|
|
|
|
|
+ assert not untranslated, f"offered by the dropdown with no editArchive.failureReasons entry: {untranslated}"
|