//! Bathroom-occupied primitive (§3.12.1 row 6). //! //! `bathroom_occupied = ON` iff `presence == true` AND any zone in //! `active_zones` is configured as a bathroom (`cfg.bathroom_zone_tag`, //! cross-referenced against `bed_zones`/`active_zones` via the //! `--semantic-zones-file` config). //! //! Per §3.12.3 — explicitly safe in privacy mode because the entity is //! a zone-derived boolean, not biometric. use super::common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason}; #[derive(Debug, Default, Clone)] pub struct BathroomOccupied { pub active: bool, } impl BathroomOccupied { pub fn new() -> Self { Self::default() } pub fn tick(&mut self, snap: &RawSnapshot, cfg: &PrimitiveConfig) -> PrimitiveState { if snap.since_start < cfg.warmup { return PrimitiveState::Idle; } let occupied = snap.presence && snap.active_zones.iter().any(|z| z == &cfg.bathroom_zone_tag); if occupied != self.active { self.active = occupied; let tag = if occupied { "presence=true,zone=bathroom" } else { "exit-bathroom" }; return PrimitiveState::Boolean { active: occupied, changed: true, reason: Reason::new(&[tag]), }; } PrimitiveState::Idle } } #[cfg(test)] mod tests { use super::*; use std::time::Duration; fn cfg() -> PrimitiveConfig { PrimitiveConfig::default() } #[test] fn fires_when_presence_in_bathroom_zone() { let mut p = BathroomOccupied::new(); let s = RawSnapshot { since_start: Duration::from_secs(120), presence: true, active_zones: vec!["bathroom".into()], ..Default::default() }; let state = p.tick(&s, &cfg()); match state { PrimitiveState::Boolean { active, changed, .. } => { assert!(active && changed); } other => panic!("expected on/change, got {:?}", other), } } #[test] fn does_not_fire_for_other_zone() { let mut p = BathroomOccupied::new(); let s = RawSnapshot { since_start: Duration::from_secs(120), presence: true, active_zones: vec!["kitchen".into()], ..Default::default() }; let state = p.tick(&s, &cfg()); assert!(matches!(state, PrimitiveState::Idle)); } #[test] fn requires_presence_true() { let mut p = BathroomOccupied::new(); let s = RawSnapshot { since_start: Duration::from_secs(120), presence: false, active_zones: vec!["bathroom".into()], ..Default::default() }; assert!(matches!(p.tick(&s, &cfg()), PrimitiveState::Idle)); } #[test] fn warmup_blocks_initial_fire() { let mut p = BathroomOccupied::new(); let s = RawSnapshot { since_start: Duration::from_secs(30), presence: true, active_zones: vec!["bathroom".into()], ..Default::default() }; assert!(matches!(p.tick(&s, &cfg()), PrimitiveState::Idle)); } #[test] fn emits_off_on_zone_exit() { let mut p = BathroomOccupied::new(); let s_in = RawSnapshot { since_start: Duration::from_secs(120), presence: true, active_zones: vec!["bathroom".into()], ..Default::default() }; let _ = p.tick(&s_in, &cfg()); let s_out = RawSnapshot { since_start: Duration::from_secs(180), presence: true, active_zones: vec!["kitchen".into()], ..Default::default() }; let state = p.tick(&s_out, &cfg()); match state { PrimitiveState::Boolean { active, changed, .. } => { assert!(!active && changed); } other => panic!("expected off/change, got {:?}", other), } } }