| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323 |
- """Test hook state evaluation logic (evaluate_hook_state).
- The state machine transitions:
- IDLE -> HOOKING (hook-lug intersecting, debounce 3 frames)
- HOOKING -> READY (hook-lug intersecting + ready object, debounce 3)
- HOOKING/READY -> IDLE (no intersection, immediate)
- Any -> RELEASING (downward movement, debounce 3)
- For RELEASING: both hook and lug centers must move down by > RELEASE_DELTA_THRESHOLD pixels.
- """
- import pytest
- from hookvision.models import HookState
- def make_hook(x, y, w=20, h=20):
- return {"x": x, "y": y, "w": w, "h": h, "label": "hook", "color": (0, 0, 255)}
- def make_lug(x, y, w=20, h=20):
- return {"x": x, "y": y, "w": w, "h": h, "label": "lug", "color": (255, 0, 0)}
- def make_ready(x, y, w=10, h=10):
- return {"x": x, "y": y, "w": w, "h": h, "label": "ready", "color": (0, 255, 255)}
- class MockMainWindow:
- """Minimal mock of MainWindow for testing evaluate_hook_state."""
- STATE_THRESHOLD = 3
- RELEASE_DELTA_THRESHOLD = 5
- def __init__(self):
- self.left_objects = []
- self.right_objects = []
- self.hook_state = HookState.IDLE
- self.prev_state = HookState.IDLE
- self.state_counter = 0
- self.prev_left_hook = None
- self.prev_right_hook = None
- self.prev_left_lug = None
- self.prev_right_lug = None
- self.last_state = None # tracks update_ui_for_state calls
- def evaluate_hook_state(self):
- # This is the exact implementation from main_window.py
- left_hooks = [o for o in self.left_objects if 'hook' in o['label'].lower()]
- left_lugs = [o for o in self.left_objects if 'lug' in o['label'].lower()]
- right_hooks = [o for o in self.right_objects if 'hook' in o['label'].lower()]
- right_lugs = [o for o in self.right_objects if 'lug' in o['label'].lower()]
- def find_nearest_pair(hooks, lugs):
- if not hooks or not lugs:
- return None
- best_pair = None
- best_dist = float('inf')
- for h in hooks:
- hx = h['x'] + h['w'] / 2
- hy = h['y'] + h['h'] / 2
- for l in lugs:
- lx = l['x'] + l['w'] / 2
- ly = l['y'] + l['h'] / 2
- dist = (hx - lx) ** 2 + (hy - ly) ** 2
- if dist < best_dist:
- best_dist = dist
- best_pair = (h, l)
- return best_pair
- left_pair = find_nearest_pair(left_hooks, left_lugs)
- right_pair = find_nearest_pair(right_hooks, right_lugs)
- is_releasing = False
- # 处理左侧配对
- if left_pair:
- h, l = left_pair
- if self.prev_left_hook and self.prev_left_lug:
- dy_h = (h['y'] + h['h'] / 2) - self.prev_left_hook[1]
- dy_l = (l['y'] + l['h'] / 2) - self.prev_left_lug[1]
- if dy_h > self.RELEASE_DELTA_THRESHOLD and dy_l > self.RELEASE_DELTA_THRESHOLD:
- is_releasing = True
- self.prev_left_hook = (h['x'] + h['w'] / 2, h['y'] + h['h'] / 2)
- self.prev_left_lug = (l['x'] + l['w'] / 2, l['y'] + l['h'] / 2)
- else:
- self.prev_left_hook = None
- self.prev_left_lug = None
- # 处理右侧配对
- if right_pair:
- h, l = right_pair
- if self.prev_right_hook and self.prev_right_lug:
- dy_h = (h['y'] + h['h'] / 2) - self.prev_right_hook[1]
- dy_l = (l['y'] + l['h'] / 2) - self.prev_right_lug[1]
- if dy_h > self.RELEASE_DELTA_THRESHOLD and dy_l > self.RELEASE_DELTA_THRESHOLD:
- is_releasing = True
- self.prev_right_hook = (h['x'] + h['w'] / 2, h['y'] + h['h'] / 2)
- self.prev_right_lug = (l['x'] + l['w'] / 2, l['y'] + l['h'] / 2)
- else:
- self.prev_right_hook = None
- self.prev_right_lug = None
- # 状态判断
- if is_releasing:
- new_state = HookState.RELEASING
- else:
- any_ready = any('ready' in o['label'].lower() for o in self.left_objects + self.right_objects)
- has_intersecting_pair = self._has_intersecting_pair(left_pair, right_pair)
- if any_ready and has_intersecting_pair:
- new_state = HookState.READY
- elif has_intersecting_pair:
- new_state = HookState.HOOKING
- else:
- new_state = HookState.IDLE
- # 防抖
- threshold = 1 if new_state == HookState.IDLE else self.STATE_THRESHOLD
- if new_state == self.prev_state:
- self.state_counter += 1
- else:
- self.prev_state = new_state
- self.state_counter = 1
- if self.state_counter >= threshold:
- if new_state != self.hook_state:
- self.hook_state = new_state
- self.last_state = new_state
- self.state_counter = 0
- for obj in self.left_objects + self.right_objects:
- obj.pop('paired', None)
- if left_pair:
- left_pair[0]['paired'] = True
- left_pair[1]['paired'] = True
- if right_pair:
- right_pair[0]['paired'] = True
- right_pair[1]['paired'] = True
- def rects_intersect(self, a, b):
- ax1, ay1, ax2, ay2 = a['x'], a['y'], a['x']+a['w'], a['y']+a['h']
- bx1, by1, bx2, by2 = b['x'], b['y'], b['x']+b['w'], b['y']+b['h']
- return not (ax2 < bx1 or bx2 < ax1 or ay2 < by1 or by2 < ay1)
- def _has_intersecting_pair(self, left_pair, right_pair):
- if left_pair:
- h, l = left_pair
- if self.rects_intersect(h, l):
- return True
- if right_pair:
- h, l = right_pair
- if self.rects_intersect(h, l):
- return True
- return False
- class TestStateMachine:
- def test_initial_state_idle(self):
- mw = MockMainWindow()
- mw.evaluate_hook_state()
- assert mw.hook_state == HookState.IDLE
- def test_no_objects_stays_idle(self):
- mw = MockMainWindow()
- for _ in range(5):
- mw.evaluate_hook_state()
- assert mw.hook_state == HookState.IDLE
- def test_hooking_requires_debounce(self):
- mw = MockMainWindow()
- hook = make_hook(0, 0)
- lug = make_lug(5, 5) # overlapping
- mw.left_objects = [hook, lug]
- # First call: counter starts, no transition
- mw.evaluate_hook_state()
- assert mw.hook_state == HookState.IDLE
- assert mw.state_counter == 1
- # Second call: still not enough
- mw.evaluate_hook_state()
- assert mw.hook_state == HookState.IDLE
- # Third call: threshold reached, transition to HOOKING
- mw.evaluate_hook_state()
- assert mw.hook_state == HookState.HOOKING
- def test_idle_immediate_no_debounce(self):
- mw = MockMainWindow()
- hook = make_hook(0, 0)
- lug = make_lug(5, 5)
- mw.left_objects = [hook, lug]
- # Transition to HOOKING
- for _ in range(3):
- mw.evaluate_hook_state()
- assert mw.hook_state == HookState.HOOKING
- # Remove objects - should immediately go IDLE
- mw.left_objects = []
- mw.evaluate_hook_state()
- assert mw.hook_state == HookState.IDLE
- def test_ready_state(self):
- mw = MockMainWindow()
- hook = make_hook(0, 0)
- lug = make_lug(5, 5)
- ready = make_ready(100, 100)
- mw.left_objects = [hook, lug, ready]
- for _ in range(3):
- mw.evaluate_hook_state()
- assert mw.hook_state == HookState.READY
- def test_releasing_state(self):
- mw = MockMainWindow()
- hook = make_hook(0, 0)
- lug = make_lug(5, 5)
- mw.left_objects = [hook, lug]
- # First establish HOOKING
- for _ in range(3):
- mw.evaluate_hook_state()
- assert mw.hook_state == HookState.HOOKING
- # Simulate continuous downward movement (>5px each frame) for debounce
- for step in range(1, 4):
- mw.left_objects = [make_hook(0, step * 6), make_lug(5, step * 6 + 5)]
- mw.evaluate_hook_state()
- assert mw.hook_state == HookState.RELEASING
- def test_releasing_upward_movement_no_change(self):
- mw = MockMainWindow()
- hook = make_hook(0, 0)
- lug = make_lug(5, 5)
- mw.left_objects = [hook, lug]
- for _ in range(3):
- mw.evaluate_hook_state()
- assert mw.hook_state == HookState.HOOKING
- # Move upward (negative dy) - should NOT trigger releasing
- mw.left_objects = [make_hook(0, -10), make_lug(5, -10)]
- for _ in range(3):
- mw.evaluate_hook_state()
- assert mw.hook_state != HookState.RELEASING
- def test_releasing_small_movement_no_change(self):
- mw = MockMainWindow()
- hook = make_hook(0, 0)
- lug = make_lug(5, 5)
- mw.left_objects = [hook, lug]
- for _ in range(3):
- mw.evaluate_hook_state()
- assert mw.hook_state == HookState.HOOKING
- # Move down by 3px (less than threshold of 5) - should NOT trigger
- mw.left_objects = [make_hook(0, 3), make_lug(5, 3)]
- for _ in range(3):
- mw.evaluate_hook_state()
- assert mw.hook_state != HookState.RELEASING
- def test_both_sides_for_releasing(self):
- mw = MockMainWindow()
- hook_l = make_hook(0, 0)
- lug_l = make_lug(5, 5)
- hook_r = make_hook(100, 0)
- lug_r = make_lug(105, 5)
- mw.left_objects = [hook_l, lug_l]
- mw.right_objects = [hook_r, lug_r]
- for _ in range(3):
- mw.evaluate_hook_state()
- assert mw.hook_state == HookState.HOOKING
- # Only left side moves down progressively
- for step in range(1, 4):
- mw.left_objects = [make_hook(0, step * 6), make_lug(5, step * 6 + 5)]
- mw.evaluate_hook_state()
- assert mw.hook_state == HookState.RELEASING
- def test_ready_requires_intersecting_pair(self):
- mw = MockMainWindow()
- # Non-intersecting hook/lug + ready should be IDLE, not READY
- hook = make_hook(0, 0)
- lug = make_lug(200, 200) # far apart
- ready = make_ready(100, 100)
- mw.left_objects = [hook, lug, ready]
- for _ in range(3):
- mw.evaluate_hook_state()
- # No intersecting pair -> IDLE, even with ready present
- assert mw.hook_state == HookState.IDLE
- def test_paired_flag_set(self):
- mw = MockMainWindow()
- hook1 = make_hook(0, 0)
- lug1 = make_lug(5, 5)
- hook2 = make_hook(100, 100) # far away, unpaired
- lug2 = make_lug(200, 200)
- mw.left_objects = [hook1, lug1, hook2, lug2]
- mw.evaluate_hook_state()
- paired = [o for o in mw.left_objects if o.get('paired')]
- unpaired = [o for o in mw.left_objects if not o.get('paired')]
- assert len(paired) == 2 # hook1 and lug1
- assert len(unpaired) == 2 # hook2 and lug2
- def test_reset_prev_positions_when_no_pair(self):
- mw = MockMainWindow()
- hook = make_hook(0, 0)
- lug = make_lug(5, 5)
- mw.left_objects = [hook, lug]
- mw.evaluate_hook_state()
- assert mw.prev_left_hook is not None
- mw.left_objects = []
- mw.evaluate_hook_state()
- assert mw.prev_left_hook is None
- assert mw.prev_left_lug is None
|