use super::correlation::{ candidate_index_offsets, collapse_segments_by_phase, correlate_onsets, estimate_phase, index_onsets_by_spacing, marker_index_offsets, marker_onsets, shortest_wrapped_difference, }; use super::{ PulseSegment, correlate_segments, detect_audio_onsets, detect_audio_segments, detect_video_onsets, detect_video_segments, median, }; use crate::sync_probe::analyze::report::SyncAnalysisReport; use std::collections::BTreeMap; #[test] fn detect_video_onsets_finds_bright_transitions() { let timestamps = (0..60).map(|idx| idx as f64 / 10.0).collect::>(); let brightness = timestamps .iter() .enumerate() .map(|(idx, _)| { if idx == 0 || idx == 10 || idx == 20 { 250 } else { 5 } }) .collect::>(); let onsets = detect_video_onsets(×tamps, &brightness).expect("video onsets"); assert_eq!(onsets, vec![0.0, 0.95, 1.95]); } #[test] fn detect_audio_onsets_finds_click_bursts() { let mut samples = vec![0i16; 48_000]; for start in [0usize, 48_000 / 2] { for sample in samples.iter_mut().skip(start).take(300) { *sample = 18_000; } } let onsets = detect_audio_onsets(&samples, 48_000, 5).expect("audio onsets"); assert_eq!(onsets.len(), 2); assert!((onsets[0] - 0.0).abs() < 0.01); assert!((onsets[1] - 0.5).abs() < 0.02); } #[test] fn detect_video_segments_keeps_regular_and_marker_durations_distinct() { let timestamps = (0..30).map(|idx| idx as f64 / 30.0).collect::>(); let brightness = [ 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ]; let segments = detect_video_segments(×tamps, &brightness).expect("video segments"); assert_eq!(segments.len(), 2); assert!(segments[1].duration_s > segments[0].duration_s); } #[test] fn detect_audio_segments_keeps_regular_and_marker_durations_distinct() { let mut samples = vec![0i16; 48_000]; for sample in samples.iter_mut().take(3_000) { *sample = 18_000; } for sample in samples.iter_mut().skip(24_000).take(6_000) { *sample = 18_000; } let segments = detect_audio_segments(&samples, 48_000, 5).expect("audio segments"); assert_eq!(segments.len(), 2); assert!(segments[1].duration_s > segments[0].duration_s); } #[test] fn detect_video_segments_closes_a_pulse_that_stays_active_until_the_last_frame() { let timestamps = [0.0, 0.1, 0.2, 0.3]; let brightness = [0, 0, 255, 255]; let segments = detect_video_segments(×tamps, &brightness).expect("trailing video segment"); assert_eq!(segments.len(), 1); assert!(segments[0].end_s > segments[0].start_s); assert!(segments[0].end_s >= 0.3); } #[test] fn detect_audio_segments_closes_a_click_that_stays_active_until_the_capture_ends() { let mut samples = vec![0i16; 4_800]; let midpoint = samples.len() / 2; for sample in samples.iter_mut().skip(midpoint) { *sample = 18_000; } let segments = detect_audio_segments(&samples, 48_000, 5).expect("trailing audio segment"); assert_eq!(segments.len(), 1); assert!(segments[0].end_s > segments[0].start_s); } #[test] fn correlate_onsets_reports_skew_and_drift() { let report = correlate_onsets(&[0.0, 1.0, 2.0, 3.0], &[0.05, 1.04, 2.03, 3.02], 1.0, 0.2) .expect("correlated report"); assert_sync_report_shape(&report, 4); assert!((report.first_skew_ms - 50.0).abs() < 0.001); assert!((report.last_skew_ms - 20.0).abs() < 0.001); assert!((report.drift_ms + 30.0).abs() < 0.001); assert!(report.max_abs_skew_ms >= 50.0); } #[test] fn correlate_onsets_single_pulse_uses_phase_fallback() { let report = correlate_onsets(&[0.95], &[0.05], 1.0, 0.2).expect("single-pulse fallback"); assert_eq!(report.paired_event_count, 1); assert!((report.first_skew_ms - 100.0).abs() < 0.001); } #[test] fn detect_video_onsets_rejects_empty_low_contrast_and_missing_edges() { assert!(detect_video_onsets(&[], &[]).is_err()); assert!(detect_video_onsets(&[0.0, 0.1], &[10, 12]).is_err()); assert!(detect_video_onsets(&[0.0, 0.1, 0.2], &[255, 255, 255]).is_err()); } #[test] fn detect_audio_onsets_rejects_empty_invalid_and_too_quiet_inputs() { assert!(detect_audio_onsets(&[], 48_000, 5).is_err()); assert!(detect_audio_onsets(&[1, 2, 3], 0, 5).is_err()); assert!(detect_audio_onsets(&[1, 2, 3], 48_000, 0).is_err()); assert!(detect_audio_onsets(&vec![1i16; 4_800], 48_000, 5).is_err()); } #[test] fn correlate_onsets_rejects_empty_inputs_invalid_gap_and_unpairable_events() { assert!(correlate_onsets(&[], &[0.0], 1.0, 0.2).is_err()); assert!(correlate_onsets(&[0.0], &[], 1.0, 0.2).is_err()); assert!(correlate_onsets(&[0.0], &[0.0], 1.0, 0.0).is_err()); assert!(correlate_onsets(&[0.0, 1.0], &[2.0, 3.0], 1.0, 0.1).is_err()); assert!(correlate_onsets(&[0.0], &[0.0], 0.0, 0.1).is_err()); } #[test] fn correlate_segments_validate_inputs_and_support_single_pulse_fallback() { let video = [PulseSegment { start_s: 0.95, end_s: 1.05, duration_s: 0.1, }]; let audio = [PulseSegment { start_s: 0.05, end_s: 0.15, duration_s: 0.1, }]; let report = correlate_segments(&video, &audio, 1.0, 0.1, 3, 0.2).expect("single segment fallback"); assert_eq!(report.paired_event_count, 1); assert!((report.first_skew_ms - 100.0).abs() < 0.001); assert!(correlate_segments(&[], &audio, 1.0, 0.1, 3, 0.2).is_err()); assert!(correlate_segments(&video, &[], 1.0, 0.1, 3, 0.2).is_err()); assert!(correlate_segments(&video, &audio, 0.0, 0.1, 3, 0.2).is_err()); assert!(correlate_segments(&video, &audio, 1.0, 0.0, 3, 0.2).is_err()); assert!(correlate_segments(&video, &audio, 1.0, 0.1, 0, 0.2).is_err()); assert!(correlate_segments(&video, &audio, 1.0, 0.1, 3, 0.0).is_err()); assert!(correlate_segments(&video, &audio, 1.0, 0.1, 3, 0.05).is_err()); } #[test] fn phase_estimation_and_indexing_stay_stable_when_pulses_are_missing() { let video_phase = estimate_phase(&[4.0, 5.0, 7.0, 8.0, 10.0], 1.0); let audio_phase = estimate_phase(&[4.018, 5.017, 6.019, 8.018, 9.018], 1.0); assert!((video_phase - 0.0).abs() < 0.02); assert!((audio_phase - 0.018).abs() < 0.02); let video_indexed = index_onsets_by_spacing(&[4.0, 5.0, 7.0, 8.0, 10.0], 1.0); let audio_indexed = index_onsets_by_spacing(&[4.018, 5.017, 6.019, 8.018, 9.018], 1.0); assert_eq!( video_indexed.keys().copied().collect::>(), vec![0, 1, 3, 4, 6] ); assert_eq!( audio_indexed.keys().copied().collect::>(), vec![0, 1, 2, 4, 5] ); } #[test] fn correlation_helpers_cover_empty_index_sets_and_wrapped_phase_math() { assert!(index_onsets_by_spacing(&[], 1.0).is_empty()); assert!(candidate_index_offsets(&BTreeMap::new(), &BTreeMap::new()).is_empty()); let mut video_only = BTreeMap::new(); video_only.insert(0, 1.0); assert!(candidate_index_offsets(&video_only, &BTreeMap::new()).is_empty()); let mut audio_only = BTreeMap::new(); audio_only.insert(0, 1.0); assert!(candidate_index_offsets(&BTreeMap::new(), &audio_only).is_empty()); let mut video_indexed = BTreeMap::new(); video_indexed.insert(2, 2.0); let mut audio_indexed = BTreeMap::new(); audio_indexed.insert(5, 5.0); assert_eq!( candidate_index_offsets(&video_indexed, &audio_indexed), vec![3] ); assert!((shortest_wrapped_difference(0.6, 1.0) + 0.4).abs() < 0.000_001); assert!((shortest_wrapped_difference(-0.6, 1.0) - 0.4).abs() < 0.000_001); } #[test] fn marker_index_offsets_include_marker_alignment_and_general_fallback() { let video_indexed = index_onsets_by_spacing(&[4.0, 5.0, 7.0, 8.0, 10.0], 1.0); let audio_indexed = index_onsets_by_spacing(&[5.018, 6.017, 7.019, 9.018, 10.018], 1.0); let offsets = marker_index_offsets(&video_indexed, &audio_indexed, &[10.0], &[10.018]); assert!(offsets.contains(&1)); assert!(offsets.contains(&0)); } #[test] fn correlate_onsets_ignores_missing_pulses_and_preserves_stable_skew() { let report = correlate_onsets( &[4.0, 5.0, 7.0, 8.0, 10.0], &[4.018, 5.017, 6.019, 8.018, 9.018], 1.0, 0.2, ) .expect("correlated report"); assert_eq!(report.paired_event_count, 3); assert!((report.mean_skew_ms - 17.666).abs() < 5.0); assert!(report.max_abs_skew_ms < 30.0); } #[test] fn correlate_segments_uses_markers_to_break_period_aliasing() { let video = vec![ PulseSegment { start_s: 3.3, end_s: 3.55, duration_s: 0.25, }, PulseSegment { start_s: 4.266667, end_s: 4.4, duration_s: 0.133333, }, PulseSegment { start_s: 5.3, end_s: 5.433333, duration_s: 0.133333, }, ]; let audio = vec![ PulseSegment { start_s: 3.35, end_s: 3.59, duration_s: 0.24, }, PulseSegment { start_s: 4.316667, end_s: 4.436667, duration_s: 0.12, }, PulseSegment { start_s: 5.35, end_s: 5.47, duration_s: 0.12, }, ]; let report = correlate_segments(&video, &audio, 1.0, 0.12, 5, 0.2).expect("marker-correlated report"); assert_eq!(report.paired_event_count, 3); assert!((report.mean_skew_ms - 50.0).abs() < 10.0); } #[test] fn collapse_segments_by_phase_keeps_one_best_segment_per_pulse_slot() { let collapsed = collapse_segments_by_phase( &[ PulseSegment { start_s: 4.00, end_s: 4.12, duration_s: 0.12, }, PulseSegment { start_s: 4.08, end_s: 4.10, duration_s: 0.02, }, PulseSegment { start_s: 5.01, end_s: 5.13, duration_s: 0.12, }, PulseSegment { start_s: 5.09, end_s: 5.11, duration_s: 0.02, }, ], 1.0, 0.32, ); assert_eq!(collapsed.len(), 2); assert!((collapsed[0].start_s - 4.0).abs() < 0.001); assert!((collapsed[1].start_s - 5.01).abs() < 0.001); } #[test] fn collapse_segments_by_phase_prefers_the_longest_regular_cadence() { let collapsed = collapse_segments_by_phase( &[ PulseSegment { start_s: 4.00, end_s: 4.12, duration_s: 0.12, }, PulseSegment { start_s: 5.02, end_s: 5.14, duration_s: 0.12, }, PulseSegment { start_s: 6.00, end_s: 6.12, duration_s: 0.12, }, PulseSegment { start_s: 7.01, end_s: 7.13, duration_s: 0.12, }, PulseSegment { start_s: 4.42, end_s: 4.67, duration_s: 0.25, }, PulseSegment { start_s: 6.42, end_s: 6.67, duration_s: 0.25, }, ], 1.0, 0.32, ); assert_eq!(collapsed.len(), 4); assert!((collapsed[0].start_s - 4.0).abs() < 0.001); assert!((collapsed[3].start_s - 7.01).abs() < 0.001); } #[test] fn correlate_segments_collapses_repeated_noise_within_each_pulse_slot() { let video = vec![ PulseSegment { start_s: 4.0, end_s: 4.12, duration_s: 0.12, }, PulseSegment { start_s: 4.03, end_s: 4.05, duration_s: 0.02, }, PulseSegment { start_s: 5.0, end_s: 5.12, duration_s: 0.12, }, PulseSegment { start_s: 5.03, end_s: 5.05, duration_s: 0.02, }, ]; let audio = vec![ PulseSegment { start_s: 4.02, end_s: 4.14, duration_s: 0.12, }, PulseSegment { start_s: 4.05, end_s: 4.07, duration_s: 0.02, }, PulseSegment { start_s: 5.02, end_s: 5.14, duration_s: 0.12, }, PulseSegment { start_s: 5.05, end_s: 5.07, duration_s: 0.02, }, ]; let report = correlate_segments(&video, &audio, 1.0, 0.12, 5, 0.2).expect("collapsed"); assert_eq!(report.video_event_count, 2); assert_eq!(report.audio_event_count, 2); assert_eq!(report.paired_event_count, 2); assert!(report.max_abs_skew_ms < 30.0); } #[test] fn marker_detection_finds_wider_segments_only() { let markers = marker_onsets( &[ PulseSegment { start_s: 1.0, end_s: 1.12, duration_s: 0.12, }, PulseSegment { start_s: 5.0, end_s: 5.24, duration_s: 0.24, }, ], 0.12, ); assert_eq!(markers, vec![5.0]); } #[test] fn median_handles_empty_even_and_odd_inputs() { assert_eq!(median(Vec::new()), 0.0); assert_eq!(median(vec![1.0, 3.0, 2.0]), 2.0); assert_eq!(median(vec![4.0, 1.0, 3.0, 2.0]), 2.5); } fn assert_sync_report_shape(report: &SyncAnalysisReport, paired_events: usize) { assert_eq!(report.video_event_count, paired_events); assert_eq!(report.audio_event_count, paired_events); assert_eq!(report.paired_event_count, paired_events); assert_eq!(report.skews_ms.len(), paired_events); assert_eq!(report.video_onsets_s.len(), paired_events); assert_eq!(report.audio_onsets_s.len(), paired_events); }