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