test_fallback_archive_mqtt_filament.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  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 {}) == {}
  186. class TestOnPrintStartCallbackShape:
  187. """Regression: the callback wrapper shape the bambu_mqtt service
  188. actually hands to ``on_print_start`` at runtime (#1533 follow-up).
  189. The original #1533 fix only handled the bare ``{"ams": {"ams": [...]}}``
  190. inner shape, but the call site at ``backend/app/main.py::on_print_start``
  191. receives the wrapper
  192. ``{"filename", "subtask_name", "remaining_time", "raw_data": <mqtt>,
  193. "ams_mapping"}`` from ``backend/app/services/bambu_mqtt.py:2971-2980``.
  194. The lookup at ``data["ams"]`` therefore missed every real print and
  195. fallback archives kept their filament fields NULL — the exact regression
  196. the fix was supposed to close. Reproduced from JmanB52D's support
  197. bundle whose print start log line showed
  198. ``AMS 0: T0(type=PETG, color=FFFFFFFF, …)`` was sitting right there at
  199. ``data["raw_data"]["ams"]["ams"][0]["tray"][0]``.
  200. """
  201. def test_callback_wrapper_payload_resolves_raw_data_path(self):
  202. """The wrapper-shape payload must produce the same result the
  203. inner-shape payload would."""
  204. inner = {
  205. "ams": {
  206. "ams": [
  207. _ams_unit(0, [_tray(0, "PETG", "FFFFFFFF")]),
  208. ],
  209. },
  210. }
  211. wrapper = {
  212. "filename": "/data/Metadata/plate_1.gcode",
  213. "subtask_name": "xyz-10mm-calibration-cube",
  214. "remaining_time": 1200,
  215. "raw_data": inner,
  216. "ams_mapping": [0],
  217. }
  218. result = _extract_filament_data_from_mqtt(wrapper, ams_mapping=[0])
  219. # The 6-char rgba `FFFFFF` is what the AMS reports (with `FF` alpha
  220. # tail trimmed by the catalog) — the helper preserves whatever the
  221. # firmware sends.
  222. assert result == {"filament_type": "PETG", "filament_color": "FFFFFFFF"}
  223. def test_wrapper_with_no_ams_mapping_falls_back_to_all_loaded(self):
  224. """Wrapper shape without an ams_mapping behaves the same as the
  225. inner-shape no-mapping path: lists every loaded slot."""
  226. inner = {
  227. "ams": {
  228. "ams": [
  229. _ams_unit(0, [_tray(0, "PLA", "FF0000"), _tray(1, "PETG", "00FF00")]),
  230. ],
  231. },
  232. }
  233. wrapper = {"raw_data": inner}
  234. result = _extract_filament_data_from_mqtt(wrapper)
  235. assert result == {"filament_type": "PLA,PETG", "filament_color": "FF0000,00FF00"}
  236. def test_inner_shape_still_supported_after_wrapper_lookup(self):
  237. """Existing callers that pass the inner shape directly (e.g. the
  238. unit tests above) must keep working — the new lookup is additive."""
  239. inner = {
  240. "ams": {
  241. "ams": [_ams_unit(0, [_tray(0, "ASA", "112233")])],
  242. },
  243. }
  244. assert _extract_filament_data_from_mqtt(inner) == {
  245. "filament_type": "ASA",
  246. "filament_color": "112233",
  247. }
  248. def test_wrapper_with_missing_raw_data_returns_empty(self):
  249. """No raw_data wrapper AND no top-level ams → empty, no raise."""
  250. wrapper = {"filename": "foo.gcode", "ams_mapping": [0]}
  251. assert _extract_filament_data_from_mqtt(wrapper, ams_mapping=[0]) == {}
  252. def test_wrapper_with_non_dict_raw_data_falls_through_to_inner_lookup(self):
  253. """Defensive: a junk raw_data value (string / None) shouldn't crash
  254. and shouldn't shadow a present inner ``ams`` either. Lets us catch
  255. the case where MQTT decoding partially fails but the rest of the
  256. payload is fine."""
  257. wrapper = {
  258. "raw_data": "garbage",
  259. "ams": {"ams": [_ams_unit(0, [_tray(0, "PLA", "FF0000")])]},
  260. }
  261. assert _extract_filament_data_from_mqtt(wrapper) == {
  262. "filament_type": "PLA",
  263. "filament_color": "FF0000",
  264. }