Skip to main content

fastapi_output/
testing.rs

1//! Test utilities for output component testing.
2//!
3//! This module provides infrastructure for capturing and asserting on
4//! output from RichOutput components in tests.
5
6use 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/// Test output buffer that captures all output for assertions.
15#[derive(Debug, Clone)]
16pub struct TestOutput {
17    mode: OutputMode,
18    buffer: Rc<RefCell<Vec<OutputEntry>>>,
19    terminal_width: usize,
20}
21
22/// A single captured output entry with metadata.
23#[derive(Debug, Clone)]
24pub struct OutputEntry {
25    /// Stripped/plain content for assertions.
26    pub content: String,
27    /// Capture time.
28    pub timestamp: Instant,
29    /// Output level.
30    pub level: OutputLevel,
31    /// Optional component identifier.
32    pub component: Option<String>,
33    /// Raw output including ANSI codes.
34    pub raw_ansi: String,
35}
36
37/// Output classification for test assertions.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum OutputLevel {
40    /// Debug output.
41    Debug,
42    /// Informational output.
43    Info,
44    /// Success output.
45    Success,
46    /// Warning output.
47    Warning,
48    /// Error output.
49    Error,
50}
51
52impl TestOutput {
53    /// Create a new test output buffer with specified mode.
54    #[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    /// Create with custom terminal width for width-dependent tests.
64    #[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    /// Get the current output mode.
74    #[must_use]
75    pub const fn mode(&self) -> OutputMode {
76        self.mode
77    }
78
79    /// Get configured terminal width.
80    #[must_use]
81    pub const fn terminal_width(&self) -> usize {
82        self.terminal_width
83    }
84
85    /// Add an entry to the buffer (called by RichOutput facade).
86    pub fn push(&self, entry: OutputEntry) {
87        self.buffer.borrow_mut().push(entry);
88    }
89
90    /// Get all captured output as a single string (stripped of ANSI).
91    #[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    /// Get all captured output with ANSI codes preserved.
102    #[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    /// Get captured entries for detailed inspection.
113    #[must_use]
114    pub fn entries(&self) -> Vec<OutputEntry> {
115        self.buffer.borrow().clone()
116    }
117
118    /// Clear the buffer.
119    pub fn clear(&self) {
120        self.buffer.borrow_mut().clear();
121    }
122
123    /// Get count of entries by level.
124    #[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
134/// Capture output from a closure in the specified mode.
135///
136/// # Example
137/// ```rust
138/// use fastapi_output::prelude::*;
139/// use fastapi_output::testing::*;
140///
141/// let output = capture(OutputMode::Plain, || {
142///     let out = RichOutput::plain();
143///     out.success("Hello");
144/// });
145///
146/// assert_contains(&output, "Hello");
147/// ```
148pub 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
163/// Capture with custom terminal width.
164pub 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
179/// Capture both plain and rich output for comparison.
180pub 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// =============================================================================
187// Assertion Utilities
188// =============================================================================
189
190/// Strip ANSI escape codes from a string.
191#[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/// Assert output contains text (after stripping ANSI codes).
198#[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/// Assert output does NOT contain text.
208#[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/// Assert output has no ANSI codes (for plain mode testing).
218#[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/// Assert output has ANSI codes (for rich mode testing).
227#[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/// Assert all lines are within max width.
236#[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/// Assert output contains all expected substrings in order.
253#[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// =============================================================================
273// Debug Logging for Tests
274// =============================================================================
275
276/// Enable verbose test logging (set FASTAPI_TEST_VERBOSE=1).
277#[must_use]
278pub fn is_verbose() -> bool {
279    std::env::var("FASTAPI_TEST_VERBOSE").is_ok()
280}
281
282/// Log message if verbose mode is enabled.
283#[macro_export]
284macro_rules! test_log {
285    ($($arg:tt)*) => {
286        if $crate::testing::is_verbose() {
287            eprintln!("[TEST] {}", format!($($arg)*));
288        }
289    };
290}
291
292/// Log captured output for debugging.
293pub 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
305/// Test fixtures for common scenarios.
306pub 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}