use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct Color {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl Color {
pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b }
}
pub fn from_hex(hex: &str) -> Option<Self> {
let hex = hex.strip_prefix('#').unwrap_or(hex);
if hex.len() != 6 {
return None;
}
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some(Self { r, g, b })
}
pub fn to_hex(&self) -> String {
format!("#{:02X}{:02X}{:02X}", self.r, self.g, self.b)
}
pub const BLACK: Self = Self::rgb(0, 0, 0);
pub const WHITE: Self = Self::rgb(255, 255, 255);
pub const RED: Self = Self::rgb(255, 0, 0);
pub const GREEN: Self = Self::rgb(0, 255, 0);
pub const BLUE: Self = Self::rgb(0, 0, 255);
pub const CYAN: Self = Self::rgb(0, 255, 255);
pub const YELLOW: Self = Self::rgb(255, 255, 0);
pub const MAGENTA: Self = Self::rgb(255, 0, 255);
}
impl fmt::Display for Color {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_hex())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cell {
pub ch: char,
pub fg: Color,
pub bg: Color,
pub bold: bool,
pub underline: bool,
}
impl Default for Cell {
fn default() -> Self {
Self {
ch: ' ',
fg: Color::WHITE,
bg: Color::BLACK,
bold: false,
underline: false,
}
}
}
impl Cell {
pub fn new(ch: char) -> Self {
Self {
ch,
..Default::default()
}
}
pub fn with_fg(mut self, fg: Color) -> Self {
self.fg = fg;
self
}
pub fn with_bg(mut self, bg: Color) -> Self {
self.bg = bg;
self
}
}
#[derive(Debug, Clone)]
pub struct TerminalSnapshot {
cells: Vec<Cell>,
width: u16,
height: u16,
}
impl TerminalSnapshot {
pub fn new(width: u16, height: u16) -> Self {
let cells = vec![Cell::default(); (width as usize) * (height as usize)];
Self {
cells,
width,
height,
}
}
pub fn from_string(text: &str, width: u16, height: u16) -> Self {
let mut snapshot = Self::new(width, height);
for (y, line) in text.lines().enumerate() {
if y >= height as usize {
break;
}
for (x, ch) in line.chars().enumerate() {
if x >= width as usize {
break;
}
snapshot.set(x as u16, y as u16, Cell::new(ch));
}
}
snapshot
}
pub fn get(&self, x: u16, y: u16) -> Option<&Cell> {
if x >= self.width || y >= self.height {
return None;
}
let idx = (y as usize) * (self.width as usize) + (x as usize);
self.cells.get(idx)
}
pub fn set(&mut self, x: u16, y: u16, cell: Cell) {
if x < self.width && y < self.height {
let idx = (y as usize) * (self.width as usize) + (x as usize);
self.cells[idx] = cell;
}
}
pub fn dimensions(&self) -> (u16, u16) {
(self.width, self.height)
}
pub fn to_text(&self) -> String {
let mut result = String::new();
for y in 0..self.height {
for x in 0..self.width {
if let Some(cell) = self.get(x, y) {
result.push(cell.ch);
}
}
result.push('\n');
}
result
}
pub fn contains(&self, text: &str) -> bool {
self.to_text().contains(text)
}
pub fn contains_all(&self, texts: &[&str]) -> bool {
let content = self.to_text();
texts.iter().all(|t| content.contains(t))
}
pub fn contains_any(&self, texts: &[&str]) -> bool {
let content = self.to_text();
texts.iter().any(|t| content.contains(t))
}
pub fn fg_color_at(&self, x: u16, y: u16) -> Option<Color> {
self.get(x, y).map(|c| c.fg)
}
pub fn bg_color_at(&self, x: u16, y: u16) -> Option<Color> {
self.get(x, y).map(|c| c.bg)
}
pub fn count_char(&self, ch: char) -> usize {
self.cells.iter().filter(|c| c.ch == ch).count()
}
pub fn find(&self, text: &str) -> Option<(u16, u16)> {
let content = self.to_text();
let pos = content.find(text)?;
let line_start = content[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
let x = pos - line_start;
let y = content[..pos].matches('\n').count();
Some((x as u16, y as u16))
}
pub fn region(&self, x: u16, y: u16, width: u16, height: u16) -> Self {
let mut result = Self::new(width, height);
for dy in 0..height {
for dx in 0..width {
if let Some(cell) = self.get(x + dx, y + dy) {
result.set(dx, dy, cell.clone());
}
}
}
result
}
}
impl fmt::Display for TerminalSnapshot {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_text())
}
}
#[derive(Debug, Clone)]
pub enum TerminalAssertion {
Contains(String),
NotContains(String),
ColorAt {
x: u16,
y: u16,
expected: Color,
},
CharAt {
x: u16,
y: u16,
expected: char,
},
RegionEquals {
x: u16,
y: u16,
width: u16,
height: u16,
expected: String,
},
}
impl TerminalAssertion {
pub fn check(&self, snapshot: &TerminalSnapshot) -> Result<(), String> {
match self {
Self::Contains(text) => {
if snapshot.contains(text) {
Ok(())
} else {
Err(format!("Expected to contain: {}", text))
}
}
Self::NotContains(text) => {
if !snapshot.contains(text) {
Ok(())
} else {
Err(format!("Expected not to contain: {}", text))
}
}
Self::ColorAt { x, y, expected } => match snapshot.fg_color_at(*x, *y) {
Some(actual) if actual == *expected => Ok(()),
Some(actual) => Err(format!(
"Color at ({}, {}): expected {}, got {}",
x, y, expected, actual
)),
None => Err(format!("Position ({}, {}) out of bounds", x, y)),
},
Self::CharAt { x, y, expected } => match snapshot.get(*x, *y) {
Some(cell) if cell.ch == *expected => Ok(()),
Some(cell) => Err(format!(
"Char at ({}, {}): expected '{}', got '{}'",
x, y, expected, cell.ch
)),
None => Err(format!("Position ({}, {}) out of bounds", x, y)),
},
Self::RegionEquals {
x,
y,
width,
height,
expected,
} => {
let region = snapshot.region(*x, *y, *width, *height);
let actual = region.to_text().trim_end().to_string();
let expected = expected.trim_end();
if actual == expected {
Ok(())
} else {
Err(format!(
"Region at ({}, {}) {}x{}: expected\n{}\ngot\n{}",
x, y, width, height, expected, actual
))
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_color_from_hex() {
let color = Color::from_hex("#64C8FF").unwrap();
assert_eq!(color.r, 100);
assert_eq!(color.g, 200);
assert_eq!(color.b, 255);
}
#[test]
fn test_color_to_hex() {
let color = Color::rgb(100, 200, 255);
assert_eq!(color.to_hex(), "#64C8FF");
}
#[test]
fn test_color_from_hex_invalid() {
assert!(Color::from_hex("invalid").is_none());
assert!(Color::from_hex("#12345").is_none());
assert!(Color::from_hex("#GGGGGG").is_none());
}
#[test]
fn test_cell_default() {
let cell = Cell::default();
assert_eq!(cell.ch, ' ');
assert_eq!(cell.fg, Color::WHITE);
assert_eq!(cell.bg, Color::BLACK);
}
#[test]
fn test_cell_builder() {
let cell = Cell::new('A').with_fg(Color::RED).with_bg(Color::BLUE);
assert_eq!(cell.ch, 'A');
assert_eq!(cell.fg, Color::RED);
assert_eq!(cell.bg, Color::BLUE);
}
#[test]
fn test_snapshot_new() {
let snapshot = TerminalSnapshot::new(80, 24);
assert_eq!(snapshot.dimensions(), (80, 24));
}
#[test]
fn test_snapshot_from_string() {
let snapshot = TerminalSnapshot::from_string("Hello\nWorld", 80, 24);
assert!(snapshot.contains("Hello"));
assert!(snapshot.contains("World"));
}
#[test]
fn test_snapshot_get_set() {
let mut snapshot = TerminalSnapshot::new(10, 10);
snapshot.set(5, 5, Cell::new('X'));
let cell = snapshot.get(5, 5).unwrap();
assert_eq!(cell.ch, 'X');
}
#[test]
fn test_snapshot_get_out_of_bounds() {
let snapshot = TerminalSnapshot::new(10, 10);
assert!(snapshot.get(100, 100).is_none());
}
#[test]
fn test_snapshot_contains() {
let snapshot = TerminalSnapshot::from_string("CPU 45%\nMEM 60%", 80, 24);
assert!(snapshot.contains("CPU"));
assert!(snapshot.contains("45%"));
assert!(!snapshot.contains("GPU"));
}
#[test]
fn test_snapshot_contains_all() {
let snapshot = TerminalSnapshot::from_string("CPU 45%\nMEM 60%", 80, 24);
assert!(snapshot.contains_all(&["CPU", "MEM"]));
assert!(!snapshot.contains_all(&["CPU", "GPU"]));
}
#[test]
fn test_snapshot_contains_any() {
let snapshot = TerminalSnapshot::from_string("CPU 45%", 80, 24);
assert!(snapshot.contains_any(&["CPU", "GPU"]));
assert!(!snapshot.contains_any(&["GPU", "DISK"]));
}
#[test]
fn test_snapshot_find() {
let snapshot = TerminalSnapshot::from_string("Hello World", 80, 24);
let pos = snapshot.find("World").unwrap();
assert_eq!(pos, (6, 0));
}
#[test]
fn test_snapshot_count_char() {
let snapshot = TerminalSnapshot::from_string("AAABBC", 80, 24);
assert_eq!(snapshot.count_char('A'), 3);
assert_eq!(snapshot.count_char('B'), 2);
assert_eq!(snapshot.count_char('C'), 1);
}
#[test]
fn test_snapshot_region() {
let snapshot = TerminalSnapshot::from_string("ABCD\nEFGH\nIJKL", 80, 24);
let region = snapshot.region(1, 1, 2, 2);
assert!(region.contains("FG"));
}
#[test]
fn test_assertion_contains() {
let snapshot = TerminalSnapshot::from_string("Hello", 80, 24);
let assertion = TerminalAssertion::Contains("Hello".into());
assert!(assertion.check(&snapshot).is_ok());
let assertion = TerminalAssertion::Contains("World".into());
assert!(assertion.check(&snapshot).is_err());
}
#[test]
fn test_assertion_not_contains() {
let snapshot = TerminalSnapshot::from_string("Hello", 80, 24);
let assertion = TerminalAssertion::NotContains("World".into());
assert!(assertion.check(&snapshot).is_ok());
let assertion = TerminalAssertion::NotContains("Hello".into());
assert!(assertion.check(&snapshot).is_err());
}
#[test]
fn test_assertion_color_at() {
let mut snapshot = TerminalSnapshot::new(10, 10);
snapshot.set(5, 5, Cell::new('X').with_fg(Color::RED));
let assertion = TerminalAssertion::ColorAt {
x: 5,
y: 5,
expected: Color::RED,
};
assert!(assertion.check(&snapshot).is_ok());
let assertion = TerminalAssertion::ColorAt {
x: 5,
y: 5,
expected: Color::BLUE,
};
assert!(assertion.check(&snapshot).is_err());
}
#[test]
fn test_assertion_char_at() {
let snapshot = TerminalSnapshot::from_string("ABC", 80, 24);
let assertion = TerminalAssertion::CharAt {
x: 1,
y: 0,
expected: 'B',
};
assert!(assertion.check(&snapshot).is_ok());
}
#[test]
fn test_color_display() {
let color = Color::rgb(100, 200, 255);
assert_eq!(format!("{}", color), "#64C8FF");
}
#[test]
fn test_snapshot_display() {
let snapshot = TerminalSnapshot::from_string("Test", 10, 1);
let display = format!("{}", snapshot);
assert!(display.contains("Test"));
}
}