test_failure_reason_derivation.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. """Regression tests for derive_failure_reason in backend.app.main.
  2. Ensures user-cancelled prints don't get archived as "layerShift" — the bug
  3. seen on H2D where the firmware's cancel-sequence module-0x0C HMS was being
  4. matched by the old broad heuristic (`module == 0x0C → Layer shift`).
  5. """
  6. from __future__ import annotations
  7. import re
  8. from pathlib import Path
  9. import pytest
  10. from backend.app.main import _HMS_FAILURE_REASONS, derive_failure_reason
  11. REPO_ROOT = Path(__file__).resolve().parents[3]
  12. _EN_TS = REPO_ROOT / "frontend" / "src" / "i18n" / "locales" / "en.ts"
  13. _EDIT_ARCHIVE_MODAL = REPO_ROOT / "frontend" / "src" / "components" / "EditArchiveModal.tsx"
  14. # Dockerfile.test copies backend/, pyproject.toml and the requirements files and
  15. # nothing else, so frontend/ does not exist inside the test image and the two
  16. # tests below that read it have nothing to check. A source checkout always has
  17. # it and keeps those guards live on every test_backend.sh run.
  18. # frontend/package.json is present in every checkout and never in the image,
  19. # which is what the launcher config tests use to tell the two apart.
  20. _needs_the_frontend_tree = pytest.mark.skipif(
  21. not (REPO_ROOT / "frontend" / "package.json").is_file(),
  22. reason="frontend/ isn't shipped in the Docker test image; the guards run in native runs",
  23. )
  24. # ---------------------------------------------------------------------------
  25. # Status-based reasons (no HMS lookup needed)
  26. # ---------------------------------------------------------------------------
  27. @pytest.mark.parametrize("status", ["aborted", "cancelled"])
  28. def test_user_cancel_status_yields_user_cancelled(status: str) -> None:
  29. assert derive_failure_reason(status, None) == "userCancelled"
  30. assert derive_failure_reason(status, []) == "userCancelled"
  31. def test_completed_status_returns_none() -> None:
  32. assert derive_failure_reason("completed", None) is None
  33. # ---------------------------------------------------------------------------
  34. # H2D regression: cancel-sequence HMS must not be labelled "layerShift"
  35. # ---------------------------------------------------------------------------
  36. def test_h2d_cancel_module_0x0c_is_not_layer_shift() -> None:
  37. """0C00_001B is the H2D cancel-sequence echo, not a real layer-shift code.
  38. The old `module == 0x0C → Layer shift` heuristic mislabeled every user-cancel
  39. on H2D as a layer-shift failure. This pins that code to None.
  40. """
  41. h2d_cancel_hms = [
  42. {"code": "0x2001b", "attr": 0x0C000C00, "module": 0x0C, "severity": 1},
  43. {"code": "0x400c", "attr": 0x03002C0C, "module": 0x03, "severity": 3},
  44. ]
  45. assert derive_failure_reason("failed", h2d_cancel_hms) is None
  46. def test_unknown_module_0x0c_code_returns_none() -> None:
  47. """Any module-0x0C code we don't have an explicit short-code mapping for must
  48. leave failure_reason=None — being honest beats guessing."""
  49. unknown_hms = [{"code": "0x4099", "attr": 0x0C00_0000, "module": 0x0C, "severity": 2}]
  50. assert derive_failure_reason("failed", unknown_hms) is None
  51. # ---------------------------------------------------------------------------
  52. # Genuine failure modes still classified correctly
  53. # ---------------------------------------------------------------------------
  54. def test_real_layer_shift_short_code_detected() -> None:
  55. """0300_4057 ("Z-axis step loss") is a real layer-shift code from the wiki."""
  56. hms = [{"code": "0x4057", "attr": 0x0300_0000, "module": 0x03, "severity": 1}]
  57. assert derive_failure_reason("failed", hms) == "layerShift"
  58. def test_real_filament_runout_short_code_detected() -> None:
  59. """07FF_8011 = external filament runout."""
  60. hms = [{"code": "0x8011", "attr": 0x07FF_0000, "module": 0x07, "severity": 2}]
  61. assert derive_failure_reason("failed", hms) == "filamentRunout"
  62. def test_real_clogged_nozzle_short_code_detected() -> None:
  63. """0300_4006 = "The nozzle is clogged"."""
  64. hms = [{"code": "0x4006", "attr": 0x0300_0000, "module": 0x03, "severity": 1}]
  65. assert derive_failure_reason("failed", hms) == "cloggedNozzle"
  66. def test_first_matching_code_wins() -> None:
  67. """When multiple known codes are present, the first one in the list wins."""
  68. hms = [
  69. {"code": "0x4057", "attr": 0x0300_0000, "module": 0x03, "severity": 1}, # layer shift
  70. {"code": "0x8011", "attr": 0x07FF_0000, "module": 0x07, "severity": 2}, # filament runout
  71. ]
  72. assert derive_failure_reason("failed", hms) == "layerShift"
  73. def test_failed_with_no_hms_returns_none() -> None:
  74. assert derive_failure_reason("failed", None) is None
  75. assert derive_failure_reason("failed", []) is None
  76. # ---------------------------------------------------------------------------
  77. # Code-format tolerance (MQTT may send int or hex string)
  78. # ---------------------------------------------------------------------------
  79. def test_int_code_field_accepted() -> None:
  80. """The MQTT parser sometimes leaves `code` as an int rather than a hex string."""
  81. hms = [{"code": 0x4057, "attr": 0x0300_0000, "module": 0x03, "severity": 1}]
  82. assert derive_failure_reason("failed", hms) == "layerShift"
  83. # ---------------------------------------------------------------------------
  84. # AI print monitoring (issue #2946)
  85. # ---------------------------------------------------------------------------
  86. def test_ai_spaghetti_detection_is_classified() -> None:
  87. """0300_8003 is what the onboard AI raises when it halts a print for spaghetti.
  88. Taken from the archive that reported this: the printer sent
  89. ``attr=50364419, code='0x8003'``, which is 0x0300_8003, and the archive was
  90. written with failure_reason=None because the map had no row for it. The text
  91. for the code was already in the tree twice — hms_errors.py and
  92. HMSErrorModal.tsx — so this was a missing key, not a missing meaning.
  93. The dict is the one bambu_mqtt.py builds for it: attr holding the whole
  94. word is the print_error branch, which is also where severity=3 comes from.
  95. That 3 is a constant for every print_error entry, not a level the printer
  96. sent, and nothing here depends on it.
  97. """
  98. hms = [{"code": "0x8003", "attr": 50364419, "module": 0x03, "severity": 3}]
  99. assert derive_failure_reason("failed", hms) == "spaghettiDetached"
  100. def test_the_ai_monitors_other_code_is_classified_too() -> None:
  101. """0C00_8042 is the same event reported from the motion-controller module.
  102. hms_errors.py documents it as "The AI print monitor has detected a spaghetti
  103. defect", so it is a full short code with a published meaning rather than the
  104. module-0x0C guessing the map header rules out.
  105. """
  106. hms = [{"code": "0x8042", "attr": 0x0C00_0000, "module": 0x0C}]
  107. assert derive_failure_reason("failed", hms) == "spaghettiDetached"
  108. @pytest.mark.parametrize(
  109. ("short_code", "attr", "code"),
  110. [
  111. # "Possible spaghetti failure was detected." — a warning about a print
  112. # that is still running, not a print that stopped.
  113. ("0C00_C004", 0x0C00_0000, "0xC004"),
  114. # AI monitoring, but a filament pile-up in the waste chute.
  115. ("0300_800A", 0x0300_0000, "0x800A"),
  116. ],
  117. )
  118. def test_the_ai_monitors_warnings_are_left_unclassified(short_code: str, attr: int, code: str) -> None:
  119. """Being AI monitoring is not the criterion — halting the print is.
  120. Both of these are in hms_errors.py and both would be easy to sweep in with
  121. the two that are mapped. Neither means the print failed, and a wrong reason
  122. on an archive is worse than none, so they stay out and this says so.
  123. """
  124. assert short_code not in _HMS_FAILURE_REASONS
  125. hms = [{"code": code, "attr": attr, "module": attr >> 24}]
  126. assert derive_failure_reason("failed", hms) is None
  127. def test_ai_detection_and_its_runout_neighbour_are_distinct() -> None:
  128. """0300_8003 and 0300_8004 are one hex digit apart and arrive by the same
  129. path. The runout side was already mapped; this keeps them from drifting into
  130. each other."""
  131. ai = [{"code": "0x8003", "attr": 0x0300_0000, "module": 0x03}]
  132. runout = [{"code": "0x8004", "attr": 0x0300_0000, "module": 0x03}]
  133. assert derive_failure_reason("failed", ai) == "spaghettiDetached"
  134. assert derive_failure_reason("failed", runout) == "filamentRunout"
  135. # ---------------------------------------------------------------------------
  136. # One vocabulary in storage (issue #2974)
  137. # ---------------------------------------------------------------------------
  138. def test_every_derived_reason_is_a_canonical_key() -> None:
  139. """The map may only hold values the rest of the stack agrees are reasons.
  140. Three writers used to put three spellings of one cause into
  141. ``failure_reason``. The whole point of #2974 is that there is now exactly
  142. one, so a display label sneaking back into the map -- which is what shipped
  143. for months -- has to fail here rather than in a user's Statistics panel.
  144. """
  145. from backend.app.api.routes.print_log import _FAILURE_REASON_KEYS
  146. from backend.app.main import _HMS_FAILURE_REASONS
  147. offenders = sorted(set(_HMS_FAILURE_REASONS.values()) - _FAILURE_REASON_KEYS)
  148. assert not offenders, f"not canonical failure-reason keys: {offenders}"
  149. @pytest.mark.parametrize("status", ["aborted", "cancelled", "failed"])
  150. def test_derived_reason_is_always_a_canonical_key(status: str) -> None:
  151. """Covers the status branch too, not just the HMS table."""
  152. from backend.app.api.routes.print_log import _FAILURE_REASON_KEYS
  153. from backend.app.main import _HMS_FAILURE_REASONS
  154. for code in _HMS_FAILURE_REASONS:
  155. attr = int(code.split("_")[0], 16) << 16
  156. reason = derive_failure_reason(status, [{"attr": attr, "code": int(code.split("_")[1], 16)}])
  157. assert reason is None or reason in _FAILURE_REASON_KEYS, reason
  158. def test_the_stale_paths_write_a_key_the_editor_will_not_discard() -> None:
  159. """Both stale writers in main.py store ``noStatusUpdate``.
  160. Read from the source rather than by calling them: they sit deep inside the
  161. MQTT archive paths and need a printer, a session and a live status. What
  162. matters is the value, and that the archive editor recognises it -- an
  163. unrecognised value opens the dropdown empty and the next save clears the
  164. classification outright.
  165. """
  166. from pathlib import Path
  167. from backend.app.api.routes.print_log import _FAILURE_REASON_KEYS
  168. source = Path(__file__).resolve().parents[3] / "backend" / "app" / "main.py"
  169. text = source.read_text(encoding="utf-8")
  170. assert "noStatusUpdate" in _FAILURE_REASON_KEYS
  171. assert text.count('failure_reason = "noStatusUpdate"') == 2
  172. assert "Stale - print likely cancelled" not in text
  173. assert "Stale - reconciled after reconnect" not in text
  174. # ---------------------------------------------------------------------------
  175. # The vocabulary spans two languages, and only a comment says so
  176. # ---------------------------------------------------------------------------
  177. def _keys_the_dropdown_offers() -> set[str]:
  178. """The `FAILURE_REASON_KEYS` array exported from EditArchiveModal.tsx."""
  179. source = _EDIT_ARCHIVE_MODAL.read_text(encoding="utf-8")
  180. block = re.search(r"export const FAILURE_REASON_KEYS = \[(.*?)\] as const;", source, re.S)
  181. assert block is not None, f"no FAILURE_REASON_KEYS array in {_EDIT_ARCHIVE_MODAL}"
  182. return set(re.findall(r"'([^']+)'", block.group(1)))
  183. def _keys_the_frontend_can_translate() -> set[str]:
  184. """Every key in the `editArchive.failureReasons` block of en.ts."""
  185. source = _EN_TS.read_text(encoding="utf-8")
  186. # Up to the brace that closes the block on its own line, so a `}` inside a
  187. # label (an ICU placeholder, say) does not cut the block short.
  188. block = re.search(r"failureReasons:\s*\{(.*?)^\s*\}", source, re.S | re.M)
  189. assert block is not None, f"no failureReasons block in {_EN_TS}"
  190. # Either quote: a label with an apostrophe is written double-quoted in TS.
  191. return set(re.findall(r"^\s*(\w+):\s*['\"]", block.group(1), re.M))
  192. @_needs_the_frontend_tree
  193. def test_the_backend_vocabulary_matches_the_one_the_frontend_offers() -> None:
  194. """The only thing holding the two lists together is a comment asking nicely.
  195. ``_FAILURE_REASON_KEYS`` in api/routes/print_log.py gates every write, and
  196. its own comment says "Keep these two lists in sync if the EditArchiveModal
  197. options ever change". Nothing enforces it, and the drift is silent in both
  198. directions: a key the frontend offers but the backend rejects turns a save
  199. into a 400 the modal has no surface for, and a key the backend accepts but
  200. the dropdown omits is a value the editor discards the next time anyone
  201. opens that archive.
  202. Asserting one Python literal against another cannot see either — the other
  203. end lives in TypeScript, so the check has to read it.
  204. """
  205. from backend.app.api.routes.print_log import _FAILURE_REASON_KEYS
  206. offered = _keys_the_dropdown_offers()
  207. assert offered, "the FAILURE_REASON_KEYS array parsed as empty; the regex has gone stale"
  208. # "" is the backend's "clear the classification" value; the dropdown spells
  209. # that as its own placeholder option rather than a key, so it is not drift.
  210. backend_keys = set(_FAILURE_REASON_KEYS) - {""}
  211. assert backend_keys == offered, (
  212. f"backend-only: {sorted(backend_keys - offered)}, frontend-only: {sorted(offered - backend_keys)}"
  213. )
  214. @_needs_the_frontend_tree
  215. def test_every_offered_key_has_english_text() -> None:
  216. """A key with no en.ts entry renders as the raw key in the dropdown.
  217. The parity script covers the other 13 locales against en.ts, so en.ts is the
  218. one end of this that nothing else checks.
  219. """
  220. translatable = _keys_the_frontend_can_translate()
  221. assert translatable, "the failureReasons block parsed as empty; the regex has gone stale"
  222. untranslated = sorted(_keys_the_dropdown_offers() - translatable)
  223. assert not untranslated, f"offered by the dropdown with no editArchive.failureReasons entry: {untranslated}"