test_hms_description_surfaces.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. """One fault, one sentence, on every surface that reports it (#2926).
  2. The catalogue in ``services/hms_errors.py`` has always held the text, and the
  3. status response never carried it, so each client resolved the same codes from
  4. its own copy of the same table. The description is now resolved once, at parse
  5. time, and passed through by all three serializers of an ``HMSError``: the
  6. status response, the WebSocket broadcast, and the print-completion payload the
  7. queue's failure reason is built from. These tests pin that they agree — the
  8. point of resolving it in one place is that they cannot drift apart.
  9. """
  10. import pytest
  11. from backend.app.main import _format_hms_error_summary
  12. from backend.app.schemas.printer import HMSErrorResponse
  13. from backend.app.services.bambu_mqtt import HMSError, PrinterState
  14. from backend.app.services.printer_manager import printer_state_to_dict
  15. RUNOUT_SENTENCE = "Filament ran out. Please load new filament."
  16. def _runout() -> HMSError:
  17. """A `print_error` fault the catalogue covers, as the parser builds it."""
  18. return HMSError(
  19. code="0x8004",
  20. attr=0x03008004,
  21. module=3,
  22. severity=3,
  23. full_code="03008004",
  24. description=RUNOUT_SENTENCE,
  25. )
  26. def _uncatalogued() -> HMSError:
  27. """An `hms[]` fault the catalogue cannot describe — a real P2S code (#2728).
  28. Its G1_G4 collapse is "0500_000A", which is not a key either."""
  29. return HMSError(
  30. code="0x3000a",
  31. attr=0x05000200,
  32. module=5,
  33. severity=2,
  34. full_code="050002000003000A",
  35. description=None,
  36. )
  37. class TestStatusResponse:
  38. def test_carries_the_description(self):
  39. """What the route's mapper produces — the field a third-party client
  40. needs so it does not have to ship the catalogue itself."""
  41. e = _runout()
  42. assert (
  43. HMSErrorResponse(
  44. code=e.code,
  45. attr=e.attr,
  46. module=e.module,
  47. severity=e.severity,
  48. actions=e.actions,
  49. job_id=e.job_id,
  50. full_code=e.full_code,
  51. description=e.description,
  52. ).description
  53. == RUNOUT_SENTENCE
  54. )
  55. def test_defaults_to_none_when_not_supplied(self):
  56. """A producer that never sets it still validates, so the field cannot
  57. break an existing construction path."""
  58. assert HMSErrorResponse(code="0x8004", attr=0, module=3, severity=3).description is None
  59. def test_serializes_as_null_rather_than_being_dropped(self):
  60. """A client distinguishing "no text" from "field absent" needs the key
  61. present. Pydantic includes None by default; pin it so a later
  62. `exclude_none` does not silently change the contract."""
  63. payload = HMSErrorResponse(code="0x3000a", attr=0, module=5, severity=2).model_dump()
  64. assert "description" in payload
  65. assert payload["description"] is None
  66. class TestWebSocketBroadcast:
  67. def test_carries_the_description(self):
  68. """The broadcast is a separate hand-rolled serializer; a relay watching
  69. the stream should not have to poll REST to find out what a fault means."""
  70. state = PrinterState()
  71. state.hms_errors = [_runout()]
  72. assert printer_state_to_dict(state, printer_id=1)["hms_errors"][0]["description"] == RUNOUT_SENTENCE
  73. def test_passes_none_through_for_an_uncatalogued_fault(self):
  74. """The fault is still broadcast — only the text is missing."""
  75. state = PrinterState()
  76. state.hms_errors = [_uncatalogued()]
  77. entry = printer_state_to_dict(state, printer_id=1)["hms_errors"][0]
  78. assert entry["full_code"] == "050002000003000A"
  79. assert entry["description"] is None
  80. class TestQueueFailureReason:
  81. def test_prefers_the_resolved_description(self):
  82. """Deliberately a sentence the local fallback would NOT produce, so the
  83. preference is observable rather than coincidentally identical."""
  84. supplied = "Filament ran out, as resolved at parse time."
  85. assert _format_hms_error_summary([{"code": "0x8004", "attr": 0x03008004, "description": supplied}]) == (
  86. f"[0300_8004] {supplied}"
  87. )
  88. def test_falls_back_for_an_entry_without_the_field(self):
  89. """Entries predating the field still resolve, so the helper's own
  90. contract is unchanged for any other caller."""
  91. assert _format_hms_error_summary([{"code": "0x8004", "attr": 0x03008004}]) == (f"[0300_8004] {RUNOUT_SENTENCE}")
  92. def test_bare_short_code_when_nothing_describes_it(self):
  93. assert _format_hms_error_summary([{"code": "0x9999", "attr": 0x99990000, "description": None}]) == "[9999_9999]"
  94. class TestSurfacesAgree:
  95. @pytest.mark.parametrize("fault,expected", [(_runout(), RUNOUT_SENTENCE), (_uncatalogued(), None)])
  96. def test_the_same_fault_reads_the_same_everywhere(self, fault, expected):
  97. """The reason to resolve once rather than at each boundary: these three
  98. cannot report different text for one fault."""
  99. state = PrinterState()
  100. state.hms_errors = [fault]
  101. broadcast = printer_state_to_dict(state, printer_id=1)["hms_errors"][0]["description"]
  102. rest = HMSErrorResponse(
  103. code=fault.code,
  104. attr=fault.attr,
  105. module=fault.module,
  106. severity=fault.severity,
  107. full_code=fault.full_code,
  108. description=fault.description,
  109. ).description
  110. assert broadcast == expected
  111. assert rest == expected
  112. assert fault.description == expected