test(mmwave-bridge): production-ready CLI coverage + CI wiring (iter 118)

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 `<name> <version>`.

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 <ruv@ruv.net>
This commit is contained in:
ruvnet 2026-05-02 14:48:40 -04:00
parent 2f331ad3a4
commit 99549cec2c
2 changed files with 214 additions and 0 deletions

View file

@ -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

View file

@ -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");
}