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