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