test_state_machine.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. """Test hook state evaluation logic (evaluate_hook_state).
  2. The state machine transitions:
  3. IDLE -> HOOKING (hook-lug intersecting, debounce 3 frames)
  4. HOOKING -> READY (hook-lug intersecting + ready object, debounce 3)
  5. HOOKING/READY -> IDLE (no intersection, immediate)
  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
  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. class MockMainWindow:
  18. """Minimal mock of MainWindow for testing evaluate_hook_state."""
  19. STATE_THRESHOLD = 3
  20. RELEASE_DELTA_THRESHOLD = 5
  21. def __init__(self):
  22. self.left_objects = []
  23. self.right_objects = []
  24. self.hook_state = HookState.IDLE
  25. self.prev_state = HookState.IDLE
  26. self.state_counter = 0
  27. self.prev_left_hook = None
  28. self.prev_right_hook = None
  29. self.prev_left_lug = None
  30. self.prev_right_lug = None
  31. self.last_state = None # tracks update_ui_for_state calls
  32. def evaluate_hook_state(self):
  33. # This is the exact implementation from main_window.py
  34. left_hooks = [o for o in self.left_objects if 'hook' in o['label'].lower()]
  35. left_lugs = [o for o in self.left_objects if 'lug' in o['label'].lower()]
  36. right_hooks = [o for o in self.right_objects if 'hook' in o['label'].lower()]
  37. right_lugs = [o for o in self.right_objects if 'lug' in o['label'].lower()]
  38. def find_nearest_pair(hooks, lugs):
  39. if not hooks or not lugs:
  40. return None
  41. best_pair = None
  42. best_dist = float('inf')
  43. for h in hooks:
  44. hx = h['x'] + h['w'] / 2
  45. hy = h['y'] + h['h'] / 2
  46. for l in lugs:
  47. lx = l['x'] + l['w'] / 2
  48. ly = l['y'] + l['h'] / 2
  49. dist = (hx - lx) ** 2 + (hy - ly) ** 2
  50. if dist < best_dist:
  51. best_dist = dist
  52. best_pair = (h, l)
  53. return best_pair
  54. left_pair = find_nearest_pair(left_hooks, left_lugs)
  55. right_pair = find_nearest_pair(right_hooks, right_lugs)
  56. is_releasing = False
  57. # 处理左侧配对
  58. if left_pair:
  59. h, l = left_pair
  60. if self.prev_left_hook and self.prev_left_lug:
  61. dy_h = (h['y'] + h['h'] / 2) - self.prev_left_hook[1]
  62. dy_l = (l['y'] + l['h'] / 2) - self.prev_left_lug[1]
  63. if dy_h > self.RELEASE_DELTA_THRESHOLD and dy_l > self.RELEASE_DELTA_THRESHOLD:
  64. is_releasing = True
  65. self.prev_left_hook = (h['x'] + h['w'] / 2, h['y'] + h['h'] / 2)
  66. self.prev_left_lug = (l['x'] + l['w'] / 2, l['y'] + l['h'] / 2)
  67. else:
  68. self.prev_left_hook = None
  69. self.prev_left_lug = None
  70. # 处理右侧配对
  71. if right_pair:
  72. h, l = right_pair
  73. if self.prev_right_hook and self.prev_right_lug:
  74. dy_h = (h['y'] + h['h'] / 2) - self.prev_right_hook[1]
  75. dy_l = (l['y'] + l['h'] / 2) - self.prev_right_lug[1]
  76. if dy_h > self.RELEASE_DELTA_THRESHOLD and dy_l > self.RELEASE_DELTA_THRESHOLD:
  77. is_releasing = True
  78. self.prev_right_hook = (h['x'] + h['w'] / 2, h['y'] + h['h'] / 2)
  79. self.prev_right_lug = (l['x'] + l['w'] / 2, l['y'] + l['h'] / 2)
  80. else:
  81. self.prev_right_hook = None
  82. self.prev_right_lug = None
  83. # 状态判断
  84. if is_releasing:
  85. new_state = HookState.RELEASING
  86. else:
  87. any_ready = any('ready' in o['label'].lower() for o in self.left_objects + self.right_objects)
  88. has_intersecting_pair = self._has_intersecting_pair(left_pair, right_pair)
  89. if any_ready and has_intersecting_pair:
  90. new_state = HookState.READY
  91. elif has_intersecting_pair:
  92. new_state = HookState.HOOKING
  93. else:
  94. new_state = HookState.IDLE
  95. # 防抖
  96. threshold = 1 if new_state == HookState.IDLE else self.STATE_THRESHOLD
  97. if new_state == self.prev_state:
  98. self.state_counter += 1
  99. else:
  100. self.prev_state = new_state
  101. self.state_counter = 1
  102. if self.state_counter >= threshold:
  103. if new_state != self.hook_state:
  104. self.hook_state = new_state
  105. self.last_state = new_state
  106. self.state_counter = 0
  107. for obj in self.left_objects + self.right_objects:
  108. obj.pop('paired', None)
  109. if left_pair:
  110. left_pair[0]['paired'] = True
  111. left_pair[1]['paired'] = True
  112. if right_pair:
  113. right_pair[0]['paired'] = True
  114. right_pair[1]['paired'] = True
  115. def rects_intersect(self, a, b):
  116. ax1, ay1, ax2, ay2 = a['x'], a['y'], a['x']+a['w'], a['y']+a['h']
  117. bx1, by1, bx2, by2 = b['x'], b['y'], b['x']+b['w'], b['y']+b['h']
  118. return not (ax2 < bx1 or bx2 < ax1 or ay2 < by1 or by2 < ay1)
  119. def _has_intersecting_pair(self, left_pair, right_pair):
  120. if left_pair:
  121. h, l = left_pair
  122. if self.rects_intersect(h, l):
  123. return True
  124. if right_pair:
  125. h, l = right_pair
  126. if self.rects_intersect(h, l):
  127. return True
  128. return False
  129. class TestStateMachine:
  130. def test_initial_state_idle(self):
  131. mw = MockMainWindow()
  132. mw.evaluate_hook_state()
  133. assert mw.hook_state == HookState.IDLE
  134. def test_no_objects_stays_idle(self):
  135. mw = MockMainWindow()
  136. for _ in range(5):
  137. mw.evaluate_hook_state()
  138. assert mw.hook_state == HookState.IDLE
  139. def test_hooking_requires_debounce(self):
  140. mw = MockMainWindow()
  141. hook = make_hook(0, 0)
  142. lug = make_lug(5, 5) # overlapping
  143. mw.left_objects = [hook, lug]
  144. # First call: counter starts, no transition
  145. mw.evaluate_hook_state()
  146. assert mw.hook_state == HookState.IDLE
  147. assert mw.state_counter == 1
  148. # Second call: still not enough
  149. mw.evaluate_hook_state()
  150. assert mw.hook_state == HookState.IDLE
  151. # Third call: threshold reached, transition to HOOKING
  152. mw.evaluate_hook_state()
  153. assert mw.hook_state == HookState.HOOKING
  154. def test_idle_immediate_no_debounce(self):
  155. mw = MockMainWindow()
  156. hook = make_hook(0, 0)
  157. lug = make_lug(5, 5)
  158. mw.left_objects = [hook, lug]
  159. # Transition to HOOKING
  160. for _ in range(3):
  161. mw.evaluate_hook_state()
  162. assert mw.hook_state == HookState.HOOKING
  163. # Remove objects - should immediately go IDLE
  164. mw.left_objects = []
  165. mw.evaluate_hook_state()
  166. assert mw.hook_state == HookState.IDLE
  167. def test_ready_state(self):
  168. mw = MockMainWindow()
  169. hook = make_hook(0, 0)
  170. lug = make_lug(5, 5)
  171. ready = make_ready(100, 100)
  172. mw.left_objects = [hook, lug, ready]
  173. for _ in range(3):
  174. mw.evaluate_hook_state()
  175. assert mw.hook_state == HookState.READY
  176. def test_releasing_state(self):
  177. mw = MockMainWindow()
  178. hook = make_hook(0, 0)
  179. lug = make_lug(5, 5)
  180. mw.left_objects = [hook, lug]
  181. # First establish HOOKING
  182. for _ in range(3):
  183. mw.evaluate_hook_state()
  184. assert mw.hook_state == HookState.HOOKING
  185. # Simulate continuous downward movement (>5px each frame) for debounce
  186. for step in range(1, 4):
  187. mw.left_objects = [make_hook(0, step * 6), make_lug(5, step * 6 + 5)]
  188. mw.evaluate_hook_state()
  189. assert mw.hook_state == HookState.RELEASING
  190. def test_releasing_upward_movement_no_change(self):
  191. mw = MockMainWindow()
  192. hook = make_hook(0, 0)
  193. lug = make_lug(5, 5)
  194. mw.left_objects = [hook, lug]
  195. for _ in range(3):
  196. mw.evaluate_hook_state()
  197. assert mw.hook_state == HookState.HOOKING
  198. # Move upward (negative dy) - should NOT trigger releasing
  199. mw.left_objects = [make_hook(0, -10), make_lug(5, -10)]
  200. for _ in range(3):
  201. mw.evaluate_hook_state()
  202. assert mw.hook_state != HookState.RELEASING
  203. def test_releasing_small_movement_no_change(self):
  204. mw = MockMainWindow()
  205. hook = make_hook(0, 0)
  206. lug = make_lug(5, 5)
  207. mw.left_objects = [hook, lug]
  208. for _ in range(3):
  209. mw.evaluate_hook_state()
  210. assert mw.hook_state == HookState.HOOKING
  211. # Move down by 3px (less than threshold of 5) - should NOT trigger
  212. mw.left_objects = [make_hook(0, 3), make_lug(5, 3)]
  213. for _ in range(3):
  214. mw.evaluate_hook_state()
  215. assert mw.hook_state != HookState.RELEASING
  216. def test_both_sides_for_releasing(self):
  217. mw = MockMainWindow()
  218. hook_l = make_hook(0, 0)
  219. lug_l = make_lug(5, 5)
  220. hook_r = make_hook(100, 0)
  221. lug_r = make_lug(105, 5)
  222. mw.left_objects = [hook_l, lug_l]
  223. mw.right_objects = [hook_r, lug_r]
  224. for _ in range(3):
  225. mw.evaluate_hook_state()
  226. assert mw.hook_state == HookState.HOOKING
  227. # Only left side moves down progressively
  228. for step in range(1, 4):
  229. mw.left_objects = [make_hook(0, step * 6), make_lug(5, step * 6 + 5)]
  230. mw.evaluate_hook_state()
  231. assert mw.hook_state == HookState.RELEASING
  232. def test_ready_requires_intersecting_pair(self):
  233. mw = MockMainWindow()
  234. # Non-intersecting hook/lug + ready should be IDLE, not READY
  235. hook = make_hook(0, 0)
  236. lug = make_lug(200, 200) # far apart
  237. ready = make_ready(100, 100)
  238. mw.left_objects = [hook, lug, ready]
  239. for _ in range(3):
  240. mw.evaluate_hook_state()
  241. # No intersecting pair -> IDLE, even with ready present
  242. assert mw.hook_state == HookState.IDLE
  243. def test_paired_flag_set(self):
  244. mw = MockMainWindow()
  245. hook1 = make_hook(0, 0)
  246. lug1 = make_lug(5, 5)
  247. hook2 = make_hook(100, 100) # far away, unpaired
  248. lug2 = make_lug(200, 200)
  249. mw.left_objects = [hook1, lug1, hook2, lug2]
  250. mw.evaluate_hook_state()
  251. paired = [o for o in mw.left_objects if o.get('paired')]
  252. unpaired = [o for o in mw.left_objects if not o.get('paired')]
  253. assert len(paired) == 2 # hook1 and lug1
  254. assert len(unpaired) == 2 # hook2 and lug2
  255. def test_reset_prev_positions_when_no_pair(self):
  256. mw = MockMainWindow()
  257. hook = make_hook(0, 0)
  258. lug = make_lug(5, 5)
  259. mw.left_objects = [hook, lug]
  260. mw.evaluate_hook_state()
  261. assert mw.prev_left_hook is not None
  262. mw.left_objects = []
  263. mw.evaluate_hook_state()
  264. assert mw.prev_left_hook is None
  265. assert mw.prev_left_lug is None