fastmcp_console/testing/
snapshots.rs1use std::fs;
31use std::path::{Path, PathBuf};
32
33use crate::testing::TestConsole;
34
35pub struct SnapshotTest {
52 name: String,
53 snapshot_dir: PathBuf,
54 update_snapshots: bool,
55}
56
57impl SnapshotTest {
58 #[must_use]
70 pub fn new(name: &str) -> Self {
71 let snapshot_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
72 .join("tests")
73 .join("snapshots");
74
75 Self {
76 name: name.to_string(),
77 snapshot_dir,
78 update_snapshots: std::env::var("UPDATE_SNAPSHOTS").is_ok(),
79 }
80 }
81
82 #[must_use]
91 pub fn with_snapshot_dir(mut self, dir: impl AsRef<Path>) -> Self {
92 self.snapshot_dir = dir.as_ref().to_path_buf();
93 self
94 }
95
96 #[must_use]
100 pub fn with_update_mode(mut self, update: bool) -> Self {
101 self.update_snapshots = update;
102 self
103 }
104
105 pub fn assert_snapshot(&self, console: &TestConsole) {
124 let actual = console.output_string();
125 self.assert_snapshot_string(&actual);
126 }
127
128 pub fn assert_snapshot_string(&self, actual: &str) {
135 let snapshot_path = self.snapshot_path();
136
137 if self.update_snapshots {
138 self.save_snapshot(actual);
139 return;
140 }
141
142 if !snapshot_path.exists() {
143 panic!(
144 "Snapshot '{}' does not exist at {}.\n\
145 Run with UPDATE_SNAPSHOTS=1 to create it.\n\
146 Actual output ({} bytes):\n{}\n",
147 self.name,
148 snapshot_path.display(),
149 actual.len(),
150 truncate_for_display(actual, 1000)
151 );
152 }
153
154 let expected = fs::read_to_string(&snapshot_path).unwrap_or_else(|e| {
155 panic!(
156 "Failed to read snapshot file '{}': {}",
157 snapshot_path.display(),
158 e
159 )
160 });
161
162 if actual != expected {
163 let diff = self.generate_diff(&expected, actual);
164 panic!(
165 "Snapshot '{}' does not match.\n\
166 Run with UPDATE_SNAPSHOTS=1 to update.\n\
167 Diff (expected vs actual):\n{}\n",
168 self.name, diff
169 );
170 }
171 }
172
173 pub fn assert_raw_snapshot(&self, console: &TestConsole) {
182 let actual = console.raw_output().join("\n");
183 let snapshot_path = self.snapshot_path_raw();
184
185 if self.update_snapshots {
186 fs::create_dir_all(&self.snapshot_dir).ok();
187 fs::write(&snapshot_path, &actual).unwrap_or_else(|e| {
188 panic!(
189 "Failed to write raw snapshot '{}': {}",
190 snapshot_path.display(),
191 e
192 )
193 });
194 eprintln!(
195 "Updated raw snapshot: {} -> {}",
196 self.name,
197 snapshot_path.display()
198 );
199 return;
200 }
201
202 if !snapshot_path.exists() {
203 panic!(
204 "Raw snapshot '{}' does not exist at {}.\n\
205 Run with UPDATE_SNAPSHOTS=1 to create.",
206 self.name,
207 snapshot_path.display()
208 );
209 }
210
211 let expected = fs::read_to_string(&snapshot_path).expect("Failed to read raw snapshot");
212
213 if actual != expected {
214 let diff = self.generate_diff(&expected, &actual);
215 panic!(
216 "Raw snapshot '{}' does not match.\n\
217 Run with UPDATE_SNAPSHOTS=1 to update.\n\
218 Diff:\n{}",
219 self.name, diff
220 );
221 }
222 }
223
224 #[must_use]
226 pub fn snapshot_path(&self) -> PathBuf {
227 self.snapshot_dir.join(format!("{}.txt", self.name))
228 }
229
230 #[must_use]
232 pub fn snapshot_path_raw(&self) -> PathBuf {
233 self.snapshot_dir.join(format!("{}.raw.txt", self.name))
234 }
235
236 #[must_use]
238 pub fn snapshot_exists(&self) -> bool {
239 self.snapshot_path().exists()
240 }
241
242 #[must_use]
244 pub fn raw_snapshot_exists(&self) -> bool {
245 self.snapshot_path_raw().exists()
246 }
247
248 fn save_snapshot(&self, content: &str) {
250 fs::create_dir_all(&self.snapshot_dir).unwrap_or_else(|e| {
251 panic!(
252 "Failed to create snapshot directory '{}': {}",
253 self.snapshot_dir.display(),
254 e
255 )
256 });
257
258 let path = self.snapshot_path();
259 fs::write(&path, content)
260 .unwrap_or_else(|e| panic!("Failed to write snapshot '{}': {}", path.display(), e));
261
262 eprintln!("Updated snapshot: {} -> {}", self.name, path.display());
263 }
264
265 fn generate_diff(&self, expected: &str, actual: &str) -> String {
267 let expected_lines: Vec<&str> = expected.lines().collect();
268 let actual_lines: Vec<&str> = actual.lines().collect();
269
270 let mut diff = String::new();
271 let max_lines = expected_lines.len().max(actual_lines.len());
272
273 diff.push_str(&format!(
275 "Expected: {} lines, Actual: {} lines\n",
276 expected_lines.len(),
277 actual_lines.len()
278 ));
279 diff.push_str("---\n");
280
281 let mut differences = 0;
282 for i in 0..max_lines {
283 let exp = expected_lines.get(i);
284 let act = actual_lines.get(i);
285
286 match (exp, act) {
287 (Some(e), Some(a)) if e != a => {
288 diff.push_str(&format!("L{}: - {}\n", i + 1, e));
289 diff.push_str(&format!("L{}: + {}\n", i + 1, a));
290 differences += 1;
291 }
292 (Some(e), None) => {
293 diff.push_str(&format!("L{}: - {}\n", i + 1, e));
294 differences += 1;
295 }
296 (None, Some(a)) => {
297 diff.push_str(&format!("L{}: + {}\n", i + 1, a));
298 differences += 1;
299 }
300 _ => {}
301 }
302
303 if differences > 50 {
305 diff.push_str(&format!(
306 "... ({} more differences truncated)\n",
307 max_lines - i - 1
308 ));
309 break;
310 }
311 }
312
313 if differences == 0 {
314 diff.push_str("(no line differences - possible whitespace/encoding issue)\n");
315
316 if expected.len() != actual.len() {
318 diff.push_str(&format!(
319 "Byte lengths differ: expected {} vs actual {}\n",
320 expected.len(),
321 actual.len()
322 ));
323 }
324 }
325
326 diff
327 }
328}
329
330fn truncate_for_display(s: &str, max_len: usize) -> &str {
332 if s.len() <= max_len {
333 s
334 } else {
335 let truncate_at = s
337 .char_indices()
338 .take_while(|(i, _)| *i < max_len - 3)
339 .last()
340 .map(|(i, c)| i + c.len_utf8())
341 .unwrap_or(max_len - 3);
342 &s[..truncate_at]
343 }
344}
345
346#[macro_export]
362macro_rules! assert_snapshot {
363 ($name:expr, $console:expr) => {
364 $crate::testing::SnapshotTest::new($name).assert_snapshot(&$console)
365 };
366}
367
368#[macro_export]
384macro_rules! assert_raw_snapshot {
385 ($name:expr, $console:expr) => {
386 $crate::testing::SnapshotTest::new($name).assert_raw_snapshot(&$console)
387 };
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393 use tempfile::tempdir;
394
395 #[test]
396 fn test_snapshot_path() {
397 let snap = SnapshotTest::new("my_test");
398 let path = snap.snapshot_path();
399 assert!(path.ends_with("my_test.txt"));
400 }
401
402 #[test]
403 fn test_snapshot_path_raw() {
404 let snap = SnapshotTest::new("my_test");
405 let path = snap.snapshot_path_raw();
406 assert!(path.ends_with("my_test.raw.txt"));
407 }
408
409 #[test]
410 fn test_custom_snapshot_dir() {
411 let snap = SnapshotTest::new("test").with_snapshot_dir("/tmp/custom");
412 assert_eq!(snap.snapshot_dir, PathBuf::from("/tmp/custom"));
413 }
414
415 #[test]
416 fn test_snapshot_creation_and_matching() {
417 let temp_dir = tempdir().expect("Failed to create temp dir");
418
419 let snap = SnapshotTest::new("creation_test")
420 .with_snapshot_dir(temp_dir.path())
421 .with_update_mode(true);
422
423 let console = TestConsole::new();
425 console.console().print("Test content for snapshot");
426 snap.assert_snapshot(&console);
427
428 assert!(snap.snapshot_exists());
430
431 let snap2 = SnapshotTest::new("creation_test")
433 .with_snapshot_dir(temp_dir.path())
434 .with_update_mode(false);
435
436 let console2 = TestConsole::new();
437 console2.console().print("Test content for snapshot");
438 snap2.assert_snapshot(&console2); }
440
441 #[test]
442 fn test_snapshot_string_matching() {
443 let temp_dir = tempdir().expect("Failed to create temp dir");
444
445 let snap = SnapshotTest::new("string_test")
447 .with_snapshot_dir(temp_dir.path())
448 .with_update_mode(true);
449 snap.assert_snapshot_string("Hello, world!");
450
451 let snap2 = SnapshotTest::new("string_test")
453 .with_snapshot_dir(temp_dir.path())
454 .with_update_mode(false);
455 snap2.assert_snapshot_string("Hello, world!"); }
457
458 #[test]
459 #[should_panic(expected = "does not match")]
460 fn test_snapshot_mismatch_panics() {
461 let temp_dir = tempdir().expect("Failed to create temp dir");
462
463 let snap = SnapshotTest::new("mismatch_test")
465 .with_snapshot_dir(temp_dir.path())
466 .with_update_mode(true);
467 snap.assert_snapshot_string("Original content");
468
469 let snap2 = SnapshotTest::new("mismatch_test")
471 .with_snapshot_dir(temp_dir.path())
472 .with_update_mode(false);
473 snap2.assert_snapshot_string("Different content"); }
475
476 #[test]
477 #[should_panic(expected = "does not exist")]
478 fn test_missing_snapshot_panics() {
479 let temp_dir = tempdir().expect("Failed to create temp dir");
480
481 let snap = SnapshotTest::new("nonexistent")
482 .with_snapshot_dir(temp_dir.path())
483 .with_update_mode(false);
484
485 snap.assert_snapshot_string("Content"); }
487
488 #[test]
489 fn test_raw_snapshot() {
490 let temp_dir = tempdir().expect("Failed to create temp dir");
491
492 let snap = SnapshotTest::new("raw_test")
494 .with_snapshot_dir(temp_dir.path())
495 .with_update_mode(true);
496
497 let console = TestConsole::new_rich();
498 console.console().print("[bold]Styled text[/]");
499 snap.assert_raw_snapshot(&console);
500
501 assert!(snap.raw_snapshot_exists());
503 }
504
505 #[test]
506 fn test_generate_diff() {
507 let snap = SnapshotTest::new("diff_test");
508
509 let expected = "line 1\nline 2\nline 3";
510 let actual = "line 1\nmodified line 2\nline 3";
511
512 let diff = snap.generate_diff(expected, actual);
513
514 assert!(diff.contains("- line 2"));
515 assert!(diff.contains("+ modified line 2"));
516 }
517
518 #[test]
519 fn test_generate_diff_added_lines() {
520 let snap = SnapshotTest::new("diff_test");
521
522 let expected = "line 1";
523 let actual = "line 1\nline 2";
524
525 let diff = snap.generate_diff(expected, actual);
526
527 assert!(diff.contains("+ line 2"));
528 }
529
530 #[test]
531 fn test_generate_diff_removed_lines() {
532 let snap = SnapshotTest::new("diff_test");
533
534 let expected = "line 1\nline 2";
535 let actual = "line 1";
536
537 let diff = snap.generate_diff(expected, actual);
538
539 assert!(diff.contains("- line 2"));
540 }
541
542 #[test]
543 fn test_truncate_for_display() {
544 assert_eq!(truncate_for_display("short", 10), "short");
545 assert_eq!(
546 truncate_for_display("a longer string that needs truncation", 20).len(),
547 17
548 );
549 }
550
551 #[test]
552 fn test_snapshot_exists() {
553 let temp_dir = tempdir().expect("Failed to create temp dir");
554
555 let snap = SnapshotTest::new("exists_test").with_snapshot_dir(temp_dir.path());
556
557 assert!(!snap.snapshot_exists());
558
559 let snap_create = snap.with_update_mode(true);
561 snap_create.assert_snapshot_string("content");
562
563 let snap_check = SnapshotTest::new("exists_test").with_snapshot_dir(temp_dir.path());
565 assert!(snap_check.snapshot_exists());
566 }
567}