//! Acceptance tests for `BfldPipelineHandle`. ADR-118 §2.1 worker surface. #![cfg(feature = "std")] use std::sync::{Arc, Mutex}; use std::thread; use std::time::Duration; use wifi_densepose_bfld::{ BfldConfig, BfldPipeline, BfldPipelineHandle, CapturePublisher, IdentityEmbedding, PipelineInput, PrivacyClass, SensingInputs, EMBEDDING_DIM, }; fn inputs(ts_ns: u64) -> SensingInputs { SensingInputs { timestamp_ns: ts_ns, presence: true, motion: 0.5, person_count: 1, sensing_confidence: 0.9, sep: 0.2, stab: 0.2, consist: 0.2, risk_conf: 0.2, rf_signature_hash: None, } } fn embedding() -> IdentityEmbedding { IdentityEmbedding::from_raw([0.05; EMBEDDING_DIM]) } fn input(ts_ns: u64) -> PipelineInput { PipelineInput { inputs: inputs(ts_ns), embedding: Some(embedding()), } } fn drain(published: &Arc>) -> Vec { published .lock() .unwrap() .published .iter() .map(|m| m.topic.clone()) .collect() } #[test] fn handle_publishes_single_input() { let pipeline = BfldPipeline::new(BfldConfig::new("seed-01")); let pub_arc = Arc::new(Mutex::new(CapturePublisher::default())); let handle = BfldPipelineHandle::spawn(pipeline, pub_arc.clone()); handle.send(input(0)).expect("send must succeed"); // Give the worker a moment to drain the channel. thread::sleep(Duration::from_millis(50)); handle.shutdown(); let topics = drain(&pub_arc); assert_eq!(topics.len(), 5, "Anonymous + no zone → 5 topics"); } #[test] fn handle_publishes_multiple_inputs_in_order() { let pipeline = BfldPipeline::new(BfldConfig::new("seed-01")); let pub_arc = Arc::new(Mutex::new(CapturePublisher::default())); let handle = BfldPipelineHandle::spawn(pipeline, pub_arc.clone()); for i in 0..3 { handle.send(input(i * 1_000_000)).unwrap(); } thread::sleep(Duration::from_millis(80)); handle.shutdown(); let topics = drain(&pub_arc); assert_eq!(topics.len(), 15, "3 inputs × 5 topics each = 15"); } #[test] fn handle_send_after_shutdown_errors() { let pipeline = BfldPipeline::new(BfldConfig::new("seed-01")); let pub_arc = Arc::new(Mutex::new(CapturePublisher::default())); let handle = BfldPipelineHandle::spawn(pipeline, pub_arc); // Save the sender by cloning before shutdown — but BfldPipelineHandle // owns the sender, so the test demonstrates this via post-shutdown send: handle.shutdown(); // shutdown consumed handle; we can't call send afterward at the type // level. The compile-time guarantee IS the test. } #[test] fn handle_drop_without_explicit_shutdown_joins_worker_cleanly() { let pipeline = BfldPipeline::new(BfldConfig::new("seed-01")); let pub_arc = Arc::new(Mutex::new(CapturePublisher::default())); { let handle = BfldPipelineHandle::spawn(pipeline, pub_arc.clone()); handle.send(input(0)).unwrap(); thread::sleep(Duration::from_millis(50)); // No explicit shutdown — Drop must handle worker join. } // If we reached here without hanging or panicking, the Drop path worked. let topics = drain(&pub_arc); assert_eq!(topics.len(), 5); } #[test] fn handle_honors_privacy_mode_toggle_via_pipeline_state() { let mut pipeline = BfldPipeline::new(BfldConfig::new("seed-01")); pipeline.enable_privacy_mode(); let pub_arc = Arc::new(Mutex::new(CapturePublisher::default())); let handle = BfldPipelineHandle::spawn(pipeline, pub_arc.clone()); handle.send(input(0)).unwrap(); thread::sleep(Duration::from_millis(50)); handle.shutdown(); let topics = drain(&pub_arc); // Restricted + no zone: presence/motion/count/confidence = 4 topics. assert_eq!(topics.len(), 4, "Restricted strips identity_risk topic"); assert!(!topics.iter().any(|t| t.contains("identity_risk"))); } #[test] fn handle_drops_event_when_gate_rejects() { let pipeline = BfldPipeline::new(BfldConfig::new("seed-01")); let pub_arc = Arc::new(Mutex::new(CapturePublisher::default())); let handle = BfldPipelineHandle::spawn(pipeline, pub_arc.clone()); // Two high-risk inputs back-to-back force the gate into Reject after debounce. use wifi_densepose_bfld::coherence_gate::DEBOUNCE_NS; let mut high_risk = inputs(0); high_risk.sep = 1.0; high_risk.stab = 1.0; high_risk.consist = 1.0; high_risk.risk_conf = 0.8; handle .send(PipelineInput { inputs: high_risk.clone(), embedding: Some(embedding()), }) .unwrap(); high_risk.timestamp_ns = DEBOUNCE_NS; handle .send(PipelineInput { inputs: high_risk, embedding: Some(embedding()), }) .unwrap(); thread::sleep(Duration::from_millis(80)); handle.shutdown(); let topics = drain(&pub_arc); // First input emits (Accept state) → 5 topics. Second input gate-promoted // to Reject → 0 topics. Total = 5. assert_eq!(topics.len(), 5, "Reject must drop the second event entirely"); } #[test] fn handle_with_zone_threads_through_to_published_topics() { let pipeline = BfldPipeline::new(BfldConfig::new("seed-01").with_zone("kitchen")); let pub_arc = Arc::new(Mutex::new(CapturePublisher::default())); let handle = BfldPipelineHandle::spawn(pipeline, pub_arc.clone()); handle.send(input(0)).unwrap(); thread::sleep(Duration::from_millis(50)); handle.shutdown(); let topics = drain(&pub_arc); assert!( topics.iter().any(|t| t.contains("zone_activity")), "zone_activity topic must be present when zone configured", ); let zone_msg = pub_arc .lock() .unwrap() .published .iter() .find(|m| m.topic.contains("zone_activity")) .map(|m| m.payload.clone()); assert_eq!(zone_msg.as_deref(), Some("\"kitchen\"")); } #[test] fn class_3_pipeline_baseline_produces_four_topics_per_input() { // Baseline class = Restricted (no privacy_mode toggle needed). let pipeline = BfldPipeline::new( BfldConfig::new("seed-01").with_privacy_class(PrivacyClass::Restricted), ); let pub_arc = Arc::new(Mutex::new(CapturePublisher::default())); let handle = BfldPipelineHandle::spawn(pipeline, pub_arc.clone()); handle.send(input(0)).unwrap(); thread::sleep(Duration::from_millis(50)); handle.shutdown(); assert_eq!(drain(&pub_arc).len(), 4); }