1#![forbid(unsafe_code)]
2
3pub mod asciicast;
46pub mod determinism;
47pub mod flicker_detection;
48pub mod golden;
49pub mod hdd;
50pub mod input_storm;
51pub mod resize_storm;
52pub mod terminal_model;
53pub mod time_travel;
54pub mod time_travel_inspector;
55pub mod trace_replay;
56
57#[cfg(feature = "pty-capture")]
58pub mod pty_capture;
59
60use std::fmt::Write as FmtWrite;
61use std::path::{Path, PathBuf};
62
63use ftui_core::terminal_capabilities::{TerminalCapabilities, TerminalProfile};
64use ftui_render::buffer::Buffer;
65use ftui_render::cell::{PackedRgba, StyleFlags};
66use ftui_render::grapheme_pool::GraphemePool;
67
68pub use determinism::{
70 DeterminismFixture, JsonValue, LabScenario, LabScenarioContext, LabScenarioResult,
71 LabScenarioRun, TestJsonlLogger, lab_scenarios_run_total,
72};
73pub use ftui_core::geometry::Rect;
74pub use ftui_render::buffer;
75pub use ftui_render::cell;
76pub use time_travel_inspector::TimeTravelInspector;
77
78pub fn buffer_to_text(buf: &Buffer) -> String {
92 let capacity = (buf.width() as usize + 1) * buf.height() as usize;
93 let mut out = String::with_capacity(capacity);
94
95 for y in 0..buf.height() {
96 if y > 0 {
97 out.push('\n');
98 }
99 for x in 0..buf.width() {
100 let cell = buf.get(x, y).unwrap();
101 if cell.is_continuation() {
102 continue;
103 }
104 if cell.is_empty() {
105 out.push(' ');
106 } else if let Some(c) = cell.content.as_char() {
107 out.push(c);
108 } else {
109 let w = cell.content.width();
111 for _ in 0..w.max(1) {
112 out.push('?');
113 }
114 }
115 }
116 }
117 out
118}
119
120pub fn buffer_to_text_with_pool(buf: &Buffer, pool: Option<&GraphemePool>) -> String {
127 let capacity = (buf.width() as usize + 1) * buf.height() as usize;
128 let mut out = String::with_capacity(capacity);
129
130 for y in 0..buf.height() {
131 if y > 0 {
132 out.push('\n');
133 }
134 for x in 0..buf.width() {
135 let cell = buf.get(x, y).unwrap();
136 if cell.is_continuation() {
137 continue;
138 }
139 if cell.is_empty() {
140 out.push(' ');
141 } else if let Some(c) = cell.content.as_char() {
142 out.push(c);
143 } else if let (Some(pool), Some(gid)) = (pool, cell.content.grapheme_id()) {
144 if let Some(text) = pool.get(gid) {
145 out.push_str(text);
146 } else {
147 let w = cell.content.width();
148 for _ in 0..w.max(1) {
149 out.push('?');
150 }
151 }
152 } else {
153 let w = cell.content.width();
155 for _ in 0..w.max(1) {
156 out.push('?');
157 }
158 }
159 }
160 }
161 out
162}
163
164pub fn buffer_to_ansi(buf: &Buffer) -> String {
169 let capacity = (buf.width() as usize + 32) * buf.height() as usize;
170 let mut out = String::with_capacity(capacity);
171
172 for y in 0..buf.height() {
173 if y > 0 {
174 out.push('\n');
175 }
176
177 let mut prev_fg = PackedRgba::WHITE; let mut prev_bg = PackedRgba::TRANSPARENT; let mut prev_flags = StyleFlags::empty();
180 let mut style_active = false;
181
182 for x in 0..buf.width() {
183 let cell = buf.get(x, y).unwrap();
184 if cell.is_continuation() {
185 continue;
186 }
187
188 let fg = cell.fg;
189 let bg = cell.bg;
190 let flags = cell.attrs.flags();
191
192 let style_changed = fg != prev_fg || bg != prev_bg || flags != prev_flags;
193
194 if style_changed {
195 let has_style =
196 fg != PackedRgba::WHITE || bg != PackedRgba::TRANSPARENT || !flags.is_empty();
197
198 if has_style {
199 if style_active {
201 out.push_str("\x1b[0m");
202 }
203
204 let mut params: Vec<String> = Vec::new();
205 if !flags.is_empty() {
206 if flags.contains(StyleFlags::BOLD) {
207 params.push("1".into());
208 }
209 if flags.contains(StyleFlags::DIM) {
210 params.push("2".into());
211 }
212 if flags.contains(StyleFlags::ITALIC) {
213 params.push("3".into());
214 }
215 if flags.contains(StyleFlags::UNDERLINE) {
216 params.push("4".into());
217 }
218 if flags.contains(StyleFlags::BLINK) {
219 params.push("5".into());
220 }
221 if flags.contains(StyleFlags::REVERSE) {
222 params.push("7".into());
223 }
224 if flags.contains(StyleFlags::HIDDEN) {
225 params.push("8".into());
226 }
227 if flags.contains(StyleFlags::STRIKETHROUGH) {
228 params.push("9".into());
229 }
230 }
231 if fg.a() > 0 && fg != PackedRgba::WHITE {
232 params.push(format!("38;2;{};{};{}", fg.r(), fg.g(), fg.b()));
233 }
234 if bg.a() > 0 && bg != PackedRgba::TRANSPARENT {
235 params.push(format!("48;2;{};{};{}", bg.r(), bg.g(), bg.b()));
236 }
237
238 if !params.is_empty() {
239 write!(out, "\x1b[{}m", params.join(";")).unwrap();
240 style_active = true;
241 }
242 } else if style_active {
243 out.push_str("\x1b[0m");
244 style_active = false;
245 }
246
247 prev_fg = fg;
248 prev_bg = bg;
249 prev_flags = flags;
250 }
251
252 if cell.is_empty() {
253 out.push(' ');
254 } else if let Some(c) = cell.content.as_char() {
255 out.push(c);
256 } else {
257 let w = cell.content.width();
259 for _ in 0..w.max(1) {
260 out.push('?');
261 }
262 }
263 }
264
265 if style_active {
266 out.push_str("\x1b[0m");
267 }
268 }
269 out
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub enum MatchMode {
279 Exact,
281 TrimTrailing,
283 Fuzzy,
285}
286
287fn normalize(text: &str, mode: MatchMode) -> String {
289 match mode {
290 MatchMode::Exact => text.to_string(),
291 MatchMode::TrimTrailing => text
292 .lines()
293 .map(|l| l.trim_end())
294 .collect::<Vec<_>>()
295 .join("\n"),
296 MatchMode::Fuzzy => text
297 .lines()
298 .map(|l| l.split_whitespace().collect::<Vec<_>>().join(" "))
299 .collect::<Vec<_>>()
300 .join("\n"),
301 }
302}
303
304pub fn diff_text(expected: &str, actual: &str) -> String {
317 let expected_lines: Vec<&str> = expected.lines().collect();
318 let actual_lines: Vec<&str> = actual.lines().collect();
319
320 let max_lines = expected_lines.len().max(actual_lines.len());
321 let mut out = String::new();
322 let mut has_diff = false;
323
324 for i in 0..max_lines {
325 let exp = expected_lines.get(i).copied();
326 let act = actual_lines.get(i).copied();
327
328 match (exp, act) {
329 (Some(e), Some(a)) if e == a => {
330 writeln!(out, " {e}").unwrap();
331 }
332 (Some(e), Some(a)) => {
333 writeln!(out, "-{e}").unwrap();
334 writeln!(out, "+{a}").unwrap();
335 has_diff = true;
336 }
337 (Some(e), None) => {
338 writeln!(out, "-{e}").unwrap();
339 has_diff = true;
340 }
341 (None, Some(a)) => {
342 writeln!(out, "+{a}").unwrap();
343 has_diff = true;
344 }
345 (None, None) => {}
346 }
347 }
348
349 if has_diff { out } else { String::new() }
350}
351
352#[must_use]
360pub fn current_test_profile() -> Option<TerminalProfile> {
361 std::env::var("FTUI_TEST_PROFILE")
362 .ok()
363 .and_then(|value| value.parse::<TerminalProfile>().ok())
364 .and_then(|profile| {
365 if profile == TerminalProfile::Detected {
366 None
367 } else {
368 Some(profile)
369 }
370 })
371}
372
373fn snapshot_name_with_profile(name: &str) -> String {
374 if let Some(profile) = current_test_profile() {
375 let suffix = format!("__{}", profile.as_str());
376 if name.ends_with(&suffix) {
377 return name.to_string();
378 }
379 return format!("{name}{suffix}");
380 }
381 name.to_string()
382}
383
384fn snapshot_path(base_dir: &Path, name: &str) -> PathBuf {
386 let resolved_name = snapshot_name_with_profile(name);
387 base_dir
388 .join("tests")
389 .join("snapshots")
390 .join(format!("{resolved_name}.snap"))
391}
392
393fn is_bless() -> bool {
395 std::env::var("BLESS").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
396}
397
398pub fn assert_buffer_snapshot(name: &str, buf: &Buffer, base_dir: &str, mode: MatchMode) {
420 let base = Path::new(base_dir);
421 let path = snapshot_path(base, name);
422 let actual = buffer_to_text(buf);
423
424 if is_bless() {
425 if let Some(parent) = path.parent() {
426 std::fs::create_dir_all(parent).expect("failed to create snapshot directory");
427 }
428 std::fs::write(&path, &actual).expect("failed to write snapshot");
429 return;
430 }
431
432 match std::fs::read_to_string(&path) {
433 Ok(expected) => {
434 let norm_expected = normalize(&expected, mode);
435 let norm_actual = normalize(&actual, mode);
436
437 if norm_expected != norm_actual {
438 let diff = diff_text(&norm_expected, &norm_actual);
439 std::panic::panic_any(format!(
440 "\n\
442 === Snapshot mismatch: '{name}' ===\n\
443 File: {}\n\
444 Mode: {mode:?}\n\
445 Set BLESS=1 to update.\n\n\
446 Diff (- expected, + actual):\n{diff}",
447 path.display()
448 ));
449 }
450 }
451 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
452 std::panic::panic_any(format!(
453 "\n\
455 === No snapshot found: '{name}' ===\n\
456 Expected at: {}\n\
457 Run with BLESS=1 to create it.\n\n\
458 Actual output ({w}x{h}):\n{actual}",
459 path.display(),
460 w = buf.width(),
461 h = buf.height(),
462 ));
463 }
464 Err(e) => {
465 std::panic::panic_any(format!(
466 "Failed to read snapshot '{}': {e}",
468 path.display()
469 ));
470 }
471 }
472}
473
474pub fn assert_buffer_snapshot_ansi(name: &str, buf: &Buffer, base_dir: &str) {
479 let base = Path::new(base_dir);
480 let resolved_name = snapshot_name_with_profile(name);
481 let path = base
482 .join("tests")
483 .join("snapshots")
484 .join(format!("{resolved_name}.ansi.snap"));
485 let actual = buffer_to_ansi(buf);
486
487 if is_bless() {
488 if let Some(parent) = path.parent() {
489 std::fs::create_dir_all(parent).expect("failed to create snapshot directory");
490 }
491 std::fs::write(&path, &actual).expect("failed to write snapshot");
492 return;
493 }
494
495 match std::fs::read_to_string(&path) {
496 Ok(expected) => {
497 if expected != actual {
498 let diff = diff_text(&expected, &actual);
499 std::panic::panic_any(format!(
500 "\n\
502 === ANSI snapshot mismatch: '{name}' ===\n\
503 File: {}\n\
504 Set BLESS=1 to update.\n\n\
505 Diff (- expected, + actual):\n{diff}",
506 path.display()
507 ));
508 }
509 }
510 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
511 std::panic::panic_any(format!(
512 "\n\
514 === No ANSI snapshot found: '{resolved_name}' ===\n\
515 Expected at: {}\n\
516 Run with BLESS=1 to create it.\n\n\
517 Actual output:\n{actual}",
518 path.display(),
519 ));
520 }
521 Err(e) => {
522 std::panic::panic_any(format!(
523 "Failed to read snapshot '{}': {e}",
525 path.display()
526 ));
527 }
528 }
529}
530
531#[macro_export]
549macro_rules! assert_snapshot {
550 ($name:expr, $buf:expr) => {
551 $crate::assert_buffer_snapshot(
552 $name,
553 $buf,
554 env!("CARGO_MANIFEST_DIR"),
555 $crate::MatchMode::TrimTrailing,
556 )
557 };
558 ($name:expr, $buf:expr, $mode:expr) => {
559 $crate::assert_buffer_snapshot($name, $buf, env!("CARGO_MANIFEST_DIR"), $mode)
560 };
561}
562
563#[macro_export]
567macro_rules! assert_snapshot_ansi {
568 ($name:expr, $buf:expr) => {
569 $crate::assert_buffer_snapshot_ansi($name, $buf, env!("CARGO_MANIFEST_DIR"))
570 };
571}
572
573#[derive(Debug, Clone, Copy, PartialEq, Eq)]
579pub enum ProfileCompareMode {
580 None,
582 Report,
584 Strict,
586}
587
588impl ProfileCompareMode {
589 #[must_use]
591 pub fn from_env() -> Self {
592 match std::env::var("FTUI_TEST_PROFILE_COMPARE")
593 .ok()
594 .map(|v| v.to_lowercase())
595 .as_deref()
596 {
597 Some("strict") | Some("1") | Some("true") => Self::Strict,
598 Some("report") | Some("log") => Self::Report,
599 _ => Self::None,
600 }
601 }
602}
603
604#[derive(Debug, Clone)]
606pub struct ProfileSnapshot {
607 pub profile: TerminalProfile,
608 pub text: String,
609 pub checksum: String,
610}
611
612pub fn profile_matrix_text<F>(profiles: &[TerminalProfile], mut render: F) -> Vec<ProfileSnapshot>
619where
620 F: FnMut(TerminalProfile, &TerminalCapabilities) -> String,
621{
622 profile_matrix_text_with_options(
623 profiles,
624 ProfileCompareMode::from_env(),
625 MatchMode::TrimTrailing,
626 &mut render,
627 )
628}
629
630pub fn profile_matrix_text_with_options<F>(
632 profiles: &[TerminalProfile],
633 compare: ProfileCompareMode,
634 mode: MatchMode,
635 render: &mut F,
636) -> Vec<ProfileSnapshot>
637where
638 F: FnMut(TerminalProfile, &TerminalCapabilities) -> String,
639{
640 let mut outputs = Vec::with_capacity(profiles.len());
641 for profile in profiles {
642 let caps = TerminalCapabilities::from_profile(*profile);
643 let text = render(*profile, &caps);
644 let checksum = crate::golden::compute_text_checksum(&text);
645 outputs.push(ProfileSnapshot {
646 profile: *profile,
647 text,
648 checksum,
649 });
650 }
651
652 if compare != ProfileCompareMode::None && outputs.len() > 1 {
653 let baseline = normalize(&outputs[0].text, mode);
654 let baseline_profile = outputs[0].profile;
655 for snapshot in outputs.iter().skip(1) {
656 let candidate = normalize(&snapshot.text, mode);
657 if baseline != candidate {
658 let diff = diff_text(&baseline, &candidate);
659 match compare {
660 ProfileCompareMode::Report => {
661 eprintln!(
662 "=== Profile comparison drift: {} vs {} ===\n{diff}",
663 baseline_profile.as_str(),
664 snapshot.profile.as_str()
665 );
666 }
667 ProfileCompareMode::Strict => {
668 std::panic::panic_any(format!(
669 "Profile comparison drift: {} vs {}\n{diff}",
671 baseline_profile.as_str(),
672 snapshot.profile.as_str()
673 ));
674 }
675 ProfileCompareMode::None => {}
676 }
677 }
678 }
679 }
680
681 outputs
682}
683
684#[cfg(test)]
689mod tests {
690 use super::*;
691 use ftui_render::cell::{Cell, CellContent, GraphemeId};
692
693 #[test]
694 fn buffer_to_text_empty() {
695 let buf = Buffer::new(5, 2);
696 let text = buffer_to_text(&buf);
697 assert_eq!(text, " \n ");
698 }
699
700 #[test]
701 fn buffer_to_text_simple() {
702 let mut buf = Buffer::new(5, 1);
703 buf.set(0, 0, Cell::from_char('H'));
704 buf.set(1, 0, Cell::from_char('i'));
705 let text = buffer_to_text(&buf);
706 assert_eq!(text, "Hi ");
707 }
708
709 #[test]
710 fn buffer_to_text_multiline() {
711 let mut buf = Buffer::new(3, 2);
712 buf.set(0, 0, Cell::from_char('A'));
713 buf.set(1, 0, Cell::from_char('B'));
714 buf.set(0, 1, Cell::from_char('C'));
715 let text = buffer_to_text(&buf);
716 assert_eq!(text, "AB \nC ");
717 }
718
719 #[test]
720 fn buffer_to_text_wide_char() {
721 let mut buf = Buffer::new(4, 1);
722 buf.set(0, 0, Cell::from_char('中'));
724 buf.set(2, 0, Cell::from_char('!'));
725 let text = buffer_to_text(&buf);
726 assert_eq!(text, "中! ");
728 }
729
730 #[test]
731 fn buffer_to_text_grapheme_width_correct_placeholder() {
732 let gid = GraphemeId::new(1, 2); let content = CellContent::from_grapheme(gid);
735 let mut buf = Buffer::new(6, 1);
736 buf.set(0, 0, Cell::new(content));
738 buf.set(2, 0, Cell::from_char('A'));
739 buf.set(3, 0, Cell::from_char('B'));
740 let text = buffer_to_text(&buf);
741 assert_eq!(text, "??AB ");
743 }
744
745 #[test]
746 fn buffer_to_text_with_pool_resolves_grapheme() {
747 let mut pool = GraphemePool::new();
748 let gid = pool.intern("⚙\u{fe0f}", 2);
749 let content = CellContent::from_grapheme(gid);
750 let mut buf = Buffer::new(6, 1);
751 buf.set(0, 0, Cell::new(content));
753 buf.set(2, 0, Cell::from_char('A'));
754 let text = buffer_to_text_with_pool(&buf, Some(&pool));
755 assert_eq!(text, "⚙\u{fe0f}A ");
757 }
758
759 #[test]
760 fn buffer_to_text_with_pool_none_falls_back() {
761 let gid = GraphemeId::new(1, 2);
762 let content = CellContent::from_grapheme(gid);
763 let mut buf = Buffer::new(4, 1);
764 buf.set(0, 0, Cell::new(content));
766 buf.set(2, 0, Cell::from_char('!'));
767 let text = buffer_to_text_with_pool(&buf, None);
768 assert_eq!(text, "??! ");
770 }
771
772 #[test]
773 fn buffer_to_ansi_grapheme_width_correct_placeholder() {
774 let gid = GraphemeId::new(1, 2);
775 let content = CellContent::from_grapheme(gid);
776 let mut buf = Buffer::new(4, 1);
777 buf.set(0, 0, Cell::new(content));
779 buf.set(2, 0, Cell::from_char('X'));
780 let ansi = buffer_to_ansi(&buf);
781 assert_eq!(ansi, "??X ");
783 }
784
785 #[test]
786 fn buffer_to_ansi_no_style() {
787 let mut buf = Buffer::new(3, 1);
788 buf.set(0, 0, Cell::from_char('X'));
789 let ansi = buffer_to_ansi(&buf);
790 assert_eq!(ansi, "X ");
792 }
793
794 #[test]
795 fn buffer_to_ansi_with_style() {
796 let mut buf = Buffer::new(3, 1);
797 let styled = Cell::from_char('R').with_fg(PackedRgba::rgb(255, 0, 0));
798 buf.set(0, 0, styled);
799 let ansi = buffer_to_ansi(&buf);
800 assert!(ansi.contains("\x1b[38;2;255;0;0m"));
802 assert!(ansi.contains('R'));
803 assert!(ansi.contains("\x1b[0m"));
805 }
806
807 #[test]
808 fn diff_text_identical() {
809 let diff = diff_text("hello\nworld", "hello\nworld");
810 assert!(diff.is_empty());
811 }
812
813 #[test]
814 fn diff_text_single_line_change() {
815 let diff = diff_text("hello\nworld", "hello\nearth");
816 assert!(diff.contains("-world"));
817 assert!(diff.contains("+earth"));
818 assert!(diff.contains(" hello"));
819 }
820
821 #[test]
822 fn diff_text_added_lines() {
823 let diff = diff_text("A", "A\nB");
824 assert!(diff.contains("+B"));
825 }
826
827 #[test]
828 fn diff_text_removed_lines() {
829 let diff = diff_text("A\nB", "A");
830 assert!(diff.contains("-B"));
831 }
832
833 #[test]
834 fn normalize_exact() {
835 let text = " hello \n world ";
836 assert_eq!(normalize(text, MatchMode::Exact), text);
837 }
838
839 #[test]
840 fn normalize_trim_trailing() {
841 let text = "hello \n world ";
842 assert_eq!(normalize(text, MatchMode::TrimTrailing), "hello\n world");
843 }
844
845 #[test]
846 fn normalize_fuzzy() {
847 let text = " hello world \n foo bar ";
848 assert_eq!(normalize(text, MatchMode::Fuzzy), "hello world\nfoo bar");
849 }
850
851 #[test]
852 fn snapshot_path_construction() {
853 let p = snapshot_path(Path::new("/crates/my-crate"), "widget_test");
854 assert_eq!(
855 p,
856 PathBuf::from("/crates/my-crate/tests/snapshots/widget_test.snap")
857 );
858 }
859
860 #[test]
861 fn bless_creates_snapshot() {
862 let dir = std::env::temp_dir().join("ftui_harness_test_bless");
863 let _ = std::fs::remove_dir_all(&dir);
864
865 let mut buf = Buffer::new(3, 1);
866 buf.set(0, 0, Cell::from_char('X'));
867
868 let path = snapshot_path(&dir, "bless_test");
870 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
871 let text = buffer_to_text(&buf);
872 std::fs::write(&path, &text).unwrap();
873
874 let stored = std::fs::read_to_string(&path).unwrap();
876 assert_eq!(stored, "X ");
877
878 let _ = std::fs::remove_dir_all(&dir);
879 }
880
881 #[test]
882 fn snapshot_match_succeeds() {
883 let dir = std::env::temp_dir().join("ftui_harness_test_match");
884 let _ = std::fs::remove_dir_all(&dir);
885
886 let mut buf = Buffer::new(5, 1);
887 buf.set(0, 0, Cell::from_char('O'));
888 buf.set(1, 0, Cell::from_char('K'));
889
890 let path = snapshot_path(&dir, "match_test");
892 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
893 std::fs::write(&path, "OK ").unwrap();
894
895 assert_buffer_snapshot("match_test", &buf, dir.to_str().unwrap(), MatchMode::Exact);
897
898 let _ = std::fs::remove_dir_all(&dir);
899 }
900
901 #[test]
902 fn snapshot_trim_trailing_mode() {
903 let dir = std::env::temp_dir().join("ftui_harness_test_trim");
904 let _ = std::fs::remove_dir_all(&dir);
905
906 let mut buf = Buffer::new(5, 1);
907 buf.set(0, 0, Cell::from_char('A'));
908
909 let path = snapshot_path(&dir, "trim_test");
911 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
912 std::fs::write(&path, "A").unwrap();
913
914 assert_buffer_snapshot(
916 "trim_test",
917 &buf,
918 dir.to_str().unwrap(),
919 MatchMode::TrimTrailing,
920 );
921
922 let _ = std::fs::remove_dir_all(&dir);
923 }
924
925 #[test]
926 #[should_panic(expected = "Snapshot mismatch")]
927 fn snapshot_mismatch_panics() {
928 let dir = std::env::temp_dir().join("ftui_harness_test_mismatch");
929 let _ = std::fs::remove_dir_all(&dir);
930
931 let mut buf = Buffer::new(3, 1);
932 buf.set(0, 0, Cell::from_char('X'));
933
934 let path = snapshot_path(&dir, "mismatch_test");
936 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
937 std::fs::write(&path, "Y ").unwrap();
938
939 assert_buffer_snapshot(
940 "mismatch_test",
941 &buf,
942 dir.to_str().unwrap(),
943 MatchMode::Exact,
944 );
945 }
946
947 #[test]
948 #[should_panic(expected = "No snapshot found")]
949 fn missing_snapshot_panics() {
950 let dir = std::env::temp_dir().join("ftui_harness_test_missing");
951 let _ = std::fs::remove_dir_all(&dir);
952
953 let buf = Buffer::new(3, 1);
954 assert_buffer_snapshot("nonexistent", &buf, dir.to_str().unwrap(), MatchMode::Exact);
955 }
956
957 #[test]
958 fn profile_matrix_collects_outputs() {
959 let profiles = [TerminalProfile::Modern, TerminalProfile::Dumb];
960 let outputs = profile_matrix_text_with_options(
961 &profiles,
962 ProfileCompareMode::Report,
963 MatchMode::Exact,
964 &mut |profile, _caps| format!("profile:{}", profile.as_str()),
965 );
966 assert_eq!(outputs.len(), 2);
967 assert!(outputs.iter().all(|o| o.checksum.starts_with("blake3:")));
968 }
969
970 #[test]
971 fn profile_matrix_strict_allows_identical_output() {
972 let profiles = [TerminalProfile::Modern, TerminalProfile::Dumb];
973 let outputs = profile_matrix_text_with_options(
974 &profiles,
975 ProfileCompareMode::Strict,
976 MatchMode::Exact,
977 &mut |_profile, _caps| "same".to_string(),
978 );
979 assert_eq!(outputs.len(), 2);
980 }
981}