//! Contract tests for UVC frame metadata artifact summarization. //! //! Scope: optional `LESAVKA_UVC_FRAME_META_LOG_PATH` artifacts. //! Targets: `scripts/manual/summarize_uvc_frame_meta_log.py`. //! Why: HEVC client-to-RCT work needs a safe local way to decide whether //! event-coded frames reached the UVC spool before we add riskier server-side //! introspection. use std::{fs, path::PathBuf, process::Command}; use serde_json::Value; const SUMMARIZER: &str = include_str!("../../scripts/manual/summarize_uvc_frame_meta_log.py"); fn repo_script_path() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() .expect("repo root") .join("scripts/manual/summarize_uvc_frame_meta_log.py") } #[test] fn uvc_frame_meta_summarizer_documents_boundary_diagnostics() { for expected in [ "lesavka.uvc-mjpeg-spool-meta.v1", "lesavka.uvc-mjpeg-spool-summary.v1", "event_coverage", "source_cadence_hiccup_count", "decoded_pts_delta_p95_ms", "sequence_gap_count", "HEVC client-to-RCT", ] { assert!( SUMMARIZER.contains(expected), "UVC frame metadata summarizer should preserve marker {expected}" ); } } #[test] fn uvc_frame_meta_summarizer_reports_cadence_and_event_coverage() { let dir = tempfile::tempdir().expect("tempdir"); let log_path = dir.path().join("uvc-frame-meta.jsonl"); let timeline_path = dir.path().join("client-timeline.json"); let json_out = dir.path().join("summary.json"); let txt_out = dir.path().join("summary.txt"); fs::write( &log_path, r#"not-json {"schema":"lesavka.uvc-mjpeg-spool-meta.v1","sequence":10,"profile":"hevc-decoded-mjpeg","bytes":1000,"source_pts_us":0,"decoded_pts_us":2000,"spool_unix_ns":1000000000} {"schema":"lesavka.uvc-mjpeg-spool-meta.v1","sequence":11,"profile":"hevc-decoded-mjpeg","bytes":1100,"source_pts_us":33333,"decoded_pts_us":35333,"spool_unix_ns":1033333000} {"schema":"lesavka.uvc-mjpeg-spool-meta.v1","sequence":12,"profile":"hevc-decoded-mjpeg","bytes":1200,"source_pts_us":66666,"decoded_pts_us":68666,"spool_unix_ns":1066666000} {"schema":"lesavka.uvc-mjpeg-spool-meta.v1","sequence":14,"profile":"hevc-decoded-mjpeg","bytes":1300,"source_pts_us":133333,"decoded_pts_us":135333,"spool_unix_ns":1133333000} "#, ) .expect("write log"); fs::write( &timeline_path, r#"{"events":[{"event_id":0,"code":1,"planned_start_us":0,"planned_end_us":120000},{"event_id":1,"code":2,"planned_start_us":200000,"planned_end_us":320000}]}"#, ) .expect("write timeline"); let output = Command::new("python3") .arg(repo_script_path()) .arg(&log_path) .arg(&json_out) .arg(&txt_out) .arg("--fps") .arg("30") .arg("--timeline") .arg(&timeline_path) .output() .expect("run summarizer"); assert!( output.status.success(), "summarizer should succeed: stderr={}", String::from_utf8_lossy(&output.stderr) ); let summary: Value = serde_json::from_str(&fs::read_to_string(&json_out).expect("summary json")) .expect("parse summary json"); assert_eq!(summary["record_count"], 4); assert_eq!(summary["ignored_line_count"], 1); assert_eq!(summary["profiles"]["hevc-decoded-mjpeg"], 4); assert_eq!(summary["sequence_gap_count"], 1); assert_eq!(summary["source_cadence_hiccup_count"], 1); assert_eq!(summary["event_coverage"]["covered_events"], 1); assert_eq!(summary["event_coverage"]["missing_codes"][0], 2); assert_eq!(summary["decoded_pts_delta_median_ms"], 2.0); let text = fs::read_to_string(&txt_out).expect("summary text"); assert!(text.contains("event coverage: 1/2 missing_codes=[2]")); assert!(text.contains("sequence: 10..14 gaps=1")); }