mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
failure_reason was written three different ways and nothing reconciled
them. derive_failure_reason wrote English display labels ("Layer shift"),
older builds of the archive editor wrote the translated label in whatever
locale that user was running, and the two stale-archive paths wrote
English prose sentences. All three reach one column -- the archive PATCH
has mirrored the field onto the latest print-log entry since #1444 -- and
the Failure Analysis widget groups on the raw value, so one real cause
occupied several buckets. On a live install before this landed:
print_log_entries held 91 rows reading "User cancelled" beside 1 reading
"userCancelled".
In an English UI those two render as the same words twice with different
counts, which is why nobody spotted it. In any other locale one of them
stays English, because a stored label has no key for t() to resolve. The
editor was worse than cosmetic about it: its reverse lookup compared the
stored value against t() in the current locale, so for a non-English user
nothing matched and the dropdown opened empty over an archive that
plainly showed a reason.
The keys were already canonical and already enforced.
_FAILURE_REASON_KEYS in api/routes/print_log.py rejects anything else
with a 400 and explains why in its own comment -- the widget renders
values back through t(), so an unrecognised one surfaces as a raw string.
derive_failure_reason had simply never been held to that rule. It now
produces keys, and the cancel branch returns userCancelled.
The two "Stale - ..." sentences become one new noStatusUpdate key. Both
describe the same observation, that no end-of-print status ever arrived;
which of the two situations occurred is already carried by status --
cancelled at the stale-cleanup site, the reconciled outcome at the
reconnect site -- so collapsing them loses nothing and gives Statistics
one bucket instead of two sentences that could never be translated. It
had to enter the vocabulary rather than merely be tolerated, because the
editor discards any value it does not recognise.
Existing rows are converted by a startup migration folding 168 historical
labels onto the 12 keys across both columns. It is exact rather than a
guess: every label across all 14 locales resolves to exactly one key,
with no collisions. The map is a frozen snapshot rather than something
read from the locale files at run time -- it maps what was written
historically, so regenerating it from the current translations would
silently stop recognising the very rows it exists to convert. A value
outside the map is left alone; guessing would be worse than leaving one
honest string in its own bucket. There is no one-shot settings flag, on
purpose: the statement only matches values in the map and a key is never
a label, so it is self-terminating, and a flag would permanently skip
anyone who restores an older database.
The last part is a data-loss bug that was not in the report. The editor's
fallback to '' was not merely a wrong-looking dropdown -- the empty
selection was then saved over the stored text, so opening the editor on
an archive whose reason was free text and pressing Save destroyed the
classification. An unrecognised value now keeps its own option and
survives a save.
154 lines
6.5 KiB
Python
154 lines
6.5 KiB
Python
"""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 pytest
|
|
|
|
from backend.app.main import derive_failure_reason
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|