tui-test-rs 0.1.0-beta.2

In-process terminal automation, inspection, assertions, and recording
Documentation
use std::path::{Path, PathBuf};
use std::time::Duration;

use crate::api::RecordingFormat;
use crate::profile::Profile;
use crate::record::frames::Frame;
use crate::render::svg::RenderState;
use crate::terminal::alacritty::AlacrittyEmu;
use crate::terminal::cell::{Attrs, Color, EmuCell, NamedColor, UnderlineStyle};

use super::encode;
use super::raster::GridRenderer;

const COLS: u16 = 28;
const ROWS: usize = 5;
const UPDATE_ENV: &str = "UPDATE_TUI_TEST_RENDER_SNAPSHOTS";

#[test]
fn single_frame_gif_and_png_renders_match_snapshots() {
    for case in cases() {
        for (format, extension) in [
            (RecordingFormat::Gif, "gif"),
            (RecordingFormat::Apng, "png"),
        ] {
            let output = temporary_output(case.name, extension);
            let frame = Frame {
                grid: case.grid.clone(),
                title: None,
                duration: Duration::from_millis(250),
                render_state: RenderState::capture(&AlacrittyEmu::new(
                    COLS,
                    ROWS as u16,
                    &Profile::default(),
                )),
                cursor: None,
            };
            let mut renderer = GridRenderer::with_scale(COLS, ROWS, 2);
            encode::encode(&output, format, &[frame], &mut renderer, 30, None).unwrap();
            let actual = std::fs::read(&output).unwrap();
            std::fs::remove_file(output).unwrap();
            assert_snapshot(case.name, extension, &actual);
        }
    }
}

struct SnapshotCase {
    name: &'static str,
    grid: Vec<Vec<EmuCell>>,
}

fn cases() -> [SnapshotCase; 3] {
    [
        SnapshotCase {
            name: "regular",
            grid: regular_grid(),
        },
        SnapshotCase {
            name: "styles",
            grid: styles_grid(),
        },
        SnapshotCase {
            name: "nerd-fonts",
            grid: nerd_font_grid(),
        },
    ]
}

fn regular_grid() -> Vec<Vec<EmuCell>> {
    let mut grid = blank_grid();
    write_text(
        &mut grid,
        0,
        0,
        "tui-test render snapshot",
        CellStyle::default(),
    );
    write_text(
        &mut grid,
        1,
        0,
        "ANSI color palette",
        CellStyle {
            fg: Some(Color::Named(NamedColor::BrightCyan)),
            ..CellStyle::default()
        },
    );
    write_text(
        &mut grid,
        2,
        0,
        "truecolor background",
        CellStyle {
            fg: Some(Color::Rgb(250, 245, 235)),
            bg: Some(Color::Rgb(96, 48, 128)),
            ..CellStyle::default()
        },
    );
    write_text(&mut grid, 3, 0, "0123456789 -> [] {}", CellStyle::default());
    write_text(
        &mut grid,
        4,
        0,
        "full glyphs: \u{03bb} \u{0416} \u{2260}",
        CellStyle::default(),
    );
    grid
}

fn styles_grid() -> Vec<Vec<EmuCell>> {
    let mut grid = blank_grid();
    write_text(&mut grid, 0, 0, "Regular", CellStyle::default());
    write_text(
        &mut grid,
        1,
        0,
        "Bold",
        CellStyle {
            attrs: Attrs::BOLD,
            fg: Some(Color::Named(NamedColor::BrightYellow)),
            ..CellStyle::default()
        },
    );
    write_text(
        &mut grid,
        2,
        0,
        "Italic",
        CellStyle {
            attrs: Attrs::ITALIC,
            fg: Some(Color::Named(NamedColor::BrightGreen)),
            ..CellStyle::default()
        },
    );
    write_text(
        &mut grid,
        3,
        0,
        "Bold Italic",
        CellStyle {
            attrs: Attrs::BOLD | Attrs::ITALIC,
            fg: Some(Color::Named(NamedColor::BrightMagenta)),
            ..CellStyle::default()
        },
    );
    write_text(
        &mut grid,
        4,
        0,
        "Underline Strike",
        CellStyle {
            attrs: Attrs::STRIKE,
            fg: Some(Color::Rgb(220, 220, 220)),
            underline: UnderlineStyle::Single,
            underline_color: Some(Color::Rgb(90, 180, 255)),
            ..CellStyle::default()
        },
    );
    grid
}

fn nerd_font_grid() -> Vec<Vec<EmuCell>> {
    let mut grid = blank_grid();
    write_text(
        &mut grid,
        0,
        0,
        "Nerd Font:",
        CellStyle {
            fg: Some(Color::Named(NamedColor::BrightBlue)),
            ..CellStyle::default()
        },
    );
    set_cell(
        &mut grid,
        0,
        11,
        "\u{f115}",
        CellStyle {
            fg: Some(Color::Named(NamedColor::BrightYellow)),
            ..CellStyle::default()
        },
    );
    set_cell(
        &mut grid,
        0,
        13,
        "\u{e0b0}",
        CellStyle {
            fg: Some(Color::Rgb(80, 160, 255)),
            bg: Some(Color::Rgb(30, 60, 100)),
            ..CellStyle::default()
        },
    );
    write_text(
        &mut grid,
        1,
        0,
        "\u{250c}\u{2500}\u{2500}\u{2500}\u{2510}  \u{2190} \u{2192}  \u{2588}\u{2593}\u{2592}\u{2591}",
        CellStyle::default(),
    );
    write_text(
        &mut grid,
        2,
        0,
        "private-use vector paths",
        CellStyle {
            attrs: Attrs::ITALIC,
            ..CellStyle::default()
        },
    );
    write_text(
        &mut grid,
        3,
        0,
        "Powerline cell fill",
        CellStyle {
            attrs: Attrs::BOLD,
            fg: Some(Color::Rgb(180, 210, 255)),
            bg: Some(Color::Rgb(35, 45, 65)),
            ..CellStyle::default()
        },
    );
    write_text(
        &mut grid,
        4,
        0,
        "deterministic raster output",
        CellStyle::default(),
    );
    grid
}

#[derive(Clone, Copy, Default)]
struct CellStyle {
    fg: Option<Color>,
    bg: Option<Color>,
    attrs: Attrs,
    underline: UnderlineStyle,
    underline_color: Option<Color>,
}

fn blank_grid() -> Vec<Vec<EmuCell>> {
    vec![vec![EmuCell::blank(); usize::from(COLS)]; ROWS]
}

fn write_text(grid: &mut [Vec<EmuCell>], row: usize, column: usize, text: &str, style: CellStyle) {
    for (offset, character) in text.chars().enumerate() {
        set_cell(grid, row, column + offset, &character.to_string(), style);
    }
}

fn set_cell(
    grid: &mut [Vec<EmuCell>],
    row: usize,
    column: usize,
    character: &str,
    style: CellStyle,
) {
    let cell = grid
        .get_mut(row)
        .and_then(|row| row.get_mut(column))
        .unwrap_or_else(|| panic!("snapshot cell {column},{row} exceeds {COLS}x{ROWS}"));
    *cell = EmuCell {
        ch: character.into(),
        fg: style.fg,
        bg: style.bg,
        attrs: style.attrs,
        underline: style.underline,
        underline_color: style.underline_color,
    };
}

fn assert_snapshot(name: &str, extension: &str, actual: &[u8]) {
    let expected_path = snapshot_dir().join(format!("{name}.{extension}"));
    if std::env::var(UPDATE_ENV).as_deref() == Ok("1") {
        std::fs::create_dir_all(snapshot_dir()).unwrap();
        std::fs::write(expected_path, actual).unwrap();
        return;
    }

    let expected = std::fs::read(&expected_path).unwrap_or_else(|error| {
        panic!(
            "could not read render snapshot {}: {error}; set {UPDATE_ENV}=1 to create it",
            expected_path.display()
        )
    });
    if expected != actual {
        let actual_path = failure_dir().join(format!("{name}.actual.{extension}"));
        std::fs::create_dir_all(failure_dir()).unwrap();
        std::fs::write(&actual_path, actual).unwrap();
        panic!(
            "render snapshot {} changed (expected {} bytes, got {}); actual output: {}; \
             set {UPDATE_ENV}=1 to accept it",
            expected_path.display(),
            expected.len(),
            actual.len(),
            actual_path.display()
        );
    }
}

fn snapshot_dir() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("render-snapshots")
}

fn failure_dir() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("..")
        .join("..")
        .join("target")
        .join("render-snapshot-failures")
}

fn temporary_output(name: &str, extension: &str) -> PathBuf {
    std::env::temp_dir().join(format!(
        "tui-test-render-snapshot-{}-{name}.{extension}",
        std::process::id()
    ))
}