Skip to main content

ftui_harness/
determinism.rs

1#![forbid(unsafe_code)]
2
3//! Deterministic fixtures for tests and E2E harnesses.
4//!
5//! This module centralizes seed selection, deterministic timestamps, and
6//! environment capture so tests can produce stable hashes and JSONL logs.
7
8use std::collections::BTreeMap;
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::time::{Instant, SystemTime, UNIX_EPOCH};
11use tracing::info_span;
12
13/// Counter for total executed lab scenarios in-process.
14static LAB_SCENARIOS_RUN_TOTAL: AtomicU64 = AtomicU64::new(0);
15
16/// Read the total number of executed lab scenarios.
17#[must_use]
18pub fn lab_scenarios_run_total() -> u64 {
19    LAB_SCENARIOS_RUN_TOTAL.load(Ordering::Relaxed)
20}
21
22/// Shared deterministic fixture for a test run.
23#[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    /// Create a fixture with a stable run id and seed.
36    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    /// Create a fixture with explicit configuration (used by tests).
44    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    /// Current deterministic seed.
62    pub fn seed(&self) -> u64 {
63        self.seed
64    }
65
66    /// True when deterministic mode is enabled.
67    pub fn deterministic(&self) -> bool {
68        self.deterministic
69    }
70
71    /// Stable run identifier for JSONL logs.
72    pub fn run_id(&self) -> &str {
73        &self.run_id
74    }
75
76    /// Return a deterministic timestamp string (or wall time if disabled).
77    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    /// Return a monotonically increasing time in ms.
90    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    /// Capture environment fields for logging.
101    pub fn env_snapshot(&self) -> EnvSnapshot {
102        EnvSnapshot::capture(self.seed, self.deterministic)
103    }
104}
105
106/// Environment snapshot with deterministic field ordering.
107#[derive(Debug, Clone)]
108pub struct EnvSnapshot {
109    fields: BTreeMap<String, String>,
110}
111
112impl EnvSnapshot {
113    /// Capture common environment fields for reproducibility.
114    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    /// Add a string field (value will be JSON-escaped and quoted).
127    #[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    /// Add a numeric field.
134    #[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    /// Add a boolean field.
141    #[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    /// Add a raw JSON field (caller is responsible for correctness).
148    #[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    /// Render as JSON object string.
155    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/// JSONL field value for test logging.
172#[derive(Debug, Clone)]
173pub enum JsonValue {
174    /// JSON-escaped string value.
175    Str(String),
176    /// Raw JSON (caller is responsible for correctness).
177    Raw(String),
178    /// Boolean value.
179    Bool(bool),
180    /// Unsigned integer value.
181    U64(u64),
182    /// Signed integer value.
183    I64(i64),
184}
185
186impl JsonValue {
187    /// Convenience constructor for JSON string values.
188    pub fn str(value: impl Into<String>) -> Self {
189        Self::Str(value.into())
190    }
191
192    /// Convenience constructor for raw JSON values.
193    pub fn raw(value: impl Into<String>) -> Self {
194        Self::Raw(value.into())
195    }
196
197    /// Convenience constructor for boolean values.
198    pub fn bool(value: bool) -> Self {
199        Self::Bool(value)
200    }
201
202    /// Convenience constructor for unsigned integers.
203    pub fn u64(value: u64) -> Self {
204        Self::U64(value)
205    }
206
207    /// Convenience constructor for signed integers.
208    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/// Deterministic JSONL logger for tests.
224#[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    /// Create a JSONL logger with a deterministic fixture.
234    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    /// Create a JSONL logger with explicit determinism controls.
244    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    /// Access the underlying determinism fixture.
254    pub fn fixture(&self) -> &DeterminismFixture {
255        &self.fixture
256    }
257
258    /// Return the number of emitted lines for this logger.
259    pub fn emitted_count(&self) -> u64 {
260        self.seq.load(Ordering::Relaxed)
261    }
262
263    /// Set the JSONL schema version.
264    #[must_use]
265    pub fn with_schema_version(mut self, version: u32) -> Self {
266        self.schema_version = version;
267        self
268    }
269
270    /// Add a context string field.
271    pub fn add_context_str(&mut self, key: &str, value: &str) {
272        self.context.insert(key.to_string(), json_string(value));
273    }
274
275    /// Add a context numeric field.
276    pub fn add_context_u64(&mut self, key: &str, value: u64) {
277        self.context.insert(key.to_string(), value.to_string());
278    }
279
280    /// Add a context boolean field.
281    pub fn add_context_bool(&mut self, key: &str, value: bool) {
282        self.context.insert(key.to_string(), value.to_string());
283    }
284
285    /// Add a context raw JSON field (caller ensures correctness).
286    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    /// Emit a JSONL line (returned as a string).
291    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    /// Emit a JSONL line to stderr.
345    pub fn log(&self, event: &str, fields: &[(&str, JsonValue)]) {
346        eprintln!("{}", self.emit_line(event, fields));
347    }
348
349    /// Emit a JSONL environment snapshot line.
350    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/// Metadata emitted for one deterministic lab scenario run.
357#[derive(Debug, Clone, PartialEq, Eq)]
358pub struct LabScenarioResult {
359    /// Stable scenario identifier.
360    pub scenario_name: String,
361    /// Run identifier from the deterministic fixture.
362    pub run_id: String,
363    /// Seed used for this scenario.
364    pub seed: u64,
365    /// Whether deterministic mode was active.
366    pub deterministic: bool,
367    /// Number of emitted JSONL events in this run.
368    pub event_count: u64,
369    /// Wall-clock duration for the run in microseconds.
370    pub duration_us: u64,
371    /// Global total count of executed scenarios in this process.
372    pub run_total: u64,
373}
374
375/// Output plus run metadata for a deterministic lab scenario.
376#[derive(Debug, Clone, PartialEq, Eq)]
377pub struct LabScenarioRun<T> {
378    /// Scenario execution metadata.
379    pub result: LabScenarioResult,
380    /// Scenario return value.
381    pub output: T,
382}
383
384/// Helper context passed to a running lab scenario.
385#[derive(Debug, Clone, Copy)]
386pub struct LabScenarioContext<'a> {
387    logger: &'a TestJsonlLogger,
388}
389
390impl<'a> LabScenarioContext<'a> {
391    /// Emit an informational scenario event.
392    pub fn log_info(&self, event: &str, fields: &[(&str, JsonValue)]) {
393        self.logger.log(event, fields);
394    }
395
396    /// Emit a warning event for scheduler or ordering anomalies.
397    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    /// Access the underlying deterministic fixture.
408    pub fn fixture(&self) -> &DeterminismFixture {
409        self.logger.fixture()
410    }
411
412    /// Deterministic monotonic time helper.
413    pub fn now_ms(&self) -> u64 {
414        self.logger.fixture().now_ms()
415    }
416}
417
418/// Deterministic scenario runner for FrankenLab-style test harnesses.
419#[derive(Debug)]
420pub struct LabScenario {
421    scenario_name: String,
422    logger: TestJsonlLogger,
423}
424
425impl LabScenario {
426    /// Create a scenario runner using environment-driven determinism settings.
427    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    /// Create a scenario runner with explicit determinism settings.
437    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    /// Access the underlying fixture for this scenario.
453    pub fn fixture(&self) -> &DeterminismFixture {
454        self.logger.fixture()
455    }
456
457    /// Execute a deterministic scenario closure and emit start/end JSONL records.
458    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
509/// True when deterministic mode is enabled via environment.
510pub fn deterministic_mode() -> bool {
511    env_flag("FTUI_TEST_DETERMINISTIC")
512        || env_flag("FTUI_DETERMINISTIC")
513        || env_flag("E2E_DETERMINISTIC")
514}
515
516/// Choose a seed from environment or use the provided default.
517pub 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
526/// Time step in milliseconds for deterministic clocks.
527pub 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    // ── escape_json ───────────────────────────────────────────────────
692
693    #[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    // ── json_string ───────────────────────────────────────────────────
719
720    #[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    // ── env helper semantics (tested safely via unset vars) ──────────
731
732    #[test]
733    fn env_flag_unset_is_false() {
734        // Unique key that is guaranteed unset
735        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        // With no FTUI_TEST_SEED etc. set, fixture_seed returns default
756        // (This relies on __FTUI_NEVER env vars not being set.)
757        let default = 12345u64;
758        // fixture_seed reads real env vars, so we can't control them here,
759        // but we can verify the function doesn't panic and returns a u64
760        let result = fixture_seed(default);
761        // fixture_seed always returns a u64; just verify it doesn't panic
762        let _ = result;
763    }
764
765    #[test]
766    fn fixture_time_step_ms_default() {
767        // When no env vars are set, default is 100
768        let result = fixture_time_step_ms();
769        assert!(result > 0, "time step should be positive");
770    }
771
772    // ── EnvSnapshot builder ───────────────────────────────────────────
773
774    #[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    // ── JsonValue variants ────────────────────────────────────────────
803
804    #[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    // ── Non-deterministic fixture ─────────────────────────────────────
833
834    #[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    // ── Logger seq counter ────────────────────────────────────────────
846
847    #[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        // The explicit field should be present, and no duplicate "seed":99
892        assert!(line.contains("\"seed\":7"), "overridden seed: {line}");
893        // Should NOT contain the default seed=99 since we override it
894        assert!(
895            !line.contains("\"seed\":99"),
896            "default seed should be suppressed: {line}"
897        );
898    }
899
900    // ── emit_line produces valid JSON ─────────────────────────────────
901
902    #[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        // Parse with serde_json to validate
915        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}