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