Files
ruvnet--RuView/v2/crates/wifi-densepose-bfld/tests/ha_discovery.rs
T
ruv 05609ef51c feat(adr-118/p5.6): HA auto-discovery payload publisher (187/187 GREEN)
Iter 26. Lands ADR-122 §2.1 HA-DISCO config-message generator.
Counterpart to iter 21's state-topic router: this produces the
homeassistant/<type>/<unique_id>/config messages HA reads on
startup to auto-create the six BFLD entities as a single device.

Discovery payloads are intended to be published once per node
session with retain = true (so HA finds them on subsequent starts).
The RumqttPublisher from iter 23 already exposes with_retain(true)
for this purpose; the state-topic loop must keep retain = false to
avoid stale-state flapping.

Added (gated on `feature = "std"`):
- src/ha_discovery.rs:
  * render_discovery_payloads(node_id, class) -> Vec<TopicMessage>
      class < Anonymous: empty vec (HA doesn't see raw/derived)
      class == Anonymous: 6 entities incl. identity_risk
      class == Restricted: 5 entities, no identity_risk
  * Per-entity HA metadata:
      presence       binary_sensor, device_class: occupancy
      motion         sensor, entity_category: diagnostic
      person_count   sensor, unit_of_measurement: people
      zone_activity  sensor, entity_category: diagnostic
      confidence     sensor, entity_category: diagnostic
      identity_risk  sensor, entity_category: diagnostic
  * Each payload carries:
      name, unique_id, state_topic (pointing at the iter-21 path),
      device block with identifiers / model: "BFLD" / manufacturer: "RuView"
  * Manual JSON builder with minimal escape coverage — node_id is
    ASCII alphanumeric + dash by convention; full escape via
    serde_json is a follow-up if operator-controlled names ever land.
- pub use render_discovery_payloads from lib.rs

tests/ha_discovery.rs (10 named tests, all green):
  raw_and_derived_classes_produce_no_discovery_payloads
  anonymous_class_produces_six_discovery_payloads
  restricted_class_omits_identity_risk_discovery
  discovery_topic_format_matches_ha_convention
    (validates all six homeassistant/.../config topics exist)
  presence_payload_carries_occupancy_device_class
  motion_payload_marked_as_diagnostic
  person_count_payload_carries_unit_of_measurement
  every_payload_contains_unique_id_and_state_topic_pointing_at_correct_state_topic
    (the state_topic in the discovery payload must match the topic the
     state-topic router from iter 21 actually publishes on — closes
     the discovery↔state loop)
  unique_id_matches_topic_segment
    (the unique_id baked into the payload equals the topic segment so
     HA dedupe works correctly across reboot/restart)
  class_2_discovery_includes_identity_risk_explicitly

ACs progressed:
- ADR-122 §2.1 — HA auto-discovery surface now complete: an operator
  can start mosquitto, publish-retained discovery once, and HA spins
  up the entire BFLD device on next start with zero YAML config.
- ADR-122 AC1 (six entities per node) — discovery + state-topic
  publishers are now symmetric: render_discovery_payloads emits the
  same six entity definitions render_events emits state messages for.
- ADR-118 §1.5 — privacy_mode = Restricted strips identity_risk at
  BOTH the discovery layer (entity not advertised to HA) AND the
  state layer (no state messages). Two-layer defense.

Test config:
- cargo test --no-default-features → 72 passed (ha_discovery cfg-out)
- cargo test                       → 187 passed (177 + 10)

Out of scope (next iter target):
- HA discovery + state publish coordinator: a small function or
  BfldPipelineHandle::publish_discovery(&mut self, retained: bool)
  that calls render_discovery_payloads + publish_event(retained=true)
  once at startup, then enters the per-frame loop.
- GitHub Actions workflow with mosquitto Docker service so the
  iter-24 integration test runs in CI with BFLD_MQTT_BROKER set.

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

130 lines
4.7 KiB
Rust

//! Acceptance tests for ADR-122 §2.1 — HA auto-discovery payloads.
#![cfg(feature = "std")]
use wifi_densepose_bfld::{render_discovery_payloads, PrivacyClass};
fn topics(class: PrivacyClass) -> Vec<String> {
render_discovery_payloads("seed-01", class)
.into_iter()
.map(|m| m.topic)
.collect()
}
#[test]
fn raw_and_derived_classes_produce_no_discovery_payloads() {
for class in [PrivacyClass::Raw, PrivacyClass::Derived] {
assert!(
render_discovery_payloads("seed-01", class).is_empty(),
"class {class:?} must not emit HA discovery",
);
}
}
#[test]
fn anonymous_class_produces_six_discovery_payloads() {
let ts = topics(PrivacyClass::Anonymous);
assert_eq!(ts.len(), 6);
}
#[test]
fn restricted_class_omits_identity_risk_discovery() {
let ts = topics(PrivacyClass::Restricted);
assert_eq!(ts.len(), 5, "Restricted: 5 entities, no identity_risk");
assert!(
!ts.iter().any(|t| t.contains("identity_risk")),
"Restricted must not advertise identity_risk entity to HA",
);
}
#[test]
fn discovery_topic_format_matches_ha_convention() {
let ts = topics(PrivacyClass::Anonymous);
assert!(ts.contains(&"homeassistant/binary_sensor/seed-01_bfld_presence/config".into()));
assert!(ts.contains(&"homeassistant/sensor/seed-01_bfld_motion/config".into()));
assert!(ts.contains(&"homeassistant/sensor/seed-01_bfld_person_count/config".into()));
assert!(ts.contains(&"homeassistant/sensor/seed-01_bfld_zone_activity/config".into()));
assert!(ts.contains(&"homeassistant/sensor/seed-01_bfld_confidence/config".into()));
assert!(ts.contains(&"homeassistant/sensor/seed-01_bfld_identity_risk/config".into()));
}
#[test]
fn presence_payload_carries_occupancy_device_class() {
let msgs = render_discovery_payloads("seed-01", PrivacyClass::Anonymous);
let pres = msgs
.iter()
.find(|m| m.topic.contains("presence"))
.expect("presence config");
assert!(pres.payload.contains("\"device_class\":\"occupancy\""));
}
#[test]
fn motion_payload_marked_as_diagnostic() {
let msgs = render_discovery_payloads("seed-01", PrivacyClass::Anonymous);
let motion = msgs
.iter()
.find(|m| m.topic.contains("motion"))
.expect("motion config");
assert!(motion.payload.contains("\"entity_category\":\"diagnostic\""));
}
#[test]
fn person_count_payload_carries_unit_of_measurement() {
let msgs = render_discovery_payloads("seed-01", PrivacyClass::Anonymous);
let pc = msgs
.iter()
.find(|m| m.topic.contains("person_count"))
.expect("person_count config");
assert!(pc.payload.contains("\"unit_of_measurement\":\"people\""));
}
#[test]
fn every_payload_contains_unique_id_and_state_topic_pointing_at_correct_state_topic() {
let msgs = render_discovery_payloads("seed-99", PrivacyClass::Anonymous);
for msg in &msgs {
// unique_id is required for HA to dedupe entity creation.
assert!(
msg.payload.contains("\"unique_id\":\""),
"missing unique_id in {msg:?}",
);
// state_topic must point back at the BFLD `ruview/<node>/bfld/<entity>/state` path.
assert!(
msg.payload.contains("\"state_topic\":\"ruview/seed-99/bfld/"),
"state_topic wrong in {msg:?}",
);
// Device block ties all six entities to one HA device.
assert!(msg.payload.contains("\"device\":{"));
assert!(msg.payload.contains("\"identifiers\":\"seed-99\""));
assert!(msg.payload.contains("\"manufacturer\":\"RuView\""));
}
}
#[test]
fn unique_id_matches_topic_segment() {
let msgs = render_discovery_payloads("seed-01", PrivacyClass::Anonymous);
for msg in &msgs {
// topic is homeassistant/<type>/<unique_id>/config — the unique_id segment
// must appear in the payload too.
let parts: Vec<&str> = msg.topic.split('/').collect();
assert_eq!(parts.len(), 4, "topic shape wrong: {}", msg.topic);
assert_eq!(parts[0], "homeassistant");
assert_eq!(parts[3], "config");
let unique_id_from_topic = parts[2];
let needle = format!("\"unique_id\":\"{unique_id_from_topic}\"");
assert!(
msg.payload.contains(&needle),
"unique_id mismatch between topic and payload: {msg:?}",
);
}
}
#[test]
fn class_2_discovery_includes_identity_risk_explicitly() {
let msgs = render_discovery_payloads("seed-01", PrivacyClass::Anonymous);
let risk = msgs
.iter()
.find(|m| m.topic.contains("identity_risk"))
.expect("identity_risk config must be present at class 2");
assert!(risk.payload.contains("\"entity_category\":\"diagnostic\""));
}