use std::collections::HashMap;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, PartialEq)]
pub struct TuiCell {
pub ch: char,
pub fg: (u8, u8, u8),
pub bg: (u8, u8, u8),
pub bold: bool,
}
impl Default for TuiCell {
fn default() -> Self {
Self {
ch: ' ',
fg: (255, 255, 255),
bg: (0, 0, 0),
bold: false,
}
}
}
#[derive(Debug)]
pub struct TuiTestBackend {
pub width: u16,
pub height: u16,
cells: Vec<TuiCell>,
frame_count: u64,
metrics: RenderMetrics,
deterministic: bool,
}
impl TuiTestBackend {
pub fn new(width: u16, height: u16) -> Self {
let size = width as usize * height as usize;
Self {
width,
height,
cells: vec![TuiCell::default(); size],
frame_count: 0,
metrics: RenderMetrics::new(),
deterministic: true,
}
}
pub fn with_deterministic(mut self, enabled: bool) -> Self {
self.deterministic = enabled;
self
}
pub fn clear(&mut self) {
for cell in &mut self.cells {
*cell = TuiCell::default();
}
}
pub fn get(&self, x: u16, y: u16) -> Option<&TuiCell> {
if x < self.width && y < self.height {
Some(&self.cells[y as usize * self.width as usize + x as usize])
} else {
None
}
}
pub fn set(&mut self, x: u16, y: u16, cell: TuiCell) {
if x < self.width && y < self.height {
self.cells[y as usize * self.width as usize + x as usize] = cell;
}
}
pub fn draw_text(&mut self, x: u16, y: u16, text: &str, fg: (u8, u8, u8)) {
for (i, ch) in text.chars().enumerate() {
let col = x + i as u16;
if col < self.width {
self.set(
col,
y,
TuiCell {
ch,
fg,
bg: (0, 0, 0),
bold: false,
},
);
}
}
}
pub fn render<F: FnOnce(&mut Self)>(&mut self, f: F) {
let start = Instant::now();
self.clear();
f(self);
let elapsed = start.elapsed();
self.metrics.record_frame(elapsed);
self.frame_count += 1;
}
pub fn extract_row(&self, y: u16) -> String {
if y >= self.height {
return String::new();
}
let start = y as usize * self.width as usize;
let end = start + self.width as usize;
self.cells[start..end].iter().map(|c| c.ch).collect()
}
pub fn extract_text_at(&self, x: u16, y: u16) -> String {
let mut result = String::new();
let mut col = x;
while col < self.width {
if let Some(cell) = self.get(col, y) {
if cell.ch == ' ' && !result.is_empty() {
break;
}
if cell.ch != ' ' {
result.push(cell.ch);
}
}
col += 1;
}
result
}
pub fn extract_region(&self, x: u16, y: u16, width: u16, height: u16) -> Vec<String> {
let mut lines = Vec::with_capacity(height as usize);
for row in y..(y + height).min(self.height) {
let mut line = String::with_capacity(width as usize);
for col in x..(x + width).min(self.width) {
if let Some(cell) = self.get(col, row) {
line.push(cell.ch);
}
}
lines.push(line);
}
lines
}
pub fn to_string_plain(&self) -> String {
let mut result = String::with_capacity((self.width as usize + 1) * self.height as usize);
for y in 0..self.height {
result.push_str(&self.extract_row(y));
result.push('\n');
}
result
}
pub fn frame_count(&self) -> u64 {
self.frame_count
}
pub fn metrics(&self) -> &RenderMetrics {
&self.metrics
}
pub fn snapshot(&self) -> TuiSnapshot {
TuiSnapshot {
width: self.width,
height: self.height,
cells: self.cells.clone(),
metadata: HashMap::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct TuiSnapshot {
pub width: u16,
pub height: u16,
pub cells: Vec<TuiCell>,
pub metadata: HashMap<String, String>,
}
impl TuiSnapshot {
pub fn load(path: &str) -> Result<Self, SnapshotError> {
let content =
std::fs::read_to_string(path).map_err(|e| SnapshotError::IoError(e.to_string()))?;
Self::parse(&content)
}
pub fn save(&self, path: &str) -> Result<(), SnapshotError> {
let content = self.serialize();
std::fs::write(path, content).map_err(|e| SnapshotError::IoError(e.to_string()))
}
pub fn parse(content: &str) -> Result<Self, SnapshotError> {
let lines: Vec<&str> = content.lines().collect();
if lines.is_empty() {
return Err(SnapshotError::ParseError("Empty snapshot".into()));
}
let dims: Vec<u16> = lines[0]
.split('x')
.filter_map(|s| s.trim().parse().ok())
.collect();
if dims.len() != 2 {
return Err(SnapshotError::ParseError("Invalid dimensions".into()));
}
let width = dims[0];
let height = dims[1];
let mut cells = vec![TuiCell::default(); width as usize * height as usize];
for (y, line) in lines.iter().skip(1).take(height as usize).enumerate() {
for (x, ch) in line.chars().take(width as usize).enumerate() {
cells[y * width as usize + x].ch = ch;
}
}
Ok(Self {
width,
height,
cells,
metadata: HashMap::new(),
})
}
pub fn serialize(&self) -> String {
let mut result = format!("{}x{}\n", self.width, self.height);
for y in 0..self.height {
for x in 0..self.width {
let idx = y as usize * self.width as usize + x as usize;
result.push(self.cells[idx].ch);
}
result.push('\n');
}
result
}
pub fn metadata(&self, key: &str) -> &str {
self.metadata.get(key).map(|s| s.as_str()).unwrap_or("")
}
pub fn with_metadata(mut self, key: &str, value: &str) -> Self {
self.metadata.insert(key.to_string(), value.to_string());
self
}
pub fn diff(&self, other: &TuiSnapshot) -> SnapshotDiff {
let mut diff = SnapshotDiff {
matches: true,
differences: Vec::new(),
total_cells: self.width as usize * self.height as usize,
matching_cells: 0,
};
if self.width != other.width || self.height != other.height {
diff.matches = false;
diff.differences.push(DiffEntry {
x: 0,
y: 0,
expected: format!("{}x{}", self.width, self.height),
actual: format!("{}x{}", other.width, other.height),
});
return diff;
}
for y in 0..self.height {
for x in 0..self.width {
let idx = y as usize * self.width as usize + x as usize;
if self.cells[idx] == other.cells[idx] {
diff.matching_cells += 1;
} else {
diff.matches = false;
diff.differences.push(DiffEntry {
x,
y,
expected: self.cells[idx].ch.to_string(),
actual: other.cells[idx].ch.to_string(),
});
}
}
}
diff
}
}
#[derive(Debug)]
pub enum SnapshotError {
IoError(String),
ParseError(String),
}
#[derive(Debug)]
pub struct SnapshotDiff {
pub matches: bool,
pub differences: Vec<DiffEntry>,
pub total_cells: usize,
pub matching_cells: usize,
}
impl SnapshotDiff {
pub fn match_percentage(&self) -> f64 {
if self.total_cells == 0 {
100.0
} else {
self.matching_cells as f64 / self.total_cells as f64 * 100.0
}
}
}
#[derive(Debug)]
pub struct DiffEntry {
pub x: u16,
pub y: u16,
pub expected: String,
pub actual: String,
}
#[derive(Debug, Clone, Default)]
pub struct RenderMetrics {
pub frame_count: u64,
samples: Vec<u64>,
}
impl RenderMetrics {
pub fn new() -> Self {
Self {
frame_count: 0,
samples: Vec::with_capacity(1000),
}
}
pub fn record_frame(&mut self, duration: Duration) {
self.frame_count += 1;
self.samples.push(duration.as_micros() as u64);
}
pub fn min_us(&self) -> u64 {
self.samples.iter().min().copied().unwrap_or(0)
}
pub fn max_us(&self) -> u64 {
self.samples.iter().max().copied().unwrap_or(0)
}
pub fn mean_us(&self) -> f64 {
if self.samples.is_empty() {
0.0
} else {
self.samples.iter().sum::<u64>() as f64 / self.samples.len() as f64
}
}
pub fn percentile(&self, p: u8) -> u64 {
if self.samples.is_empty() {
return 0;
}
let mut sorted = self.samples.clone();
sorted.sort_unstable();
let idx = (sorted.len() as f64 * p as f64 / 100.0) as usize;
sorted[idx.min(sorted.len() - 1)]
}
pub fn meets_targets(&self, targets: &PerformanceTargets) -> bool {
self.max_us() <= targets.max_frame_us && self.percentile(99) <= targets.p99_frame_us
}
pub fn to_json(&self) -> String {
format!(
r#"{{"frame_count":{},"min_us":{},"max_us":{},"mean_us":{:.2},"p50_us":{},"p95_us":{},"p99_us":{}}}"#,
self.frame_count,
self.min_us(),
self.max_us(),
self.mean_us(),
self.percentile(50),
self.percentile(95),
self.percentile(99),
)
}
}
#[derive(Debug, Clone)]
pub struct PerformanceTargets {
pub max_frame_us: u64,
pub p99_frame_us: u64,
pub max_memory_bytes: usize,
}
impl Default for PerformanceTargets {
fn default() -> Self {
Self {
max_frame_us: 16_667, p99_frame_us: 1_000, max_memory_bytes: 100 * 1024, }
}
}
pub struct FrameAssertion<'a> {
backend: &'a TuiTestBackend,
tolerance: usize,
ignore_color: bool,
ignore_trailing_whitespace: bool,
region: Option<(u16, u16, u16, u16)>,
}
impl FrameAssertion<'_> {
pub fn with_tolerance(mut self, tolerance: usize) -> Self {
self.tolerance = tolerance;
self
}
pub fn ignore_color(mut self) -> Self {
self.ignore_color = true;
self
}
pub fn ignore_whitespace_at_eol(mut self) -> Self {
self.ignore_trailing_whitespace = true;
self
}
pub fn with_region(mut self, x: u16, y: u16, width: u16, height: u16) -> Self {
self.region = Some((x, y, width, height));
self
}
pub fn to_match_snapshot(self, snapshot: &TuiSnapshot) {
let current = self.backend.snapshot();
let diff = current.diff(snapshot);
if !diff.matches && diff.differences.len() > self.tolerance {
panic!(
"Frame does not match snapshot:\n\
- {}/{} cells differ ({:.1}% match)\n\
- Tolerance: {}\n\
- First 5 differences:\n{}",
diff.differences.len(),
diff.total_cells,
diff.match_percentage(),
self.tolerance,
diff.differences
.iter()
.take(5)
.map(|d| format!(
" ({}, {}): expected '{}', got '{}'",
d.x, d.y, d.expected, d.actual
))
.collect::<Vec<_>>()
.join("\n")
);
}
}
pub fn to_contain_text(self, text: &str) {
let content = self.backend.to_string_plain();
assert!(
content.contains(text),
"Frame does not contain text: '{}'",
text
);
}
pub fn to_not_contain_text(self, text: &str) {
let content = self.backend.to_string_plain();
assert!(
!content.contains(text),
"Frame should not contain text: '{}'",
text
);
}
pub fn text_at(self, x: u16, y: u16, expected: &str) {
let actual = self.backend.extract_text_at(x, y);
assert_eq!(
actual, expected,
"Text at ({}, {}) expected '{}', got '{}'",
x, y, expected, actual
);
}
pub fn row_equals(self, y: u16, expected: &str) {
let actual = self.backend.extract_row(y);
let actual_trimmed = if self.ignore_trailing_whitespace {
actual.trim_end()
} else {
&actual
};
let expected_trimmed = if self.ignore_trailing_whitespace {
expected.trim_end()
} else {
expected
};
assert_eq!(
actual_trimmed, expected_trimmed,
"Row {} expected:\n'{}'\ngot:\n'{}'",
y, expected_trimmed, actual_trimmed
);
}
}
pub fn expect_frame(backend: &TuiTestBackend) -> FrameAssertion<'_> {
FrameAssertion {
backend,
tolerance: 0,
ignore_color: false,
ignore_trailing_whitespace: false,
region: None,
}
}
pub struct BenchmarkHarness {
backend: TuiTestBackend,
warmup_frames: u32,
benchmark_frames: u32,
}
impl BenchmarkHarness {
pub fn new(width: u16, height: u16) -> Self {
Self {
backend: TuiTestBackend::new(width, height),
warmup_frames: 100,
benchmark_frames: 1000,
}
}
pub fn with_frames(mut self, warmup: u32, benchmark: u32) -> Self {
self.warmup_frames = warmup;
self.benchmark_frames = benchmark;
self
}
pub fn benchmark<F: FnMut(&mut TuiTestBackend)>(&mut self, mut render: F) -> BenchmarkResult {
for _ in 0..self.warmup_frames {
self.backend.render(|b| render(b));
}
self.backend.metrics = RenderMetrics::new();
for _ in 0..self.benchmark_frames {
self.backend.render(|b| render(b));
}
BenchmarkResult {
metrics: self.backend.metrics().clone(),
final_frame: self.backend.to_string_plain(),
}
}
}
#[derive(Debug)]
pub struct BenchmarkResult {
pub metrics: RenderMetrics,
pub final_frame: String,
}
impl BenchmarkResult {
pub fn meets_targets(&self, targets: &PerformanceTargets) -> bool {
self.metrics.meets_targets(targets)
}
}
pub struct AsyncUpdateAssertion {
field: String,
initial: Option<String>,
values: Vec<String>,
}
impl AsyncUpdateAssertion {
pub fn new(field: &str) -> Self {
Self {
field: field.to_string(),
initial: None,
values: Vec::new(),
}
}
pub fn record_initial(&mut self, value: &str) {
self.initial = Some(value.to_string());
}
pub fn record_update(&mut self, value: &str) {
self.values.push(value.to_string());
}
pub fn assert_present(&self) {
if let Some(ref initial) = self.initial {
assert!(
!initial.is_empty(),
"Field '{}' initial value should be present, got empty",
self.field
);
} else {
panic!("Field '{}' has no initial value recorded", self.field);
}
}
pub fn assert_changed(&self) {
let Some(ref initial) = self.initial else {
panic!("Field '{}' has no initial value", self.field);
};
let changed = self.values.iter().any(|v| v != initial);
assert!(
changed,
"Field '{}' expected to change from '{}' but never did. Updates: {:?}",
self.field, initial, self.values
);
}
pub fn assert_numeric_in_range(&self, min: f64, max: f64) {
let Some(ref initial) = self.initial else {
panic!("Field '{}' has no initial value", self.field);
};
let num_str = initial
.trim_end_matches('%')
.trim_end_matches("MHz")
.trim_end_matches("GHz")
.trim_end_matches("°C")
.trim();
let value: f64 = num_str.parse().unwrap_or_else(|_| {
panic!(
"Field '{}' expected numeric value, got '{}'",
self.field, initial
)
});
assert!(
value >= min && value <= max,
"Field '{}' value {} not in range [{}, {}]",
self.field,
value,
min,
max
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_backend_basic() {
let mut backend = TuiTestBackend::new(80, 24);
assert_eq!(backend.width, 80);
assert_eq!(backend.height, 24);
backend.draw_text(0, 0, "Hello", (255, 255, 255));
assert_eq!(backend.extract_text_at(0, 0), "Hello");
}
#[test]
fn test_backend_render_metrics() {
let mut backend = TuiTestBackend::new(80, 24);
backend.render(|b| {
b.draw_text(0, 0, "Frame 1", (255, 255, 255));
});
backend.render(|b| {
b.draw_text(0, 0, "Frame 2", (255, 255, 255));
});
assert_eq!(backend.frame_count(), 2);
assert!(backend.metrics().mean_us() >= 0.0);
}
#[test]
fn test_snapshot_diff() {
let mut backend1 = TuiTestBackend::new(10, 2);
backend1.draw_text(0, 0, "Hello", (255, 255, 255));
let snap1 = backend1.snapshot();
let mut backend2 = TuiTestBackend::new(10, 2);
backend2.draw_text(0, 0, "Hello", (255, 255, 255));
let snap2 = backend2.snapshot();
let diff = snap1.diff(&snap2);
assert!(diff.matches);
assert_eq!(diff.match_percentage(), 100.0);
}
#[test]
fn test_snapshot_diff_mismatch() {
let mut backend1 = TuiTestBackend::new(10, 2);
backend1.draw_text(0, 0, "Hello", (255, 255, 255));
let snap1 = backend1.snapshot();
let mut backend2 = TuiTestBackend::new(10, 2);
backend2.draw_text(0, 0, "World", (255, 255, 255));
let snap2 = backend2.snapshot();
let diff = snap1.diff(&snap2);
assert!(!diff.matches);
assert!(!diff.differences.is_empty());
}
#[test]
fn test_expect_frame_contains_text() {
let mut backend = TuiTestBackend::new(80, 24);
backend.draw_text(10, 5, "CPU: 45%", (255, 255, 255));
expect_frame(&backend).to_contain_text("CPU: 45%");
}
#[test]
#[should_panic(expected = "does not contain")]
fn test_expect_frame_missing_text() {
let backend = TuiTestBackend::new(80, 24);
expect_frame(&backend).to_contain_text("Missing text");
}
#[test]
fn test_async_update_assertion() {
let mut assertion = AsyncUpdateAssertion::new("cpu_freq");
assertion.record_initial("4.5GHz");
assertion.record_update("4.6GHz");
assertion.record_update("4.7GHz");
assertion.assert_present();
assertion.assert_changed();
}
#[test]
#[should_panic(expected = "expected to change")]
fn test_async_update_no_change() {
let mut assertion = AsyncUpdateAssertion::new("stale_field");
assertion.record_initial("static");
assertion.record_update("static");
assertion.record_update("static");
assertion.assert_changed();
}
#[test]
fn test_benchmark_harness() {
let mut harness = BenchmarkHarness::new(80, 24).with_frames(10, 100);
let result = harness.benchmark(|backend| {
backend.draw_text(0, 0, "Test", (255, 255, 255));
});
assert_eq!(result.metrics.frame_count, 100);
assert!(result.metrics.mean_us() < 1_000_000.0); }
#[test]
fn test_render_metrics() {
let mut metrics = RenderMetrics::new();
metrics.record_frame(Duration::from_micros(100));
metrics.record_frame(Duration::from_micros(200));
metrics.record_frame(Duration::from_micros(150));
assert_eq!(metrics.frame_count, 3);
assert_eq!(metrics.min_us(), 100);
assert_eq!(metrics.max_us(), 200);
assert!((metrics.mean_us() - 150.0).abs() < 1.0);
}
#[test]
fn test_performance_targets() {
let mut metrics = RenderMetrics::new();
for _ in 0..100 {
metrics.record_frame(Duration::from_micros(500));
}
let targets = PerformanceTargets::default();
assert!(metrics.meets_targets(&targets));
}
#[test]
#[ignore = "Enable when MetricsSnapshot includes freq/temp"]
fn test_exploded_cpu_receives_async_freq_temp_updates() {
let mut backend = TuiTestBackend::new(140, 45);
let mut freq_assertion = AsyncUpdateAssertion::new("per_core_freq[0]");
let mut temp_assertion = AsyncUpdateAssertion::new("per_core_temp[0]");
backend.render(|b| {
b.draw_text(50, 3, "4.76GHz", (255, 255, 255));
b.draw_text(60, 3, "65°C", (255, 255, 255));
});
freq_assertion.record_initial(&backend.extract_text_at(50, 3));
temp_assertion.record_initial(&backend.extract_text_at(60, 3));
backend.render(|b| {
b.draw_text(50, 3, "4.82GHz", (255, 255, 255));
b.draw_text(60, 3, "67°C", (255, 255, 255));
});
freq_assertion.record_update(&backend.extract_text_at(50, 3));
temp_assertion.record_update(&backend.extract_text_at(60, 3));
freq_assertion.assert_present();
freq_assertion.assert_changed();
freq_assertion.assert_numeric_in_range(0.0, 10.0);
temp_assertion.assert_present();
temp_assertion.assert_changed();
temp_assertion.assert_numeric_in_range(0.0, 150.0); }
}