test_assignment_verification_2582.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. """Read-back verification of AMS spool assignments (#2582).
  2. After Bambuddy pushes an assignment (``ams_filament_setting`` +
  3. ``extrusion_cali_sel``) it registers the desired end-state and watches the
  4. periodic AMS telemetry to confirm the tray actually accepted it. Historically
  5. this was fire-and-forget, so a silently-dropped assignment (the reporter's
  6. "assigned in Bambuddy but Studio never saw it") produced no feedback at all.
  7. These tests lock in the matcher: a tray_info_idx echo confirms the push landed,
  8. cali_idx is a secondary "K-profile applied" signal, and a timeout without a
  9. matching echo reports a non-confirmation instead of inventing success.
  10. """
  11. import time
  12. from unittest.mock import MagicMock
  13. from backend.app.services.bambu_mqtt import BambuMQTTClient
  14. def _client(on_verified=None) -> BambuMQTTClient:
  15. return BambuMQTTClient(
  16. ip_address="10.0.0.1",
  17. serial_number="SERIAL",
  18. access_code="code",
  19. model="P1S",
  20. on_assignment_verified=on_verified,
  21. )
  22. def _ams_frame(tray_id=0, ams_id=0, **tray_fields):
  23. """One AMS unit with the given tray carrying content fields."""
  24. tray = {"id": tray_id}
  25. tray.update(tray_fields)
  26. return {"ams": [{"id": ams_id, "tray": [tray]}]}
  27. class TestAssignmentMatch:
  28. def test_matching_tray_info_idx_fires_verified(self):
  29. cb = MagicMock()
  30. client = _client(cb)
  31. client.register_assignment_verification(
  32. ams_id=0, tray_id=0, tray_info_idx="GFL05", tray_color="FF0000FF", cali_idx=-1
  33. )
  34. client._handle_ams_data(_ams_frame(tray_info_idx="GFL05", tray_type="PLA"))
  35. cb.assert_called_once()
  36. ams_id, tray_id, verified, detail = cb.call_args.args
  37. assert (ams_id, tray_id, verified) == (0, 0, True)
  38. assert detail["kprofile_applied"] is True
  39. # Pending entry is cleared once resolved.
  40. assert (0, 0) not in client._pending_assignments
  41. def test_match_is_case_insensitive(self):
  42. cb = MagicMock()
  43. client = _client(cb)
  44. client.register_assignment_verification(
  45. ams_id=0, tray_id=0, tray_info_idx="gfl05", tray_color="", cali_idx=None
  46. )
  47. client._handle_ams_data(_ams_frame(tray_info_idx="GFL05"))
  48. assert cb.call_args.args[2] is True
  49. def test_kprofile_mismatch_flags_not_applied(self):
  50. cb = MagicMock()
  51. client = _client(cb)
  52. client.register_assignment_verification(ams_id=0, tray_id=0, tray_info_idx="GFL05", tray_color="", cali_idx=3)
  53. # Filament id landed but the printer kept a different cali_idx.
  54. client._handle_ams_data(_ams_frame(tray_info_idx="GFL05", cali_idx=1))
  55. verified, detail = cb.call_args.args[2], cb.call_args.args[3]
  56. assert verified is True
  57. assert detail["kprofile_applied"] is False
  58. def test_kprofile_match_flags_applied(self):
  59. cb = MagicMock()
  60. client = _client(cb)
  61. client.register_assignment_verification(ams_id=0, tray_id=0, tray_info_idx="GFL05", tray_color="", cali_idx=3)
  62. client._handle_ams_data(_ams_frame(tray_info_idx="GFL05", cali_idx=3))
  63. assert cb.call_args.args[3]["kprofile_applied"] is True
  64. class TestAssignmentPendingAndTimeout:
  65. def test_divergent_idx_within_window_keeps_waiting(self):
  66. cb = MagicMock()
  67. client = _client(cb)
  68. client.register_assignment_verification(ams_id=0, tray_id=0, tray_info_idx="GFL05", tray_color="", cali_idx=-1)
  69. # Tray still shows the previous filament — no callback, stay pending.
  70. client._handle_ams_data(_ams_frame(tray_info_idx="GFU00"))
  71. cb.assert_not_called()
  72. assert (0, 0) in client._pending_assignments
  73. def test_timeout_after_seeing_divergent_tray_reports_failure(self):
  74. cb = MagicMock()
  75. client = _client(cb)
  76. client.register_assignment_verification(ams_id=0, tray_id=0, tray_info_idx="GFL05", tray_color="", cali_idx=-1)
  77. # First push observes a divergent id (records last_seen_idx).
  78. client._handle_ams_data(_ams_frame(tray_info_idx="GFU00"))
  79. # Force the deadline into the past, then another push evaluates it.
  80. client._pending_assignments[(0, 0)]["deadline"] = time.monotonic() - 1
  81. client._handle_ams_data(_ams_frame(tray_info_idx="GFU00"))
  82. verified, detail = cb.call_args.args[2], cb.call_args.args[3]
  83. assert verified is False
  84. assert detail["saw_tray"] is True
  85. assert detail["actual_tray_info_idx"] == "GFU00"
  86. assert (0, 0) not in client._pending_assignments
  87. def test_timeout_without_ever_seeing_tray_reports_no_tray(self):
  88. cb = MagicMock()
  89. client = _client(cb)
  90. client.register_assignment_verification(ams_id=1, tray_id=2, tray_info_idx="GFL05", tray_color="", cali_idx=-1)
  91. client._pending_assignments[(1, 2)]["deadline"] = time.monotonic() - 1
  92. # A push for an unrelated AMS unit still triggers deadline evaluation.
  93. client._handle_ams_data(_ams_frame(ams_id=0, tray_id=0, tray_info_idx="GFL05"))
  94. verified, detail = cb.call_args.args[2], cb.call_args.args[3]
  95. assert verified is False
  96. assert detail["saw_tray"] is False
  97. assert detail["actual_tray_info_idx"] is None
  98. class TestRegistrationGuards:
  99. def test_blank_tray_info_idx_is_not_registered(self):
  100. client = _client()
  101. client.register_assignment_verification(
  102. ams_id=0, tray_id=0, tray_info_idx="", tray_color="FF0000FF", cali_idx=-1
  103. )
  104. assert not client._pending_assignments
  105. def test_reconnect_clears_pending(self):
  106. client = _client()
  107. client.register_assignment_verification(ams_id=0, tray_id=0, tray_info_idx="GFL05", tray_color="", cali_idx=-1)
  108. assert client._pending_assignments
  109. # Mirror the on_connect reset path.
  110. client._pending_assignments.clear()
  111. assert not client._pending_assignments
  112. class TestExternalSpool:
  113. def test_external_tray_matches_via_vt_tray(self):
  114. cb = MagicMock()
  115. client = _client(cb)
  116. # External-left spool: logical ams_id 255 / tray 0 lives at vt_tray id 254.
  117. client.state.raw_data["vt_tray"] = [{"id": 254, "tray_info_idx": "GFL05"}]
  118. client.register_assignment_verification(
  119. ams_id=255, tray_id=0, tray_info_idx="GFL05", tray_color="", cali_idx=-1
  120. )
  121. # Any AMS push drives the check; the tray is resolved from vt_tray.
  122. client._handle_ams_data(_ams_frame(tray_info_idx="GFU00"))
  123. cb.assert_called_once()
  124. assert cb.call_args.args[2] is True