Files
bambuddy/backend/tests/unit/test_failure_reason_derivation.py
T
maziggy 88b5f56eb2 fix: cancel = layer shift, stuck "1 problem", and dropped child-logger logs
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)
2026-04-26 14:24:28 +02:00

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"