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