use std::io::Write;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use zenkey::qos::QosProfile;
use zenkey_fleet::judge::condition::Condition;
use zenkey_fleet::report::{CondState, PreambleSemantics};
use zenkey_fleet::{
TriggerEvent, TriggerSpec, ZrecItem, ZrecReader, declare_publication, declare_responder,
record_on,
};
mod util;
use util::peer_pair;
const HEALTH: &str = "v1/h-aaaaaaaaaaaa/state/demo/health";
const CONFIG: &str = "v1/h-aaaaaaaaaaaa/state/demo/config";
const SELECTOR: &str = "v1/h-aaaaaaaaaaaa/state/demo/**";
#[derive(Clone, Default)]
struct SharedBuf(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuf {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().expect("buffer lock").write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[derive(Debug)]
enum Line {
Preamble(String),
Sample(String, u64),
Trigger(String, CondState),
Dropped(u64),
}
async fn capture(semantics: PreambleSemantics) -> (zenkey_fleet::RecordReport, Vec<Line>) {
let (a, b) = peer_pair().await;
let config = declare_responder(&a, CONFIG, br#"{"mode":"x"}"#.to_vec(), None, true)
.await
.expect("config responder");
let health_held = declare_responder(&a, HEALTH, br#"{"ok":false}"#.to_vec(), None, true)
.await
.expect("health responder");
let serving = tokio::spawn(async move {
loop {
tokio::select! {
Some(q) = config.next() => { config.answer(q).await; }
Some(q) = health_held.next() => { health_held.answer(q).await; }
else => break,
}
}
});
let publication =
declare_publication(&a, HEALTH, QosProfile::Transition, Some("application/json"))
.await
.expect("declare");
let matching = publication.matching_events().await.expect("matching");
let (fired_tx, mut fired_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
let buf = SharedBuf::default();
let recorder = tokio::spawn({
let b = b.clone();
let buf = buf.clone();
async move {
let fleet = zenkey_fleet::Fleet::new(&b, "");
let store = zenkey_fleet::SchemaStore::new("", Duration::from_millis(300));
let spec = TriggerSpec {
selectors: vec![SELECTOR.into()],
pre: Duration::from_secs(2),
post: Duration::from_secs(1),
rules: vec![Condition::parse(&format!("silent-for {HEALTH} 0.7")).expect("rule")],
tick: Duration::from_millis(250),
timeout: Duration::from_secs(1),
give_up: Some(Duration::from_secs(20)),
preamble: Some(semantics),
max_samples: None,
max_replies: zenkey_fleet::DEFAULT_MAX_REPLIES,
};
record_on(
&fleet,
None,
&store,
&spec,
|| async move { Ok(buf) },
|ev| {
if let TriggerEvent::Fired(_) = ev {
let _ = fired_tx.send(());
}
},
)
.await
}
});
assert!(
tokio::time::timeout(util::SETTLE, matching.recv())
.await
.expect("matching within the settle window")
.expect("listener alive")
);
for i in 0..4u8 {
publication
.send(format!(r#"{{"ok":true,"n":{i}}}"#).into_bytes(), None)
.await
.expect("send");
tokio::time::sleep(Duration::from_millis(500)).await;
}
tokio::time::timeout(util::SETTLE, fired_rx.recv())
.await
.expect("the rule fired within the settle window");
publication
.send(br#"{"ok":true,"post":true}"#.to_vec(), None)
.await
.expect("send post");
let report = recorder.await.expect("join").expect("the capture ran");
serving.abort();
let bytes = buf.0.lock().expect("buffer lock").clone();
let mut reader = ZrecReader::new(bytes.as_slice()).expect("a .zrec header");
assert_eq!(reader.header().zrec, 2, "the file is version 2");
let mut lines = Vec::new();
while let Some(item) = reader.next() {
lines.push(match item.expect("a well-formed line") {
ZrecItem::Preamble { row, .. } => Line::Preamble(row.key),
ZrecItem::Sample { row, t_us, .. } => Line::Sample(row.key, t_us.expect("t")),
ZrecItem::Trigger(t) => Line::Trigger(t.rule.clone(), t.to),
ZrecItem::Dropped(n) => Line::Dropped(n),
});
}
(report, lines)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_condition_firing_writes_preamble_pre_trigger_and_post_in_one_file() {
let (report, lines) = capture(PreambleSemantics::AbsentFromWindow).await;
let header = &report.header;
let preamble = header
.preamble
.as_ref()
.expect("the header states the preamble");
assert_eq!(preamble.semantics, PreambleSemantics::AbsentFromWindow);
assert_eq!(
preamble.count, 1,
"only the key the ring cannot show: {preamble:?}"
);
assert_eq!(preamble.selectors, vec![SELECTOR.to_string()]);
assert!(preamble.failed.is_empty(), "{preamble:?}");
let pre_roll = header
.pre_roll
.as_ref()
.expect("the header states the pre-roll");
assert!(
(pre_roll.asked_s - 2.0).abs() < 1e-9 && pre_roll.covered_s <= 2.0,
"{pre_roll:?}"
);
assert_eq!(pre_roll.watched, vec![SELECTOR.to_string()]);
assert_eq!(pre_roll.evicted, 0);
assert_eq!(report.preamble_rows, 1);
let trigger = report
.trigger
.as_ref()
.expect("the report names the trigger");
assert!(trigger.rule.starts_with("silent-for"), "{trigger:?}");
assert_eq!(trigger.to, CondState::Firing);
assert!(
report.samples >= 4,
"pre-roll and post-roll rows: {report:?}"
);
let mut i = 0;
assert!(
matches!(&lines[i], Line::Preamble(k) if k == CONFIG),
"line 1 is the held key's preamble row: {lines:?}"
);
i += 1;
let mut last_t = 0u64;
let mut pre_rows = 0;
while let Line::Sample(key, t) = &lines[i] {
assert_eq!(key, HEALTH);
assert!(*t >= last_t, "ascending t: {lines:?}");
last_t = *t;
pre_rows += 1;
i += 1;
}
assert!(pre_rows >= 3, "the ring held the 2 Hz burst: {lines:?}");
assert!(
matches!(&lines[i], Line::Trigger(rule, CondState::Firing) if rule.starts_with("silent-for")),
"the trigger record sits after the pre-roll: {lines:?}"
);
i += 1;
assert!(
lines[i..]
.iter()
.any(|l| matches!(l, Line::Sample(k, _) if k == HEALTH)),
"the post-roll row lands after the trigger: {lines:?}"
);
assert!(
!lines
.iter()
.any(|l| matches!(l, Line::Dropped(n) if *n > 0)),
"a quiet fixture drops nothing: {lines:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn full_semantics_keep_every_fetched_key() {
let (report, lines) = capture(PreambleSemantics::Full).await;
let preamble = report.header.preamble.as_ref().expect("preamble");
assert_eq!(preamble.semantics, PreambleSemantics::Full);
assert_eq!(preamble.count, 2, "{preamble:?}");
let keys: Vec<&str> = lines
.iter()
.filter_map(|l| match l {
Line::Preamble(k) => Some(k.as_str()),
_ => None,
})
.collect();
assert_eq!(
keys,
vec![CONFIG, HEALTH],
"preamble rows come first, by key"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_rule_that_never_fires_leaves_no_file() {
let (_a, b) = peer_pair().await;
let fleet = zenkey_fleet::Fleet::new(&b, "");
let store = zenkey_fleet::SchemaStore::new("", Duration::from_millis(300));
let spec = TriggerSpec {
selectors: vec![SELECTOR.into()],
pre: Duration::from_secs(2),
post: Duration::from_secs(1),
rules: vec![Condition::parse(&format!("silent-for {HEALTH} 60")).expect("rule")],
tick: Duration::from_millis(200),
timeout: Duration::from_millis(300),
give_up: Some(Duration::from_millis(900)),
preamble: Some(PreambleSemantics::AbsentFromWindow),
max_samples: None,
max_replies: zenkey_fleet::DEFAULT_MAX_REPLIES,
};
let opened = Arc::new(Mutex::new(false));
let mut gave_up = false;
let report = record_on(
&fleet,
None,
&store,
&spec,
|| async {
*opened.lock().expect("lock") = true;
Ok(SharedBuf::default())
},
|ev| {
if let TriggerEvent::GaveUp { .. } = ev {
gave_up = true;
}
},
)
.await
.expect("a run that gives up is still a clean run");
assert!(gave_up);
assert!(!*opened.lock().expect("lock"), "no file was opened");
assert!(report.out.is_none() && report.trigger.is_none());
assert_eq!(report.samples, 0);
assert_eq!(report.header.preamble, None);
}