1use std::time::Duration;
16
17use crate::{Error, Result};
18use zenkey::grammar::with_base;
19use zenkey::pattern::{PatternChunk, SubjectPattern};
20use zenkey::qos::QosProfile;
21use zenkey::schema::SchemaSet;
22use zenkey::{Class, Declared, RateClass};
23
24use crate::model::decode::SchemaStore;
25use crate::model::registry::SliceSet;
26use crate::report::{Fault, GenPlanEntry, GenReport};
27use crate::tape::synth::Synth;
28
29pub fn synthetic_marker(tool: &str, origin: &str, fault: Option<&str>) -> Vec<u8> {
31 let mut obj = serde_json::json!({
32 "synthetic": true,
33 "tool": tool,
34 "origin": origin,
35 });
36 if let Some(kind) = fault {
37 obj["fault"] = kind.into();
38 }
39 serde_json::to_vec(&obj).expect("the marker serializes")
40}
41
42impl Fault {
43 pub fn as_str(self) -> &'static str {
46 match self {
47 Fault::Truncate => "truncate",
48 Fault::WrongType => "wrong-type",
49 Fault::ExtraField => "extra-field",
50 Fault::UnregisteredKey => "unregistered-key",
51 Fault::WrongQos => "wrong-qos",
52 Fault::MissingEncoding => "missing-encoding",
53 Fault::Unstamped => "unstamped",
54 }
55 }
56
57 pub const ALL: [Fault; 7] = [
59 Fault::Truncate,
60 Fault::WrongType,
61 Fault::ExtraField,
62 Fault::UnregisteredKey,
63 Fault::WrongQos,
64 Fault::MissingEncoding,
65 Fault::Unstamped,
66 ];
67
68 pub fn parse(s: &str) -> Result<Fault> {
71 Fault::ALL
72 .into_iter()
73 .find(|f| f.as_str() == s)
74 .ok_or_else(|| {
75 let known = Fault::ALL.map(Fault::as_str).join(", ");
76 Error::unaskable(
77 format!("--fault {s:?}"),
78 format!("is not a known fault kind — known kinds: {known}"),
79 )
80 })
81 }
82
83 fn perturb_key(self, key: &str) -> String {
87 match self {
88 Fault::UnregisteredKey => format!("{key}/unregistered"),
89 _ => key.to_string(),
90 }
91 }
92
93 fn perturb_qos(self, declared: QosProfile) -> QosProfile {
96 match self {
97 Fault::WrongQos if declared == QosProfile::Sampled => QosProfile::Transition,
98 Fault::WrongQos => QosProfile::Sampled,
99 _ => declared,
100 }
101 }
102
103 fn drops_encoding(self) -> bool {
105 matches!(self, Fault::MissingEncoding)
106 }
107
108 fn drops_timestamp(self) -> bool {
110 matches!(self, Fault::Unstamped)
111 }
112
113 fn perturb_body(self, bytes: Vec<u8>) -> Vec<u8> {
118 match self {
119 Fault::Truncate => {
120 let n = bytes.len() / 2;
121 let mut out = bytes;
122 out.truncate(n);
123 out
124 }
125 Fault::WrongType => {
126 serde_json::to_vec(&serde_json::Value::String("fault:wrong-type".into()))
129 .expect("a string serializes")
130 }
131 Fault::ExtraField => match serde_json::from_slice::<serde_json::Value>(&bytes) {
132 Ok(serde_json::Value::Object(mut m)) => {
133 m.insert("_fault".into(), serde_json::Value::Bool(true));
134 serde_json::to_vec(&serde_json::Value::Object(m)).expect("object serializes")
135 }
136 Ok(other) => {
137 let wrapped = serde_json::json!({ "_orig": other, "_fault": true });
139 serde_json::to_vec(&wrapped).expect("object serializes")
140 }
141 Err(_) => {
142 let mut out = bytes;
145 out.extend_from_slice(b"_fault");
146 out
147 }
148 },
149 _ => bytes,
150 }
151 }
152
153 fn delta(self, valid: &GenPlanEntry) -> String {
157 match self {
158 Fault::Truncate => {
159 "payload truncated to half its encoded bytes — a partial frame".into()
160 }
161 Fault::WrongType => format!(
162 "body replaced with a JSON string where {} is declared",
163 valid.type_name
164 ),
165 Fault::ExtraField => "an undeclared `_fault` field added to the body".into(),
166 Fault::UnregisteredKey => format!(
167 "key → {} (an unregistered subject; RFC 09 §5.1 O1: a fact to report)",
168 self.perturb_key(&valid.key)
169 ),
170 Fault::WrongQos => format!(
171 "qos {} → {} (declared profile not honoured, RFC 04 §3)",
172 valid.qos,
173 self.perturb_qos(QosProfile::from_name(&valid.qos).unwrap_or(QosProfile::Sampled))
174 .name()
175 ),
176 Fault::MissingEncoding => match &valid.encoding {
177 Some(e) => format!("wire encoding {e} omitted"),
178 None => "no wire encoding set (none was declared either)".into(),
179 },
180 Fault::Unstamped => "no HLC timestamp — state LWW cannot order it (RFC 04 §4)".into(),
181 }
182 }
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum GenPattern {
188 Steady,
190 Jitter,
192 Burst,
194 Ramp,
196}
197
198#[derive(Debug, Clone)]
200pub struct GenSpec {
201 pub origin: String,
205 pub producer: Option<String>,
207 pub subject: Option<String>,
209 pub vars: Vec<(String, String)>,
212 pub rate_hz: Option<f64>,
216 pub pattern: GenPattern,
217 pub duration: Duration,
218 pub seed: u64,
220 pub tool: String,
222 pub faults: Vec<Fault>,
227}
228
229fn synthetic_var(name: &str) -> String {
232 let clean: String = name
233 .chars()
234 .filter(|c| c.is_ascii_alphanumeric())
235 .flat_map(|c| c.to_lowercase())
236 .collect();
237 if clean.is_empty() {
238 "v1".into()
239 } else {
240 format!("{clean}1")
241 }
242}
243
244pub async fn build_plan(
249 fleet: Option<&crate::Fleet<'_>>,
250 store: &SchemaStore,
251 slices: &SliceSet,
252 base: &str,
253 schema_set: Option<&SchemaSet>,
254 spec: &GenSpec,
255) -> Result<Vec<GenPlanEntry>> {
256 let session = fleet.map(crate::Fleet::session);
257
258 let mut plan = Vec::new();
259
260 for slice in slices.slices() {
261 if slice.service_origin.is_some() {
262 continue;
266 }
267 if let Some(p) = &spec.producer
268 && &slice.name != p
269 {
270 continue;
271 }
272 for subject in &slice.subjects {
273 if let Some(filter) = &spec.subject
274 && !subject.path.contains(filter.as_str())
275 {
276 continue;
277 }
278 let pattern = SubjectPattern::parse(&subject.path).map_err(|e| {
279 Error::unaskable(format!("{}/{}", slice.name, subject.path), e.to_string())
280 })?;
281 let mut tail: Vec<String> = Vec::new();
282 let mut synthetic_vars: Vec<String> = Vec::new();
283 let mut unique_tail_idx = None;
284 for chunk in pattern.chunks() {
285 match chunk {
286 PatternChunk::Literal(l) => tail.push(l.clone()),
287 PatternChunk::Var(name) | PatternChunk::Rest(name) => {
288 let value = spec
289 .vars
290 .iter()
291 .find(|(k, _)| k == name)
292 .map(|(_, v)| v.clone())
293 .unwrap_or_else(|| {
294 synthetic_vars.push(name.clone());
295 synthetic_var(name)
296 });
297 if subject.class.is(&Class::Events) {
298 unique_tail_idx = Some(tail.len());
301 }
302 tail.push(value);
303 }
304 }
305 }
306 let key = with_base(
307 base,
308 format!(
309 "v1/{}/{}/{}/{}",
310 spec.origin,
311 subject.class,
312 slice.name,
313 tail.join("/")
314 ),
315 );
316 let base_chunks = if base.is_empty() {
319 0
320 } else {
321 base.split('/').count()
322 };
323 let unique_chunk = unique_tail_idx.map(|i| base_chunks + 4 + i);
324
325 let (qos, qos_source) = match subject.qos.as_ref().and_then(Declared::known) {
329 Some(q) => (*q, "declared"),
330 None => (QosProfile::Sampled, "default"),
331 };
332
333 let mut events_cap = None;
336 let mut note: Option<String> = None;
337 let rate_hz = match subject.class.known() {
338 Some(Class::Events) => {
339 let cap_h = subject
340 .rate
341 .as_ref()
342 .and_then(RateClass::cap_per_hour)
343 .unwrap_or(1);
344 let cap_run = ((f64::from(u32::try_from(cap_h.min(3600)).unwrap_or(3600))
345 * spec.duration.as_secs_f64())
346 / 3600.0)
347 .floor()
348 .max(1.0) as u64;
349 events_cap = Some(cap_run.min(cap_h));
350 (events_cap.unwrap_or(1) as f64 / spec.duration.as_secs_f64()).min(1.0)
352 }
353 Some(Class::State) => match subject.ttl_s {
354 Some(ttl) if ttl > 0 => 2.0 / ttl as f64,
356 _ => 0.5,
357 },
358 _ => 1.0,
359 };
360 let rate_hz = spec.rate_hz.unwrap_or(rate_hz).clamp(0.001, 1000.0);
361
362 let mut body_source = "placeholder";
364 let mut schema = None;
365 if let Some(session) = session
366 && let Some(s) = store
367 .schema_for(session, &slice.name, &subject.type_name)
368 .await
369 {
370 schema = Some(s);
371 body_source = "describe";
372 }
373 if schema.is_none()
374 && let Some(set) = schema_set
375 && let Some(s) = set.get(&subject.type_name)
376 {
377 schema = Some(s.clone());
378 body_source = "schema-set";
379 }
380 if schema.is_none() {
381 note = Some(format!(
382 "no schema for {} — sending a placeholder {{}} body, labelled",
383 subject.type_name
384 ));
385 }
386 if !synthetic_vars.is_empty() {
387 let vars = synthetic_vars.join(", ");
388 note = Some(match note.take() {
389 Some(n) => format!("{n}; synthetic values for {{{vars}}}"),
390 None => format!("synthetic values for {{{vars}}} (override with --var)"),
391 });
392 }
393 let encoding =
394 crate::bus::body::encode_encoding(None, subject.encoding.as_ref(), schema.as_ref());
395
396 let valid = GenPlanEntry {
397 key,
398 class: subject.class.token().to_string(),
399 producer: slice.name.clone(),
400 type_name: subject.type_name.clone(),
401 qos: qos.name().to_string(),
402 qos_source,
403 rate_hz,
404 body_source,
405 encoding,
406 events_cap,
407 note,
408 fault: None,
409 fault_delta: None,
410 schema,
411 unique_chunk,
412 };
413
414 if spec.faults.is_empty() {
415 plan.push(valid);
416 continue;
417 }
418 for &fault in &spec.faults {
424 let mut variant = valid.clone();
425 variant.fault_delta = Some(fault.delta(&valid));
426 variant.key = fault.perturb_key(&valid.key);
427 variant.qos = fault
428 .perturb_qos(QosProfile::from_name(&valid.qos).unwrap_or(QosProfile::Sampled))
429 .name()
430 .to_string();
431 if fault.drops_encoding() {
432 variant.encoding = None;
433 }
434 variant.fault = Some(fault);
435 plan.push(variant);
436 }
437 }
438 }
439 Ok(plan)
440}
441
442#[derive(Debug)]
447pub struct MockProducer {
448 pub keys: usize,
450 tasks: Vec<tokio::task::JoinHandle<()>>,
451}
452
453impl Drop for MockProducer {
454 fn drop(&mut self) {
455 for t in &self.tasks {
456 t.abort();
457 }
458 }
459}
460
461pub async fn serve_describe(
466 fleet: &crate::Fleet<'_>,
467 origin: &str,
468 slices: &SliceSet,
469 schema_set: Option<&SchemaSet>,
470 producer: Option<&str>,
471) -> Result<MockProducer> {
472 let (session, base) = (fleet.session(), fleet.base());
473
474 let mut up = crate::bus::producer::BringUp::new(session);
482 let mut bodies: Vec<(Vec<u8>, &'static str)> = Vec::new();
483 for (slice, raw) in slices.entries() {
484 if slice.service_origin.is_some() {
485 continue;
486 }
487 if let Some(p) = producer
488 && slice.name != p
489 {
490 continue;
491 }
492 if raw.is_empty() {
493 continue; }
495 let introspect = with_base(base, format!("v1/{origin}/@rpc/{}/introspect", slice.name));
496 up.serve(&introspect).await?;
497 bodies.push((raw.as_bytes().to_vec(), "text/plain"));
498 if let Some(set) = schema_set {
499 let describe = with_base(base, format!("v1/{origin}/@rpc/{}/describe", slice.name));
500 up.serve(&describe).await?;
501 bodies.push((set.to_json().into_bytes(), "application/json"));
502 }
503 }
504 let responders = up.without_alive();
507 let keys = responders.len();
508 let mut tasks = Vec::new();
509 for (responder, (body, encoding)) in responders.into_iter().zip(bodies) {
510 tasks.push(tokio::spawn(async move {
511 while let Some(query) = responder.next().await {
512 if let Err(e) = responder.reply(&query, body.clone(), Some(encoding)).await {
518 tracing::warn!(key = %responder.key(), "mock producer reply failed: {e}");
519 }
520 }
521 }));
522 }
523 Ok(MockProducer { keys, tasks })
524}
525
526pub async fn run_gen(
544 fleet: &crate::Fleet<'_>,
545 plan: &[GenPlanEntry],
546 spec: &GenSpec,
547) -> Result<GenReport> {
548 let session = fleet.session();
549
550 let synth = Synth::new(spec.seed);
551
552 let deadline = tokio::time::Instant::now() + spec.duration;
553
554 let total_s = spec.duration.as_secs_f64();
555
556 let mut tasks: tokio::task::JoinSet<(usize, u64, u64, Vec<String>)> =
557 tokio::task::JoinSet::new();
558
559 for (i, entry) in plan.iter().enumerate() {
560 let entry = entry.clone();
561 let session = session.clone();
562 let marker = synthetic_marker(&spec.tool, &spec.origin, entry.fault.map(Fault::as_str));
566 let store_encoding = entry.encoding.clone();
567 let pattern = spec.pattern;
568 let seed = spec.seed;
569 tasks.spawn(async move {
570 let registry = zenkey::schema::decode::DecoderRegistry::new();
571 let started = tokio::time::Instant::now();
572 let mut sent = 0u64;
573 let mut refused = 0u64;
574 let mut first_errors: Vec<String> = Vec::new();
575 let record_err = |e: String, refused: &mut u64, errs: &mut Vec<String>| {
576 *refused += 1;
577 if errs.len() < 3 {
578 errs.push(e);
579 }
580 };
581 let publication = if entry.unique_chunk.is_none() {
584 match crate::bus::write::declare_publication(
585 &session,
586 &entry.key,
587 QosProfile::from_name(&entry.qos).unwrap_or(QosProfile::Sampled),
588 entry.encoding.as_deref(),
589 )
590 .await
591 {
592 Ok(p) => Some(p),
593 Err(e) => {
594 return (i, 0, 1, vec![format!("{}: declare: {e}", entry.key)]);
595 }
596 }
597 } else {
598 None
599 };
600
601 let base_interval = Duration::from_secs_f64(1.0 / entry.rate_hz);
602 let mut tick: u64 = 0;
603 let run_over = tokio::time::sleep_until(deadline);
604 tokio::pin!(run_over);
605 loop {
606 if let Some(cap) = entry.events_cap
607 && sent >= cap
608 {
609 (&mut run_over).await;
613 break;
614 }
615 let bytes = match &entry.schema {
617 Some(schema) => match synth.instance(schema, tick) {
618 Some(value) => {
619 let wire = zenkey::schema::WireEncoding::from_encoding_str(
620 store_encoding.as_deref().unwrap_or("application/json"),
621 );
622 match registry.encode(schema, &value, &wire) {
623 Ok(b) => b,
624 Err(e) => {
625 record_err(
626 format!("{}: encode: {e}", entry.key),
627 &mut refused,
628 &mut first_errors,
629 );
630 tick += 1;
631 continue;
632 }
633 }
634 }
635 None => b"{}".to_vec(),
636 },
637 None => b"{}".to_vec(),
638 };
639 let bytes = match entry.fault {
644 Some(f) => f.perturb_body(bytes),
645 None => bytes,
646 };
647 let stamp = if entry.fault.map(Fault::drops_timestamp).unwrap_or(false) {
651 None
652 } else {
653 Some(session.new_timestamp())
654 };
655 let outcome = match &publication {
656 Some(p) => p.send_stamped(bytes, Some(marker.clone()), stamp).await,
657 None => {
658 let key = unique_key(&entry, seed, sent);
660 match crate::bus::write::declare_publication(
661 &session,
662 &key,
663 QosProfile::from_name(&entry.qos).unwrap_or(QosProfile::Sampled),
664 entry.encoding.as_deref(),
665 )
666 .await
667 {
668 Ok(p) => {
669 let r = p.send_stamped(bytes, Some(marker.clone()), stamp).await;
670 let _ = p.undeclare().await;
671 r
672 }
673 Err(e) => Err(e),
674 }
675 }
676 };
677 match outcome {
678 Ok(()) => sent += 1,
679 Err(e) => record_err(
680 format!("{}: send: {e}", entry.key),
681 &mut refused,
682 &mut first_errors,
683 ),
684 }
685 tick += 1;
686
687 let interval = match pattern {
689 GenPattern::Steady => base_interval,
690 GenPattern::Jitter => {
691 let f = 0.7 + 0.6 * halton(seed ^ (i as u64) ^ tick);
692 base_interval.mul_f64(f)
693 }
694 GenPattern::Burst => {
695 let per_burst = entry.rate_hz.ceil().max(1.0) as u64;
696 if tick.is_multiple_of(per_burst) {
697 Duration::from_secs(1)
698 } else {
699 Duration::ZERO
700 }
701 }
702 GenPattern::Ramp => {
703 let progress = (started.elapsed().as_secs_f64() / total_s).clamp(0.05, 1.0);
704 base_interval.div_f64(progress)
705 }
706 };
707 tokio::select! {
711 _ = tokio::time::sleep(interval) => {}
712 () = &mut run_over => break,
713 }
714 if tokio::time::Instant::now() >= deadline {
715 break;
716 }
717 }
718 if let Some(p) = publication {
719 let _ = p.undeclare().await;
720 }
721 (i, sent, refused, first_errors)
722 });
723 }
724
725 let mut done: Vec<Option<(u64, u64, Vec<String>)>> = vec![None; plan.len()];
729 let mut failed: Option<Error> = None;
730 while let Some(joined) = tasks.join_next().await {
731 match joined {
732 Ok((i, s, r, errs)) => done[i] = Some((s, r, errs)),
733 Err(e) => {
734 failed = Some(Error::Internal(format!("a gen task did not join: {e}")));
735 break;
736 }
737 }
738 }
739 tasks.shutdown().await;
744 if let Some(e) = failed {
745 return Err(e);
746 }
747
748 let mut sent = 0u64;
749 let mut refused = 0u64;
750 let mut first_errors = Vec::new();
751 for (s, r, errs) in done.into_iter().flatten() {
752 sent += s;
753 refused += r;
754 for e in errs {
755 if first_errors.len() < 5 {
756 first_errors.push(e);
757 }
758 }
759 }
760 Ok(GenReport {
761 duration_s: spec.duration.as_secs_f64(),
762 entries: plan.len(),
763 sent,
764 refused,
765 first_errors,
766 })
767}
768
769fn unique_key(entry: &GenPlanEntry, seed: u64, n: u64) -> String {
772 let Some(idx) = entry.unique_chunk else {
773 return entry.key.clone();
774 };
775 let id = format!("{:012x}{:04x}", seed & 0xffff_ffff_ffff, n & 0xffff);
776 entry
777 .key
778 .split('/')
779 .enumerate()
780 .map(|(i, c)| if i == idx { id.as_str() } else { c })
781 .collect::<Vec<_>>()
782 .join("/")
783}
784
785fn halton(n: u64) -> f64 {
787 let mut f = 1.0;
788 let mut r = 0.0;
789 let mut i = n.wrapping_mul(2654435761) % 4096 + 1;
790 while i > 0 {
791 f /= 2.0;
792 r += f * (i % 2) as f64;
793 i /= 2;
794 }
795 r
796}
797
798#[cfg(test)]
799mod tests {
800 use super::*;
801
802 const SLICES: &str = r#"
803[registry]
804version = "1.0"
805app = "t"
806convention = 1
807[producer]
808name = "demo"
809[[subject]]
810path = "health"
811class = "state"
812type = "Health"
813qos = "transition"
814ttl_s = 30
815[[subject]]
816path = "cpu/{core}/usage"
817class = "telemetry"
818type = "Point"
819[[subject]]
820path = "boom/{id}"
821class = "events"
822type = "Boom"
823rate = "rare"
824"#;
825
826 fn spec() -> GenSpec {
827 GenSpec {
828 origin: "h-abababababab".into(),
829 producer: None,
830 subject: None,
831 vars: vec![("core".into(), "cpu0".into())],
832 rate_hz: None,
833 pattern: GenPattern::Steady,
834 duration: Duration::from_secs(10),
835 seed: 42,
836 tool: "zenctl gen".into(),
837 faults: vec![],
838 }
839 }
840
841 async fn plan_for(base: &str) -> Vec<GenPlanEntry> {
842 let slices =
843 SliceSet::from_slices(vec![zenkey::parse_slice(SLICES).expect("fixture parses")]);
844 let store = SchemaStore::new(base, Duration::from_millis(100));
845 let set = SchemaSet::parse(
846 r#"{"schema_version":1,"app":"t","types":{
847 "Health":{"kind":"json-schema","hash":"","schema":{"type":"object",
848 "properties":{"ok":{"type":"boolean"}}}}}}"#,
849 )
850 .expect("set parses");
851 build_plan(None, &store, &slices, base, Some(&set), &spec())
852 .await
853 .expect("plan builds")
854 }
855
856 #[tokio::test]
861 async fn the_plan_resolves_declared_qos_rates_and_the_schema_ladder() {
862 let plan = plan_for("").await;
863 assert_eq!(plan.len(), 3);
864
865 let health = &plan[0];
866 assert_eq!(health.key, "v1/h-abababababab/state/demo/health");
867 assert_eq!(
868 (health.qos.as_str(), health.qos_source),
869 ("transition", "declared")
870 );
871 assert!(
872 (health.rate_hz - 2.0 / 30.0).abs() < 1e-9,
873 "{}",
874 health.rate_hz
875 );
876 assert_eq!(health.body_source, "schema-set");
877 assert!(health.note.is_none());
878
879 let cpu = &plan[1];
880 assert_eq!(cpu.key, "v1/h-abababababab/telemetry/demo/cpu/cpu0/usage");
881 assert_eq!((cpu.qos.as_str(), cpu.qos_source), ("sampled", "default"));
882 assert_eq!(cpu.rate_hz, 1.0);
883 assert_eq!(cpu.body_source, "placeholder");
884 assert!(
885 cpu.note.as_deref().unwrap_or("").contains("no schema"),
886 "{:?}",
887 cpu.note
888 );
889
890 let boom = &plan[2];
891 assert_eq!(boom.class, "events");
892 assert_eq!(boom.events_cap, Some(1), "rare = 1/h caps a 10s run at 1");
893 assert!(boom.unique_chunk.is_some(), "events keys are write-once");
894 assert!(
895 boom.note.as_deref().unwrap_or("").contains("{id}"),
896 "the synthesized var is stated: {:?}",
897 boom.note
898 );
899 }
900
901 #[tokio::test]
903 async fn events_keys_get_a_fresh_id_where_the_var_was() {
904 for base in ["", "acme", "acme/fleet-a"] {
905 let plan = plan_for(base).await;
906 let boom = plan.iter().find(|e| e.class == "events").unwrap();
907 let k1 = unique_key(boom, 42, 0);
908 let k2 = unique_key(boom, 42, 1);
909 assert_ne!(k1, k2, "each send gets its own key ({base:?})");
910 let tail1: Vec<&str> = k1.split('/').collect();
911 let tail2: Vec<&str> = k2.split('/').collect();
912 assert_eq!(tail1.len(), tail2.len());
913 let diffs: Vec<usize> = (0..tail1.len()).filter(|&i| tail1[i] != tail2[i]).collect();
914 assert_eq!(diffs.len(), 1, "only the id chunk moves ({base:?})");
915 assert!(
916 k1.ends_with(tail1[diffs[0]]),
917 "the id is the declared {{id}} position ({base:?}): {k1}"
918 );
919 }
920 }
921
922 #[test]
924 fn the_marker_round_trips_through_the_doctors_detector() {
925 let m = synthetic_marker("zenctl gen", "h-abababababab", None);
926 let v: serde_json::Value = serde_json::from_slice(&m).unwrap();
927 assert_eq!(v["synthetic"], true);
928 assert_eq!(v["tool"], "zenctl gen");
929 assert_eq!(v["origin"], "h-abababababab");
930 assert!(v.get("fault").is_none(), "no fault key unless injecting");
931 let f = synthetic_marker("zenctl gen", "h-abababababab", Some("truncate"));
932 let v: serde_json::Value = serde_json::from_slice(&f).unwrap();
933 assert_eq!(v["fault"], "truncate");
934 }
935
936 #[test]
939 fn fault_kinds_parse_and_an_unknown_is_refused() {
940 for f in Fault::ALL {
941 assert_eq!(Fault::parse(f.as_str()).unwrap(), f);
942 }
943 let err = Fault::parse("scramble").unwrap_err().to_string();
944 assert!(err.contains("is not a known fault kind"), "{err}");
945 assert!(err.contains("truncate"), "the vocabulary is named: {err}");
946 }
947
948 #[tokio::test]
953 async fn faults_expand_the_plan_one_variant_per_kind_with_a_stated_delta() {
954 let slices =
955 SliceSet::from_slices(vec![zenkey::parse_slice(SLICES).expect("fixture parses")]);
956 let store = SchemaStore::new("", Duration::from_millis(100));
957 let mut spec = spec();
958 spec.faults = Fault::ALL.to_vec();
959 let plan = build_plan(None, &store, &slices, "", None, &spec)
960 .await
961 .expect("plan builds");
962 assert_eq!(plan.len(), 3 * 7);
964 assert!(
965 plan.iter()
966 .all(|e| e.fault.is_some() && e.fault_delta.is_some()),
967 "every faulted entry names its kind and delta"
968 );
969
970 let health: Vec<&GenPlanEntry> = plan
973 .iter()
974 .filter(|e| e.key.starts_with("v1/h-abababababab/state/demo/health"))
975 .collect();
976 assert_eq!(health.len(), 7);
977
978 let unregistered = health
979 .iter()
980 .find(|e| e.fault == Some(Fault::UnregisteredKey))
981 .unwrap();
982 assert_eq!(
983 unregistered.key,
984 "v1/h-abababababab/state/demo/health/unregistered"
985 );
986
987 let wrong_qos = health
988 .iter()
989 .find(|e| e.fault == Some(Fault::WrongQos))
990 .unwrap();
991 assert_ne!(
992 wrong_qos.qos, "transition",
993 "the declared profile is not honoured"
994 );
995
996 let missing_enc = health
997 .iter()
998 .find(|e| e.fault == Some(Fault::MissingEncoding))
999 .unwrap();
1000 assert!(
1001 missing_enc.encoding.is_none(),
1002 "the wire encoding is dropped"
1003 );
1004
1005 let truncate = health
1008 .iter()
1009 .find(|e| e.fault == Some(Fault::Truncate))
1010 .unwrap();
1011 assert_eq!(truncate.qos, "transition");
1012 assert!(truncate.key.ends_with("/health"));
1013 }
1014
1015 #[test]
1019 fn body_faults_perturb_the_encoded_bytes() {
1020 let valid = br#"{"ok":true,"load":3}"#.to_vec();
1021
1022 let truncated = Fault::Truncate.perturb_body(valid.clone());
1023 assert_eq!(truncated.len(), valid.len() / 2, "half the bytes survive");
1024
1025 let wrong = Fault::WrongType.perturb_body(valid.clone());
1026 let v: serde_json::Value = serde_json::from_slice(&wrong).unwrap();
1027 assert!(v.is_string(), "a bare string where an object was declared");
1028
1029 let extra = Fault::ExtraField.perturb_body(valid.clone());
1030 let v: serde_json::Value = serde_json::from_slice(&extra).unwrap();
1031 assert_eq!(v["_fault"], true, "the undeclared field rides");
1032 assert_eq!(v["ok"], true, "the valid fields survive alongside it");
1033 }
1034}