use crate::facade::RichOutput;
use crate::mode::OutputMode;
use regex::Regex;
use std::cell::RefCell;
use std::rc::Rc;
use std::time::Instant;
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone)]
pub struct TestOutput {
mode: OutputMode,
buffer: Rc<RefCell<Vec<OutputEntry>>>,
terminal_width: usize,
}
#[derive(Debug, Clone)]
pub struct OutputEntry {
pub content: String,
pub timestamp: Instant,
pub level: OutputLevel,
pub component: Option<String>,
pub raw_ansi: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputLevel {
Debug,
Info,
Success,
Warning,
Error,
}
impl TestOutput {
#[must_use]
pub fn new(mode: OutputMode) -> Self {
Self {
mode,
buffer: Rc::new(RefCell::new(Vec::new())),
terminal_width: 80,
}
}
#[must_use]
pub fn with_width(mode: OutputMode, width: usize) -> Self {
Self {
mode,
buffer: Rc::new(RefCell::new(Vec::new())),
terminal_width: width,
}
}
#[must_use]
pub const fn mode(&self) -> OutputMode {
self.mode
}
#[must_use]
pub const fn terminal_width(&self) -> usize {
self.terminal_width
}
pub fn push(&self, entry: OutputEntry) {
self.buffer.borrow_mut().push(entry);
}
#[must_use]
pub fn captured(&self) -> String {
self.buffer
.borrow()
.iter()
.map(|entry| entry.content.as_str())
.collect::<Vec<_>>()
.join("\n")
}
#[must_use]
pub fn captured_raw(&self) -> String {
self.buffer
.borrow()
.iter()
.map(|entry| entry.raw_ansi.as_str())
.collect::<Vec<_>>()
.join("\n")
}
#[must_use]
pub fn entries(&self) -> Vec<OutputEntry> {
self.buffer.borrow().clone()
}
pub fn clear(&self) {
self.buffer.borrow_mut().clear();
}
#[must_use]
pub fn count_by_level(&self, level: OutputLevel) -> usize {
self.buffer
.borrow()
.iter()
.filter(|entry| entry.level == level)
.count()
}
}
pub fn capture<F: FnOnce()>(mode: OutputMode, f: F) -> String {
let test_output = TestOutput::new(mode);
let original_mode = { RichOutput::global().mode() };
{
let mut global = RichOutput::global_mut();
global.set_mode(mode);
}
RichOutput::with_test_output(&test_output, f);
{
let mut global = RichOutput::global_mut();
global.set_mode(original_mode);
}
test_output.captured()
}
pub fn capture_with_width<F: FnOnce()>(mode: OutputMode, width: usize, f: F) -> String {
let test_output = TestOutput::with_width(mode, width);
let original_mode = { RichOutput::global().mode() };
{
let mut global = RichOutput::global_mut();
global.set_mode(mode);
}
RichOutput::with_test_output(&test_output, f);
{
let mut global = RichOutput::global_mut();
global.set_mode(original_mode);
}
test_output.captured()
}
pub fn capture_both<F: FnOnce() + Clone>(f: F) -> (String, String) {
let plain = capture(OutputMode::Plain, f.clone());
let rich = capture(OutputMode::Rich, f);
(plain, rich)
}
#[must_use]
pub fn strip_ansi_codes(input: &str) -> String {
let re = Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]").expect("invalid ANSI regex");
re.replace_all(input, "").to_string()
}
#[track_caller]
pub fn assert_contains(output: &str, expected: &str) {
let stripped = strip_ansi_codes(output);
assert!(
stripped.contains(expected),
"Expected output to contain: '{expected}'\nActual output (stripped):\n{stripped}\n---"
);
}
#[track_caller]
pub fn assert_not_contains(output: &str, unexpected: &str) {
let stripped = strip_ansi_codes(output);
assert!(
!stripped.contains(unexpected),
"Expected output to NOT contain: '{unexpected}'\nActual output (stripped):\n{stripped}"
);
}
#[track_caller]
pub fn assert_no_ansi(output: &str) {
assert!(
!output.contains("\x1b["),
"Found ANSI escape codes in output that should be plain:\n{output}\n---"
);
}
#[track_caller]
pub fn assert_has_ansi(output: &str) {
assert!(
output.contains("\x1b["),
"Expected ANSI escape codes in rich output but found none:\n{output}\n---"
);
}
#[track_caller]
pub fn assert_max_width(output: &str, max_width: usize) {
let stripped = strip_ansi_codes(output);
for (idx, line) in stripped.lines().enumerate() {
let width = UnicodeWidthStr::width(line);
assert!(
width <= max_width,
"Line {} exceeds max width {}. Width: {}, Content: '{}'",
idx + 1,
max_width,
width,
line
);
}
}
#[track_caller]
pub fn assert_contains_in_order(output: &str, expected: &[&str]) {
let stripped = strip_ansi_codes(output);
let mut last_pos = 0;
for (idx, exp) in expected.iter().enumerate() {
match stripped[last_pos..].find(exp) {
Some(pos) => {
last_pos += pos + exp.len();
}
None => {
panic!(
"Expected '{exp}' (item {idx}) not found after position {last_pos}\nOutput:\n{stripped}\n---"
);
}
}
}
}
#[must_use]
pub fn is_verbose() -> bool {
std::env::var("FASTAPI_TEST_VERBOSE").is_ok()
}
#[macro_export]
macro_rules! test_log {
($($arg:tt)*) => {
if $crate::testing::is_verbose() {
eprintln!("[TEST] {}", format!($($arg)*));
}
};
}
pub fn debug_output(label: &str, output: &str) {
if is_verbose() {
eprintln!(
"\n=== {} (raw) ===\n{}\n=== {} (stripped) ===\n{}\n=== END ===\n",
label,
output,
label,
strip_ansi_codes(output)
);
}
}
pub mod fixtures;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_capture_captures_output() {
let output = capture(OutputMode::Plain, || {
RichOutput::global().success("Hello, World!");
});
assert_contains(&output, "Hello, World!");
}
#[test]
fn test_strip_ansi_removes_codes() {
let with_ansi = "\x1b[31mRed Text\x1b[0m";
let stripped = strip_ansi_codes(with_ansi);
assert_eq!(stripped, "Red Text");
}
#[test]
fn test_assert_no_ansi_passes_for_plain() {
let plain = "Just plain text";
assert_no_ansi(plain);
}
#[test]
#[should_panic(expected = "Found ANSI escape codes")]
fn test_assert_no_ansi_fails_for_rich() {
let with_ansi = "\x1b[31mColored\x1b[0m";
assert_no_ansi(with_ansi);
}
#[test]
fn test_capture_both_modes() {
let (plain, rich) = capture_both(|| {
RichOutput::global().success("Success!");
});
assert_no_ansi(&plain);
assert_contains(&plain, "Success");
assert_contains(&rich, "Success");
}
#[test]
fn test_assert_contains_in_order() {
let output = "First line\nSecond line\nThird line";
assert_contains_in_order(output, &["First", "Second", "Third"]);
}
#[test]
fn test_max_width_assertion() {
let output = "Short\nAlso short";
assert_max_width(output, 20);
}
}