tazuna 0.1.0

TUI tool for managing multiple Claude Code sessions in parallel
Documentation
//! Test utilities for TUI snapshot testing.
//!
//! Provides style-aware buffer rendering for visual regression tests.

use ratatui::{
    buffer::Buffer,
    layout::Rect,
    style::{Color, Modifier},
    widgets::{ListState, StatefulWidget, Widget},
};

/// Render widget to style-aware string for snapshot testing.
///
/// Format: `[fg:mods]char` where default style omits brackets.
/// Examples:
/// - `[G]●` - Green circle
/// - `[W+B]text` - White Bold text
/// - `[dG]|` - `DarkGray` separator
#[must_use]
pub fn render_to_snapshot<W: Widget>(widget: W, width: u16, height: u16) -> String {
    let area = Rect::new(0, 0, width, height);
    let mut buf = Buffer::empty(area);
    widget.render(area, &mut buf);
    buffer_to_snapshot(&buf)
}

/// Render stateful widget to style-aware string for snapshot testing.
///
/// Use for widgets implementing `StatefulWidget` trait (e.g., `List`, `SelectableList`).
#[must_use]
pub fn render_stateful_to_snapshot<W, S>(
    widget: W,
    state: &mut S,
    width: u16,
    height: u16,
) -> String
where
    W: StatefulWidget<State = S>,
{
    let area = Rect::new(0, 0, width, height);
    let mut buf = Buffer::empty(area);
    widget.render(area, &mut buf, state);
    buffer_to_snapshot(&buf)
}

/// Create a `ListState` with optional selection for testing.
///
/// # Examples
/// ```ignore
/// let state = mock_list_state(Some(0)); // First item selected
/// let state = mock_list_state(None);    // No selection
/// ```
#[must_use]
pub fn mock_list_state(selected: Option<usize>) -> ListState {
    let mut state = ListState::default();
    state.select(selected);
    state
}

/// Convert buffer to plain text (symbols only, no style).
///
/// Use for simple content verification without style info.
#[must_use]
pub fn buffer_to_text(buf: &Buffer) -> String {
    let mut output = String::new();
    for y in 0..buf.area.height {
        for x in 0..buf.area.width {
            output.push_str(buf[(x, y)].symbol());
        }
        output.push('\n');
    }
    output
}

/// Convert buffer to style-aware snapshot string.
#[must_use]
pub fn buffer_to_snapshot(buf: &Buffer) -> String {
    let mut output = String::new();
    for y in 0..buf.area.height {
        for x in 0..buf.area.width {
            let cell = &buf[(x, y)];
            output.push_str(&format_cell(cell.symbol(), cell.fg, cell.modifier));
        }
        output.push('\n');
    }
    // Remove trailing empty lines but keep structure
    output.trim_end_matches('\n').to_string() + "\n"
}

/// Format single cell with optional style prefix.
fn format_cell(symbol: &str, fg: Color, modifier: Modifier) -> String {
    let style_prefix = format_style(fg, modifier);
    if style_prefix.is_empty() {
        symbol.to_string()
    } else {
        format!("[{style_prefix}]{symbol}")
    }
}

/// Format style as compact string.
/// Returns empty string for default style.
fn format_style(fg: Color, modifier: Modifier) -> String {
    let color_code = color_to_code(fg);
    let mod_code = modifier_to_code(modifier);

    match (color_code.is_empty(), mod_code.is_empty()) {
        (true, true) => String::new(),
        (false, true) => color_code,
        (true, false) => mod_code,
        (false, false) => format!("{color_code}+{mod_code}"),
    }
}

/// Convert color to compact code.
fn color_to_code(color: Color) -> String {
    match color {
        Color::Reset => String::new(),
        Color::Black => "Bk".to_string(),
        Color::Red => "R".to_string(),
        Color::Green => "G".to_string(),
        Color::Yellow => "Y".to_string(),
        Color::Blue => "Bl".to_string(),
        Color::Magenta => "M".to_string(),
        Color::Cyan => "C".to_string(),
        Color::Gray => "Gy".to_string(),
        Color::DarkGray => "dG".to_string(),
        Color::LightRed => "lR".to_string(),
        Color::LightGreen => "lG".to_string(),
        Color::LightYellow => "lY".to_string(),
        Color::LightBlue => "lB".to_string(),
        Color::LightMagenta => "lM".to_string(),
        Color::LightCyan => "lC".to_string(),
        Color::White => "W".to_string(),
        Color::Indexed(i) => format!("i{i}"),
        Color::Rgb(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"),
    }
}

/// Convert modifiers to compact code.
fn modifier_to_code(modifier: Modifier) -> String {
    let mut codes = Vec::new();
    if modifier.contains(Modifier::BOLD) {
        codes.push("B");
    }
    if modifier.contains(Modifier::DIM) {
        codes.push("D");
    }
    if modifier.contains(Modifier::ITALIC) {
        codes.push("I");
    }
    if modifier.contains(Modifier::UNDERLINED) {
        codes.push("U");
    }
    if modifier.contains(Modifier::REVERSED) {
        codes.push("V");
    }
    if modifier.contains(Modifier::CROSSED_OUT) {
        codes.push("X");
    }
    codes.join("")
}

#[cfg(test)]
mod tests {
    use super::*;
    use ratatui::style::Style;
    use rstest::rstest;

    #[rstest]
    #[case(Color::Reset, Modifier::empty(), "")]
    #[case(Color::Green, Modifier::empty(), "G")]
    #[case(Color::Red, Modifier::empty(), "R")]
    #[case(Color::DarkGray, Modifier::empty(), "dG")]
    #[case(Color::Reset, Modifier::BOLD, "B")]
    #[case(Color::Reset, Modifier::BOLD | Modifier::ITALIC, "BI")]
    #[case(Color::White, Modifier::BOLD, "W+B")]
    #[case(Color::Green, Modifier::BOLD | Modifier::UNDERLINED, "G+BU")]
    fn format_style_variants(#[case] fg: Color, #[case] m: Modifier, #[case] expected: &str) {
        assert_eq!(format_style(fg, m), expected);
    }

    #[rstest]
    #[case("a", Color::Reset, Modifier::empty(), "a")]
    #[case("", Color::Green, Modifier::empty(), "[G]●")]
    #[case("x", Color::White, Modifier::BOLD, "[W+B]x")]
    fn format_cell_variants(
        #[case] symbol: &str,
        #[case] fg: Color,
        #[case] m: Modifier,
        #[case] expected: &str,
    ) {
        assert_eq!(format_cell(symbol, fg, m), expected);
    }

    #[rstest]
    #[case(Color::Indexed(1), "i1")]
    #[case(Color::Indexed(255), "i255")]
    #[case(Color::Rgb(255, 0, 128), "#ff0080")]
    fn color_to_code_special(#[case] color: Color, #[case] expected: &str) {
        assert_eq!(color_to_code(color), expected);
    }

    #[test]
    fn test_buffer_to_snapshot_simple() {
        let area = Rect::new(0, 0, 3, 1);
        let mut buf = Buffer::empty(area);
        buf[(0, 0)].set_symbol("a");
        buf[(1, 0)].set_symbol("b");
        buf[(2, 0)].set_symbol("c");

        let snapshot = buffer_to_snapshot(&buf);
        assert_eq!(snapshot, "abc\n");
    }

    #[test]
    fn test_buffer_to_snapshot_with_style() {
        let area = Rect::new(0, 0, 2, 1);
        let mut buf = Buffer::empty(area);
        buf[(0, 0)]
            .set_symbol("")
            .set_style(Style::default().fg(Color::Green));
        buf[(1, 0)].set_symbol(" ");

        let snapshot = buffer_to_snapshot(&buf);
        assert_eq!(snapshot, "[G]● \n");
    }

    #[test]
    fn test_mock_list_state_with_selection() {
        let state = mock_list_state(Some(2));
        assert_eq!(state.selected(), Some(2));
    }

    #[test]
    fn test_mock_list_state_no_selection() {
        let state = mock_list_state(None);
        assert_eq!(state.selected(), None);
    }

    #[test]
    fn test_render_stateful_to_snapshot() {
        use ratatui::widgets::{List, ListItem};

        use crate::tui::theme::{self, BlockVariant};

        let items = vec![ListItem::new("Item 1"), ListItem::new("Item 2")];
        let list = List::new(items).block(theme::block("Test", BlockVariant::Focused));
        let mut state = mock_list_state(Some(0));

        let snapshot = render_stateful_to_snapshot(list, &mut state, 20, 5);
        assert!(snapshot.contains("Item 1"));
        assert!(snapshot.contains("Item 2"));
    }
}