mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Three bugs that surfaced together while debugging an H2D cancel:
1. Cancelling a print stamped failure_reason="Layer shift" in archives
AND left the printer card stuck on "1 problem" forever. Four causes:
(a) POST /printers/{id}/print/stop never set the user-stopped flag, so
on_print_complete couldn't override "failed" -> "cancelled".
(b) HMS-derived failure_reason heuristic mapped any module-0x0C HMS to
"Layer shift". Module 0x0C is "Motion Controller" broadly (includes
cameras, markers, AND the cancel-sequence echo 0C00_001B). Real
layer-shift codes live in module 0x03. Same false-positive class
existed for "Filament runout" (any 0x07) and "Clogged nozzle" (any
0x05). Replaced with a 23-code curated short-code map; unknowns
leave failure_reason=None.
(c) Cancel-echo HMS codes (0300_400C "The task was canceled.",
0500_400E "Printing was cancelled.") were polluting state.hms_errors
via both the hms[] and print_error parse paths. Filter them at
parse time so the frontend never sees them.
(d) Frontend bucketed gcode_state="FAILED" as a problem unconditionally.
Real failures attach an HMS error; user-cancels don't — so FAILED-
without-HMS now buckets as "finished" and only escalates to "error"
when there's an active known HMS.
2. logs/bambuddy.log was silently dropping records from named child
loggers. TraceIDFilter was attached to root_logger, but Python's
logging only invokes a Logger's filters on records originating at that
logger — propagated child-logger records skipped it, formatter raised
KeyError, handler.handleError dropped the record. Moved the filter
from root_logger.addFilter() to handler.addFilter() on each handler,
matching the filter's own docstring guidance.
derive_failure_reason() extracted as a pure function for testability.
status="cancelled" now symmetrically yields "User cancelled" alongside
"aborted".
20 regression tests across:
- backend/tests/unit/test_failure_reason_derivation.py (11)
- backend/tests/unit/services/test_bambu_mqtt.py::TestHMSUserActionFiltering (4)
- backend/tests/unit/test_trace.py::TestFilterMustBeAttachedToHandlerNotLogger (1)
- frontend/src/__tests__/pages/PrintersPageBucketing.test.ts (5; includes
the H2D-cancel-echo "FAILED + only unknown HMS" case)
100 lines
4.1 KiB
Python
100 lines
4.1 KiB
Python
"""Regression tests for derive_failure_reason in backend.app.main.
|
|
|
|
Ensures user-cancelled prints don't get archived as "Layer shift" — 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) == "User cancelled"
|
|
assert derive_failure_reason(status, []) == "User cancelled"
|
|
|
|
|
|
def test_completed_status_returns_none() -> None:
|
|
assert derive_failure_reason("completed", None) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# H2D regression: cancel-sequence HMS must not be labelled "Layer shift"
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
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) == "Layer shift"
|
|
|
|
|
|
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) == "Filament runout"
|
|
|
|
|
|
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) == "Clogged nozzle"
|
|
|
|
|
|
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) == "Layer shift"
|
|
|
|
|
|
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) == "Layer shift"
|