test_fallback_archive_mqtt_filament.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. """Tests for _extract_filament_data_from_mqtt (#1533).
  2. The fallback PrintArchive path in main.py fires when the source 3MF can't
  3. be downloaded from the printer at print start — common on P1S / A1 / P2S
  4. firmwares that lock the file during printing. Before this fix the
  5. fallback archive had every filament field NULL even though the MQTT
  6. print-start payload already carried the AMS state and the slicer's
  7. slot-per-print-filament mapping. The helper extracts a comma-separated
  8. ``filament_type`` / ``filament_color`` from that payload so the inventory
  9. views can at least show what's loaded, and operators planning AMS
  10. expansion can count filaments per print.
  11. """
  12. import pytest
  13. from backend.app.main import _extract_filament_data_from_mqtt
  14. def _ams_unit(unit_id: int, trays: list[dict]) -> dict:
  15. return {"id": unit_id, "tray": trays}
  16. def _tray(tray_id: int, ttype: str | None, color: str | None) -> dict:
  17. out: dict = {"id": tray_id}
  18. if ttype is not None:
  19. out["tray_type"] = ttype
  20. if color is not None:
  21. out["tray_color"] = color
  22. return out
  23. class TestExtractFilamentDataFromMqtt:
  24. def test_empty_payload_returns_empty_dict(self):
  25. assert _extract_filament_data_from_mqtt({}) == {}
  26. assert _extract_filament_data_from_mqtt({"ams": None}) == {}
  27. assert _extract_filament_data_from_mqtt({"ams": {}}) == {}
  28. assert _extract_filament_data_from_mqtt({"ams": {"ams": []}}) == {}
  29. def test_no_loaded_slots_returns_empty(self):
  30. """All slots empty (no tray_type) → nothing to report."""
  31. data = {
  32. "ams": {
  33. "ams": [
  34. _ams_unit(0, [_tray(i, "", "") for i in range(4)]),
  35. ],
  36. }
  37. }
  38. assert _extract_filament_data_from_mqtt(data) == {}
  39. def test_no_mapping_lists_all_loaded_slots_sorted(self):
  40. data = {
  41. "ams": {
  42. "ams": [
  43. _ams_unit(
  44. 0,
  45. [
  46. _tray(0, "PLA", "FF0000"),
  47. _tray(1, "PETG", "00FF00"),
  48. _tray(2, "", ""), # Empty slot — skipped.
  49. _tray(3, "ABS", "0000ff"),
  50. ],
  51. ),
  52. ],
  53. }
  54. }
  55. result = _extract_filament_data_from_mqtt(data)
  56. # Order is by ascending global tray id, colors uppercased.
  57. assert result == {"filament_type": "PLA,PETG,ABS", "filament_color": "FF0000,00FF00,0000FF"}
  58. def test_ams_mapping_narrows_to_used_slots(self):
  59. """The slicer's slot-per-print-filament mapping wins — only used
  60. slots contribute, in the slicer's order (which is the order the
  61. print materially consumes them)."""
  62. data = {
  63. "ams": {
  64. "ams": [
  65. _ams_unit(
  66. 0,
  67. [
  68. _tray(0, "PLA", "FF0000"),
  69. _tray(1, "PETG", "00FF00"),
  70. _tray(2, "ABS", "0000FF"),
  71. _tray(3, "TPU", "FFFF00"),
  72. ],
  73. ),
  74. ],
  75. }
  76. }
  77. # Print uses slots 3 then 0 then 1 (slot 2 untouched, no entry).
  78. result = _extract_filament_data_from_mqtt(data, ams_mapping=[3, 0, 1])
  79. assert result == {"filament_type": "TPU,PLA,PETG", "filament_color": "FFFF00,FF0000,00FF00"}
  80. def test_ams_mapping_with_vt_tray_sentinels_filtered_out(self):
  81. """ams_mapping entries equal to -1 represent the VT tray (external
  82. spool feed). We have no AMS tray data for them — they must be
  83. skipped, not treated as global tray id 0."""
  84. data = {
  85. "ams": {
  86. "ams": [
  87. _ams_unit(
  88. 0,
  89. [
  90. _tray(0, "PLA", "FF0000"),
  91. _tray(1, "PETG", "00FF00"),
  92. ],
  93. ),
  94. ],
  95. }
  96. }
  97. result = _extract_filament_data_from_mqtt(data, ams_mapping=[-1, 0, 1])
  98. assert result == {"filament_type": "PLA,PETG", "filament_color": "FF0000,00FF00"}
  99. def test_dual_ams_global_ids_use_unit4_offset(self):
  100. """A dual-AMS rig has unit 0 → trays 0-3, unit 1 → trays 4-7.
  101. ``ams_mapping=4`` must resolve to unit 1, tray 0 — not unit 0."""
  102. data = {
  103. "ams": {
  104. "ams": [
  105. _ams_unit(0, [_tray(0, "PLA", "FF0000")]),
  106. _ams_unit(1, [_tray(0, "PETG-CF", "112233")]),
  107. ],
  108. }
  109. }
  110. result = _extract_filament_data_from_mqtt(data, ams_mapping=[4, 0])
  111. assert result == {"filament_type": "PETG-CF,PLA", "filament_color": "112233,FF0000"}
  112. def test_mapping_pointing_at_unknown_slot_falls_through_to_known_only(self):
  113. data = {
  114. "ams": {
  115. "ams": [
  116. _ams_unit(0, [_tray(0, "PLA", "FF0000")]),
  117. ],
  118. }
  119. }
  120. # Slot 7 isn't in our AMS — entry skipped, only slot 0 remains.
  121. result = _extract_filament_data_from_mqtt(data, ams_mapping=[7, 0])
  122. assert result == {"filament_type": "PLA", "filament_color": "FF0000"}
  123. def test_mapping_entirely_unknown_returns_empty(self):
  124. """If every mapped slot is unknown the helper returns {} rather
  125. than silently misreporting from the all-slots fallback — the
  126. slicer was explicit about which slots to use."""
  127. data = {
  128. "ams": {
  129. "ams": [
  130. _ams_unit(0, [_tray(0, "PLA", "FF0000")]),
  131. ],
  132. }
  133. }
  134. assert _extract_filament_data_from_mqtt(data, ams_mapping=[5, 6]) == {}
  135. def test_color_truncation_at_column_limit(self):
  136. """filament_color column is VARCHAR(200); long multi-color prints
  137. must not exceed it."""
  138. # 16 trays of 6-char colors + 15 commas = 96+15 = 111 chars. Safe.
  139. # Construct an oversized synthetic case with many distinct colors.
  140. trays = [_tray(i, "PLA", f"{i:06X}") for i in range(4)]
  141. data = {"ams": {"ams": [_ams_unit(u, trays) for u in range(8)]}}
  142. result = _extract_filament_data_from_mqtt(data)
  143. assert "filament_color" in result
  144. assert len(result["filament_color"]) <= 200
  145. def test_type_truncation_at_column_limit(self):
  146. """filament_type column is VARCHAR(50). Many filaments must truncate."""
  147. # 16 PETG-CF entries: 7 chars × 16 + 15 commas = 127 chars.
  148. trays = [_tray(i, "PETG-CF", "AABBCC") for i in range(4)]
  149. data = {"ams": {"ams": [_ams_unit(u, trays) for u in range(4)]}}
  150. result = _extract_filament_data_from_mqtt(data)
  151. assert "filament_type" in result
  152. assert len(result["filament_type"]) <= 50
  153. def test_color_missing_only_emits_type(self):
  154. """A tray with type but blank color still contributes to filament_type."""
  155. data = {
  156. "ams": {
  157. "ams": [
  158. _ams_unit(0, [_tray(0, "PLA", "")]),
  159. ],
  160. }
  161. }
  162. result = _extract_filament_data_from_mqtt(data)
  163. assert result == {"filament_type": "PLA"}
  164. # filament_color absent — not empty string.
  165. assert "filament_color" not in result
  166. def test_malformed_unit_skipped_without_crash(self):
  167. """Defensive: unexpected MQTT shapes (non-dict in ams list, missing
  168. id, string tray.id) must not raise. The fallback-archive write
  169. runs in a hot path during print start — anything that throws here
  170. would bubble up and break the print log entirely."""
  171. data = {
  172. "ams": {
  173. "ams": [
  174. "garbage",
  175. {"id": "not-an-int", "tray": []},
  176. _ams_unit(0, [_tray(0, "PLA", "FF0000"), {"id": "x", "tray_type": "PETG"}]),
  177. ],
  178. }
  179. }
  180. result = _extract_filament_data_from_mqtt(data)
  181. # Only the well-formed entry contributes; no exception.
  182. assert result.get("filament_type") == "PLA"
  183. @pytest.mark.parametrize("data", [None, {}, {"ams": "weird-string"}])
  184. def test_garbage_top_level_is_empty(self, data):
  185. assert _extract_filament_data_from_mqtt(data or {}) == {}