1#![forbid(unsafe_code)]
2
3use std::collections::BTreeMap;
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::time::{Instant, SystemTime, UNIX_EPOCH};
11use tracing::info_span;
12
13static LAB_SCENARIOS_RUN_TOTAL: AtomicU64 = AtomicU64::new(0);
15
16#[must_use]
18pub fn lab_scenarios_run_total() -> u64 {
19 LAB_SCENARIOS_RUN_TOTAL.load(Ordering::Relaxed)
20}
21
22#[derive(Debug)]
24pub struct DeterminismFixture {
25 seed: u64,
26 deterministic: bool,
27 time_step_ms: u64,
28 run_id: String,
29 ts_counter: AtomicU64,
30 ms_counter: AtomicU64,
31 start: Instant,
32}
33
34impl DeterminismFixture {
35 pub fn new(prefix: &str, default_seed: u64) -> Self {
37 let deterministic = deterministic_mode();
38 let seed = fixture_seed(default_seed);
39 let time_step_ms = fixture_time_step_ms();
40 Self::new_with(prefix, seed, deterministic, time_step_ms)
41 }
42
43 pub fn new_with(prefix: &str, seed: u64, deterministic: bool, time_step_ms: u64) -> Self {
45 let run_id = if deterministic {
46 format!("{prefix}_seed{seed}")
47 } else {
48 format!("{prefix}_{}_{}", std::process::id(), unix_secs())
49 };
50 Self {
51 seed,
52 deterministic,
53 time_step_ms,
54 run_id,
55 ts_counter: AtomicU64::new(0),
56 ms_counter: AtomicU64::new(0),
57 start: Instant::now(),
58 }
59 }
60
61 pub fn seed(&self) -> u64 {
63 self.seed
64 }
65
66 pub fn deterministic(&self) -> bool {
68 self.deterministic
69 }
70
71 pub fn run_id(&self) -> &str {
73 &self.run_id
74 }
75
76 pub fn timestamp(&self) -> String {
78 if self.deterministic {
79 let n = self.ts_counter.fetch_add(1, Ordering::Relaxed);
80 format!("T{n:06}")
81 } else {
82 let now = SystemTime::now()
83 .duration_since(UNIX_EPOCH)
84 .unwrap_or_default();
85 format!("{}.{:03}", now.as_secs(), now.subsec_millis())
86 }
87 }
88
89 pub fn now_ms(&self) -> u64 {
91 if self.deterministic {
92 self.ms_counter
93 .fetch_add(self.time_step_ms, Ordering::Relaxed)
94 .saturating_add(self.time_step_ms)
95 } else {
96 self.start.elapsed().as_millis() as u64
97 }
98 }
99
100 pub fn env_snapshot(&self) -> EnvSnapshot {
102 EnvSnapshot::capture(self.seed, self.deterministic)
103 }
104}
105
106#[derive(Debug, Clone)]
108pub struct EnvSnapshot {
109 fields: BTreeMap<String, String>,
110}
111
112impl EnvSnapshot {
113 pub fn capture(seed: u64, deterministic: bool) -> Self {
115 let mut fields = BTreeMap::new();
116 fields.insert("term".into(), json_string(&env_string("TERM")));
117 fields.insert("colorterm".into(), json_string(&env_string("COLORTERM")));
118 fields.insert("no_color".into(), env_bool("NO_COLOR").to_string());
119 fields.insert("tmux".into(), env_bool("TMUX").to_string());
120 fields.insert("zellij".into(), env_bool("ZELLIJ").to_string());
121 fields.insert("seed".into(), seed.to_string());
122 fields.insert("deterministic".into(), deterministic.to_string());
123 Self { fields }
124 }
125
126 #[must_use]
128 pub fn with_str(mut self, key: &str, value: &str) -> Self {
129 self.fields.insert(key.to_string(), json_string(value));
130 self
131 }
132
133 #[must_use]
135 pub fn with_u64(mut self, key: &str, value: u64) -> Self {
136 self.fields.insert(key.to_string(), value.to_string());
137 self
138 }
139
140 #[must_use]
142 pub fn with_bool(mut self, key: &str, value: bool) -> Self {
143 self.fields.insert(key.to_string(), value.to_string());
144 self
145 }
146
147 #[must_use]
149 pub fn with_raw(mut self, key: &str, raw_json: &str) -> Self {
150 self.fields.insert(key.to_string(), raw_json.to_string());
151 self
152 }
153
154 pub fn to_json(&self) -> String {
156 let mut out = String::from("{");
157 for (idx, (k, v)) in self.fields.iter().enumerate() {
158 if idx > 0 {
159 out.push(',');
160 }
161 out.push('"');
162 out.push_str(&escape_json(k));
163 out.push_str("\":");
164 out.push_str(v);
165 }
166 out.push('}');
167 out
168 }
169}
170
171#[derive(Debug, Clone)]
173pub enum JsonValue {
174 Str(String),
176 Raw(String),
178 Bool(bool),
180 U64(u64),
182 I64(i64),
184}
185
186impl JsonValue {
187 pub fn str(value: impl Into<String>) -> Self {
189 Self::Str(value.into())
190 }
191
192 pub fn raw(value: impl Into<String>) -> Self {
194 Self::Raw(value.into())
195 }
196
197 pub fn bool(value: bool) -> Self {
199 Self::Bool(value)
200 }
201
202 pub fn u64(value: u64) -> Self {
204 Self::U64(value)
205 }
206
207 pub fn i64(value: i64) -> Self {
209 Self::I64(value)
210 }
211
212 fn to_json(&self) -> String {
213 match self {
214 Self::Str(value) => json_string(value),
215 Self::Raw(value) => value.clone(),
216 Self::Bool(value) => value.to_string(),
217 Self::U64(value) => value.to_string(),
218 Self::I64(value) => value.to_string(),
219 }
220 }
221}
222
223#[derive(Debug)]
225pub struct TestJsonlLogger {
226 fixture: DeterminismFixture,
227 schema_version: u32,
228 seq: AtomicU64,
229 context: BTreeMap<String, String>,
230}
231
232impl TestJsonlLogger {
233 pub fn new(prefix: &str, default_seed: u64) -> Self {
235 Self {
236 fixture: DeterminismFixture::new(prefix, default_seed),
237 schema_version: 1,
238 seq: AtomicU64::new(0),
239 context: BTreeMap::new(),
240 }
241 }
242
243 pub fn new_with(prefix: &str, seed: u64, deterministic: bool, time_step_ms: u64) -> Self {
245 Self {
246 fixture: DeterminismFixture::new_with(prefix, seed, deterministic, time_step_ms),
247 schema_version: 1,
248 seq: AtomicU64::new(0),
249 context: BTreeMap::new(),
250 }
251 }
252
253 pub fn fixture(&self) -> &DeterminismFixture {
255 &self.fixture
256 }
257
258 pub fn emitted_count(&self) -> u64 {
260 self.seq.load(Ordering::Relaxed)
261 }
262
263 #[must_use]
265 pub fn with_schema_version(mut self, version: u32) -> Self {
266 self.schema_version = version;
267 self
268 }
269
270 pub fn add_context_str(&mut self, key: &str, value: &str) {
272 self.context.insert(key.to_string(), json_string(value));
273 }
274
275 pub fn add_context_u64(&mut self, key: &str, value: u64) {
277 self.context.insert(key.to_string(), value.to_string());
278 }
279
280 pub fn add_context_bool(&mut self, key: &str, value: bool) {
282 self.context.insert(key.to_string(), value.to_string());
283 }
284
285 pub fn add_context_raw(&mut self, key: &str, raw_json: &str) {
287 self.context.insert(key.to_string(), raw_json.to_string());
288 }
289
290 pub fn emit_line(&self, event: &str, fields: &[(&str, JsonValue)]) -> String {
292 let seq = self.seq.fetch_add(1, Ordering::Relaxed);
293 let mut used_keys: BTreeMap<String, ()> = BTreeMap::new();
294 for (key, _) in fields {
295 used_keys.insert((*key).to_string(), ());
296 }
297
298 let mut parts = Vec::new();
299 parts.push(format!("\"schema_version\":{}", self.schema_version));
300 parts.push(format!("\"seq\":{seq}"));
301 parts.push(format!(
302 "\"ts\":\"{}\"",
303 escape_json(&self.fixture.timestamp())
304 ));
305 parts.push(format!("\"event\":\"{}\"", escape_json(event)));
306
307 if !used_keys.contains_key("run_id") {
308 parts.push(format!(
309 "\"run_id\":\"{}\"",
310 escape_json(self.fixture.run_id())
311 ));
312 }
313 if !used_keys.contains_key("seed") {
314 parts.push(format!("\"seed\":{}", self.fixture.seed()));
315 }
316 if !used_keys.contains_key("deterministic") {
317 parts.push(format!(
318 "\"deterministic\":{}",
319 self.fixture.deterministic()
320 ));
321 }
322 if !self.context.is_empty() && !used_keys.contains_key("context") {
323 let mut context_parts = String::from("{");
324 for (idx, (k, v)) in self.context.iter().enumerate() {
325 if idx > 0 {
326 context_parts.push(',');
327 }
328 context_parts.push('"');
329 context_parts.push_str(&escape_json(k));
330 context_parts.push_str("\":");
331 context_parts.push_str(v);
332 }
333 context_parts.push('}');
334 parts.push(format!("\"context\":{context_parts}"));
335 }
336
337 for (key, value) in fields {
338 parts.push(format!("\"{}\":{}", escape_json(key), value.to_json()));
339 }
340
341 format!("{{{}}}", parts.join(","))
342 }
343
344 pub fn log(&self, event: &str, fields: &[(&str, JsonValue)]) {
346 eprintln!("{}", self.emit_line(event, fields));
347 }
348
349 pub fn log_env(&self) {
351 let env_json = self.fixture.env_snapshot().to_json();
352 self.log("env", &[("env", JsonValue::raw(env_json))]);
353 }
354}
355
356#[derive(Debug, Clone, PartialEq, Eq)]
358pub struct LabScenarioResult {
359 pub scenario_name: String,
361 pub run_id: String,
363 pub seed: u64,
365 pub deterministic: bool,
367 pub event_count: u64,
369 pub duration_us: u64,
371 pub run_total: u64,
373}
374
375#[derive(Debug, Clone, PartialEq, Eq)]
377pub struct LabScenarioRun<T> {
378 pub result: LabScenarioResult,
380 pub output: T,
382}
383
384#[derive(Debug, Clone, Copy)]
386pub struct LabScenarioContext<'a> {
387 logger: &'a TestJsonlLogger,
388}
389
390impl<'a> LabScenarioContext<'a> {
391 pub fn log_info(&self, event: &str, fields: &[(&str, JsonValue)]) {
393 self.logger.log(event, fields);
394 }
395
396 pub fn log_warn(&self, anomaly: &str, detail: &str) {
398 self.logger.log(
399 "lab.scenario.warn",
400 &[
401 ("anomaly", JsonValue::str(anomaly)),
402 ("detail", JsonValue::str(detail)),
403 ],
404 );
405 }
406
407 pub fn fixture(&self) -> &DeterminismFixture {
409 self.logger.fixture()
410 }
411
412 pub fn now_ms(&self) -> u64 {
414 self.logger.fixture().now_ms()
415 }
416}
417
418#[derive(Debug)]
420pub struct LabScenario {
421 scenario_name: String,
422 logger: TestJsonlLogger,
423}
424
425impl LabScenario {
426 pub fn new(prefix: &str, scenario_name: &str, default_seed: u64) -> Self {
428 let mut logger = TestJsonlLogger::new(prefix, default_seed);
429 logger.add_context_str("scenario_name", scenario_name);
430 Self {
431 scenario_name: scenario_name.to_string(),
432 logger,
433 }
434 }
435
436 pub fn new_with(
438 prefix: &str,
439 scenario_name: &str,
440 seed: u64,
441 deterministic: bool,
442 time_step_ms: u64,
443 ) -> Self {
444 let mut logger = TestJsonlLogger::new_with(prefix, seed, deterministic, time_step_ms);
445 logger.add_context_str("scenario_name", scenario_name);
446 Self {
447 scenario_name: scenario_name.to_string(),
448 logger,
449 }
450 }
451
452 pub fn fixture(&self) -> &DeterminismFixture {
454 self.logger.fixture()
455 }
456
457 pub fn run<T>(&self, run: impl FnOnce(&LabScenarioContext<'_>) -> T) -> LabScenarioRun<T> {
459 let seed = self.fixture().seed();
460 let deterministic = self.fixture().deterministic();
461 let _span = info_span!(
462 "lab.scenario",
463 scenario_name = self.scenario_name.as_str(),
464 seed,
465 deterministic
466 )
467 .entered();
468 self.logger.log(
469 "lab.scenario.start",
470 &[
471 ("scenario_name", JsonValue::str(self.scenario_name.clone())),
472 ("seed", JsonValue::u64(seed)),
473 ],
474 );
475
476 let started_at = Instant::now();
477 let context = LabScenarioContext {
478 logger: &self.logger,
479 };
480 let output = run(&context);
481 let duration_us = started_at.elapsed().as_micros().min(u64::MAX as u128) as u64;
482 let event_count = self.logger.emitted_count().saturating_add(1);
483 self.logger.log(
484 "lab.scenario.end",
485 &[
486 ("scenario_name", JsonValue::str(self.scenario_name.clone())),
487 ("seed", JsonValue::u64(seed)),
488 ("event_count", JsonValue::u64(event_count)),
489 ("duration_us", JsonValue::u64(duration_us)),
490 ],
491 );
492
493 let run_total = LAB_SCENARIOS_RUN_TOTAL
494 .fetch_add(1, Ordering::Relaxed)
495 .saturating_add(1);
496 let result = LabScenarioResult {
497 scenario_name: self.scenario_name.clone(),
498 run_id: self.fixture().run_id().to_string(),
499 seed,
500 deterministic,
501 event_count,
502 duration_us,
503 run_total,
504 };
505 LabScenarioRun { result, output }
506 }
507}
508
509pub fn deterministic_mode() -> bool {
511 env_flag("FTUI_TEST_DETERMINISTIC")
512 || env_flag("FTUI_DETERMINISTIC")
513 || env_flag("E2E_DETERMINISTIC")
514}
515
516pub fn fixture_seed(default_seed: u64) -> u64 {
518 env_u64("FTUI_TEST_SEED")
519 .or_else(|| env_u64("FTUI_SEED"))
520 .or_else(|| env_u64("FTUI_HARNESS_SEED"))
521 .or_else(|| env_u64("E2E_SEED"))
522 .or_else(|| env_u64("E2E_CONTEXT_SEED"))
523 .unwrap_or(default_seed)
524}
525
526pub fn fixture_time_step_ms() -> u64 {
528 env_u64("FTUI_TEST_TIME_STEP_MS")
529 .or_else(|| env_u64("E2E_TIME_STEP_MS"))
530 .unwrap_or(100)
531}
532
533fn env_u64(key: &str) -> Option<u64> {
534 std::env::var(key).ok().and_then(|v| v.parse().ok())
535}
536
537fn env_bool(key: &str) -> bool {
538 std::env::var(key).is_ok()
539}
540
541fn env_flag(key: &str) -> bool {
542 matches!(
543 std::env::var(key).as_deref(),
544 Ok("1") | Ok("true") | Ok("TRUE")
545 )
546}
547
548fn env_string(key: &str) -> String {
549 std::env::var(key).unwrap_or_default()
550}
551
552fn unix_secs() -> u64 {
553 SystemTime::now()
554 .duration_since(UNIX_EPOCH)
555 .unwrap_or_default()
556 .as_secs()
557}
558
559fn json_string(value: &str) -> String {
560 format!("\"{}\"", escape_json(value))
561}
562
563fn escape_json(s: &str) -> String {
564 s.replace('\\', "\\\\")
565 .replace('"', "\\\"")
566 .replace('\n', "\\n")
567 .replace('\r', "\\r")
568 .replace('\t', "\\t")
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574
575 #[test]
576 fn deterministic_timestamps_are_monotonic() {
577 let fixture = DeterminismFixture::new_with("fixture_ts", 123, true, 7);
578 let t0 = fixture.timestamp();
579 let t1 = fixture.timestamp();
580 assert_eq!(t0, "T000000");
581 assert_eq!(t1, "T000001");
582 }
583
584 #[test]
585 fn deterministic_clock_advances_by_step() {
586 let fixture = DeterminismFixture::new_with("fixture_clock", 123, true, 7);
587 let first = fixture.now_ms();
588 let second = fixture.now_ms();
589 assert_eq!(first, 7);
590 assert_eq!(second, 14);
591 }
592
593 #[test]
594 fn env_snapshot_includes_seed_and_flag() {
595 let fixture = DeterminismFixture::new_with("fixture_env", 123, true, 7);
596 let json = fixture.env_snapshot().to_json();
597 assert!(
598 json.contains("\"seed\":123"),
599 "env snapshot should include deterministic seed"
600 );
601 assert!(
602 json.contains("\"deterministic\":true"),
603 "env snapshot should include deterministic flag"
604 );
605 }
606
607 #[test]
608 fn fixture_seed_and_run_id_are_stable() {
609 let fixture = DeterminismFixture::new_with("fixture_seed", 4242, true, 5);
610 assert_eq!(
611 fixture.seed(),
612 4242,
613 "expected DeterminismFixture to retain the explicit seed"
614 );
615 assert!(
616 fixture.deterministic(),
617 "expected DeterminismFixture to retain the deterministic flag"
618 );
619 assert_eq!(
620 fixture.run_id(),
621 "fixture_seed_seed4242",
622 "expected deterministic run_id to embed prefix + seed"
623 );
624 }
625
626 #[test]
627 fn fixture_time_step_is_deterministic() {
628 let fixture = DeterminismFixture::new_with("fixture_time_step", 1, true, 25);
629 let t1 = fixture.now_ms();
630 let t2 = fixture.now_ms();
631 assert_eq!(
632 t2 - t1,
633 25,
634 "expected deterministic time step of 25ms (t1={t1}, t2={t2})"
635 );
636 }
637
638 #[test]
639 fn jsonl_logger_emits_core_fields() {
640 let logger = TestJsonlLogger::new("jsonl_logger", 99);
641 let line = logger.emit_line("case_start", &[("case", JsonValue::str("alpha"))]);
642 assert!(line.contains("\"event\":\"case_start\""));
643 assert!(line.contains("\"run_id\""));
644 assert!(line.contains("\"seed\":99"));
645 assert!(line.contains("\"deterministic\""));
646 assert!(line.contains("\"schema_version\":1"));
647 }
648
649 #[test]
650 fn jsonl_logger_includes_context() {
651 let mut logger = TestJsonlLogger::new("jsonl_logger_ctx", 7);
652 logger.add_context_str("suite", "determinism");
653 let line = logger.emit_line("step", &[("ok", JsonValue::bool(true))]);
654 assert!(line.contains("\"context\":{"));
655 assert!(line.contains("\"suite\":\"determinism\""));
656 }
657
658 #[test]
659 fn lab_scenario_reports_deterministic_metadata() {
660 let scenario = LabScenario::new_with("lab_scenario", "deterministic_case", 4242, true, 9);
661 let run = scenario.run(|ctx| {
662 ctx.log_info("lab.step", &[("phase", JsonValue::str("init"))]);
663 ctx.log_warn("schedule_gap", "simulated warning");
664 ctx.now_ms()
665 });
666
667 assert_eq!(run.output, 9);
668 assert_eq!(run.result.scenario_name, "deterministic_case");
669 assert_eq!(run.result.seed, 4242);
670 assert!(run.result.deterministic);
671 assert_eq!(run.result.event_count, 4);
672 assert!(run.result.run_total >= 1);
673 }
674
675 #[test]
676 fn lab_scenario_runs_are_repeatable_with_fixed_seed() {
677 fn run_once() -> LabScenarioRun<u64> {
678 let scenario = LabScenario::new_with("lab_repeat", "repeat_case", 77, true, 5);
679 scenario.run(|ctx| ctx.now_ms())
680 }
681
682 let first = run_once();
683 let second = run_once();
684
685 assert_eq!(first.output, second.output);
686 assert_eq!(first.result.seed, second.result.seed);
687 assert_eq!(first.result.event_count, second.result.event_count);
688 assert_eq!(first.result.scenario_name, second.result.scenario_name);
689 }
690
691 #[test]
694 fn escape_json_no_special_chars() {
695 assert_eq!(escape_json("hello"), "hello");
696 }
697
698 #[test]
699 fn escape_json_backslash() {
700 assert_eq!(escape_json(r"a\b"), r"a\\b");
701 }
702
703 #[test]
704 fn escape_json_double_quote() {
705 assert_eq!(escape_json(r#"say "hi""#), r#"say \"hi\""#);
706 }
707
708 #[test]
709 fn escape_json_newline_cr_tab() {
710 assert_eq!(escape_json("a\nb\rc\td"), r"a\nb\rc\td");
711 }
712
713 #[test]
714 fn escape_json_combined() {
715 assert_eq!(escape_json("a\\b\n\"c\""), r#"a\\b\n\"c\""#);
716 }
717
718 #[test]
721 fn json_string_wraps_in_quotes() {
722 assert_eq!(json_string("hello"), "\"hello\"");
723 }
724
725 #[test]
726 fn json_string_escapes_content() {
727 assert_eq!(json_string("a\"b"), "\"a\\\"b\"");
728 }
729
730 #[test]
733 fn env_flag_unset_is_false() {
734 assert!(!env_flag("__FTUI_NEVER_SET_FLAG_9d3a1f"));
736 }
737
738 #[test]
739 fn env_u64_unset_returns_none() {
740 assert_eq!(env_u64("__FTUI_NEVER_SET_U64_9d3a1f"), None);
741 }
742
743 #[test]
744 fn env_bool_unset_is_false() {
745 assert!(!env_bool("__FTUI_NEVER_SET_BOOL_9d3a1f"));
746 }
747
748 #[test]
749 fn env_string_unset_is_empty() {
750 assert_eq!(env_string("__FTUI_NEVER_SET_STR_9d3a1f"), "");
751 }
752
753 #[test]
754 fn fixture_seed_defaults_when_unset() {
755 let default = 12345u64;
758 let result = fixture_seed(default);
761 let _ = result;
763 }
764
765 #[test]
766 fn fixture_time_step_ms_default() {
767 let result = fixture_time_step_ms();
769 assert!(result > 0, "time step should be positive");
770 }
771
772 #[test]
775 fn env_snapshot_with_str() {
776 let snap = EnvSnapshot::capture(1, true).with_str("custom", "value");
777 let json = snap.to_json();
778 assert!(json.contains("\"custom\":\"value\""));
779 }
780
781 #[test]
782 fn env_snapshot_with_u64() {
783 let snap = EnvSnapshot::capture(1, true).with_u64("count", 42);
784 let json = snap.to_json();
785 assert!(json.contains("\"count\":42"));
786 }
787
788 #[test]
789 fn env_snapshot_with_bool() {
790 let snap = EnvSnapshot::capture(1, true).with_bool("flag", false);
791 let json = snap.to_json();
792 assert!(json.contains("\"flag\":false"));
793 }
794
795 #[test]
796 fn env_snapshot_with_raw() {
797 let snap = EnvSnapshot::capture(1, true).with_raw("nested", r#"{"a":1}"#);
798 let json = snap.to_json();
799 assert!(json.contains(r#""nested":{"a":1}"#));
800 }
801
802 #[test]
805 fn json_value_str_escapes() {
806 let v = JsonValue::str("he\"llo");
807 assert_eq!(v.to_json(), "\"he\\\"llo\"");
808 }
809
810 #[test]
811 fn json_value_raw_passthrough() {
812 let v = JsonValue::raw(r#"{"x":1}"#);
813 assert_eq!(v.to_json(), r#"{"x":1}"#);
814 }
815
816 #[test]
817 fn json_value_bool() {
818 assert_eq!(JsonValue::bool(true).to_json(), "true");
819 assert_eq!(JsonValue::bool(false).to_json(), "false");
820 }
821
822 #[test]
823 fn json_value_u64() {
824 assert_eq!(JsonValue::u64(12345).to_json(), "12345");
825 }
826
827 #[test]
828 fn json_value_i64_negative() {
829 assert_eq!(JsonValue::i64(-7).to_json(), "-7");
830 }
831
832 #[test]
835 fn non_deterministic_run_id_contains_pid() {
836 let fixture = DeterminismFixture::new_with("nd", 0, false, 100);
837 let run_id = fixture.run_id().to_string();
838 let pid = format!("{}", std::process::id());
839 assert!(
840 run_id.contains(&pid),
841 "non-deterministic run_id should contain PID: {run_id}"
842 );
843 }
844
845 #[test]
848 fn logger_seq_increments() {
849 let logger = TestJsonlLogger::new("seq_test", 1);
850 let line0 = logger.emit_line("ev0", &[]);
851 let line1 = logger.emit_line("ev1", &[]);
852 assert!(line0.contains("\"seq\":0"), "first line seq=0: {line0}");
853 assert!(line1.contains("\"seq\":1"), "second line seq=1: {line1}");
854 }
855
856 #[test]
857 fn logger_custom_schema_version() {
858 let logger = TestJsonlLogger::new("schema_test", 1).with_schema_version(3);
859 let line = logger.emit_line("ev", &[]);
860 assert!(
861 line.contains("\"schema_version\":3"),
862 "custom schema version: {line}"
863 );
864 }
865
866 #[test]
867 fn logger_context_u64_and_bool() {
868 let mut logger = TestJsonlLogger::new("ctx_types", 1);
869 logger.add_context_u64("size", 80);
870 logger.add_context_bool("interactive", false);
871 let line = logger.emit_line("ev", &[]);
872 assert!(line.contains("\"size\":80"), "u64 context: {line}");
873 assert!(
874 line.contains("\"interactive\":false"),
875 "bool context: {line}"
876 );
877 }
878
879 #[test]
880 fn logger_context_raw() {
881 let mut logger = TestJsonlLogger::new("ctx_raw", 1);
882 logger.add_context_raw("meta", r#"[1,2,3]"#);
883 let line = logger.emit_line("ev", &[]);
884 assert!(line.contains(r#""meta":[1,2,3]"#), "raw context: {line}");
885 }
886
887 #[test]
888 fn logger_field_override_suppresses_default() {
889 let logger = TestJsonlLogger::new("override_test", 99);
890 let line = logger.emit_line("ev", &[("seed", JsonValue::u64(7))]);
891 assert!(line.contains("\"seed\":7"), "overridden seed: {line}");
893 assert!(
895 !line.contains("\"seed\":99"),
896 "default seed should be suppressed: {line}"
897 );
898 }
899
900 #[test]
903 fn logger_emit_line_is_valid_json() {
904 let mut logger = TestJsonlLogger::new("json_valid", 42);
905 logger.add_context_str("suite", "test");
906 let line = logger.emit_line(
907 "case_end",
908 &[
909 ("result", JsonValue::str("pass")),
910 ("duration_ms", JsonValue::u64(15)),
911 ("success", JsonValue::bool(true)),
912 ],
913 );
914 let parsed: serde_json::Value =
916 serde_json::from_str(&line).expect("emit_line should produce valid JSON");
917 assert_eq!(parsed["event"], "case_end");
918 assert_eq!(parsed["result"], "pass");
919 assert_eq!(parsed["duration_ms"], 15);
920 assert_eq!(parsed["success"], true);
921 assert_eq!(parsed["seed"], 42);
922 }
923}