test_failure_reason_derivation.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  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 pytest
  8. from backend.app.main import derive_failure_reason
  9. # ---------------------------------------------------------------------------
  10. # Status-based reasons (no HMS lookup needed)
  11. # ---------------------------------------------------------------------------
  12. @pytest.mark.parametrize("status", ["aborted", "cancelled"])
  13. def test_user_cancel_status_yields_user_cancelled(status: str) -> None:
  14. assert derive_failure_reason(status, None) == "userCancelled"
  15. assert derive_failure_reason(status, []) == "userCancelled"
  16. def test_completed_status_returns_none() -> None:
  17. assert derive_failure_reason("completed", None) is None
  18. # ---------------------------------------------------------------------------
  19. # H2D regression: cancel-sequence HMS must not be labelled "layerShift"
  20. # ---------------------------------------------------------------------------
  21. def test_h2d_cancel_module_0x0c_is_not_layer_shift() -> None:
  22. """0C00_001B is the H2D cancel-sequence echo, not a real layer-shift code.
  23. The old `module == 0x0C → Layer shift` heuristic mislabeled every user-cancel
  24. on H2D as a layer-shift failure. This pins that code to None.
  25. """
  26. h2d_cancel_hms = [
  27. {"code": "0x2001b", "attr": 0x0C000C00, "module": 0x0C, "severity": 1},
  28. {"code": "0x400c", "attr": 0x03002C0C, "module": 0x03, "severity": 3},
  29. ]
  30. assert derive_failure_reason("failed", h2d_cancel_hms) is None
  31. def test_unknown_module_0x0c_code_returns_none() -> None:
  32. """Any module-0x0C code we don't have an explicit short-code mapping for must
  33. leave failure_reason=None — being honest beats guessing."""
  34. unknown_hms = [{"code": "0x4099", "attr": 0x0C00_0000, "module": 0x0C, "severity": 2}]
  35. assert derive_failure_reason("failed", unknown_hms) is None
  36. # ---------------------------------------------------------------------------
  37. # Genuine failure modes still classified correctly
  38. # ---------------------------------------------------------------------------
  39. def test_real_layer_shift_short_code_detected() -> None:
  40. """0300_4057 ("Z-axis step loss") is a real layer-shift code from the wiki."""
  41. hms = [{"code": "0x4057", "attr": 0x0300_0000, "module": 0x03, "severity": 1}]
  42. assert derive_failure_reason("failed", hms) == "layerShift"
  43. def test_real_filament_runout_short_code_detected() -> None:
  44. """07FF_8011 = external filament runout."""
  45. hms = [{"code": "0x8011", "attr": 0x07FF_0000, "module": 0x07, "severity": 2}]
  46. assert derive_failure_reason("failed", hms) == "filamentRunout"
  47. def test_real_clogged_nozzle_short_code_detected() -> None:
  48. """0300_4006 = "The nozzle is clogged"."""
  49. hms = [{"code": "0x4006", "attr": 0x0300_0000, "module": 0x03, "severity": 1}]
  50. assert derive_failure_reason("failed", hms) == "cloggedNozzle"
  51. def test_first_matching_code_wins() -> None:
  52. """When multiple known codes are present, the first one in the list wins."""
  53. hms = [
  54. {"code": "0x4057", "attr": 0x0300_0000, "module": 0x03, "severity": 1}, # layer shift
  55. {"code": "0x8011", "attr": 0x07FF_0000, "module": 0x07, "severity": 2}, # filament runout
  56. ]
  57. assert derive_failure_reason("failed", hms) == "layerShift"
  58. def test_failed_with_no_hms_returns_none() -> None:
  59. assert derive_failure_reason("failed", None) is None
  60. assert derive_failure_reason("failed", []) is None
  61. # ---------------------------------------------------------------------------
  62. # Code-format tolerance (MQTT may send int or hex string)
  63. # ---------------------------------------------------------------------------
  64. def test_int_code_field_accepted() -> None:
  65. """The MQTT parser sometimes leaves `code` as an int rather than a hex string."""
  66. hms = [{"code": 0x4057, "attr": 0x0300_0000, "module": 0x03, "severity": 1}]
  67. assert derive_failure_reason("failed", hms) == "layerShift"
  68. # ---------------------------------------------------------------------------
  69. # One vocabulary in storage (issue #2974)
  70. # ---------------------------------------------------------------------------
  71. def test_every_derived_reason_is_a_canonical_key() -> None:
  72. """The map may only hold values the rest of the stack agrees are reasons.
  73. Three writers used to put three spellings of one cause into
  74. ``failure_reason``. The whole point of #2974 is that there is now exactly
  75. one, so a display label sneaking back into the map -- which is what shipped
  76. for months -- has to fail here rather than in a user's Statistics panel.
  77. """
  78. from backend.app.api.routes.print_log import _FAILURE_REASON_KEYS
  79. from backend.app.main import _HMS_FAILURE_REASONS
  80. offenders = sorted(set(_HMS_FAILURE_REASONS.values()) - _FAILURE_REASON_KEYS)
  81. assert not offenders, f"not canonical failure-reason keys: {offenders}"
  82. @pytest.mark.parametrize("status", ["aborted", "cancelled", "failed"])
  83. def test_derived_reason_is_always_a_canonical_key(status: str) -> None:
  84. """Covers the status branch too, not just the HMS table."""
  85. from backend.app.api.routes.print_log import _FAILURE_REASON_KEYS
  86. from backend.app.main import _HMS_FAILURE_REASONS
  87. for code in _HMS_FAILURE_REASONS:
  88. attr = int(code.split("_")[0], 16) << 16
  89. reason = derive_failure_reason(status, [{"attr": attr, "code": int(code.split("_")[1], 16)}])
  90. assert reason is None or reason in _FAILURE_REASON_KEYS, reason
  91. def test_the_stale_paths_write_a_key_the_editor_will_not_discard() -> None:
  92. """Both stale writers in main.py store ``noStatusUpdate``.
  93. Read from the source rather than by calling them: they sit deep inside the
  94. MQTT archive paths and need a printer, a session and a live status. What
  95. matters is the value, and that the archive editor recognises it -- an
  96. unrecognised value opens the dropdown empty and the next save clears the
  97. classification outright.
  98. """
  99. from pathlib import Path
  100. from backend.app.api.routes.print_log import _FAILURE_REASON_KEYS
  101. source = Path(__file__).resolve().parents[3] / "backend" / "app" / "main.py"
  102. text = source.read_text(encoding="utf-8")
  103. assert "noStatusUpdate" in _FAILURE_REASON_KEYS
  104. assert text.count('failure_reason = "noStatusUpdate"') == 2
  105. assert "Stale - print likely cancelled" not in text
  106. assert "Stale - reconciled after reconnect" not in text