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 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
120pub 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 let w = cell.content.width();
153 for _ in 0..w.max(1) {
154 out.push('?');
155 }
156 }
157 }
158 }
159 out
160}
161
162pub 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 let w = cell.content.width();
197 for _ in 0..w.max(1) {
198 out.push('?');
199 }
200 }
201 }
202 }
203 out
204}
205
206pub 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; let mut prev_bg = PackedRgba::TRANSPARENT; 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
320pub enum MatchMode {
321 Exact,
323 TrimTrailing,
325 Fuzzy,
327}
328
329fn 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
346pub 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#[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
420fn 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
429fn is_bless() -> bool {
431 std::env::var("BLESS").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
432}
433
434pub 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 "\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 "\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 "Failed to read snapshot '{}': {e}",
504 path.display()
505 ));
506 }
507 }
508}
509
510pub 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 "\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 "\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 "Failed to read snapshot '{}': {e}",
561 path.display()
562 ));
563 }
564 }
565}
566
567#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
615pub enum ProfileCompareMode {
616 None,
618 Report,
620 Strict,
622}
623
624impl ProfileCompareMode {
625 #[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#[derive(Debug, Clone)]
642pub struct ProfileSnapshot {
643 pub profile: TerminalProfile,
644 pub text: String,
645 pub checksum: String,
646}
647
648pub 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
666pub 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 "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#[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 buf.set(0, 0, Cell::from_char('中'));
760 buf.set(2, 0, Cell::from_char('!'));
761 let text = buffer_to_text(&buf);
762 assert_eq!(text, "中! ");
764 }
765
766 #[test]
767 fn buffer_to_text_grapheme_width_correct_placeholder() {
768 let gid = GraphemeId::new(1, 0, 2); let content = CellContent::from_grapheme(gid);
771 let mut buf = Buffer::new(6, 1);
772 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 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 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 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 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 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 buf.set(0, 0, Cell::new(content));
815 buf.set(2, 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_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 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 assert!(ansi.contains("\x1b[38;2;255;0;0m"));
838 assert!(ansi.contains('R'));
839 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 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 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 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_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 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 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 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}