1#![forbid(unsafe_code)]
2
3pub 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
90pub 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
104pub 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
111pub 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 let w = cell.content.width();
144 for _ in 0..w.max(1) {
145 out.push('?');
146 }
147 }
148 }
149 }
150 out
151}
152
153pub 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 let w = cell.content.width();
188 for _ in 0..w.max(1) {
189 out.push('?');
190 }
191 }
192 }
193 }
194 out
195}
196
197pub 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; let mut prev_bg = PackedRgba::TRANSPARENT; 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
311pub enum MatchMode {
312 Exact,
314 TrimTrailing,
316 Fuzzy,
318}
319
320fn 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
337pub 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#[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
411fn 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
420fn is_bless() -> bool {
422 std::env::var("BLESS").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
423}
424
425pub 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 "\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 "\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 "Failed to read snapshot '{}': {e}",
495 path.display()
496 ));
497 }
498 }
499}
500
501pub 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 "\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 "\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 "Failed to read snapshot '{}': {e}",
552 path.display()
553 ));
554 }
555 }
556}
557
558#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
606pub enum ProfileCompareMode {
607 None,
609 Report,
611 Strict,
613}
614
615impl ProfileCompareMode {
616 #[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#[derive(Debug, Clone)]
633pub struct ProfileSnapshot {
634 pub profile: TerminalProfile,
635 pub text: String,
636 pub checksum: String,
637}
638
639pub 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
657pub 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 "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#[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 buf.set(0, 0, Cell::from_char('中'));
751 buf.set(2, 0, Cell::from_char('!'));
752 let text = buffer_to_text(&buf);
753 assert_eq!(text, "中! ");
755 }
756
757 #[test]
758 fn buffer_to_text_grapheme_width_correct_placeholder() {
759 let gid = GraphemeId::new(1, 0, 2); let content = CellContent::from_grapheme(gid);
762 let mut buf = Buffer::new(6, 1);
763 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 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 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 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 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 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 buf.set(0, 0, Cell::new(content));
806 buf.set(2, 0, Cell::from_char('X'));
807 let ansi = buffer_to_ansi(&buf);
808 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 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 assert!(ansi.contains("\x1b[38;2;255;0;0m"));
829 assert!(ansi.contains('R'));
830 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 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 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 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_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 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 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 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}