From 99549cec2cba6e81abdd5ff5808917b72fad96b8 Mon Sep 17 00:00:00 2001 From: ruvnet Date: Sat, 2 May 2026 14:48:40 -0400 Subject: [PATCH] test(mmwave-bridge): production-ready CLI coverage + CI wiring (iter 118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iter 116 shipped the bridge → cluster integration with a manual live test, but nothing committed. Production-ready means the integration tests run on every commit. This iter closes the gap. New tests/mmwave_bridge_cli.rs (7 tests, ~180 LOC): bridge_simulator_emits_cycle_of_jsonl_events spawns bridge --simulator --rate 10 for 700ms; asserts all four frame kinds (breathing, heart_rate, distance, presence) appear in stdout JSONL — guards against state-machine regressions that would silently drop a frame type. bridge_simulator_with_workers_posts_to_cluster spawns fakeworker + bridge with --workers, asserts ≥3 successful "posted text=" lines on stderr in 900ms and zero "cluster post failed" lines. Verifies the iter-116 cluster sink path actually composes with a live tonic server, not just unit-level mocks. bridge_workers_without_fingerprint_refused_by_default --workers + empty --fingerprint must fail before any RPC fires (ADR-172 §2a parity with embed/bench). Guards against the gate being bypassed in the bridge's discovery path. bridge_workers_without_fingerprint_succeeds_with_opt_in --allow-empty-fingerprint is the documented escape hatch for legacy fleets; verify it actually works. bridge_no_mode_flag_errors_cleanly Running with no mode flag must produce a useful error referencing the three valid mode flags. Operator-experience guard. bridge_help_prints_synopsis --help mentions --simulator, --workers, --fingerprint. bridge_version_prints_pkg_name_and_version --version output parses as ` `. CI changes (.github/workflows/hailo-backend-audit.yml): - Path watcher now triggers on `crates/ruvector-mmwave/**` so a regression in the shared parser fails CI before consumers (firmware + bridge) can ship broken decoders. - test job adds `cargo test --all-features` + clippy for the standalone ruvector-mmwave crate. Tested independently so the parser bisect cleanly when CI fails. Validation: - 17 test groups in the cluster crate now (was 16); 7 new bridge tests join the matrix on default + tls feature configs. - clippy --all-targets -D warnings clean for both ruvector-mmwave (--all-features) and ruvector-hailo-cluster (default + tls). Co-Authored-By: claude-flow --- .github/workflows/hailo-backend-audit.yml | 11 + .../tests/mmwave_bridge_cli.rs | 203 ++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 crates/ruvector-hailo-cluster/tests/mmwave_bridge_cli.rs diff --git a/.github/workflows/hailo-backend-audit.yml b/.github/workflows/hailo-backend-audit.yml index 0d4170c35..c785e370e 100644 --- a/.github/workflows/hailo-backend-audit.yml +++ b/.github/workflows/hailo-backend-audit.yml @@ -12,6 +12,7 @@ on: paths: - 'crates/ruvector-hailo-cluster/**' - 'crates/ruvector-hailo/**' + - 'crates/ruvector-mmwave/**' - 'crates/hailort-sys/**' - 'docs/adr/ADR-167-*.md' - 'docs/adr/ADR-168-*.md' @@ -26,6 +27,7 @@ on: paths: - 'crates/ruvector-hailo-cluster/**' - 'crates/ruvector-hailo/**' + - 'crates/ruvector-mmwave/**' - 'crates/hailort-sys/**' - '.github/workflows/hailo-backend-audit.yml' @@ -111,6 +113,15 @@ jobs: - name: Run doctests working-directory: crates/ruvector-hailo-cluster run: cargo test --doc + # Iter 115/118: shared mmwave parser crate. Tested independently + # so a regression in the parser fails CI before it can corrupt + # both the firmware and the host bridge that depend on it. + - name: Test shared mmwave parser crate + working-directory: crates/ruvector-mmwave + run: cargo test --all-features + - name: Clippy shared mmwave parser crate + working-directory: crates/ruvector-mmwave + run: cargo clippy --all-targets --all-features -- -D warnings doc-warnings: name: missing-docs check diff --git a/crates/ruvector-hailo-cluster/tests/mmwave_bridge_cli.rs b/crates/ruvector-hailo-cluster/tests/mmwave_bridge_cli.rs new file mode 100644 index 000000000..5fadef012 --- /dev/null +++ b/crates/ruvector-hailo-cluster/tests/mmwave_bridge_cli.rs @@ -0,0 +1,203 @@ +//! End-to-end CLI tests for the `ruvector-mmwave-bridge` binary +//! (iter 118 — production-readiness pass). +//! +//! Verifies that the bridge actually composes with the cluster the way +//! the manual live-test in iter 116 demonstrated, but committed and +//! re-runnable in CI. Three cases: +//! +//! 1. `--simulator` mode without `--workers` produces the expected +//! cycle of JSONL events on stdout. +//! 2. `--simulator --workers` posts decoded events to a fakeworker +//! via the embed RPC; assert successful posts on stderr. +//! 3. `--workers` without `--fingerprint` is refused (ADR-172 §2a +//! gate parity with embed/bench). + +use std::process::{Command, Stdio}; +use std::time::Duration; + +mod common; +use common::{free_port, spawn_fakeworker}; + +const BRIDGE: &str = env!("CARGO_BIN_EXE_ruvector-mmwave-bridge"); + +#[test] +fn bridge_simulator_emits_cycle_of_jsonl_events() { + // 5 Hz × 1.5s = 7-8 events. Cycle is breathing → heart_rate → + // distance → presence; assert at least one of each kind in the + // window so a future state-machine bug that drops a frame type + // surfaces. + let mut child = Command::new(BRIDGE) + .args(["--simulator", "--rate", "10", "--quiet"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn bridge"); + + std::thread::sleep(Duration::from_millis(700)); + let _ = child.kill(); + let out = child.wait_with_output().expect("wait bridge"); + + let stdout = String::from_utf8_lossy(&out.stdout); + let kinds: std::collections::HashSet<&str> = stdout + .lines() + .filter_map(|l| { + // Crude but sufficient: extract the "kind":"X" value. + l.split("\"kind\":\"") + .nth(1) + .and_then(|s| s.split('"').next()) + }) + .collect(); + assert!(kinds.contains("breathing"), "no breathing event in {:?}", kinds); + assert!(kinds.contains("heart_rate"), "no heart_rate event in {:?}", kinds); + assert!(kinds.contains("distance"), "no distance event in {:?}", kinds); + assert!(kinds.contains("presence"), "no presence event in {:?}", kinds); +} + +#[test] +fn bridge_simulator_with_workers_posts_to_cluster() { + let port = free_port(); + let mut worker = spawn_fakeworker(port, 4, "fp:bridge-test"); + + let mut child = Command::new(BRIDGE) + .args([ + "--simulator", + "--rate", + "10", + "--workers", + &format!("127.0.0.1:{}", port), + "--dim", + "4", + "--fingerprint", + "fp:bridge-test", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn bridge with cluster sink"); + + // Let it pump for a moment — needs to dial the worker, send a few + // RPCs, see results come back. + std::thread::sleep(Duration::from_millis(900)); + let _ = child.kill(); + let out = child.wait_with_output().expect("wait bridge"); + let _ = worker.kill(); + let _ = worker.wait(); + + let stderr = String::from_utf8_lossy(&out.stderr); + let post_count = stderr.matches("posted text=").count(); + assert!( + post_count >= 3, + "expected ≥ 3 cluster posts in window, saw {}: {}", + post_count, + stderr + ); + // None of them should have failed — fakeworker is local, latency + // budget is generous. + assert!( + !stderr.contains("cluster post failed"), + "saw post failures: {}", + stderr + ); +} + +#[test] +fn bridge_workers_without_fingerprint_refused_by_default() { + // ADR-172 §2a parity: --workers + empty --fingerprint must fail + // before any RPC is attempted, just like embed/bench. + let out = Command::new(BRIDGE) + .args([ + "--simulator", + "--workers", + "127.0.0.1:1", // never dialed; gate fires first + "--dim", + "4", + // intentionally no --fingerprint + ]) + .output() + .expect("run bridge"); + + assert!(!out.status.success(), "expected non-zero exit"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("ADR-172 §2a") || stderr.contains("empty --fingerprint"), + "stderr should reference the §2a gate, got: {}", + stderr + ); +} + +#[test] +fn bridge_workers_without_fingerprint_succeeds_with_opt_in() { + let port = free_port(); + let mut worker = spawn_fakeworker(port, 4, ""); // fakeworker default fp + + let mut child = Command::new(BRIDGE) + .args([ + "--simulator", + "--rate", + "5", + "--workers", + &format!("127.0.0.1:{}", port), + "--dim", + "4", + "--allow-empty-fingerprint", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn bridge"); + + std::thread::sleep(Duration::from_millis(900)); + let _ = child.kill(); + let out = child.wait_with_output().expect("wait bridge"); + let _ = worker.kill(); + let _ = worker.wait(); + + let stderr = String::from_utf8_lossy(&out.stderr); + let post_count = stderr.matches("posted text=").count(); + assert!( + post_count >= 1, + "with --allow-empty-fingerprint, expected ≥ 1 post, saw {}: {}", + post_count, + stderr + ); +} + +#[test] +fn bridge_no_mode_flag_errors_cleanly() { + let out = Command::new(BRIDGE) + .output() + .expect("run bridge with no args"); + assert!(!out.status.success(), "expected non-zero exit"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("--device") || stderr.contains("--simulator") || stderr.contains("--auto"), + "error should name the missing mode flags, got: {}", + stderr + ); +} + +#[test] +fn bridge_help_prints_synopsis() { + let out = Command::new(BRIDGE) + .arg("--help") + .output() + .expect("run bridge --help"); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("--simulator")); + assert!(stdout.contains("--workers")); + assert!(stdout.contains("--fingerprint")); +} + +#[test] +fn bridge_version_prints_pkg_name_and_version() { + let out = Command::new(BRIDGE) + .arg("--version") + .output() + .expect("run bridge --version"); + assert!(out.status.success()); + let line = String::from_utf8_lossy(&out.stdout).trim().to_string(); + let parts: Vec<&str> = line.split_whitespace().collect(); + assert_eq!(parts.len(), 2); + assert_eq!(parts[0], "ruvector-hailo-cluster"); +}