| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304 |
- """Regression tests for derive_failure_reason in backend.app.main.
- Ensures user-cancelled prints don't get archived as "layerShift" — the bug
- seen on H2D where the firmware's cancel-sequence module-0x0C HMS was being
- matched by the old broad heuristic (`module == 0x0C → Layer shift`).
- """
- from __future__ import annotations
- import re
- from pathlib import Path
- import pytest
- 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)
- # ---------------------------------------------------------------------------
- @pytest.mark.parametrize("status", ["aborted", "cancelled"])
- def test_user_cancel_status_yields_user_cancelled(status: str) -> None:
- assert derive_failure_reason(status, None) == "userCancelled"
- assert derive_failure_reason(status, []) == "userCancelled"
- def test_completed_status_returns_none() -> None:
- assert derive_failure_reason("completed", None) is None
- # ---------------------------------------------------------------------------
- # H2D regression: cancel-sequence HMS must not be labelled "layerShift"
- # ---------------------------------------------------------------------------
- def test_h2d_cancel_module_0x0c_is_not_layer_shift() -> None:
- """0C00_001B is the H2D cancel-sequence echo, not a real layer-shift code.
- The old `module == 0x0C → Layer shift` heuristic mislabeled every user-cancel
- on H2D as a layer-shift failure. This pins that code to None.
- """
- h2d_cancel_hms = [
- {"code": "0x2001b", "attr": 0x0C000C00, "module": 0x0C, "severity": 1},
- {"code": "0x400c", "attr": 0x03002C0C, "module": 0x03, "severity": 3},
- ]
- assert derive_failure_reason("failed", h2d_cancel_hms) is None
- def test_unknown_module_0x0c_code_returns_none() -> None:
- """Any module-0x0C code we don't have an explicit short-code mapping for must
- leave failure_reason=None — being honest beats guessing."""
- unknown_hms = [{"code": "0x4099", "attr": 0x0C00_0000, "module": 0x0C, "severity": 2}]
- assert derive_failure_reason("failed", unknown_hms) is None
- # ---------------------------------------------------------------------------
- # Genuine failure modes still classified correctly
- # ---------------------------------------------------------------------------
- def test_real_layer_shift_short_code_detected() -> None:
- """0300_4057 ("Z-axis step loss") is a real layer-shift code from the wiki."""
- hms = [{"code": "0x4057", "attr": 0x0300_0000, "module": 0x03, "severity": 1}]
- assert derive_failure_reason("failed", hms) == "layerShift"
- def test_real_filament_runout_short_code_detected() -> None:
- """07FF_8011 = external filament runout."""
- hms = [{"code": "0x8011", "attr": 0x07FF_0000, "module": 0x07, "severity": 2}]
- assert derive_failure_reason("failed", hms) == "filamentRunout"
- def test_real_clogged_nozzle_short_code_detected() -> None:
- """0300_4006 = "The nozzle is clogged"."""
- hms = [{"code": "0x4006", "attr": 0x0300_0000, "module": 0x03, "severity": 1}]
- assert derive_failure_reason("failed", hms) == "cloggedNozzle"
- def test_first_matching_code_wins() -> None:
- """When multiple known codes are present, the first one in the list wins."""
- hms = [
- {"code": "0x4057", "attr": 0x0300_0000, "module": 0x03, "severity": 1}, # layer shift
- {"code": "0x8011", "attr": 0x07FF_0000, "module": 0x07, "severity": 2}, # filament runout
- ]
- assert derive_failure_reason("failed", hms) == "layerShift"
- def test_failed_with_no_hms_returns_none() -> None:
- assert derive_failure_reason("failed", None) is None
- assert derive_failure_reason("failed", []) is None
- # ---------------------------------------------------------------------------
- # Code-format tolerance (MQTT may send int or hex string)
- # ---------------------------------------------------------------------------
- def test_int_code_field_accepted() -> None:
- """The MQTT parser sometimes leaves `code` as an int rather than a hex string."""
- hms = [{"code": 0x4057, "attr": 0x0300_0000, "module": 0x03, "severity": 1}]
- 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)
- # ---------------------------------------------------------------------------
- def test_every_derived_reason_is_a_canonical_key() -> None:
- """The map may only hold values the rest of the stack agrees are reasons.
- Three writers used to put three spellings of one cause into
- ``failure_reason``. The whole point of #2974 is that there is now exactly
- one, so a display label sneaking back into the map -- which is what shipped
- for months -- has to fail here rather than in a user's Statistics panel.
- """
- from backend.app.api.routes.print_log import _FAILURE_REASON_KEYS
- from backend.app.main import _HMS_FAILURE_REASONS
- offenders = sorted(set(_HMS_FAILURE_REASONS.values()) - _FAILURE_REASON_KEYS)
- assert not offenders, f"not canonical failure-reason keys: {offenders}"
- @pytest.mark.parametrize("status", ["aborted", "cancelled", "failed"])
- def test_derived_reason_is_always_a_canonical_key(status: str) -> None:
- """Covers the status branch too, not just the HMS table."""
- from backend.app.api.routes.print_log import _FAILURE_REASON_KEYS
- from backend.app.main import _HMS_FAILURE_REASONS
- for code in _HMS_FAILURE_REASONS:
- attr = int(code.split("_")[0], 16) << 16
- reason = derive_failure_reason(status, [{"attr": attr, "code": int(code.split("_")[1], 16)}])
- assert reason is None or reason in _FAILURE_REASON_KEYS, reason
- def test_the_stale_paths_write_a_key_the_editor_will_not_discard() -> None:
- """Both stale writers in main.py store ``noStatusUpdate``.
- Read from the source rather than by calling them: they sit deep inside the
- MQTT archive paths and need a printer, a session and a live status. What
- matters is the value, and that the archive editor recognises it -- an
- unrecognised value opens the dropdown empty and the next save clears the
- classification outright.
- """
- from pathlib import Path
- from backend.app.api.routes.print_log import _FAILURE_REASON_KEYS
- source = Path(__file__).resolve().parents[3] / "backend" / "app" / "main.py"
- text = source.read_text(encoding="utf-8")
- assert "noStatusUpdate" in _FAILURE_REASON_KEYS
- assert text.count('failure_reason = "noStatusUpdate"') == 2
- assert "Stale - print likely cancelled" 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}"
|