rnk 0.19.3

A React-like declarative terminal UI framework for Rust, inspired by Ink and Bubbletea
Documentation
//! Golden file (snapshot) testing support
//!
//! Provides utilities for comparing rendered output against
//! saved "golden" reference files.
#![allow(dead_code)]

use std::fs;
use std::path::PathBuf;

use super::renderer::TestRenderer;
use crate::core::Element;

/// Directory for golden files
const GOLDEN_DIR: &str = "tests/golden";

/// Result of a golden file comparison
#[derive(Debug)]
pub enum GoldenResult {
    /// Output matches the golden file
    Match,
    /// Golden file was created because `UPDATE_GOLDEN` was set.
    Created,
    /// Golden file does not exist and `UPDATE_GOLDEN` was not set.
    Missing { path: PathBuf, actual: String },
    /// Output differs from golden file
    Mismatch {
        expected: String,
        actual: String,
        diff: String,
    },
}

/// Output format used for golden files.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum GoldenFormat {
    /// Render without ANSI escape sequences.
    #[default]
    Plain,
    /// Render with ANSI escape sequences.
    Ansi,
}

/// Golden file test context
pub struct GoldenTest {
    name: String,
    width: u16,
    height: u16,
    format: GoldenFormat,
}

impl GoldenTest {
    /// Create a new golden test with the given name
    pub fn new(name: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            width: 80,
            height: 24,
            format: GoldenFormat::Plain,
        }
    }

    /// Set terminal dimensions for this test
    pub fn with_size(mut self, width: u16, height: u16) -> Self {
        self.width = width;
        self.height = height;
        self
    }

    /// Set the output format for this golden test.
    pub fn with_format(mut self, format: GoldenFormat) -> Self {
        self.format = format;
        self
    }

    /// Compare against a plain-text golden file.
    pub fn plain(self) -> Self {
        self.with_format(GoldenFormat::Plain)
    }

    /// Compare against an ANSI golden file.
    pub fn ansi(self) -> Self {
        self.with_format(GoldenFormat::Ansi)
    }

    /// Get the path to the golden file
    fn golden_path(&self) -> PathBuf {
        let suffix = match self.format {
            GoldenFormat::Plain => "txt",
            GoldenFormat::Ansi => "ansi.txt",
        };
        PathBuf::from(GOLDEN_DIR).join(format!("{}.{}", self.name, suffix))
    }

    /// Compare element output against golden file
    pub fn assert_match(&self, element: &Element) {
        let result = self.compare(element);
        match result {
            GoldenResult::Match => {}
            GoldenResult::Created => {
                println!("Golden file created: {}", self.golden_path().display());
            }
            GoldenResult::Missing { path, actual } => {
                panic!(
                    "\n\nGolden file missing for '{}': {}\n\nActual:\n{}\n\nRun with UPDATE_GOLDEN=1 to create it.",
                    self.name,
                    path.display(),
                    actual
                );
            }
            GoldenResult::Mismatch {
                expected,
                actual,
                diff,
            } => {
                panic!(
                    "\n\nGolden file mismatch for '{}':\n\n{}\n\nExpected:\n{}\n\nActual:\n{}\n",
                    self.name, diff, expected, actual
                );
            }
        }
    }

    /// Compare element output against golden file (without panic)
    pub fn compare(&self, element: &Element) -> GoldenResult {
        let actual = self.render(element);

        let golden_path = self.golden_path();

        if !golden_path.exists() {
            if should_update_golden() {
                if let Some(parent) = golden_path.parent() {
                    if fs::create_dir_all(parent).is_err() {
                        return GoldenResult::Missing {
                            path: golden_path,
                            actual,
                        };
                    }
                }
                return if fs::write(&golden_path, &actual).is_ok() {
                    GoldenResult::Created
                } else {
                    GoldenResult::Missing {
                        path: golden_path,
                        actual,
                    }
                };
            }
            return GoldenResult::Missing {
                path: golden_path,
                actual,
            };
        }

        let expected = normalize_golden_output(
            &fs::read_to_string(&golden_path).unwrap_or_else(|_| String::new()),
        );

        if actual == expected {
            GoldenResult::Match
        } else {
            GoldenResult::Mismatch {
                expected: expected.clone(),
                actual: actual.clone(),
                diff: simple_diff(&expected, &actual),
            }
        }
    }

    /// Update the golden file with new output
    pub fn update(&self, element: &Element) -> std::io::Result<()> {
        let output = self.render(element);

        let golden_path = self.golden_path();
        if let Some(parent) = golden_path.parent() {
            fs::create_dir_all(parent)?;
        }
        fs::write(golden_path, output)
    }

    fn render(&self, element: &Element) -> String {
        let renderer = TestRenderer::new(self.width, self.height);
        let output = match self.format {
            GoldenFormat::Plain => renderer.render_to_plain(element),
            GoldenFormat::Ansi => renderer.render_to_ansi(element),
        };
        normalize_golden_output(&output)
    }
}

fn should_update_golden() -> bool {
    std::env::var("UPDATE_GOLDEN").is_ok()
}

fn normalize_golden_output(output: &str) -> String {
    let output = output.replace("\r\n", "\n").replace('\r', "\n");
    output
        .lines()
        .map(str::trim_end)
        .collect::<Vec<_>>()
        .join("\n")
}

/// Generate a simple diff between two strings
fn simple_diff(expected: &str, actual: &str) -> String {
    let expected_lines: Vec<&str> = expected.lines().collect();
    let actual_lines: Vec<&str> = actual.lines().collect();

    let mut diff = String::new();
    let max_lines = expected_lines.len().max(actual_lines.len());

    for i in 0..max_lines {
        let exp_line = expected_lines.get(i).copied().unwrap_or("");
        let act_line = actual_lines.get(i).copied().unwrap_or("");

        if exp_line != act_line {
            diff.push_str(&format!("Line {}:\n", i + 1));
            diff.push_str(&format!("  - {}\n", exp_line));
            diff.push_str(&format!("  + {}\n", act_line));
        }
    }

    if diff.is_empty() && expected_lines.len() != actual_lines.len() {
        diff = format!(
            "Line count differs: expected {}, got {}",
            expected_lines.len(),
            actual_lines.len()
        );
    }

    diff
}

/// Macro for creating golden tests
#[macro_export]
macro_rules! golden_test {
    ($name:ident, $element:expr) => {
        #[test]
        fn $name() {
            let golden = $crate::testing::GoldenTest::new(stringify!($name));
            golden.assert_match(&$element);
        }
    };

    ($name:ident, $width:expr, $height:expr, $element:expr) => {
        #[test]
        fn $name() {
            let golden =
                $crate::testing::GoldenTest::new(stringify!($name)).with_size($width, $height);
            golden.assert_match(&$element);
        }
    };
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_simple_diff() {
        let diff = simple_diff("hello\nworld", "hello\nearth");
        assert!(diff.contains("Line 2"));
        assert!(diff.contains("- world"));
        assert!(diff.contains("+ earth"));
    }

    #[test]
    fn test_golden_path() {
        let golden = GoldenTest::new("my_test");
        assert!(golden.golden_path().ends_with("my_test.txt"));
    }

    #[test]
    fn test_golden_ansi_path() {
        let golden = GoldenTest::new("my_test").ansi();
        assert!(golden.golden_path().ends_with("my_test.ansi.txt"));
    }

    #[test]
    fn test_normalize_golden_output_removes_terminal_padding() {
        assert_eq!(
            normalize_golden_output("alpha   \r\nbeta\t \n"),
            "alpha\nbeta"
        );
    }
}