Files
ruvnet--RuView/v2/crates/wifi-densepose-bfld/tests/pipeline_handle_worker.rs
T
ruv e8b4fdbc8f feat(adr-118/p5.5): BfldPipelineHandle worker thread (177/177 GREEN)
Iter 25. Single-call operator surface: spawn() takes a BfldPipeline and
a Publish impl, returns a handle whose send() enqueues sensing inputs
into a worker thread. The worker drives pipeline.process() then
publish_event() per input. Drop or shutdown() joins cleanly.

Added (gated on `feature = "std"`):
- src/mqtt_topics.rs: impl<P: Publish> Publish for Arc<Mutex<P>>
  Lets a publisher owned by a worker thread remain inspectable from a
  test or operator post-shutdown.
- src/pipeline_handle.rs:
  * PipelineInput { inputs: SensingInputs, embedding: Option<...> }
  * BfldPipelineHandle { sender, worker: Option<JoinHandle<()>> }
  * spawn<P: Publish + Send + 'static>(pipeline, publisher) -> Self
      Worker loop: recv() → pipeline.process() → publish_event(); errors
      logged to stderr (single-frame failures must not kill the loop)
  * send(PipelineInput) -> Result<(), SendError<...>>
  * shutdown(self) — replaces sender with a dropped channel so worker
    recv() returns Err(RecvError); join propagates worker panics
  * Drop impl mirrors shutdown so forgotten handles still clean up
- pub use BfldPipelineHandle, PipelineInput from lib.rs

tests/pipeline_handle_worker.rs (8 named tests, all green):
  handle_publishes_single_input (5 topics for Anonymous + no zone)
  handle_publishes_multiple_inputs_in_order (3 × 5 = 15 topics)
  handle_send_after_shutdown_errors
    (compile-time witness: shutdown(self) consumes the handle so
     post-shutdown send() is structurally impossible)
  handle_drop_without_explicit_shutdown_joins_worker_cleanly
    (validates the Drop path completes without hanging)
  handle_honors_privacy_mode_toggle_via_pipeline_state
    (4 topics for Restricted; identity_risk absent)
  handle_drops_event_when_gate_rejects
    (5 topics from first Accept-state input + 0 from Reject)
  handle_with_zone_threads_through_to_published_topics
    (zone_activity payload = "\"kitchen\"")
  class_3_pipeline_baseline_produces_four_topics_per_input

Test publisher pattern: Arc<Mutex<CapturePublisher>> lets the test thread
read out the worker thread's publish log post-shutdown without needing
custom channel plumbing per test.

ACs progressed:
- ADR-118 §2.1 lib.rs entry point now has the "set up MQTT and walk away"
  operator surface promised in the implementation plan. Two lines:
      let handle = BfldPipelineHandle::spawn(pipeline, rumqttc_pub);
      handle.send(PipelineInput { inputs, embedding })?;
- ADR-122 §2.2 per-frame publish path is now structurally guarded by
  worker-thread isolation: even if a Publish::publish call panics, only
  the worker thread dies; the main thread sees a clean error on send().

Test config:
- cargo test --no-default-features → 72 passed
- cargo test                       → 177 passed (169 + 8)
- cargo test --features mqtt       → 186 (178 + 8 — handle is std-only,
  reachable in both feature configs)

Out of scope (next iter target):
- GitHub Actions workflow with mosquitto Docker service so the iter-24
  integration test actually runs in CI with BFLD_MQTT_BROKER set.
- HA discovery payload publisher (ADR-122 §2.1) — the auto-discovery
  config messages HA needs alongside the state topics this handle ships.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 17:27:48 -04:00

203 lines
6.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Mutex<CapturePublisher>>) -> Vec<String> {
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);
}