//! AZ-674 acceptance criteria. //! //! AC-1 — valid response parses successfully (round-trip through the //! UDS fixture, verifying schema fields all survive). //! AC-2 — schema-invalid response returns `status: SchemaInvalid` and //! the schema-invalid counter increments. //! AC-3 — model_version change logged once; subsequent identical //! versions do NOT re-log (observed via the parser's `changes` //! counter, which is incremented exactly once per change). //! AC-4 — `VlmStatus` is exhaustive (compile-time check: this file //! contains a `match` over every variant with no `_` arm). #![cfg(feature = "vlm")] use std::path::PathBuf; use shared::contracts::VlmProvider; use shared::models::vlm::{VlmLabel, VlmStatus}; use tempfile::tempdir; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::UnixListener; use vlm_client::VlmClient; async fn fixture_emitting( path: PathBuf, bodies: Vec, ) -> tokio::task::JoinHandle<()> { let listener = UnixListener::bind(&path).unwrap(); tokio::spawn(async move { for body in bodies { let (mut s, _) = listener.accept().await.unwrap(); let mut lenbuf = [0u8; 4]; if s.read_exact(&mut lenbuf).await.is_err() { return; } let len = u32::from_be_bytes(lenbuf) as usize; let mut req = vec![0u8; len]; if s.read_exact(&mut req).await.is_err() { return; } let bytes = serde_json::to_vec(&body).unwrap(); let _ = s.write_all(&(bytes.len() as u32).to_be_bytes()).await; let _ = s.write_all(&bytes).await; let _ = s.flush().await; } }) } #[tokio::test] async fn ac1_valid_response_parses_successfully() { // Arrange let dir = tempdir().unwrap(); let path = dir.path().join("nanollm.sock"); let body = serde_json::json!({ "label": "confirmed_concealed_position", "confidence": 0.91, "evidence_spans": ["thicket"], "reason": "match", "status": "ok", "latency_ms": 42, "model_version": "VILA1.5-3B-int4" }); let fixture = fixture_emitting(path.clone(), vec![body]).await; let client = VlmClient::open(&path).await.expect("connect"); // Act let a = client .assess(b"\xff\xd8\xff".to_vec(), "describe".into()) .await .unwrap(); // Assert assert_eq!(a.status, VlmStatus::Ok); assert_eq!(a.label, VlmLabel::ConfirmedConcealedPosition); assert_eq!(a.model_version, "VILA1.5-3B-int4"); assert_eq!(a.latency_ms, 42); assert_eq!(a.evidence_spans, vec!["thicket".to_string()]); // Parser counters reflect the success path. let parser = client.inner().unwrap().parser(); assert_eq!(parser.schema_invalid_count(), 0); assert_eq!(parser.model_version_changes(), 1); fixture.await.unwrap(); } #[tokio::test] async fn ac2_schema_invalid_response_returns_schema_invalid_and_increments_counter() { // Arrange — fixture responds with valid JSON missing `model_version`. let dir = tempdir().unwrap(); let path = dir.path().join("nanollm.sock"); let bad_body = serde_json::json!({ "label": "rejected", "confidence": 0.4, "status": "ok", "latency_ms": 5 // model_version intentionally missing }); let fixture = fixture_emitting(path.clone(), vec![bad_body]).await; let client = VlmClient::open(&path).await.expect("connect"); // Act let a = client.assess(b"r".to_vec(), "p".into()).await.unwrap(); // Assert assert_eq!(a.status, VlmStatus::SchemaInvalid); assert!(a.reason.starts_with("json:"), "got reason={}", a.reason); let parser = client.inner().unwrap().parser(); assert_eq!(parser.schema_invalid_count(), 1); assert_eq!(parser.model_version_changes(), 0); fixture.await.unwrap(); } /// AC-3 is exercised at the parser level — the model-version tracker /// is a pure-state component that does not depend on the UDS layer. /// The integration path is verified by AC-1 (one happy-path round /// trip → parser sees one change event). #[test] fn ac3_model_version_change_logged_once_at_parser_level() { use vlm_client::AssessmentParser; // Arrange let parser = AssessmentParser::new(); let mk = |v: &str| { serde_json::to_vec(&serde_json::json!({ "label": "rejected", "confidence": 0.5, "status": "ok", "latency_ms": 1, "model_version": v })) .unwrap() }; // Act — three responses: v1.0, v1.0 (no change), v1.1 (change). let _ = parser.parse(&mk("v1.0")); let _ = parser.parse(&mk("v1.0")); let _ = parser.parse(&mk("v1.1")); // Assert — exactly 2 change events: None→v1.0 and v1.0→v1.1. assert_eq!(parser.model_version_changes(), 2); assert_eq!(parser.current_model_version().as_deref(), Some("v1.1")); } /// Compile-time AC-4: this match must cover every `VlmStatus` variant /// without a `_` arm. Adding a new variant breaks the build until /// the consumer is updated. #[test] fn ac4_vlm_status_match_is_exhaustive() { // Arrange — synthesise one of each variant. let cases = [ VlmStatus::Ok, VlmStatus::Inconclusive, VlmStatus::Timeout, VlmStatus::SchemaInvalid, VlmStatus::IpcError, VlmStatus::Disabled, ]; // Act / Assert — every variant must produce a labelled outcome. for s in cases { let label: &'static str = match s { VlmStatus::Ok => "ok", VlmStatus::Inconclusive => "inconclusive", VlmStatus::Timeout => "timeout", VlmStatus::SchemaInvalid => "schema_invalid", VlmStatus::IpcError => "ipc_error", VlmStatus::Disabled => "disabled", }; assert!(!label.is_empty()); } } #[test] fn schema_invalid_does_not_pollute_model_version_tracker() { use vlm_client::AssessmentParser; // Arrange — one valid body followed by one truncated/invalid body. // The tracker must not regress to None on the second call. let parser = AssessmentParser::new(); let good = serde_json::to_vec(&serde_json::json!({ "label": "rejected", "confidence": 0.5, "status": "ok", "latency_ms": 1, "model_version": "v1.0" })) .unwrap(); let bad = good[..good.len() - 10].to_vec(); // Act let r1 = parser.parse(&good); let r2 = parser.parse(&bad); // Assert assert_eq!(r1.status, VlmStatus::Ok); assert_eq!(r2.status, VlmStatus::SchemaInvalid); assert_eq!(parser.model_version_changes(), 1); assert_eq!(parser.current_model_version().as_deref(), Some("v1.0")); assert_eq!(parser.schema_invalid_count(), 1); }