fastapi_output/
testing.rs1use crate::facade::RichOutput;
7use crate::mode::OutputMode;
8use regex::Regex;
9use std::cell::RefCell;
10use std::rc::Rc;
11use std::time::Instant;
12use unicode_width::UnicodeWidthStr;
13
14#[derive(Debug, Clone)]
16pub struct TestOutput {
17 mode: OutputMode,
18 buffer: Rc<RefCell<Vec<OutputEntry>>>,
19 terminal_width: usize,
20}
21
22#[derive(Debug, Clone)]
24pub struct OutputEntry {
25 pub content: String,
27 pub timestamp: Instant,
29 pub level: OutputLevel,
31 pub component: Option<String>,
33 pub raw_ansi: String,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum OutputLevel {
40 Debug,
42 Info,
44 Success,
46 Warning,
48 Error,
50}
51
52impl TestOutput {
53 #[must_use]
55 pub fn new(mode: OutputMode) -> Self {
56 Self {
57 mode,
58 buffer: Rc::new(RefCell::new(Vec::new())),
59 terminal_width: 80,
60 }
61 }
62
63 #[must_use]
65 pub fn with_width(mode: OutputMode, width: usize) -> Self {
66 Self {
67 mode,
68 buffer: Rc::new(RefCell::new(Vec::new())),
69 terminal_width: width,
70 }
71 }
72
73 #[must_use]
75 pub const fn mode(&self) -> OutputMode {
76 self.mode
77 }
78
79 #[must_use]
81 pub const fn terminal_width(&self) -> usize {
82 self.terminal_width
83 }
84
85 pub fn push(&self, entry: OutputEntry) {
87 self.buffer.borrow_mut().push(entry);
88 }
89
90 #[must_use]
92 pub fn captured(&self) -> String {
93 self.buffer
94 .borrow()
95 .iter()
96 .map(|entry| entry.content.as_str())
97 .collect::<Vec<_>>()
98 .join("\n")
99 }
100
101 #[must_use]
103 pub fn captured_raw(&self) -> String {
104 self.buffer
105 .borrow()
106 .iter()
107 .map(|entry| entry.raw_ansi.as_str())
108 .collect::<Vec<_>>()
109 .join("\n")
110 }
111
112 #[must_use]
114 pub fn entries(&self) -> Vec<OutputEntry> {
115 self.buffer.borrow().clone()
116 }
117
118 pub fn clear(&self) {
120 self.buffer.borrow_mut().clear();
121 }
122
123 #[must_use]
125 pub fn count_by_level(&self, level: OutputLevel) -> usize {
126 self.buffer
127 .borrow()
128 .iter()
129 .filter(|entry| entry.level == level)
130 .count()
131 }
132}
133
134pub fn capture<F: FnOnce()>(mode: OutputMode, f: F) -> String {
149 let test_output = TestOutput::new(mode);
150 let original_mode = { RichOutput::global().mode() };
151 {
152 let mut global = RichOutput::global_mut();
153 global.set_mode(mode);
154 }
155 RichOutput::with_test_output(&test_output, f);
156 {
157 let mut global = RichOutput::global_mut();
158 global.set_mode(original_mode);
159 }
160 test_output.captured()
161}
162
163pub fn capture_with_width<F: FnOnce()>(mode: OutputMode, width: usize, f: F) -> String {
165 let test_output = TestOutput::with_width(mode, width);
166 let original_mode = { RichOutput::global().mode() };
167 {
168 let mut global = RichOutput::global_mut();
169 global.set_mode(mode);
170 }
171 RichOutput::with_test_output(&test_output, f);
172 {
173 let mut global = RichOutput::global_mut();
174 global.set_mode(original_mode);
175 }
176 test_output.captured()
177}
178
179pub fn capture_both<F: FnOnce() + Clone>(f: F) -> (String, String) {
181 let plain = capture(OutputMode::Plain, f.clone());
182 let rich = capture(OutputMode::Rich, f);
183 (plain, rich)
184}
185
186#[must_use]
192pub fn strip_ansi_codes(input: &str) -> String {
193 let re = Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]").expect("invalid ANSI regex");
194 re.replace_all(input, "").to_string()
195}
196
197#[track_caller]
199pub fn assert_contains(output: &str, expected: &str) {
200 let stripped = strip_ansi_codes(output);
201 assert!(
202 stripped.contains(expected),
203 "Expected output to contain: '{expected}'\nActual output (stripped):\n{stripped}\n---"
204 );
205}
206
207#[track_caller]
209pub fn assert_not_contains(output: &str, unexpected: &str) {
210 let stripped = strip_ansi_codes(output);
211 assert!(
212 !stripped.contains(unexpected),
213 "Expected output to NOT contain: '{unexpected}'\nActual output (stripped):\n{stripped}"
214 );
215}
216
217#[track_caller]
219pub fn assert_no_ansi(output: &str) {
220 assert!(
221 !output.contains("\x1b["),
222 "Found ANSI escape codes in output that should be plain:\n{output}\n---"
223 );
224}
225
226#[track_caller]
228pub fn assert_has_ansi(output: &str) {
229 assert!(
230 output.contains("\x1b["),
231 "Expected ANSI escape codes in rich output but found none:\n{output}\n---"
232 );
233}
234
235#[track_caller]
237pub fn assert_max_width(output: &str, max_width: usize) {
238 let stripped = strip_ansi_codes(output);
239 for (idx, line) in stripped.lines().enumerate() {
240 let width = UnicodeWidthStr::width(line);
241 assert!(
242 width <= max_width,
243 "Line {} exceeds max width {}. Width: {}, Content: '{}'",
244 idx + 1,
245 max_width,
246 width,
247 line
248 );
249 }
250}
251
252#[track_caller]
254pub fn assert_contains_in_order(output: &str, expected: &[&str]) {
255 let stripped = strip_ansi_codes(output);
256 let mut last_pos = 0;
257
258 for (idx, exp) in expected.iter().enumerate() {
259 match stripped[last_pos..].find(exp) {
260 Some(pos) => {
261 last_pos += pos + exp.len();
262 }
263 None => {
264 panic!(
265 "Expected '{exp}' (item {idx}) not found after position {last_pos}\nOutput:\n{stripped}\n---"
266 );
267 }
268 }
269 }
270}
271
272#[must_use]
278pub fn is_verbose() -> bool {
279 std::env::var("FASTAPI_TEST_VERBOSE").is_ok()
280}
281
282#[macro_export]
284macro_rules! test_log {
285 ($($arg:tt)*) => {
286 if $crate::testing::is_verbose() {
287 eprintln!("[TEST] {}", format!($($arg)*));
288 }
289 };
290}
291
292pub fn debug_output(label: &str, output: &str) {
294 if is_verbose() {
295 eprintln!(
296 "\n=== {} (raw) ===\n{}\n=== {} (stripped) ===\n{}\n=== END ===\n",
297 label,
298 output,
299 label,
300 strip_ansi_codes(output)
301 );
302 }
303}
304
305pub mod fixtures;
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 #[test]
313 fn test_capture_captures_output() {
314 let output = capture(OutputMode::Plain, || {
315 RichOutput::global().success("Hello, World!");
316 });
317
318 assert_contains(&output, "Hello, World!");
319 }
320
321 #[test]
322 fn test_strip_ansi_removes_codes() {
323 let with_ansi = "\x1b[31mRed Text\x1b[0m";
324 let stripped = strip_ansi_codes(with_ansi);
325 assert_eq!(stripped, "Red Text");
326 }
327
328 #[test]
329 fn test_assert_no_ansi_passes_for_plain() {
330 let plain = "Just plain text";
331 assert_no_ansi(plain);
332 }
333
334 #[test]
335 #[should_panic(expected = "Found ANSI escape codes")]
336 fn test_assert_no_ansi_fails_for_rich() {
337 let with_ansi = "\x1b[31mColored\x1b[0m";
338 assert_no_ansi(with_ansi);
339 }
340
341 #[test]
342 fn test_capture_both_modes() {
343 let (plain, rich) = capture_both(|| {
344 RichOutput::global().success("Success!");
345 });
346
347 assert_no_ansi(&plain);
348 assert_contains(&plain, "Success");
349 assert_contains(&rich, "Success");
350 }
351
352 #[test]
353 fn test_assert_contains_in_order() {
354 let output = "First line\nSecond line\nThird line";
355 assert_contains_in_order(output, &["First", "Second", "Third"]);
356 }
357
358 #[test]
359 fn test_max_width_assertion() {
360 let output = "Short\nAlso short";
361 assert_max_width(output, 20);
362 }
363}