from src.config_loader import TurnDetectionSettings from src.models import FrameInfo, HeadPoseResult from src.turn_detector import TurnDetector def config(**overrides: object) -> TurnDetectionSettings: values = dict(enabled=True, signage_yaw_direction="positive", baseline_strategy="first_valid", baseline_window_sec=.8, baseline_min_samples=3, subtle_threshold_deg=10, weak_threshold_deg=20, medium_threshold_deg=35, strong_threshold_deg=50, min_subtle_duration_sec=.2, min_turn_duration_sec=.3, max_pose_gap_sec=.3, min_pose_samples_for_evaluation=5, min_face_detection_rate_for_evaluation=.2, min_pose_estimation_rate_for_evaluation=.2, face_appearance_enabled=True, face_missing_before_appearance_sec=.3, weak_or_higher_is_turn=True) values.update(overrides) return TurnDetectionSettings(**values) # type: ignore[arg-type] def info(frame: int, elapsed: float) -> FrameInfo: return FrameInfo(frame, f"t{frame}", elapsed, 100, 100, 20) def pose(frame: int, elapsed: float, yaw: float | None, face: bool = True) -> HeadPoseResult: success = yaw is not None return HeadPoseResult(1, frame, f"t{frame}", elapsed, face, success, 0 if success else None, yaw, 0 if success else None, 6 if face else 0, None, .9 if face else None, "" if success else ("solvepnp_failed" if face else "face_not_detected")) def feed(detector: TurnDetector, samples: list[tuple[float, float | None, bool]]) -> tuple[list, list]: results, events = [], [] for frame, (elapsed, yaw, face) in enumerate(samples, 1): frame_results, new_events = detector.update([pose(frame, elapsed, yaw, face)], info(frame, elapsed)) results.extend(frame_results) events.extend(new_events) return results, events def test_first_valid_baseline_and_threshold_levels() -> None: detector = TurnDetector(config()) results, events = feed(detector, [(0, 0, True), (.1, 2, True), (.2, -1, True), (.3, 9, True), (.4, 11, True)]) state = detector.get_state(1) assert state and state.baseline_acquired and state.baseline_yaw == 0 assert state.baseline_sample_count == 3 assert sum(event.event_type == "baseline_acquired" for event in events) == 1 assert results[-2].turn_level == "none" assert results[-1].turn_level == "subtle" def test_baseline_requires_minimum_samples() -> None: detector = TurnDetector(config(baseline_min_samples=3)) results, _ = feed(detector, [(0, 0, True), (.1, 1, True)]) assert not detector.get_state(1).baseline_acquired # type: ignore[union-attr] assert results[-1].failure_reason == "baseline_not_acquired" def test_weak_duration_confirms_once_and_tracks_max_delta() -> None: detector = TurnDetector(config()) _, events = feed(detector, [(0, 0, True), (.1, 0, True), (.2, 0, True), (.3, 25, True), (.45, 30, True), (.61, 40, True), (.8, 45, True)]) state = detector.get_state(1) assert state and state.turn_confirmed and state.max_yaw_delta_toward_signage == 45 assert state.strongest_level == "medium" assert sum(event.event_type == "turn_confirmed" for event in events) == 1 def test_short_candidate_and_pose_gap_behaviour() -> None: detector = TurnDetector(config()) results, _ = feed(detector, [(0, 0, True), (.1, 0, True), (.2, 0, True), (.3, 25, True), (.4, 25, True), (.5, None, False), (.61, 25, True)]) assert results[3].turn_candidate and not results[3].turn_confirmed assert detector.get_state(1).turn_confirmed # type: ignore[union-attr] detector2 = TurnDetector(config()) _, events = feed(detector2, [(0, 0, True), (.1, 0, True), (.2, 0, True), (.3, 25, True), (.4, None, False), (.8, None, False)]) assert any(event.event_type == "turn_lost" for event in events) def test_face_appeared_and_finalize_classifications() -> None: detector = TurnDetector(config(min_pose_samples_for_evaluation=3)) _, events = feed(detector, [(0, None, False), (.4, 0, True), (.5, 0, True), (.6, 0, True)]) state = detector.get_state(1) assert state and state.face_appeared_after_missing assert any(event.event_type == "face_appeared" for event in events) final, final_events = detector.finalize_track(1, info(5, .7)) assert final and final.valid_for_turn_analysis and final.exclusion_reason == "evaluable_no_turn" assert final_events[0].event_type == "turn_track_finalized" no_pose = TurnDetector(config()) no_pose.update([pose(1, 0, None, True)], info(1, 0)) final, _ = no_pose.finalize_track(1, info(2, .1)) assert final and not final.evaluable and final.exclusion_reason == "no_pose_estimated" def test_baseline_not_acquired_finalize() -> None: detector = TurnDetector(config(baseline_min_samples=3)) feed(detector, [(0, 0, True), (.1, 1, True)]) final, _ = detector.finalize_track(1, info(3, .2)) assert final and final.exclusion_reason == "baseline_not_acquired"