test_state_machine.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. """Test hook state evaluation logic (evaluate_hook_state).
  2. The state machine transitions (main_window_v2):
  3. IDLE -> HOOKING (hook-lug intersecting, debounce 3 frames)
  4. HOOKING -> READY (calibrated offset match via _side_match, debounce ready_debounce)
  5. HOOKING/READY -> IDLE (no intersection; READY exits after ready_debounce frames)
  6. Any -> RELEASING (downward movement, debounce 3)
  7. For RELEASING: both hook and lug centers must move down by > RELEASE_DELTA_THRESHOLD pixels.
  8. """
  9. import pytest
  10. from hookvision.models import HookState, Config, BucketModel
  11. def make_hook(x, y, w=20, h=20):
  12. return {"x": x, "y": y, "w": w, "h": h, "label": "hook", "color": (0, 0, 255)}
  13. def make_lug(x, y, w=20, h=20):
  14. return {"x": x, "y": y, "w": w, "h": h, "label": "lug", "color": (255, 0, 0)}
  15. def make_ready(x, y, w=10, h=10):
  16. return {"x": x, "y": y, "w": w, "h": h, "label": "ready", "color": (0, 255, 255)}
  17. def calibrated_bucket(x_off=0.0, y_off=0.0):
  18. b = BucketModel(id=1, description="test", code_x="X")
  19. b.hook_lug_x_offset_ratio_left = x_off
  20. b.hook_lug_y_offset_ratio_left = y_off
  21. b.hook_lug_x_offset_ratio_right = x_off
  22. b.hook_lug_y_offset_ratio_right = y_off
  23. return b
  24. class MockMainWindow:
  25. """Minimal mock of MainWindow_v2 for testing evaluate_hook_state."""
  26. STATE_THRESHOLD = 3
  27. RELEASE_DELTA_THRESHOLD = 5
  28. def __init__(self):
  29. self.left_objects = []
  30. self.right_objects = []
  31. self.hook_state = HookState.IDLE
  32. self.prev_state = HookState.IDLE
  33. self.state_counter = 0
  34. self.prev_left_hook = None
  35. self.prev_right_hook = None
  36. self.prev_left_lug = None
  37. self.prev_right_lug = None
  38. self.active_bucket = None
  39. self.config = Config()
  40. self._calib_warned = set()
  41. self.last_state = None # tracks update_ui_for_state calls
  42. def evaluate_hook_state(self):
  43. # This is the exact implementation from main_window_v2.py
  44. left_hooks = [o for o in self.left_objects if 'hook' in o['label'].lower()]
  45. left_lugs = [o for o in self.left_objects if 'lug' in o['label'].lower()]
  46. right_hooks = [o for o in self.right_objects if 'hook' in o['label'].lower()]
  47. right_lugs = [o for o in self.right_objects if 'lug' in o['label'].lower()]
  48. def find_nearest_pair(hooks, lugs):
  49. if not hooks or not lugs:
  50. return None
  51. best_pair = None
  52. best_dist = float('inf')
  53. for h in hooks:
  54. hx = h['x'] + h['w'] / 2
  55. hy = h['y'] + h['h'] / 2
  56. for l in lugs:
  57. lx = l['x'] + l['w'] / 2
  58. ly = l['y'] + l['h'] / 2
  59. dist = (hx - lx) ** 2 + (hy - ly) ** 2
  60. if dist < best_dist:
  61. best_dist = dist
  62. best_pair = (h, l)
  63. return best_pair
  64. left_pair = find_nearest_pair(left_hooks, left_lugs)
  65. right_pair = find_nearest_pair(right_hooks, right_lugs)
  66. self._push_pair_traj("left", left_pair)
  67. self._push_pair_traj("right", right_pair)
  68. self._update_suppress_window()
  69. # 吊耳贴着画面上沿(框顶被裁):不再判定状态,状态框不显示
  70. if (left_pair and self._lug_touches_top(left_pair[1])) or \
  71. (right_pair and self._lug_touches_top(right_pair[1])):
  72. self.hook_state = HookState.IDLE
  73. self.prev_state = HookState.IDLE
  74. self.state_counter = 0
  75. self._clear_state_display()
  76. return
  77. is_releasing = False
  78. # 处理左侧配对
  79. if left_pair:
  80. h, l = left_pair
  81. if self.prev_left_hook and self.prev_left_lug:
  82. dy_h = (h['y'] + h['h'] / 2) - self.prev_left_hook[1]
  83. dy_l = (l['y'] + l['h'] / 2) - self.prev_left_lug[1]
  84. if dy_h > self.RELEASE_DELTA_THRESHOLD and dy_l > self.RELEASE_DELTA_THRESHOLD:
  85. is_releasing = True
  86. self.prev_left_hook = (h['x'] + h['w'] / 2, h['y'] + h['h'] / 2)
  87. self.prev_left_lug = (l['x'] + l['w'] / 2, l['y'] + l['h'] / 2)
  88. else:
  89. self.prev_left_hook = None
  90. self.prev_left_lug = None
  91. # 处理右侧配对
  92. if right_pair:
  93. h, l = right_pair
  94. if self.prev_right_hook and self.prev_right_lug:
  95. dy_h = (h['y'] + h['h'] / 2) - self.prev_right_hook[1]
  96. dy_l = (l['y'] + l['h'] / 2) - self.prev_right_lug[1]
  97. if dy_h > self.RELEASE_DELTA_THRESHOLD and dy_l > self.RELEASE_DELTA_THRESHOLD:
  98. is_releasing = True
  99. self.prev_right_hook = (h['x'] + h['w'] / 2, h['y'] + h['h'] / 2)
  100. self.prev_right_lug = (l['x'] + l['w'] / 2, l['y'] + l['h'] / 2)
  101. else:
  102. self.prev_right_hook = None
  103. self.prev_right_lug = None
  104. # 状态判断
  105. if is_releasing:
  106. new_state = HookState.RELEASING
  107. else:
  108. can_lift = False
  109. if self.active_bucket:
  110. can_lift = (self._side_match("left", left_pair)
  111. or self._side_match("right", right_pair))
  112. if can_lift:
  113. new_state = HookState.READY
  114. elif self._has_intersecting_pair(left_pair, right_pair):
  115. new_state = HookState.HOOKING
  116. else:
  117. new_state = HookState.IDLE
  118. # 防抖:进出 READY 对称用 ready_debounce,非 READY → IDLE 即时,其余用 STATE_THRESHOLD
  119. if new_state == HookState.IDLE and self.hook_state != HookState.READY:
  120. threshold = 1
  121. elif new_state == HookState.READY or self.hook_state == HookState.READY:
  122. threshold = self.config.ai.ready_debounce
  123. else:
  124. threshold = self.STATE_THRESHOLD
  125. if new_state == self.prev_state:
  126. self.state_counter += 1
  127. else:
  128. self.prev_state = new_state
  129. self.state_counter = 1
  130. if self.state_counter >= threshold:
  131. if new_state != self.hook_state:
  132. self.hook_state = new_state
  133. self.last_state = new_state
  134. self.state_counter = 0
  135. for obj in self.left_objects + self.right_objects:
  136. obj.pop('paired', None)
  137. if left_pair:
  138. left_pair[0]['paired'] = True
  139. left_pair[1]['paired'] = True
  140. if right_pair:
  141. right_pair[0]['paired'] = True
  142. right_pair[1]['paired'] = True
  143. def _push_pair_traj(self, side, pair):
  144. pass
  145. def _update_suppress_window(self):
  146. pass
  147. def _clear_state_display(self):
  148. self.last_state = None
  149. @staticmethod
  150. def _lug_touches_top(l):
  151. return l['y'] <= 0
  152. def rects_intersect(self, a, b):
  153. ax1, ay1, ax2, ay2 = a['x'], a['y'], a['x']+a['w'], a['y']+a['h']
  154. bx1, by1, bx2, by2 = b['x'], b['y'], b['x']+b['w'], b['y']+b['h']
  155. return not (ax2 < bx1 or bx2 < ax1 or ay2 < by1 or by2 < ay1)
  156. def _has_intersecting_pair(self, left_pair, right_pair):
  157. if left_pair:
  158. h, l = left_pair
  159. if self.rects_intersect(h, l):
  160. return True
  161. if right_pair:
  162. h, l = right_pair
  163. if self.rects_intersect(h, l):
  164. return True
  165. return False
  166. def _side_match(self, side, pair):
  167. bucket = self.active_bucket
  168. if not bucket or not pair:
  169. return False
  170. h, l = pair
  171. if not self.rects_intersect(h, l):
  172. return False
  173. if self._lug_touches_top(l):
  174. return False
  175. lw, lh = l['w'], l['h']
  176. if lw <= 0 or lh <= 0:
  177. return False
  178. xr = (h['x'] + h['w'] / 2 - (l['x'] + l['w'] / 2)) / lw
  179. yr = ((h['y'] + h['h']) - (l['y'] + l['h'])) / lh
  180. cal_x = getattr(bucket, f"hook_lug_x_offset_ratio_{side}", None)
  181. cal_y = getattr(bucket, f"hook_lug_y_offset_ratio_{side}", None)
  182. if cal_x is None or cal_y is None:
  183. return False
  184. tol = self.config.ai.offset_ratio_tolerance
  185. return abs(xr - cal_x) <= tol and abs(yr - cal_y) <= tol
  186. class TestStateMachine:
  187. def test_initial_state_idle(self):
  188. mw = MockMainWindow()
  189. mw.evaluate_hook_state()
  190. assert mw.hook_state == HookState.IDLE
  191. def test_no_objects_stays_idle(self):
  192. mw = MockMainWindow()
  193. for _ in range(5):
  194. mw.evaluate_hook_state()
  195. assert mw.hook_state == HookState.IDLE
  196. def test_hooking_requires_debounce(self):
  197. mw = MockMainWindow()
  198. hook = make_hook(0, 0)
  199. lug = make_lug(5, 5) # overlapping
  200. mw.left_objects = [hook, lug]
  201. # First call: counter starts, no transition
  202. mw.evaluate_hook_state()
  203. assert mw.hook_state == HookState.IDLE
  204. assert mw.state_counter == 1
  205. # Second call: still not enough
  206. mw.evaluate_hook_state()
  207. assert mw.hook_state == HookState.IDLE
  208. # Third call: threshold reached, transition to HOOKING
  209. mw.evaluate_hook_state()
  210. assert mw.hook_state == HookState.HOOKING
  211. def test_idle_immediate_no_debounce(self):
  212. mw = MockMainWindow()
  213. hook = make_hook(0, 0)
  214. lug = make_lug(5, 5)
  215. mw.left_objects = [hook, lug]
  216. # Transition to HOOKING
  217. for _ in range(3):
  218. mw.evaluate_hook_state()
  219. assert mw.hook_state == HookState.HOOKING
  220. # Remove objects - should immediately go IDLE
  221. mw.left_objects = []
  222. mw.evaluate_hook_state()
  223. assert mw.hook_state == HookState.IDLE
  224. def test_lug_at_top_skips_judgment(self):
  225. mw = MockMainWindow()
  226. mw.active_bucket = calibrated_bucket()
  227. mw.left_objects = [make_hook(40, 40), make_lug(40, 40)]
  228. for _ in range(3):
  229. mw.evaluate_hook_state()
  230. assert mw.hook_state == HookState.READY
  231. # 吊耳贴着画面上沿 → 不判定、状态重置为 IDLE、不显示状态框
  232. mw.left_objects = [make_hook(40, 0), make_lug(40, 0)]
  233. mw.evaluate_hook_state()
  234. assert mw.last_state is None
  235. assert mw.hook_state == HookState.IDLE
  236. def test_ready_state(self):
  237. mw = MockMainWindow()
  238. mw.active_bucket = calibrated_bucket()
  239. hook = make_hook(40, 40)
  240. lug = make_lug(40, 40) # 中心对齐,匹配标定偏移 0,0
  241. mw.left_objects = [hook, lug]
  242. for _ in range(3):
  243. mw.evaluate_hook_state()
  244. assert mw.hook_state == HookState.READY
  245. def test_releasing_state(self):
  246. mw = MockMainWindow()
  247. hook = make_hook(0, 0)
  248. lug = make_lug(5, 5)
  249. mw.left_objects = [hook, lug]
  250. # First establish HOOKING
  251. for _ in range(3):
  252. mw.evaluate_hook_state()
  253. assert mw.hook_state == HookState.HOOKING
  254. # Simulate continuous downward movement (>5px each frame) for debounce
  255. for step in range(1, 4):
  256. mw.left_objects = [make_hook(0, step * 6), make_lug(5, step * 6 + 5)]
  257. mw.evaluate_hook_state()
  258. assert mw.hook_state == HookState.RELEASING
  259. def test_releasing_upward_movement_no_change(self):
  260. mw = MockMainWindow()
  261. hook = make_hook(0, 0)
  262. lug = make_lug(5, 5)
  263. mw.left_objects = [hook, lug]
  264. for _ in range(3):
  265. mw.evaluate_hook_state()
  266. assert mw.hook_state == HookState.HOOKING
  267. # Move upward (negative dy) - should NOT trigger releasing
  268. mw.left_objects = [make_hook(0, -10), make_lug(5, -10)]
  269. for _ in range(3):
  270. mw.evaluate_hook_state()
  271. assert mw.hook_state != HookState.RELEASING
  272. def test_releasing_small_movement_no_change(self):
  273. mw = MockMainWindow()
  274. hook = make_hook(0, 0)
  275. lug = make_lug(5, 5)
  276. mw.left_objects = [hook, lug]
  277. for _ in range(3):
  278. mw.evaluate_hook_state()
  279. assert mw.hook_state == HookState.HOOKING
  280. # Move down by 3px (less than threshold of 5) - should NOT trigger
  281. mw.left_objects = [make_hook(0, 3), make_lug(5, 3)]
  282. for _ in range(3):
  283. mw.evaluate_hook_state()
  284. assert mw.hook_state != HookState.RELEASING
  285. def test_both_sides_for_releasing(self):
  286. mw = MockMainWindow()
  287. hook_l = make_hook(0, 0)
  288. lug_l = make_lug(5, 5)
  289. hook_r = make_hook(100, 0)
  290. lug_r = make_lug(105, 5)
  291. mw.left_objects = [hook_l, lug_l]
  292. mw.right_objects = [hook_r, lug_r]
  293. for _ in range(3):
  294. mw.evaluate_hook_state()
  295. assert mw.hook_state == HookState.HOOKING
  296. # Only left side moves down progressively
  297. for step in range(1, 4):
  298. mw.left_objects = [make_hook(0, step * 6), make_lug(5, step * 6 + 5)]
  299. mw.evaluate_hook_state()
  300. assert mw.hook_state == HookState.RELEASING
  301. def test_ready_requires_intersecting_pair(self):
  302. mw = MockMainWindow()
  303. mw.active_bucket = calibrated_bucket()
  304. # 即使标定匹配,hook/lug 不相交也不得 READY
  305. hook = make_hook(0, 0)
  306. lug = make_lug(200, 200) # far apart
  307. mw.left_objects = [hook, lug]
  308. for _ in range(3):
  309. mw.evaluate_hook_state()
  310. # No intersecting pair -> IDLE
  311. assert mw.hook_state == HookState.IDLE
  312. def test_paired_flag_set(self):
  313. mw = MockMainWindow()
  314. hook1 = make_hook(0, 0)
  315. lug1 = make_lug(5, 5)
  316. hook2 = make_hook(100, 100) # far away, unpaired
  317. lug2 = make_lug(200, 200)
  318. mw.left_objects = [hook1, lug1, hook2, lug2]
  319. mw.evaluate_hook_state()
  320. paired = [o for o in mw.left_objects if o.get('paired')]
  321. unpaired = [o for o in mw.left_objects if not o.get('paired')]
  322. assert len(paired) == 2 # hook1 and lug1
  323. assert len(unpaired) == 2 # hook2 and lug2
  324. def test_reset_prev_positions_when_no_pair(self):
  325. mw = MockMainWindow()
  326. hook = make_hook(0, 0)
  327. lug = make_lug(5, 5)
  328. mw.left_objects = [hook, lug]
  329. mw.evaluate_hook_state()
  330. assert mw.prev_left_hook is not None
  331. mw.left_objects = []
  332. mw.evaluate_hook_state()
  333. assert mw.prev_left_hook is None
  334. assert mw.prev_left_lug is None