use crate::core::Element;
use crate::hooks::context::{HookContext, with_hooks};
use crate::testing::TestRenderer;
use std::sync::{Arc, RwLock};
pub struct TestHarness<F>
where
F: Fn() -> Element,
{
component: F,
context: Arc<RwLock<HookContext>>,
renderer: TestRenderer,
last_output: String,
}
impl<F> TestHarness<F>
where
F: Fn() -> Element,
{
pub fn new(component: F) -> Self {
Self::with_size(component, 80, 24)
}
pub fn with_size(component: F, width: u16, height: u16) -> Self {
let context = Arc::new(RwLock::new(HookContext::new()));
let renderer = TestRenderer::new(width, height);
let mut harness = Self {
component,
context,
renderer,
last_output: String::new(),
};
harness.render();
harness
}
pub fn render(&mut self) -> &str {
let element = with_hooks(self.context.clone(), || (self.component)());
self.last_output = self.renderer.render_to_plain(&element);
&self.last_output
}
pub fn output(&self) -> &str {
&self.last_output
}
pub fn output_ansi(&mut self) -> String {
let element = with_hooks(self.context.clone(), || (self.component)());
self.renderer.render_to_ansi(&element)
}
pub fn update(&mut self) -> &str {
self.render()
}
pub fn assert_text_contains(&self, expected: &str) {
assert!(
self.last_output.contains(expected),
"Expected output to contain '{}', but got:\n{}",
expected,
self.last_output
);
}
pub fn assert_text_not_contains(&self, unexpected: &str) {
assert!(
!self.last_output.contains(unexpected),
"Expected output to NOT contain '{}', but got:\n{}",
unexpected,
self.last_output
);
}
pub fn assert_text_equals(&self, expected: &str) {
let actual = self.last_output.trim();
let expected = expected.trim();
assert_eq!(
actual, expected,
"Expected output to equal '{}', but got '{}'",
expected, actual
);
}
pub fn assert_lines_contain(&self, patterns: &[&str]) {
let lines: Vec<&str> = self.last_output.lines().collect();
for pattern in patterns {
assert!(
lines.iter().any(|line| line.contains(pattern)),
"Expected a line containing '{}', but none found in:\n{}",
pattern,
self.last_output
);
}
}
pub fn assert_line_count(&self, expected: usize) {
let actual = self.last_output.lines().count();
assert_eq!(
actual, expected,
"Expected {} lines, but got {}",
expected, actual
);
}
pub fn line(&self, index: usize) -> Option<&str> {
self.last_output.lines().nth(index)
}
pub fn lines(&self) -> Vec<&str> {
self.last_output.lines().collect()
}
pub fn contains(&self, text: &str) -> bool {
self.last_output.contains(text)
}
pub fn width(&self) -> u16 {
self.renderer.width()
}
pub fn height(&self) -> u16 {
self.renderer.height()
}
}
pub struct Snapshot {
name: String,
content: String,
}
impl Snapshot {
pub fn new(name: impl Into<String>, content: impl Into<String>) -> Self {
Self {
name: name.into(),
content: content.into(),
}
}
pub fn assert_match(&self, actual: &str) {
let actual = actual.trim();
let expected = self.content.trim();
assert_eq!(
actual, expected,
"Snapshot '{}' mismatch.\nExpected:\n{}\n\nActual:\n{}",
self.name, expected, actual
);
}
}
pub struct GoldenTest {
name: String,
directory: String,
}
impl GoldenTest {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
directory: "tests/golden".to_string(),
}
}
pub fn directory(mut self, dir: impl Into<String>) -> Self {
self.directory = dir.into();
self
}
pub fn path(&self) -> std::path::PathBuf {
std::path::PathBuf::from(&self.directory).join(format!("{}.golden", self.name))
}
pub fn assert_match(&self, actual: &str) {
let path = self.path();
let actual = actual.trim();
if std::env::var("UPDATE_GOLDEN").is_ok() {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(&path, actual).expect("Failed to write golden file");
return;
}
let expected = match std::fs::read_to_string(&path) {
Ok(content) => content,
Err(_) => {
panic!(
"Golden file not found: {:?}\nRun with UPDATE_GOLDEN=1 to create it.\nActual output:\n{}",
path, actual
);
}
};
let expected = expected.trim();
assert_eq!(
actual, expected,
"Golden test '{}' mismatch.\nExpected (from {:?}):\n{}\n\nActual:\n{}\n\nRun with UPDATE_GOLDEN=1 to update.",
self.name, path, expected, actual
);
}
pub fn assert_element_match(&self, element: &Element, width: u16) {
let renderer = TestRenderer::new(width, 100);
let output = renderer.render_to_plain(element);
self.assert_match(&output);
}
}
#[macro_export]
macro_rules! inline_snapshot {
($actual:expr, $expected:expr) => {{
let actual = $actual.trim();
let expected = $expected.trim();
assert_eq!(
actual, expected,
"Inline snapshot mismatch.\nExpected:\n{}\n\nActual:\n{}",
expected, actual
);
}};
}
#[macro_export]
macro_rules! assert_snapshot {
($name:expr, $actual:expr) => {{
let snapshot = $crate::testing::Snapshot::new($name, $actual);
snapshot.assert_match($actual);
}};
}
#[cfg(test)]
mod tests {
use super::*;
use crate::components::Text;
fn simple_component() -> Element {
Text::new("Hello, World!").into_element()
}
#[test]
fn test_harness_creation() {
let harness = TestHarness::new(simple_component);
assert!(harness.output().contains("Hello, World!"));
}
#[test]
fn test_assert_text_contains() {
let harness = TestHarness::new(simple_component);
harness.assert_text_contains("Hello");
harness.assert_text_contains("World");
}
#[test]
fn test_assert_text_not_contains() {
let harness = TestHarness::new(simple_component);
harness.assert_text_not_contains("Goodbye");
}
#[test]
fn test_lines() {
fn multi_line() -> Element {
use crate::components::Box as RnkBox;
use crate::core::FlexDirection;
RnkBox::new()
.flex_direction(FlexDirection::Column)
.child(Text::new("Line 1").into_element())
.child(Text::new("Line 2").into_element())
.child(Text::new("Line 3").into_element())
.into_element()
}
let harness = TestHarness::new(multi_line);
harness.assert_lines_contain(&["Line 1", "Line 2", "Line 3"]);
}
#[test]
fn test_custom_size() {
let harness = TestHarness::with_size(simple_component, 40, 10);
assert!(harness.output().contains("Hello"));
}
#[test]
fn test_contains() {
let harness = TestHarness::new(simple_component);
assert!(harness.contains("Hello"));
assert!(!harness.contains("Goodbye"));
}
}