|
@@ -1,6 +1,6 @@
|
|
|
"""Tests for HMS error code translations."""
|
|
"""Tests for HMS error code translations."""
|
|
|
|
|
|
|
|
-from backend.app.services.hms_errors import HMS_ERROR_DESCRIPTIONS, get_error_description
|
|
|
|
|
|
|
+from backend.app.services.hms_errors import HMS_ERROR_DESCRIPTIONS, describe_fault, get_error_description
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestHMSErrorDescriptions:
|
|
class TestHMSErrorDescriptions:
|
|
@@ -72,3 +72,94 @@ class TestGetErrorDescription:
|
|
|
for code in common_codes:
|
|
for code in common_codes:
|
|
|
result = get_error_description(code)
|
|
result = get_error_description(code)
|
|
|
assert result is not None, f"Missing description for common code: {code}"
|
|
assert result is not None, f"Missing description for common code: {code}"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestDescribeFault:
|
|
|
|
|
+ """`describe_fault` maps a fault's canonical `full_code` onto the catalogue,
|
|
|
|
|
+ so every surface that reports a fault resolves it the same way (#2926)."""
|
|
|
|
|
+
|
|
|
|
|
+ def test_resolves_an_eight_char_print_error_code(self):
|
|
|
|
|
+ """The parser derives full_code and the catalogue key from the same
|
|
|
|
|
+ 32-bit value, so the split is exact rather than a guess."""
|
|
|
|
|
+ assert describe_fault("03008004") == "Filament ran out. Please load new filament."
|
|
|
|
|
+
|
|
|
|
|
+ def test_resolves_regardless_of_case(self):
|
|
|
|
|
+ """Firmware-facing code is uppercase, but a client echoing a value back
|
|
|
|
|
+ from its own store may not be."""
|
|
|
|
|
+ assert describe_fault("0300400c") == "The task was canceled."
|
|
|
|
|
+
|
|
|
|
|
+ def test_tolerates_surrounding_whitespace(self):
|
|
|
|
|
+ assert describe_fault(" 03008004 ") == "Filament ran out. Please load new filament."
|
|
|
|
|
+
|
|
|
|
|
+ def test_returns_none_for_an_hms_code_outside_the_catalogue(self):
|
|
|
|
|
+ """A real P2S fault from #2728. Neither the whole 16-char key nor its
|
|
|
|
|
+ G1_G4 collapse ("0500_000A") is in the catalogue — no catalogue key has
|
|
|
|
|
+ an error group below 0x4000, and this family's is 0x000A."""
|
|
|
|
|
+ assert describe_fault("050002000003000A") is None
|
|
|
|
|
+
|
|
|
|
|
+ def test_collapses_a_sixteen_char_code_to_its_g1_g4_short_key(self):
|
|
|
|
|
+ """Lossy, and kept deliberately: this is how the notification path, the
|
|
|
|
|
+ queue's failure-reason helper and the frontend modal have always
|
|
|
|
|
+ resolved `hms[]` faults, and it resolves real ones. Refusing would stop
|
|
|
|
|
+ describing faults that are described today (see the module docstring)."""
|
|
|
|
|
+ key = next(iter(HMS_ERROR_DESCRIPTIONS)) # e.g. "0300_4000"
|
|
|
|
|
+ module, error = key.split("_")
|
|
|
|
|
+ forced = f"{module}02000003{error}" # four 4-hex groups; G1 and G4 are the key
|
|
|
|
|
+ assert len(forced) == 16
|
|
|
|
|
+ assert describe_fault(forced) == HMS_ERROR_DESCRIPTIONS[key]
|
|
|
|
|
+
|
|
|
|
|
+ def test_prefers_the_whole_sixteen_char_key_over_the_collapse(self):
|
|
|
|
|
+ """The full identifier is lossless, so it wins when the catalogue has
|
|
|
|
|
+ both. No 16-char keys ship today; this pins the order for when they do."""
|
|
|
|
|
+ key = next(iter(HMS_ERROR_DESCRIPTIONS))
|
|
|
|
|
+ module, error = key.split("_")
|
|
|
|
|
+ forced = f"{module}02000003{error}"
|
|
|
|
|
+ HMS_ERROR_DESCRIPTIONS[forced] = "specific variant"
|
|
|
|
|
+ try:
|
|
|
|
|
+ assert describe_fault(forced) == "specific variant"
|
|
|
|
|
+ finally:
|
|
|
|
|
+ del HMS_ERROR_DESCRIPTIONS[forced]
|
|
|
|
|
+
|
|
|
|
|
+ def test_matches_the_derivation_it_replaced(
|
|
|
|
|
+ self,
|
|
|
|
|
+ ):
|
|
|
|
|
+ """The regression guard for the consolidation: for every fault shape the
|
|
|
|
|
+ codebase can produce, `describe_fault` returns exactly what the
|
|
|
|
|
+ attr/code short-code lookup in the notification path returned before it.
|
|
|
|
|
+ Covers both families and all three alert levels a real `hms[]` code
|
|
|
|
|
+ carries — a divergence here means notifications silently stop firing for
|
|
|
|
|
+ faults that used to raise them."""
|
|
|
|
|
+ for key, expected in HMS_ERROR_DESCRIPTIONS.items():
|
|
|
|
|
+ module, error = int(key[:4], 16), int(key[5:], 16)
|
|
|
|
|
+
|
|
|
|
|
+ # print_error: attr is the whole 32-bit value, code its low half.
|
|
|
|
|
+ print_error = (module << 16) | error
|
|
|
|
|
+ assert describe_fault(f"{print_error:08X}") == expected
|
|
|
|
|
+
|
|
|
|
|
+ # hms[]: attr is groups 1-2, code is groups 3-4 (alert level + id).
|
|
|
|
|
+ for alert_level in (0x0000, 0x0002, 0x0003):
|
|
|
|
|
+ attr = (module << 16) | 0x0200
|
|
|
|
|
+ code = (alert_level << 16) | error
|
|
|
|
|
+ legacy = get_error_description(f"{(attr >> 16) & 0xFFFF:04X}_{code & 0xFFFF:04X}")
|
|
|
|
|
+ assert describe_fault(f"{attr:08X}{code:08X}") == legacy == expected
|
|
|
|
|
+
|
|
|
|
|
+ def test_returns_none_for_an_unknown_eight_char_code(self):
|
|
|
|
|
+ assert describe_fault("99999999") is None
|
|
|
|
|
+
|
|
|
|
|
+ def test_returns_none_for_empty_or_missing(self):
|
|
|
|
|
+ """The dataclass default is "" and the field is optional on the wire."""
|
|
|
|
|
+ assert describe_fault("") is None
|
|
|
|
|
+ assert describe_fault(None) is None
|
|
|
|
|
+
|
|
|
|
|
+ def test_returns_none_for_a_malformed_length(self):
|
|
|
|
|
+ """Neither 8 nor 16 chars — no shape to interpret, so no guess."""
|
|
|
|
|
+ assert describe_fault("0300") is None
|
|
|
|
|
+ assert describe_fault("030080040") is None
|
|
|
|
|
+
|
|
|
|
|
+ def test_agrees_with_the_short_code_lookup_for_print_error_codes(self):
|
|
|
|
|
+ """Pins the equivalence the consolidation rests on: for every 8-char
|
|
|
|
|
+ code the catalogue covers, describe_fault returns exactly what the
|
|
|
|
|
+ pre-existing short-code lookup did."""
|
|
|
|
|
+ for key, expected in HMS_ERROR_DESCRIPTIONS.items():
|
|
|
|
|
+ assert describe_fault(key.replace("_", "")) == expected
|
|
|
|
|
+ assert get_error_description(key) == expected
|