use std::time::Duration;
use bacnet_ops::safety::ledger::{Ledger, State};
use bacnet_types::enums::PropertyIdentifier;
use bacnet_types::primitives::PropertyValue;
use serde_json::{Value, json};
use super::writes::{audit, call, meta, one, slot, writes};
use super::{LAB, Lab};
use crate::policy::{ArmMode, PlanGrant};
fn plan(max_writes: u32) -> PlanGrant {
PlanGrant {
devices: vec![(LAB, LAB)],
properties: vec![PropertyIdentifier::PRESENT_VALUE],
priorities: (8, 16),
max_writes,
expires_unix: crate::writes::unix_now() + 3_600,
reason: Some("commissioning".into()),
}
}
#[tokio::test(start_paused = true)]
async fn fifty_planned_writes_run_without_prompts_and_others_are_refused() {
let dir = tempfile::tempdir().unwrap();
let mut policy = writes(dir.path(), ArmMode::Never);
policy.writes.plan = Some(plan(51));
let mut lab = Lab::start(policy).await;
for n in 0..50u8 {
let priority = if n % 2 == 0 { 8 } else { 12 };
let reply = call(&mut lab, "bacnet_write", one(json!(n), priority), json!({})).await;
assert_eq!(reply["isError"], json!(false), "write {n}: {reply}");
assert!(reply.get("inputRequests").is_none(), "no prompt");
let body = &reply["structuredContent"];
assert_eq!(body["approval"]["confirmed_via"], json!("plan"), "{body}");
}
assert_eq!(slot(&lab, 8).await, PropertyValue::Real(48.0));
assert_eq!(slot(&lab, 12).await, PropertyValue::Real(49.0));
let records = audit(dir.path());
let planned: Vec<&Value> = records
.iter()
.filter(|r| r["event"] == "write" && r["outcome"]["result"] == "ack")
.collect();
assert_eq!(planned.len(), 50);
for record in planned {
assert_eq!(record["approved_by"], json!("plan"), "{record}");
assert_eq!(record["plan"]["reason"], json!("commissioning"), "{record}");
}
let outside = [
one(json!(1), 3),
json!({ "device": LAB, "writes": [{ "object": "ao:1", "property": "out-of-service",
"value": true }] }),
];
for args in outside {
let reply = call(&mut lab, "bacnet_write", args, json!({})).await;
assert_eq!(
reply["structuredContent"]["reason"],
json!("outside-plan"),
"{reply}"
);
}
assert_eq!(slot(&lab, 3).await, PropertyValue::Null);
let reply = call(&mut lab, "bacnet_write", one(json!(7), 16), json!({})).await;
assert_eq!(reply["isError"], json!(false), "{reply}");
let reply = call(&mut lab, "bacnet_write", one(json!(8), 16), json!({})).await;
assert_eq!(
reply["structuredContent"]["reason"],
json!("not-armed"),
"{reply}"
);
lab.stop().await;
}
#[tokio::test(start_paused = true)]
async fn a_plans_reach_holds_on_every_side() {
let dir = tempfile::tempdir().unwrap();
let mut policy = writes(dir.path(), ArmMode::Never);
policy.writes.plan = Some(PlanGrant {
priorities: (1, 16),
..plan(10)
});
let mut lab = Lab::start(policy).await;
let reply = call(&mut lab, "bacnet_write", one(json!(3), 1), json!({})).await;
assert_eq!(reply["isError"], json!(false), "{reply}");
assert_eq!(slot(&lab, 1).await, PropertyValue::Real(3.0));
let reply = call(&mut lab, "bacnet_write", one(json!(3), 6), json!({})).await;
assert_eq!(reply["structuredContent"]["reason"], json!("priority-6"));
assert_eq!(slot(&lab, 6).await, PropertyValue::Null);
let other = json!({ "device": super::ALIASES.start, "writes": [
{ "object": "ao:1", "value": 1, "priority": 12 }] });
let reply = call(&mut lab, "bacnet_write", other, json!({})).await;
assert_eq!(reply["structuredContent"]["reason"], json!("outside-plan"));
lab.stop().await;
let dir = tempfile::tempdir().unwrap();
let mut policy = writes(dir.path(), ArmMode::Never);
policy.writes.plan = Some(PlanGrant {
expires_unix: crate::writes::unix_now() - 1,
..plan(10)
});
let mut lab = Lab::start(policy).await;
let reply = call(&mut lab, "bacnet_write", one(json!(3), 12), json!({})).await;
assert_eq!(
reply["structuredContent"]["reason"],
json!("not-armed"),
"{reply}"
);
assert_eq!(slot(&lab, 12).await, PropertyValue::Null);
lab.stop().await;
}
#[tokio::test(start_paused = true)]
async fn a_cancelled_planned_write_still_settles() {
let dir = tempfile::tempdir().unwrap();
let mut policy = writes(dir.path(), ArmMode::Never);
policy.writes.plan = Some(plan(10));
let mut lab = Lab::start_with(policy, Duration::from_millis(400)).await;
let params = json!({ "name": "bacnet_write", "arguments": one(json!(9), 12),
"_meta": meta() });
let id = lab.agent.post("tools/call", params).await;
tokio::time::sleep(Duration::from_millis(600)).await;
lab.agent.cancel(id).await;
tokio::time::sleep(Duration::from_secs(3)).await;
assert_eq!(slot(&lab, 12).await, PropertyValue::Real(9.0));
let ledger = Ledger::open(dir.path()).unwrap();
let [entry] = ledger.entries() else {
panic!("{:?}", ledger.entries());
};
assert_eq!(entry.state, State::Applied, "settled, not left sending");
let records = audit(dir.path());
assert!(
records
.iter()
.any(|r| r["event"] == "write" && r["outcome"]["result"] == "ack"),
"{records:?}"
);
lab.stop().await;
}
#[tokio::test(start_paused = true)]
async fn a_plan_may_approve_relinquish_default() {
let dir = tempfile::tempdir().unwrap();
let mut policy = writes(dir.path(), ArmMode::Never);
policy.writes.plan = Some(PlanGrant {
properties: vec![PropertyIdentifier::RELINQUISH_DEFAULT],
..plan(10)
});
let mut lab = Lab::start(policy).await;
let args = json!({ "device": LAB, "writes": [{ "object": "ao:1",
"property": "relinquish-default", "value": 5 }] });
let reply = call(&mut lab, "bacnet_write", args, json!({})).await;
assert_eq!(reply["isError"], json!(false), "{reply}");
assert_eq!(
reply["structuredContent"]["approval"]["confirmed_via"],
json!("plan")
);
lab.stop().await;
}