test_suppression.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. import time
  2. import math
  3. import pytest
  4. from hookvision.models import HookState, Config, BucketModel
  5. from hookvision.main_window_v2 import MainWindow
  6. def _entry(t, hx, hy, lx, ly):
  7. dist = math.hypot(hx - lx, hy - ly)
  8. return {"t": t, "hook_cx": hx, "hook_cy": hy, "lug_cx": lx, "lug_cy": ly, "dist": dist}
  9. class TestSuppressWindow:
  10. def _feed(self, mw, side, points, step=0.1):
  11. t0 = time.time()
  12. mw._pair_traj[side] = [_entry(t0 + i * step, *p) for i, p in enumerate(points)]
  13. mw._update_suppress_window()
  14. def test_releasing_suppresses(self, qapp):
  15. mw = MainWindow(Config())
  16. pts = [(50, 100 + i * 2, 60, 100 + i * 2) for i in range(6)]
  17. self._feed(mw, "left", pts)
  18. assert mw._trace_phase == "放包"
  19. assert mw._is_voice_suppressed() is True
  20. def test_separation_diagonal_suppresses(self, qapp):
  21. mw = MainWindow(Config())
  22. # 挂钩向左上远离吊耳(任意方向相离),距离由 0 增大到 ~29
  23. pts = [(60 - i * 5, 100 - i * 3, 60, 100) for i in range(6)]
  24. self._feed(mw, "left", pts)
  25. assert mw._trace_phase == "分离"
  26. assert mw._is_voice_suppressed() is True
  27. def test_separation_rightward_suppresses(self, qapp):
  28. mw = MainWindow(Config())
  29. # 挂钩向右远离吊耳,距离由 0 增大到 25
  30. pts = [(60 + i * 5, 100, 60, 100) for i in range(6)]
  31. self._feed(mw, "right", pts)
  32. assert mw._trace_phase == "分离"
  33. assert mw._is_voice_suppressed() is True
  34. def test_genuine_hooking_not_suppressed(self, qapp):
  35. mw = MainWindow(Config())
  36. # 真挂包:挂钩下移去咬合,吊耳静止,距离缩小
  37. pts = [(50, 80 + i * 2, 60, 100) for i in range(6)]
  38. self._feed(mw, "left", pts)
  39. assert mw._trace_phase == ""
  40. assert mw._is_voice_suppressed() is False
  41. def test_lift_together_not_suppressed(self, qapp):
  42. mw = MainWindow(Config())
  43. # 挂包成功后吊起:挂钩+吊耳同步上移,距离保持贴合
  44. pts = [(50, 120 - i * 2, 60, 120 - i * 2) for i in range(6)]
  45. self._feed(mw, "left", pts)
  46. assert mw._trace_phase == ""
  47. assert mw._is_voice_suppressed() is False
  48. def test_jitter_not_suppressed(self, qapp):
  49. mw = MainWindow(Config())
  50. pts = []
  51. for i in range(6):
  52. y = 100 + (i % 3)
  53. pts.append((50, y, 60, y + 1))
  54. self._feed(mw, "left", pts)
  55. assert mw._trace_phase == ""
  56. assert mw._is_voice_suppressed() is False
  57. def test_no_pair_not_suppressed(self, qapp):
  58. mw = MainWindow(Config())
  59. mw._pair_traj = {"left": [], "right": []}
  60. mw._update_suppress_window()
  61. assert mw._trace_phase == ""
  62. assert mw._is_voice_suppressed() is False
  63. def test_far_hook_moving_not_suppressed(self, qapp):
  64. mw = MainWindow(Config())
  65. # 挂钩远离吊耳(从未贴合),即使距离增大也不应命中分离
  66. pts = [(200 + i * 5, 100, 60, 100) for i in range(6)]
  67. self._feed(mw, "left", pts)
  68. assert mw._trace_phase == ""
  69. assert mw._is_voice_suppressed() is False
  70. class TestVoiceGate:
  71. def test_voice_suppressed_when_in_window(self, qapp):
  72. mw = MainWindow(Config())
  73. pts = [(50, 100 + i * 2, 60, 100 + i * 2) for i in range(6)]
  74. t0 = time.time()
  75. mw._pair_traj["left"] = [_entry(t0 + i * 0.1, *p) for i, p in enumerate(pts)]
  76. mw._update_suppress_window()
  77. mw.last_voice_state = None
  78. mw.update_ui_for_state(HookState.READY)
  79. assert mw.last_voice_state is None
  80. def test_voice_plays_when_not_suppressed(self, qapp):
  81. mw = MainWindow(Config())
  82. mw._pair_traj = {"left": [], "right": []}
  83. mw._voice_suppress_until = 0.0
  84. mw.last_voice_state = None
  85. mw.update_ui_for_state(HookState.READY)
  86. assert mw.last_voice_state == HookState.READY
  87. class TestStaleOverlayClear:
  88. def test_cleared_after_timeout(self, qapp):
  89. mw = MainWindow(Config())
  90. mw._last_mqtt_process = time.time() - 10
  91. mw._overlay_cleared = False
  92. mw._last_left_draw = [{"x": 0}]
  93. mw._last_right_draw = [{"x": 0}]
  94. mw._maybe_clear_stale_overlay()
  95. assert mw._last_left_draw == []
  96. assert mw._last_right_draw == []
  97. assert mw._overlay_cleared is True
  98. assert mw._trace_phase == ""
  99. assert mw._voice_suppress_until == 0.0
  100. def test_not_cleared_within_timeout(self, qapp):
  101. mw = MainWindow(Config())
  102. mw._last_mqtt_process = time.time()
  103. mw._overlay_cleared = False
  104. mw._last_left_draw = [{"x": 0}]
  105. mw._maybe_clear_stale_overlay()
  106. assert mw._last_left_draw == [{"x": 0}]
  107. assert mw._overlay_cleared is False
  108. def test_not_cleared_before_first_message(self, qapp):
  109. mw = MainWindow(Config())
  110. mw._last_mqtt_process = 0.0
  111. mw._overlay_cleared = False
  112. mw._maybe_clear_stale_overlay()
  113. assert mw._last_mqtt_process == 0.0
  114. def test_cleared_once_until_new_message(self, qapp):
  115. mw = MainWindow(Config())
  116. mw._last_mqtt_process = time.time() - 10
  117. mw._overlay_cleared = False
  118. mw._maybe_clear_stale_overlay()
  119. assert mw._overlay_cleared is True
  120. mw._maybe_clear_stale_overlay()
  121. assert mw._last_left_draw == []
  122. def test_disabled_ai_no_clear(self, qapp):
  123. mw = MainWindow(Config())
  124. mw.ai_enabled = False
  125. mw._last_mqtt_process = time.time() - 10
  126. mw._overlay_cleared = False
  127. mw._last_left_draw = [{"x": 0}]
  128. mw._maybe_clear_stale_overlay()
  129. assert mw._last_left_draw == [{"x": 0}]
  130. def _obj(label, x, y, w=20, h=20):
  131. return {"x": x, "y": y, "w": w, "h": h, "label": label}
  132. def _calibrated_bucket(x_off=0.0, y_off=0.0):
  133. b = BucketModel(id=1, description="test", code_x="X")
  134. b.hook_lug_x_offset_ratio_left = x_off
  135. b.hook_lug_y_offset_ratio_left = y_off
  136. b.hook_lug_x_offset_ratio_right = x_off
  137. b.hook_lug_y_offset_ratio_right = y_off
  138. return b
  139. def _matched_pair():
  140. """标定偏移为 0:hook/lug 中心对齐且框相交 → 可以起吊"""
  141. return _obj("hook", 40, 40, 20, 20), _obj("lug", 40, 40, 20, 20)
  142. def _misaligned_pair():
  143. """中心偏开超出容差(xr/yr = -0.5)但框仍相交 → 挂包中"""
  144. return _obj("hook", 30, 30, 20, 20), _obj("lug", 40, 40, 20, 20)
  145. def _fresh(mw):
  146. mw.hook_state = HookState.IDLE
  147. mw.prev_state = HookState.IDLE
  148. mw.state_counter = 0
  149. mw._pair_traj = {"left": [], "right": []}
  150. mw._voice_suppress_until = 0.0
  151. mw.prev_left_hook = None
  152. mw.prev_left_lug = None
  153. mw.prev_right_hook = None
  154. mw.prev_right_lug = None
  155. class TestSideMatch:
  156. def test_uncalibrated_side_false(self, qapp):
  157. mw = MainWindow(Config())
  158. h, l = _matched_pair()
  159. assert mw._side_match("left", (h, l)) is False
  160. def test_calibrated_match_true(self, qapp):
  161. mw = MainWindow(Config())
  162. mw.active_bucket = _calibrated_bucket()
  163. h, l = _matched_pair()
  164. assert mw._side_match("left", (h, l)) is True
  165. def test_out_of_tolerance_false(self, qapp):
  166. mw = MainWindow(Config())
  167. mw.active_bucket = _calibrated_bucket()
  168. h, l = _misaligned_pair()
  169. assert mw._side_match("left", (h, l)) is False
  170. def test_non_intersecting_false(self, qapp):
  171. mw = MainWindow(Config())
  172. mw.active_bucket = _calibrated_bucket()
  173. h = _obj("hook", 0, 0, 20, 20)
  174. l = _obj("lug", 100, 100, 20, 20)
  175. assert mw._side_match("left", (h, l)) is False
  176. def test_none_pair_false(self, qapp):
  177. mw = MainWindow(Config())
  178. mw.active_bucket = _calibrated_bucket()
  179. assert mw._side_match("left", None) is False
  180. def test_y_uses_bottom_edge_match_true(self, qapp):
  181. # 底边对齐(yr=0)但中心偏开(中心 yr 会是 -0.5):只有底边语义下才判定可以起吊
  182. mw = MainWindow(Config())
  183. mw.active_bucket = _calibrated_bucket(x_off=-0.5, y_off=0.0)
  184. h = _obj("hook", 30, 30, 20, 40)
  185. l = _obj("lug", 40, 50, 20, 20)
  186. assert mw._side_match("left", (h, l)) is True
  187. def test_lug_touches_top_side_match_false(self, qapp):
  188. # 吊耳贴着画面上沿(y=0):不再判定可以起吊
  189. mw = MainWindow(Config())
  190. mw.active_bucket = _calibrated_bucket()
  191. h = _obj("hook", 40, 0, 20, 20)
  192. l = _obj("lug", 40, 0, 20, 20)
  193. assert mw._side_match("left", (h, l)) is False
  194. class TestCanLiftStateMachine:
  195. def test_cold_start_stays_idle(self, qapp):
  196. mw = MainWindow(Config())
  197. _fresh(mw)
  198. for _ in range(5):
  199. mw.evaluate_hook_state()
  200. assert mw.hook_state == HookState.IDLE
  201. def test_uncalibrated_never_ready(self, qapp):
  202. mw = MainWindow(Config())
  203. _fresh(mw)
  204. mw.active_bucket = BucketModel(id=1, description="test", code_x="X")
  205. mw.left_objects = list(_matched_pair())
  206. for _ in range(6):
  207. mw.evaluate_hook_state()
  208. assert mw.hook_state == HookState.HOOKING
  209. def test_calibrated_match_ready(self, qapp):
  210. mw = MainWindow(Config())
  211. _fresh(mw)
  212. mw.active_bucket = _calibrated_bucket()
  213. mw.left_objects = list(_matched_pair())
  214. for _ in range(3):
  215. mw.evaluate_hook_state()
  216. assert mw.hook_state == HookState.READY
  217. def test_misaligned_hooking_not_ready(self, qapp):
  218. mw = MainWindow(Config())
  219. _fresh(mw)
  220. mw.active_bucket = _calibrated_bucket()
  221. mw.left_objects = list(_misaligned_pair())
  222. for _ in range(6):
  223. mw.evaluate_hook_state()
  224. assert mw.hook_state == HookState.HOOKING
  225. def test_no_pair_idle(self, qapp):
  226. mw = MainWindow(Config())
  227. _fresh(mw)
  228. mw.active_bucket = _calibrated_bucket()
  229. for _ in range(5):
  230. mw.evaluate_hook_state()
  231. assert mw.hook_state == HookState.IDLE
  232. def test_lug_at_top_clears_display_and_skips_judgment(self, qapp):
  233. mw = MainWindow(Config())
  234. _fresh(mw)
  235. mw.active_bucket = _calibrated_bucket()
  236. mw.left_objects = list(_matched_pair())
  237. for _ in range(3):
  238. mw.evaluate_hook_state()
  239. assert mw.hook_state == HookState.READY
  240. # 吊耳贴着画面上沿 → 不再判定,状态框清空(文字空、边框恢复默认灰),状态重置
  241. mw.left_objects = [_obj("hook", 40, 0, 20, 20), _obj("lug", 40, 0, 20, 20)]
  242. mw.evaluate_hook_state()
  243. assert mw.hook_status_label.text() == ""
  244. assert "#dcdfe6" in mw.left_label.styleSheet()
  245. assert mw.hook_state == HookState.IDLE
  246. # 吊耳回到画面内 → 恢复判定
  247. mw.left_objects = list(_matched_pair())
  248. for _ in range(3):
  249. mw.evaluate_hook_state()
  250. assert mw.hook_status_label.text() == "挂钩状态: 挂包成功"
  251. def test_non_intersecting_not_ready(self, qapp):
  252. mw = MainWindow(Config())
  253. _fresh(mw)
  254. mw.active_bucket = _calibrated_bucket()
  255. mw.left_objects = [_obj("hook", 0, 0, 20, 20), _obj("lug", 100, 100, 20, 20)]
  256. for _ in range(5):
  257. mw.evaluate_hook_state()
  258. assert mw.hook_state == HookState.IDLE
  259. def test_releasing_still_detected(self, qapp):
  260. mw = MainWindow(Config())
  261. _fresh(mw)
  262. mw.active_bucket = _calibrated_bucket()
  263. mw.left_objects = list(_matched_pair())
  264. for _ in range(3):
  265. mw.evaluate_hook_state()
  266. assert mw.hook_state == HookState.READY
  267. # 放包:hook/lug 同步下移超过阈值
  268. for step in range(1, 4):
  269. mw.left_objects = [
  270. _obj("hook", 40, 40 + step * 6, 20, 20),
  271. _obj("lug", 40, 40 + step * 6, 20, 20),
  272. ]
  273. mw.evaluate_hook_state()
  274. assert mw.hook_state == HookState.RELEASING
  275. class TestReadyStickyAntiFlicker:
  276. """READY 进出用 ready_debounce 防抖:短时失配不闪红绿"""
  277. def _engage(self, mw):
  278. mw.active_bucket = _calibrated_bucket()
  279. mw.left_objects = list(_matched_pair())
  280. for _ in range(3):
  281. mw.evaluate_hook_state()
  282. assert mw.hook_state == HookState.READY
  283. def test_ready_holds_through_short_flicker(self, qapp):
  284. mw = MainWindow(Config())
  285. _fresh(mw)
  286. self._engage(mw)
  287. # 短暂 2 帧失配(框仍相交)→ 仍保持 READY
  288. for _ in range(2):
  289. mw.left_objects = list(_misaligned_pair())
  290. mw.evaluate_hook_state()
  291. assert mw.hook_state == HookState.READY
  292. def test_ready_holds_while_matched(self, qapp):
  293. mw = MainWindow(Config())
  294. _fresh(mw)
  295. self._engage(mw)
  296. for _ in range(6):
  297. mw.left_objects = list(_matched_pair())
  298. mw.evaluate_hook_state()
  299. assert mw.hook_state == HookState.READY
  300. def test_ready_releases_to_hooking_after_persistent_misalign(self, qapp):
  301. mw = MainWindow(Config())
  302. _fresh(mw)
  303. self._engage(mw)
  304. for _ in range(3):
  305. mw.left_objects = list(_misaligned_pair())
  306. mw.evaluate_hook_state()
  307. assert mw.hook_state == HookState.HOOKING
  308. def test_ready_releases_to_idle_when_detached(self, qapp):
  309. mw = MainWindow(Config())
  310. _fresh(mw)
  311. self._engage(mw)
  312. mw.left_objects = []
  313. for _ in range(3):
  314. mw.evaluate_hook_state()
  315. assert mw.hook_state == HookState.IDLE