Skip to main content

ftui_harness/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Snapshot/golden testing and time-travel debugging for FrankenTUI.
4//!
5//! - **Snapshot testing**: Captures `Buffer` output as text, compares against stored `.snap` files.
6//! - **Time-travel debugging**: Records compressed frame snapshots for rewind inspection.
7//!
8//! Captures `Buffer` output as plain text or ANSI-styled text, compares
9//! against stored snapshots, and shows diffs on mismatch.
10//!
11//! # Role in FrankenTUI
12//! `ftui-harness` is the verification layer. It powers snapshot tests,
13//! time-travel debugging, and deterministic rendering checks used across the
14//! workspace.
15//!
16//! # How it fits in the system
17//! The harness is not the primary demo app (use `ftui-demo-showcase` for that).
18//! Instead, it is used by tests and CI to validate the behavior of render,
19//! widgets, and runtime under controlled conditions.
20//!
21//! # Quick Start
22//!
23//! ```ignore
24//! use ftui_harness::{assert_snapshot, MatchMode};
25//!
26//! #[test]
27//! fn my_widget_renders_correctly() {
28//!     let mut buf = Buffer::new(10, 3);
29//!     // ... render widget into buf ...
30//!     assert_snapshot!("my_widget_basic", &buf);
31//! }
32//! ```
33//!
34//! # Updating Snapshots
35//!
36//! Run tests with `BLESS=1` to create or update snapshot files:
37//!
38//! ```sh
39//! BLESS=1 cargo test
40//! ```
41//!
42//! Snapshot files are stored under `tests/snapshots/` relative to the
43//! crate's `CARGO_MANIFEST_DIR`.
44
45pub mod asciicast;
46pub mod determinism;
47pub mod flicker_detection;
48pub mod golden;
49pub mod resize_storm;
50pub mod terminal_model;
51pub mod time_travel;
52pub mod time_travel_inspector;
53pub mod trace_replay;
54
55#[cfg(feature = "pty-capture")]
56pub mod pty_capture;
57
58use std::fmt::Write as FmtWrite;
59use std::path::{Path, PathBuf};
60
61use ftui_core::terminal_capabilities::{TerminalCapabilities, TerminalProfile};
62use ftui_render::buffer::Buffer;
63use ftui_render::cell::{PackedRgba, StyleFlags};
64
65// Re-export types useful for harness users.
66pub use ftui_core::geometry::Rect;
67pub use ftui_render::buffer;
68pub use ftui_render::cell;
69pub use time_travel_inspector::TimeTravelInspector;
70
71// ============================================================================
72// Buffer → Text Conversion
73// ============================================================================
74
75/// Convert a `Buffer` to a plain text string.
76///
77/// Each row becomes one line. Empty cells become spaces. Continuation cells
78/// (trailing cells of wide characters) are skipped so wide characters occupy
79/// their natural display width in the output string.
80///
81/// Grapheme-pool references (multi-codepoint clusters) are rendered as `?`
82/// since the pool is not available here.
83pub fn buffer_to_text(buf: &Buffer) -> String {
84    let capacity = (buf.width() as usize + 1) * buf.height() as usize;
85    let mut out = String::with_capacity(capacity);
86
87    for y in 0..buf.height() {
88        if y > 0 {
89            out.push('\n');
90        }
91        for x in 0..buf.width() {
92            let cell = buf.get(x, y).unwrap();
93            if cell.is_continuation() {
94                continue;
95            }
96            if cell.is_empty() {
97                out.push(' ');
98            } else if let Some(c) = cell.content.as_char() {
99                out.push(c);
100            } else {
101                // Grapheme ID — pool not available, use placeholder
102                out.push('?');
103            }
104        }
105    }
106    out
107}
108
109/// Convert a `Buffer` to text with inline ANSI escape codes.
110///
111/// Emits SGR sequences when foreground, background, or style flags change
112/// between adjacent cells. Resets styling at the end of each row.
113pub fn buffer_to_ansi(buf: &Buffer) -> String {
114    let capacity = (buf.width() as usize + 32) * buf.height() as usize;
115    let mut out = String::with_capacity(capacity);
116
117    for y in 0..buf.height() {
118        if y > 0 {
119            out.push('\n');
120        }
121
122        let mut prev_fg = PackedRgba::WHITE; // Cell default fg
123        let mut prev_bg = PackedRgba::TRANSPARENT; // Cell default bg
124        let mut prev_flags = StyleFlags::empty();
125        let mut style_active = false;
126
127        for x in 0..buf.width() {
128            let cell = buf.get(x, y).unwrap();
129            if cell.is_continuation() {
130                continue;
131            }
132
133            let fg = cell.fg;
134            let bg = cell.bg;
135            let flags = cell.attrs.flags();
136
137            let style_changed = fg != prev_fg || bg != prev_bg || flags != prev_flags;
138
139            if style_changed {
140                let has_style =
141                    fg != PackedRgba::WHITE || bg != PackedRgba::TRANSPARENT || !flags.is_empty();
142
143                if has_style {
144                    // Reset and re-emit
145                    if style_active {
146                        out.push_str("\x1b[0m");
147                    }
148
149                    let mut params: Vec<String> = Vec::new();
150                    if !flags.is_empty() {
151                        if flags.contains(StyleFlags::BOLD) {
152                            params.push("1".into());
153                        }
154                        if flags.contains(StyleFlags::DIM) {
155                            params.push("2".into());
156                        }
157                        if flags.contains(StyleFlags::ITALIC) {
158                            params.push("3".into());
159                        }
160                        if flags.contains(StyleFlags::UNDERLINE) {
161                            params.push("4".into());
162                        }
163                        if flags.contains(StyleFlags::BLINK) {
164                            params.push("5".into());
165                        }
166                        if flags.contains(StyleFlags::REVERSE) {
167                            params.push("7".into());
168                        }
169                        if flags.contains(StyleFlags::HIDDEN) {
170                            params.push("8".into());
171                        }
172                        if flags.contains(StyleFlags::STRIKETHROUGH) {
173                            params.push("9".into());
174                        }
175                    }
176                    if fg.a() > 0 && fg != PackedRgba::WHITE {
177                        params.push(format!("38;2;{};{};{}", fg.r(), fg.g(), fg.b()));
178                    }
179                    if bg.a() > 0 && bg != PackedRgba::TRANSPARENT {
180                        params.push(format!("48;2;{};{};{}", bg.r(), bg.g(), bg.b()));
181                    }
182
183                    if !params.is_empty() {
184                        write!(out, "\x1b[{}m", params.join(";")).unwrap();
185                        style_active = true;
186                    }
187                } else if style_active {
188                    out.push_str("\x1b[0m");
189                    style_active = false;
190                }
191
192                prev_fg = fg;
193                prev_bg = bg;
194                prev_flags = flags;
195            }
196
197            if cell.is_empty() {
198                out.push(' ');
199            } else if let Some(c) = cell.content.as_char() {
200                out.push(c);
201            } else {
202                out.push('?');
203            }
204        }
205
206        if style_active {
207            out.push_str("\x1b[0m");
208        }
209    }
210    out
211}
212
213// ============================================================================
214// Match Modes & Normalization
215// ============================================================================
216
217/// Comparison mode for snapshot testing.
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum MatchMode {
220    /// Byte-exact string comparison.
221    Exact,
222    /// Trim trailing whitespace on each line before comparing.
223    TrimTrailing,
224    /// Collapse all whitespace runs to single spaces and trim each line.
225    Fuzzy,
226}
227
228/// Normalize text according to the requested match mode.
229fn normalize(text: &str, mode: MatchMode) -> String {
230    match mode {
231        MatchMode::Exact => text.to_string(),
232        MatchMode::TrimTrailing => text
233            .lines()
234            .map(|l| l.trim_end())
235            .collect::<Vec<_>>()
236            .join("\n"),
237        MatchMode::Fuzzy => text
238            .lines()
239            .map(|l| l.split_whitespace().collect::<Vec<_>>().join(" "))
240            .collect::<Vec<_>>()
241            .join("\n"),
242    }
243}
244
245// ============================================================================
246// Diff
247// ============================================================================
248
249/// Compute a simple line-by-line diff between two text strings.
250///
251/// Returns a human-readable string where:
252/// - Lines prefixed with ` ` are identical in both.
253/// - Lines prefixed with `-` appear only in `expected`.
254/// - Lines prefixed with `+` appear only in `actual`.
255///
256/// Returns an empty string when the inputs are identical.
257pub fn diff_text(expected: &str, actual: &str) -> String {
258    let expected_lines: Vec<&str> = expected.lines().collect();
259    let actual_lines: Vec<&str> = actual.lines().collect();
260
261    let max_lines = expected_lines.len().max(actual_lines.len());
262    let mut out = String::new();
263    let mut has_diff = false;
264
265    for i in 0..max_lines {
266        let exp = expected_lines.get(i).copied();
267        let act = actual_lines.get(i).copied();
268
269        match (exp, act) {
270            (Some(e), Some(a)) if e == a => {
271                writeln!(out, " {e}").unwrap();
272            }
273            (Some(e), Some(a)) => {
274                writeln!(out, "-{e}").unwrap();
275                writeln!(out, "+{a}").unwrap();
276                has_diff = true;
277            }
278            (Some(e), None) => {
279                writeln!(out, "-{e}").unwrap();
280                has_diff = true;
281            }
282            (None, Some(a)) => {
283                writeln!(out, "+{a}").unwrap();
284                has_diff = true;
285            }
286            (None, None) => {}
287        }
288    }
289
290    if has_diff { out } else { String::new() }
291}
292
293// ============================================================================
294// Snapshot Assertion
295// ============================================================================
296
297/// Resolve the active test profile from the environment.
298///
299/// Returns `None` when unset or when explicitly set to `detected`.
300#[must_use]
301pub fn current_test_profile() -> Option<TerminalProfile> {
302    std::env::var("FTUI_TEST_PROFILE")
303        .ok()
304        .and_then(|value| value.parse::<TerminalProfile>().ok())
305        .and_then(|profile| {
306            if profile == TerminalProfile::Detected {
307                None
308            } else {
309                Some(profile)
310            }
311        })
312}
313
314fn snapshot_name_with_profile(name: &str) -> String {
315    if let Some(profile) = current_test_profile() {
316        let suffix = format!("__{}", profile.as_str());
317        if name.ends_with(&suffix) {
318            return name.to_string();
319        }
320        return format!("{name}{suffix}");
321    }
322    name.to_string()
323}
324
325/// Resolve the snapshot file path.
326fn snapshot_path(base_dir: &Path, name: &str) -> PathBuf {
327    let resolved_name = snapshot_name_with_profile(name);
328    base_dir
329        .join("tests")
330        .join("snapshots")
331        .join(format!("{resolved_name}.snap"))
332}
333
334/// Check if the `BLESS` environment variable is set.
335fn is_bless() -> bool {
336    std::env::var("BLESS").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
337}
338
339/// Assert that a buffer's text representation matches a stored snapshot.
340///
341/// # Arguments
342///
343/// * `name`     – Snapshot identifier (used as the `.snap` filename).
344/// * `buf`      – The buffer to compare.
345/// * `base_dir` – Root directory for snapshot storage (use `env!("CARGO_MANIFEST_DIR")`).
346/// * `mode`     – How to compare the text (exact, trim trailing, or fuzzy).
347///
348/// # Panics
349///
350/// * If the snapshot file does not exist and `BLESS=1` is **not** set.
351/// * If the buffer output does not match the stored snapshot.
352///
353/// # Updating Snapshots
354///
355/// Set `BLESS=1` to write the current buffer output as the new snapshot:
356///
357/// ```sh
358/// BLESS=1 cargo test
359/// ```
360pub fn assert_buffer_snapshot(name: &str, buf: &Buffer, base_dir: &str, mode: MatchMode) {
361    let base = Path::new(base_dir);
362    let path = snapshot_path(base, name);
363    let actual = buffer_to_text(buf);
364
365    if is_bless() {
366        if let Some(parent) = path.parent() {
367            std::fs::create_dir_all(parent).expect("failed to create snapshot directory");
368        }
369        std::fs::write(&path, &actual).expect("failed to write snapshot");
370        return;
371    }
372
373    match std::fs::read_to_string(&path) {
374        Ok(expected) => {
375            let norm_expected = normalize(&expected, mode);
376            let norm_actual = normalize(&actual, mode);
377
378            if norm_expected != norm_actual {
379                let diff = diff_text(&norm_expected, &norm_actual);
380                std::panic::panic_any(format!(
381                    // ubs:ignore — snapshot assertion helper intentionally panics in tests
382                    "\n\
383                     === Snapshot mismatch: '{name}' ===\n\
384                     File: {}\n\
385                     Mode: {mode:?}\n\
386                     Set BLESS=1 to update.\n\n\
387                     Diff (- expected, + actual):\n{diff}",
388                    path.display()
389                ));
390            }
391        }
392        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
393            std::panic::panic_any(format!(
394                // ubs:ignore — snapshot assertion helper intentionally panics in tests
395                "\n\
396                 === No snapshot found: '{name}' ===\n\
397                 Expected at: {}\n\
398                 Run with BLESS=1 to create it.\n\n\
399                 Actual output ({w}x{h}):\n{actual}",
400                path.display(),
401                w = buf.width(),
402                h = buf.height(),
403            ));
404        }
405        Err(e) => {
406            std::panic::panic_any(format!(
407                // ubs:ignore — snapshot assertion helper intentionally panics in tests
408                "Failed to read snapshot '{}': {e}",
409                path.display()
410            ));
411        }
412    }
413}
414
415/// Assert that a buffer's ANSI-styled representation matches a stored snapshot.
416///
417/// Behaves like [`assert_buffer_snapshot`] but captures ANSI escape codes.
418/// Snapshot files have the `.ansi.snap` suffix.
419pub fn assert_buffer_snapshot_ansi(name: &str, buf: &Buffer, base_dir: &str) {
420    let base = Path::new(base_dir);
421    let resolved_name = snapshot_name_with_profile(name);
422    let path = base
423        .join("tests")
424        .join("snapshots")
425        .join(format!("{resolved_name}.ansi.snap"));
426    let actual = buffer_to_ansi(buf);
427
428    if is_bless() {
429        if let Some(parent) = path.parent() {
430            std::fs::create_dir_all(parent).expect("failed to create snapshot directory");
431        }
432        std::fs::write(&path, &actual).expect("failed to write snapshot");
433        return;
434    }
435
436    match std::fs::read_to_string(&path) {
437        Ok(expected) => {
438            if expected != actual {
439                let diff = diff_text(&expected, &actual);
440                std::panic::panic_any(format!(
441                    // ubs:ignore — snapshot assertion helper intentionally panics in tests
442                    "\n\
443                     === ANSI snapshot mismatch: '{name}' ===\n\
444                     File: {}\n\
445                     Set BLESS=1 to update.\n\n\
446                     Diff (- expected, + actual):\n{diff}",
447                    path.display()
448                ));
449            }
450        }
451        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
452            std::panic::panic_any(format!(
453                // ubs:ignore — snapshot assertion helper intentionally panics in tests
454                "\n\
455                 === No ANSI snapshot found: '{resolved_name}' ===\n\
456                 Expected at: {}\n\
457                 Run with BLESS=1 to create it.\n\n\
458                 Actual output:\n{actual}",
459                path.display(),
460            ));
461        }
462        Err(e) => {
463            std::panic::panic_any(format!(
464                // ubs:ignore — snapshot assertion helper intentionally panics in tests
465                "Failed to read snapshot '{}': {e}",
466                path.display()
467            ));
468        }
469    }
470}
471
472// ============================================================================
473// Convenience Macros
474// ============================================================================
475
476/// Assert that a buffer matches a stored snapshot (plain text).
477///
478/// Uses `CARGO_MANIFEST_DIR` to locate the snapshot directory automatically.
479///
480/// # Examples
481///
482/// ```ignore
483/// // Default mode: TrimTrailing
484/// assert_snapshot!("widget_basic", &buf);
485///
486/// // Explicit mode
487/// assert_snapshot!("widget_exact", &buf, MatchMode::Exact);
488/// ```
489#[macro_export]
490macro_rules! assert_snapshot {
491    ($name:expr, $buf:expr) => {
492        $crate::assert_buffer_snapshot(
493            $name,
494            $buf,
495            env!("CARGO_MANIFEST_DIR"),
496            $crate::MatchMode::TrimTrailing,
497        )
498    };
499    ($name:expr, $buf:expr, $mode:expr) => {
500        $crate::assert_buffer_snapshot($name, $buf, env!("CARGO_MANIFEST_DIR"), $mode)
501    };
502}
503
504/// Assert that a buffer matches a stored ANSI snapshot (with style info).
505///
506/// Uses `CARGO_MANIFEST_DIR` to locate the snapshot directory automatically.
507#[macro_export]
508macro_rules! assert_snapshot_ansi {
509    ($name:expr, $buf:expr) => {
510        $crate::assert_buffer_snapshot_ansi($name, $buf, env!("CARGO_MANIFEST_DIR"))
511    };
512}
513
514// ============================================================================
515// Profile Matrix (bd-k4lj.5)
516// ============================================================================
517
518/// Comparison mode for cross-profile output checks.
519#[derive(Debug, Clone, Copy, PartialEq, Eq)]
520pub enum ProfileCompareMode {
521    /// Do not compare outputs across profiles.
522    None,
523    /// Report diffs to stderr but do not fail.
524    Report,
525    /// Fail the test on the first diff.
526    Strict,
527}
528
529impl ProfileCompareMode {
530    /// Resolve compare mode from `FTUI_TEST_PROFILE_COMPARE`.
531    #[must_use]
532    pub fn from_env() -> Self {
533        match std::env::var("FTUI_TEST_PROFILE_COMPARE")
534            .ok()
535            .map(|v| v.to_lowercase())
536            .as_deref()
537        {
538            Some("strict") | Some("1") | Some("true") => Self::Strict,
539            Some("report") | Some("log") => Self::Report,
540            _ => Self::None,
541        }
542    }
543}
544
545/// Snapshot output captured for a specific profile.
546#[derive(Debug, Clone)]
547pub struct ProfileSnapshot {
548    pub profile: TerminalProfile,
549    pub text: String,
550    pub checksum: String,
551}
552
553/// Run a test closure across multiple profiles and optionally compare outputs.
554///
555/// The closure receives the profile id and a `TerminalCapabilities` derived
556/// from that profile. Use `FTUI_TEST_PROFILE_COMPARE=strict` to fail on
557/// differences or `FTUI_TEST_PROFILE_COMPARE=report` to emit diffs without
558/// failing.
559pub fn profile_matrix_text<F>(profiles: &[TerminalProfile], mut render: F) -> Vec<ProfileSnapshot>
560where
561    F: FnMut(TerminalProfile, &TerminalCapabilities) -> String,
562{
563    profile_matrix_text_with_options(
564        profiles,
565        ProfileCompareMode::from_env(),
566        MatchMode::TrimTrailing,
567        &mut render,
568    )
569}
570
571/// Profile matrix runner with explicit comparison options.
572pub fn profile_matrix_text_with_options<F>(
573    profiles: &[TerminalProfile],
574    compare: ProfileCompareMode,
575    mode: MatchMode,
576    render: &mut F,
577) -> Vec<ProfileSnapshot>
578where
579    F: FnMut(TerminalProfile, &TerminalCapabilities) -> String,
580{
581    let mut outputs = Vec::with_capacity(profiles.len());
582    for profile in profiles {
583        let caps = TerminalCapabilities::from_profile(*profile);
584        let text = render(*profile, &caps);
585        let checksum = crate::golden::compute_text_checksum(&text);
586        outputs.push(ProfileSnapshot {
587            profile: *profile,
588            text,
589            checksum,
590        });
591    }
592
593    if compare != ProfileCompareMode::None && outputs.len() > 1 {
594        let baseline = normalize(&outputs[0].text, mode);
595        let baseline_profile = outputs[0].profile;
596        for snapshot in outputs.iter().skip(1) {
597            let candidate = normalize(&snapshot.text, mode);
598            if baseline != candidate {
599                let diff = diff_text(&baseline, &candidate);
600                match compare {
601                    ProfileCompareMode::Report => {
602                        eprintln!(
603                            "=== Profile comparison drift: {} vs {} ===\n{diff}",
604                            baseline_profile.as_str(),
605                            snapshot.profile.as_str()
606                        );
607                    }
608                    ProfileCompareMode::Strict => {
609                        std::panic::panic_any(format!(
610                            // ubs:ignore — snapshot assertion helper intentionally panics in tests
611                            "Profile comparison drift: {} vs {}\n{diff}",
612                            baseline_profile.as_str(),
613                            snapshot.profile.as_str()
614                        ));
615                    }
616                    ProfileCompareMode::None => {}
617                }
618            }
619        }
620    }
621
622    outputs
623}
624
625// ============================================================================
626// Tests
627// ============================================================================
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632    use ftui_render::cell::Cell;
633
634    #[test]
635    fn buffer_to_text_empty() {
636        let buf = Buffer::new(5, 2);
637        let text = buffer_to_text(&buf);
638        assert_eq!(text, "     \n     ");
639    }
640
641    #[test]
642    fn buffer_to_text_simple() {
643        let mut buf = Buffer::new(5, 1);
644        buf.set(0, 0, Cell::from_char('H'));
645        buf.set(1, 0, Cell::from_char('i'));
646        let text = buffer_to_text(&buf);
647        assert_eq!(text, "Hi   ");
648    }
649
650    #[test]
651    fn buffer_to_text_multiline() {
652        let mut buf = Buffer::new(3, 2);
653        buf.set(0, 0, Cell::from_char('A'));
654        buf.set(1, 0, Cell::from_char('B'));
655        buf.set(0, 1, Cell::from_char('C'));
656        let text = buffer_to_text(&buf);
657        assert_eq!(text, "AB \nC  ");
658    }
659
660    #[test]
661    fn buffer_to_text_wide_char() {
662        let mut buf = Buffer::new(4, 1);
663        // '中' is width 2 — head at x=0, continuation at x=1
664        buf.set(0, 0, Cell::from_char('中'));
665        buf.set(2, 0, Cell::from_char('!'));
666        let text = buffer_to_text(&buf);
667        // '中' occupies 1 char in text, continuation skipped, '!' at col 2, space at col 3
668        assert_eq!(text, "中! ");
669    }
670
671    #[test]
672    fn buffer_to_ansi_no_style() {
673        let mut buf = Buffer::new(3, 1);
674        buf.set(0, 0, Cell::from_char('X'));
675        let ansi = buffer_to_ansi(&buf);
676        // No style changes from default → no escape codes
677        assert_eq!(ansi, "X  ");
678    }
679
680    #[test]
681    fn buffer_to_ansi_with_style() {
682        let mut buf = Buffer::new(3, 1);
683        let styled = Cell::from_char('R').with_fg(PackedRgba::rgb(255, 0, 0));
684        buf.set(0, 0, styled);
685        let ansi = buffer_to_ansi(&buf);
686        // Should contain SGR for red foreground
687        assert!(ansi.contains("\x1b[38;2;255;0;0m"));
688        assert!(ansi.contains('R'));
689        // Should end with reset
690        assert!(ansi.contains("\x1b[0m"));
691    }
692
693    #[test]
694    fn diff_text_identical() {
695        let diff = diff_text("hello\nworld", "hello\nworld");
696        assert!(diff.is_empty());
697    }
698
699    #[test]
700    fn diff_text_single_line_change() {
701        let diff = diff_text("hello\nworld", "hello\nearth");
702        assert!(diff.contains("-world"));
703        assert!(diff.contains("+earth"));
704        assert!(diff.contains(" hello"));
705    }
706
707    #[test]
708    fn diff_text_added_lines() {
709        let diff = diff_text("A", "A\nB");
710        assert!(diff.contains("+B"));
711    }
712
713    #[test]
714    fn diff_text_removed_lines() {
715        let diff = diff_text("A\nB", "A");
716        assert!(diff.contains("-B"));
717    }
718
719    #[test]
720    fn normalize_exact() {
721        let text = "  hello  \n  world  ";
722        assert_eq!(normalize(text, MatchMode::Exact), text);
723    }
724
725    #[test]
726    fn normalize_trim_trailing() {
727        let text = "hello  \n  world  ";
728        assert_eq!(normalize(text, MatchMode::TrimTrailing), "hello\n  world");
729    }
730
731    #[test]
732    fn normalize_fuzzy() {
733        let text = "  hello   world  \n  foo   bar  ";
734        assert_eq!(normalize(text, MatchMode::Fuzzy), "hello world\nfoo bar");
735    }
736
737    #[test]
738    fn snapshot_path_construction() {
739        let p = snapshot_path(Path::new("/crates/my-crate"), "widget_test");
740        assert_eq!(
741            p,
742            PathBuf::from("/crates/my-crate/tests/snapshots/widget_test.snap")
743        );
744    }
745
746    #[test]
747    fn bless_creates_snapshot() {
748        let dir = std::env::temp_dir().join("ftui_harness_test_bless");
749        let _ = std::fs::remove_dir_all(&dir);
750
751        let mut buf = Buffer::new(3, 1);
752        buf.set(0, 0, Cell::from_char('X'));
753
754        // Simulate BLESS=1 by writing directly
755        let path = snapshot_path(&dir, "bless_test");
756        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
757        let text = buffer_to_text(&buf);
758        std::fs::write(&path, &text).unwrap();
759
760        // Verify file was created with correct content
761        let stored = std::fs::read_to_string(&path).unwrap();
762        assert_eq!(stored, "X  ");
763
764        let _ = std::fs::remove_dir_all(&dir);
765    }
766
767    #[test]
768    fn snapshot_match_succeeds() {
769        let dir = std::env::temp_dir().join("ftui_harness_test_match");
770        let _ = std::fs::remove_dir_all(&dir);
771
772        let mut buf = Buffer::new(5, 1);
773        buf.set(0, 0, Cell::from_char('O'));
774        buf.set(1, 0, Cell::from_char('K'));
775
776        // Write snapshot
777        let path = snapshot_path(&dir, "match_test");
778        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
779        std::fs::write(&path, "OK   ").unwrap();
780
781        // Assert should pass
782        assert_buffer_snapshot("match_test", &buf, dir.to_str().unwrap(), MatchMode::Exact);
783
784        let _ = std::fs::remove_dir_all(&dir);
785    }
786
787    #[test]
788    fn snapshot_trim_trailing_mode() {
789        let dir = std::env::temp_dir().join("ftui_harness_test_trim");
790        let _ = std::fs::remove_dir_all(&dir);
791
792        let mut buf = Buffer::new(5, 1);
793        buf.set(0, 0, Cell::from_char('A'));
794
795        // Stored snapshot has no trailing spaces
796        let path = snapshot_path(&dir, "trim_test");
797        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
798        std::fs::write(&path, "A").unwrap();
799
800        // Should match because TrimTrailing strips trailing spaces
801        assert_buffer_snapshot(
802            "trim_test",
803            &buf,
804            dir.to_str().unwrap(),
805            MatchMode::TrimTrailing,
806        );
807
808        let _ = std::fs::remove_dir_all(&dir);
809    }
810
811    #[test]
812    #[should_panic(expected = "Snapshot mismatch")]
813    fn snapshot_mismatch_panics() {
814        let dir = std::env::temp_dir().join("ftui_harness_test_mismatch");
815        let _ = std::fs::remove_dir_all(&dir);
816
817        let mut buf = Buffer::new(3, 1);
818        buf.set(0, 0, Cell::from_char('X'));
819
820        // Write mismatching snapshot
821        let path = snapshot_path(&dir, "mismatch_test");
822        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
823        std::fs::write(&path, "Y  ").unwrap();
824
825        assert_buffer_snapshot(
826            "mismatch_test",
827            &buf,
828            dir.to_str().unwrap(),
829            MatchMode::Exact,
830        );
831    }
832
833    #[test]
834    #[should_panic(expected = "No snapshot found")]
835    fn missing_snapshot_panics() {
836        let dir = std::env::temp_dir().join("ftui_harness_test_missing");
837        let _ = std::fs::remove_dir_all(&dir);
838
839        let buf = Buffer::new(3, 1);
840        assert_buffer_snapshot("nonexistent", &buf, dir.to_str().unwrap(), MatchMode::Exact);
841    }
842
843    #[test]
844    fn profile_matrix_collects_outputs() {
845        let profiles = [TerminalProfile::Modern, TerminalProfile::Dumb];
846        let outputs = profile_matrix_text_with_options(
847            &profiles,
848            ProfileCompareMode::Report,
849            MatchMode::Exact,
850            &mut |profile, _caps| format!("profile:{}", profile.as_str()),
851        );
852        assert_eq!(outputs.len(), 2);
853        assert!(outputs.iter().all(|o| o.checksum.starts_with("sha256:")));
854    }
855
856    #[test]
857    fn profile_matrix_strict_allows_identical_output() {
858        let profiles = [TerminalProfile::Modern, TerminalProfile::Dumb];
859        let outputs = profile_matrix_text_with_options(
860            &profiles,
861            ProfileCompareMode::Strict,
862            MatchMode::Exact,
863            &mut |_profile, _caps| "same".to_string(),
864        );
865        assert_eq!(outputs.len(), 2);
866    }
867}