fastmcp_console/testing/
test_console.rs1use crate::console::FastMcpConsole;
6use std::io::Write;
7use std::sync::{Arc, Mutex};
8use strip_ansi_escapes::strip;
9
10pub struct TestConsole {
16 inner: Arc<FastMcpConsole>,
17 buffer: Arc<Mutex<TestBuffer>>,
18 report_as_rich: bool,
20}
21
22#[derive(Debug, Default)]
23struct TestBuffer {
24 lines: Vec<String>,
26 raw_lines: Vec<String>,
28}
29
30impl TestConsole {
31 fn normalize_whitespace(text: &str) -> String {
32 text.split_whitespace().collect::<Vec<_>>().join(" ")
33 }
34
35 fn canonicalize_for_assertions(text: &str) -> String {
36 let mut out = Self::normalize_whitespace(text);
37 for (from, to) in [
39 ("( ", "("),
40 (" )", ")"),
41 ("[ ", "["),
42 (" ]", "]"),
43 ("{ ", "{"),
44 (" }", "}"),
45 ("# ", "#"),
46 (" =", "="),
47 ("= ", "="),
48 (". ", "."),
49 (" /", "/"),
50 ("/ ", "/"),
51 ] {
52 out = out.replace(from, to);
53 }
54 out
55 }
56
57 fn compact_whitespace(text: &str) -> String {
58 text.chars().filter(|c| !c.is_whitespace()).collect()
59 }
60
61 #[must_use]
66 pub fn new() -> Self {
67 Self::new_inner(false)
68 }
69
70 #[must_use]
72 pub fn new_rich() -> Self {
73 Self::new_inner(true)
74 }
75
76 fn new_inner(report_as_rich: bool) -> Self {
78 let buffer = Arc::new(Mutex::new(TestBuffer::default()));
79 let writer = BufferWriter(buffer.clone());
80
81 Self {
83 inner: Arc::new(FastMcpConsole::with_writer(writer, true)),
84 buffer,
85 report_as_rich,
86 }
87 }
88
89 #[must_use]
91 pub fn console(&self) -> &FastMcpConsole {
92 &self.inner
93 }
94
95 #[must_use]
97 pub fn output(&self) -> Vec<String> {
98 self.buffer
99 .lock()
100 .map(|b| b.lines.clone())
101 .unwrap_or_default()
102 }
103
104 #[must_use]
106 pub fn raw_output(&self) -> Vec<String> {
107 self.buffer
108 .lock()
109 .map(|b| b.raw_lines.clone())
110 .unwrap_or_default()
111 }
112
113 #[must_use]
115 pub fn output_string(&self) -> String {
116 Self::canonicalize_for_assertions(&self.output().join("\n"))
117 }
118
119 #[must_use]
121 pub fn contains(&self, needle: &str) -> bool {
122 let output = self.output_string().to_lowercase();
123 let needle = Self::canonicalize_for_assertions(needle).to_lowercase();
124 if output.contains(&needle) {
125 return true;
126 }
127
128 let output_compact = Self::compact_whitespace(&output);
129 let needle_compact = Self::compact_whitespace(&needle);
130 output_compact.contains(&needle_compact)
131 }
132
133 #[must_use]
135 pub fn contains_all(&self, needles: &[&str]) -> bool {
136 needles.iter().all(|n| self.contains(n))
137 }
138
139 #[must_use]
141 pub fn matches(&self, pattern: &str) -> bool {
142 match regex::Regex::new(pattern) {
143 Ok(re) => {
144 let output = self.output_string();
145 re.is_match(&output) || re.is_match(&Self::compact_whitespace(&output))
146 }
147 Err(_) => false,
148 }
149 }
150
151 pub fn assert_contains(&self, needle: &str) {
157 assert!(
158 self.contains(needle),
159 "Output did not contain '{}'. Actual output:\n{}",
160 needle,
161 self.output_string()
162 );
163 }
164
165 pub fn assert_not_contains(&self, needle: &str) {
171 assert!(
172 !self.contains(needle),
173 "Output unexpectedly contained '{}'. Actual output:\n{}",
174 needle,
175 self.output_string()
176 );
177 }
178
179 pub fn assert_line_count(&self, expected: usize) {
185 let actual = self.output().len();
186 assert_eq!(
187 actual,
188 expected,
189 "Expected {} lines but got {}. Actual output:\n{}",
190 expected,
191 actual,
192 self.output_string()
193 );
194 }
195
196 pub fn clear(&self) {
198 if let Ok(mut buf) = self.buffer.lock() {
199 buf.lines.clear();
200 buf.raw_lines.clear();
201 }
202 }
203
204 pub fn debug_print(&self) {
206 eprintln!("=== TestConsole Output ===");
207 for (i, line) in self.output().iter().enumerate() {
208 eprintln!("{:3}: {}", i + 1, line);
209 }
210 eprintln!("==========================");
211 }
212
213 #[must_use]
218 pub fn is_rich(&self) -> bool {
219 self.report_as_rich
220 }
221}
222
223impl Default for TestConsole {
224 fn default() -> Self {
225 Self::new()
226 }
227}
228
229impl Clone for TestConsole {
230 fn clone(&self) -> Self {
231 Self {
232 inner: self.inner.clone(),
233 buffer: self.buffer.clone(),
234 report_as_rich: self.report_as_rich,
235 }
236 }
237}
238
239impl std::fmt::Debug for TestConsole {
240 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241 f.debug_struct("TestConsole")
242 .field("is_rich", &self.is_rich())
243 .field("line_count", &self.output().len())
244 .finish()
245 }
246}
247
248struct BufferWriter(Arc<Mutex<TestBuffer>>);
250
251impl std::fmt::Debug for BufferWriter {
252 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253 f.debug_struct("BufferWriter").finish_non_exhaustive()
254 }
255}
256
257impl Write for BufferWriter {
258 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
259 let s = String::from_utf8_lossy(buf);
260
261 if let Ok(mut buffer) = self.0.lock() {
262 buffer.raw_lines.extend(s.lines().map(String::from));
264
265 let stripped = strip(buf);
267 let stripped_str = String::from_utf8_lossy(&stripped);
268 buffer.lines.extend(stripped_str.lines().map(String::from));
269 }
270
271 Ok(buf.len())
272 }
273
274 fn flush(&mut self) -> std::io::Result<()> {
275 Ok(())
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use std::panic::{self, AssertUnwindSafe};
283
284 fn panic_message(payload: Box<dyn std::any::Any + Send>) -> String {
285 match payload.downcast::<String>() {
286 Ok(msg) => *msg,
287 Err(payload) => match payload.downcast::<&'static str>() {
288 Ok(msg) => (*msg).to_string(),
289 Err(_) => "<non-string panic payload>".to_string(),
290 },
291 }
292 }
293
294 #[test]
295 fn test_new_creates_plain_console() {
296 let tc = TestConsole::new();
297 assert!(!tc.is_rich());
298 }
299
300 #[test]
301 fn test_new_rich_creates_rich_console() {
302 let tc = TestConsole::new_rich();
303 assert!(tc.is_rich());
304 }
305
306 #[test]
307 fn test_output_capture() {
308 let tc = TestConsole::new();
309 tc.console().print("Hello, world!");
310 assert!(tc.contains("Hello"));
311 assert!(tc.contains("world"));
312 }
313
314 #[test]
315 fn test_contains_case_insensitive() {
316 let tc = TestConsole::new();
317 tc.console().print("Hello World");
318 assert!(tc.contains("hello"));
319 assert!(tc.contains("WORLD"));
320 }
321
322 #[test]
323 fn test_contains_all() {
324 let tc = TestConsole::new();
325 tc.console().print("The quick brown fox");
326 assert!(tc.contains_all(&["quick", "brown", "fox"]));
327 assert!(!tc.contains_all(&["quick", "lazy"]));
328 }
329
330 #[test]
331 fn test_assert_not_contains() {
332 let tc = TestConsole::new();
333 tc.console().print("Success");
334 tc.assert_not_contains("Error");
335 }
336
337 #[test]
338 fn test_clear() {
339 let tc = TestConsole::new();
340 tc.console().print("Some output");
341 assert!(!tc.output().is_empty());
342 tc.clear();
343 assert!(tc.output().is_empty());
344 }
345
346 #[test]
347 fn test_output_string() {
348 let tc = TestConsole::new();
349 tc.console().print("Line 1");
350 tc.console().print("Line 2");
351 let output = tc.output_string();
352 assert!(output.contains("Line 1"));
353 assert!(output.contains("Line 2"));
354 }
355
356 #[test]
357 fn test_matches_regex() {
358 let tc = TestConsole::new();
359 tc.console().print("Error code: 42");
360 assert!(tc.matches(r"code: \d+"));
361 assert!(!tc.matches(r"code: [a-z]+"));
362 }
363
364 #[test]
365 fn test_matches_invalid_regex_returns_false() {
366 let tc = TestConsole::new();
367 tc.console().print("anything");
368 assert!(!tc.matches("("));
369 }
370
371 #[test]
372 fn test_assert_contains_failure_includes_output() {
373 let tc = TestConsole::new();
374 tc.console().print("present text");
375 let panic = panic::catch_unwind(AssertUnwindSafe(|| tc.assert_contains("missing text")));
376 let message = panic_message(panic.expect_err("assert_contains should panic"));
377 assert!(message.contains("did not contain"));
378 assert!(message.contains("present text"));
379 }
380
381 #[test]
382 fn test_assert_not_contains_failure_includes_output() {
383 let tc = TestConsole::new();
384 tc.console().print("contains marker");
385 let panic = panic::catch_unwind(AssertUnwindSafe(|| {
386 tc.assert_not_contains("contains marker");
387 }));
388 let message = panic_message(panic.expect_err("assert_not_contains should panic"));
389 assert!(message.contains("unexpectedly contained"));
390 assert!(message.contains("contains marker"));
391 }
392
393 #[test]
394 fn test_assert_line_count_success_and_failure() {
395 let tc = TestConsole::new();
396 tc.console().print("line one");
397 let baseline = tc.output().len();
398 tc.assert_line_count(baseline);
399
400 let expected = baseline + 1;
401 let panic = panic::catch_unwind(AssertUnwindSafe(|| tc.assert_line_count(expected)));
402 let message = panic_message(panic.expect_err("assert_line_count should panic"));
403 assert!(message.contains(&format!("Expected {} lines but got {}", expected, baseline)));
404 assert!(message.contains("line one"));
405 }
406
407 #[test]
408 fn test_debug_print_executes() {
409 let tc = TestConsole::new();
410 tc.console().print("debug line");
411 tc.debug_print();
412 }
413
414 #[test]
415 fn test_debug_impls() {
416 let tc = TestConsole::new_rich();
417 tc.console().print("x");
418 let tc_debug = format!("{tc:?}");
419 assert!(tc_debug.contains("TestConsole"));
420 assert!(tc_debug.contains("is_rich"));
421 assert!(tc_debug.contains("line_count"));
422
423 let writer = BufferWriter(Arc::new(Mutex::new(TestBuffer::default())));
424 let writer_debug = format!("{writer:?}");
425 assert!(writer_debug.contains("BufferWriter"));
426 }
427
428 #[test]
429 fn test_clone() {
430 let tc = TestConsole::new();
431 tc.console().print("Test");
432 let tc2 = tc.clone();
433 assert!(tc2.contains("Test"));
434 }
435
436 #[test]
437 fn test_default() {
438 let tc = TestConsole::default();
439 assert!(!tc.is_rich());
440 }
441}