#![allow(clippy::format_push_string, clippy::uninlined_format_args)]
#![allow(clippy::doc_markdown)]
#![allow(clippy::missing_panics_doc)]
#![allow(clippy::struct_field_names)]
use opentui::{Cell, OptimizedBuffer, Rgba, Style};
use opentui_rust as opentui;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use std::time::{Duration, Instant};
const BASELINE_PATH: &str = "tests/baselines/perf_baseline.json";
const DEFAULT_REGRESSION_THRESHOLD: f64 = 0.20;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PerfBaseline {
version: String,
generated_at: String,
machine_info: MachineInfo,
thresholds: Thresholds,
baselines: Baselines,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct MachineInfo {
os: String,
note: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Thresholds {
regression_threshold_percent: f64,
improvement_threshold_percent: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Baselines {
rendering: RenderingBaselines,
buffer_operations: BufferBaselines,
text_operations: TextBaselines,
input_processing: InputBaselines,
e2e: E2EBaselines,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RenderingBaselines {
full_screen_clear_100x_ms: u64,
full_screen_text_render_100x_ms: u64,
diff_render_10pct_changes_ms: u64,
diff_render_50pct_changes_ms: u64,
diff_render_90pct_changes_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BufferBaselines {
large_buffer_create_200x60_ms: u64,
scissor_push_pop_1000x_ms: u64,
opacity_stack_1000x_ms: u64,
cell_iteration_full_buffer_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct TextBaselines {
large_file_load_100kb_ms: u64,
text_insert_1000_chars_ms: u64,
undo_redo_100x_ms: u64,
wrap_recalculation_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct InputBaselines {
parse_1000_keystrokes_ms: u64,
parse_1000_mouse_events_ms: u64,
parse_large_paste_10kb_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct E2EBaselines {
demo_showcase_tour_max_runtime_s: u64,
demo_showcase_tour_min_fps: u64,
demo_showcase_headless_smoke_max_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PerfResult {
name: String,
actual_ms: u64,
baseline_ms: u64,
diff_percent: f64,
status: PerfStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
enum PerfStatus {
Pass,
Regression,
Improvement,
}
impl PerfResult {
fn new(name: &str, actual_ms: u64, baseline_ms: u64, threshold_percent: f64) -> Self {
#[allow(clippy::cast_precision_loss)]
let diff_percent = if baseline_ms == 0 {
0.0
} else {
((actual_ms as f64 - baseline_ms as f64) / baseline_ms as f64) * 100.0
};
let status = if diff_percent > threshold_percent * 100.0 {
PerfStatus::Regression
} else if diff_percent < -10.0 {
PerfStatus::Improvement
} else {
PerfStatus::Pass
};
Self {
name: name.to_string(),
actual_ms,
baseline_ms,
diff_percent,
status,
}
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct PerfReport {
results: Vec<PerfResult>,
regressions: Vec<String>,
improvements: Vec<String>,
all_passed: bool,
}
impl PerfReport {
fn new() -> Self {
Self::default()
}
fn add(&mut self, result: PerfResult) {
match result.status {
PerfStatus::Regression => {
self.regressions.push(format!(
"{}: {:.1}% slower ({} ms vs {} ms baseline)",
result.name, result.diff_percent, result.actual_ms, result.baseline_ms
));
}
PerfStatus::Improvement => {
self.improvements.push(format!(
"{}: {:.1}% faster ({} ms vs {} ms baseline)",
result.name, -result.diff_percent, result.actual_ms, result.baseline_ms
));
}
PerfStatus::Pass => {}
}
self.results.push(result);
}
fn finalize(&mut self) {
self.all_passed = self.regressions.is_empty();
}
fn to_summary(&self) -> String {
let mut summary = String::new();
summary.push_str("=== Performance Test Report ===\n\n");
if !self.regressions.is_empty() {
summary.push_str("REGRESSIONS DETECTED:\n");
for r in &self.regressions {
summary.push_str(&format!(" - {r}\n"));
}
summary.push('\n');
}
if !self.improvements.is_empty() {
summary.push_str("Improvements:\n");
for i in &self.improvements {
summary.push_str(&format!(" + {i}\n"));
}
summary.push('\n');
}
summary.push_str(&format!(
"Total: {} tests, {} regressions, {} improvements\n",
self.results.len(),
self.regressions.len(),
self.improvements.len()
));
if self.all_passed {
summary.push_str("\nResult: PASS\n");
} else {
summary.push_str("\nResult: FAIL (regressions detected)\n");
}
summary
}
fn to_json(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
}
}
fn load_baseline() -> Option<PerfBaseline> {
let path = Path::new(BASELINE_PATH);
if !path.exists() {
eprintln!("Warning: Baseline file not found at {BASELINE_PATH}");
return None;
}
let content = fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
fn time_ms<F: FnMut()>(mut f: F, iterations: u32) -> u64 {
let start = Instant::now();
for _ in 0..iterations {
f();
}
u64::try_from(start.elapsed().as_millis()).expect("elapsed ms fits u64")
}
fn should_update_baselines() -> bool {
std::env::var("UPDATE_BASELINES").is_ok()
}
fn regression_threshold(baseline: Option<&PerfBaseline>) -> f64 {
baseline.map_or(DEFAULT_REGRESSION_THRESHOLD, |b| {
b.thresholds.regression_threshold_percent / 100.0
})
}
#[test]
fn perf_full_screen_clear() {
let baseline = load_baseline();
let threshold = regression_threshold(baseline.as_ref());
let expected = baseline
.as_ref()
.map_or(50, |b| b.baselines.rendering.full_screen_clear_100x_ms);
let mut buffer = OptimizedBuffer::new(200, 50);
let actual = time_ms(|| buffer.clear(Rgba::BLACK), 100);
let result = PerfResult::new("full_screen_clear_100x", actual, expected, threshold);
println!(
"full_screen_clear_100x: {} ms (baseline: {} ms, diff: {:.1}%)",
actual, expected, result.diff_percent
);
if should_update_baselines() {
println!(" Suggested baseline: {actual}");
}
assert!(
result.status != PerfStatus::Regression,
"REGRESSION: {} ms vs {} ms baseline ({:.1}% slower)",
actual,
expected,
result.diff_percent
);
}
#[test]
fn perf_full_screen_text_render() {
let baseline = load_baseline();
let threshold = regression_threshold(baseline.as_ref());
let expected = baseline.as_ref().map_or(100, |b| {
b.baselines.rendering.full_screen_text_render_100x_ms
});
let mut buffer = OptimizedBuffer::new(200, 50);
let style = Style::fg(Rgba::WHITE);
let text = "Hello, OpenTUI! Performance test line.";
let actual = time_ms(
|| {
for row in 0..50 {
buffer.draw_text(0, row, text, style);
}
},
100,
);
let result = PerfResult::new("full_screen_text_render_100x", actual, expected, threshold);
println!(
"full_screen_text_render_100x: {} ms (baseline: {} ms, diff: {:.1}%)",
actual, expected, result.diff_percent
);
if should_update_baselines() {
println!(" Suggested baseline: {actual}");
}
assert!(
result.status != PerfStatus::Regression,
"REGRESSION: {} ms vs {} ms baseline ({:.1}% slower)",
actual,
expected,
result.diff_percent
);
}
#[test]
fn perf_large_buffer_create() {
let baseline = load_baseline();
let threshold = regression_threshold(baseline.as_ref());
let expected = baseline.as_ref().map_or(10, |b| {
b.baselines.buffer_operations.large_buffer_create_200x60_ms
});
let actual = time_ms(|| drop(OptimizedBuffer::new(200, 60)), 100);
let result = PerfResult::new("large_buffer_create_100x", actual, expected, threshold);
println!(
"large_buffer_create_100x: {} ms (baseline: {} ms, diff: {:.1}%)",
actual, expected, result.diff_percent
);
if should_update_baselines() {
println!(" Suggested baseline: {actual}");
}
assert!(
result.status != PerfStatus::Regression,
"REGRESSION: {} ms vs {} ms baseline ({:.1}% slower)",
actual,
expected,
result.diff_percent
);
}
#[test]
fn perf_scissor_push_pop() {
use opentui::buffer::ClipRect;
let baseline = load_baseline();
let threshold = regression_threshold(baseline.as_ref());
let expected = baseline.as_ref().map_or(20, |b| {
b.baselines.buffer_operations.scissor_push_pop_1000x_ms
});
let mut buffer = OptimizedBuffer::new(200, 50);
let actual = time_ms(
|| {
for _ in 0..100 {
buffer.push_scissor(ClipRect::new(10, 10, 50, 30));
buffer.pop_scissor();
}
},
10,
);
let result = PerfResult::new("scissor_push_pop_1000x", actual, expected, threshold);
println!(
"scissor_push_pop_1000x: {} ms (baseline: {} ms, diff: {:.1}%)",
actual, expected, result.diff_percent
);
if should_update_baselines() {
println!(" Suggested baseline: {actual}");
}
assert!(
result.status != PerfStatus::Regression,
"REGRESSION: {} ms vs {} ms baseline ({:.1}% slower)",
actual,
expected,
result.diff_percent
);
}
#[test]
fn perf_opacity_stack() {
let baseline = load_baseline();
let threshold = regression_threshold(baseline.as_ref());
let expected = baseline
.as_ref()
.map_or(20, |b| b.baselines.buffer_operations.opacity_stack_1000x_ms);
let mut buffer = OptimizedBuffer::new(200, 50);
let actual = time_ms(
|| {
for _ in 0..100 {
buffer.push_opacity(0.5);
buffer.pop_opacity();
}
},
10,
);
let result = PerfResult::new("opacity_stack_1000x", actual, expected, threshold);
println!(
"opacity_stack_1000x: {} ms (baseline: {} ms, diff: {:.1}%)",
actual, expected, result.diff_percent
);
if should_update_baselines() {
println!(" Suggested baseline: {actual}");
}
assert!(
result.status != PerfStatus::Regression,
"REGRESSION: {} ms vs {} ms baseline ({:.1}% slower)",
actual,
expected,
result.diff_percent
);
}
#[test]
fn perf_cell_iteration() {
let baseline = load_baseline();
let threshold = regression_threshold(baseline.as_ref());
let expected = baseline.as_ref().map_or(30, |b| {
b.baselines.buffer_operations.cell_iteration_full_buffer_ms
});
let mut buffer = OptimizedBuffer::new(200, 50);
buffer.clear(Rgba::WHITE);
let cell = Cell::new('X', Style::fg(Rgba::RED));
let actual = time_ms(
|| {
for y in 0..50 {
for x in 0..200 {
buffer.set(x, y, cell);
}
}
},
100,
);
let result = PerfResult::new(
"cell_iteration_full_buffer_100x",
actual,
expected,
threshold,
);
println!(
"cell_iteration_full_buffer_100x: {} ms (baseline: {} ms, diff: {:.1}%)",
actual, expected, result.diff_percent
);
if should_update_baselines() {
println!(" Suggested baseline: {actual}");
}
assert!(
result.status != PerfStatus::Regression,
"REGRESSION: {} ms vs {} ms baseline ({:.1}% slower)",
actual,
expected,
result.diff_percent
);
}
#[test]
fn perf_text_insert() {
use opentui::text::EditBuffer;
let baseline = load_baseline();
let threshold = regression_threshold(baseline.as_ref());
let expected = baseline.as_ref().map_or(50, |b| {
b.baselines.text_operations.text_insert_1000_chars_ms
});
let mut edit_buffer = EditBuffer::new();
let actual = time_ms(
|| {
for i in 0u8..100 {
let c = char::from(b'a' + (i % 26));
edit_buffer.insert(&c.to_string());
}
},
10,
);
let result = PerfResult::new("text_insert_1000_chars", actual, expected, threshold);
println!(
"text_insert_1000_chars: {} ms (baseline: {} ms, diff: {:.1}%)",
actual, expected, result.diff_percent
);
if should_update_baselines() {
println!(" Suggested baseline: {actual}");
}
assert!(
result.status != PerfStatus::Regression,
"REGRESSION: {} ms vs {} ms baseline ({:.1}% slower)",
actual,
expected,
result.diff_percent
);
}
#[test]
fn perf_undo_redo() {
use opentui::text::EditBuffer;
let baseline = load_baseline();
let threshold = regression_threshold(baseline.as_ref());
let expected = baseline
.as_ref()
.map_or(30, |b| b.baselines.text_operations.undo_redo_100x_ms);
let mut edit_buffer = EditBuffer::new();
for i in 0u8..50 {
let c = char::from(b'a' + (i % 26));
edit_buffer.insert(&c.to_string());
}
let actual = time_ms(
|| {
for _ in 0..10 {
for _ in 0..10 {
edit_buffer.undo();
}
for _ in 0..10 {
edit_buffer.redo();
}
}
},
1,
);
let result = PerfResult::new("undo_redo_100x", actual, expected, threshold);
println!(
"undo_redo_100x: {} ms (baseline: {} ms, diff: {:.1}%)",
actual, expected, result.diff_percent
);
if should_update_baselines() {
println!(" Suggested baseline: {actual}");
}
assert!(
result.status != PerfStatus::Regression,
"REGRESSION: {} ms vs {} ms baseline ({:.1}% slower)",
actual,
expected,
result.diff_percent
);
}
#[test]
fn perf_parse_keystrokes() {
use opentui::input::InputParser;
let baseline = load_baseline();
let threshold = regression_threshold(baseline.as_ref());
let expected = baseline.as_ref().map_or(20, |b| {
b.baselines.input_processing.parse_1000_keystrokes_ms
});
let mut parser = InputParser::new();
let key_sequences: Vec<&[u8]> = vec![
b"a", b"\x1b[A", b"\x1b[B", b"\x1b[1;5C", b"\x1bOP", b"\x1b[15~", b"\x1b[3~", b"\x1b[H", b"\x1b[F", b"\r", ];
let actual = time_ms(
|| {
for seq in &key_sequences {
let _ = parser.parse(seq);
}
},
100,
);
let result = PerfResult::new("parse_1000_keystrokes", actual, expected, threshold);
println!(
"parse_1000_keystrokes: {} ms (baseline: {} ms, diff: {:.1}%)",
actual, expected, result.diff_percent
);
if should_update_baselines() {
println!(" Suggested baseline: {actual}");
}
assert!(
result.status != PerfStatus::Regression,
"REGRESSION: {} ms vs {} ms baseline ({:.1}% slower)",
actual,
expected,
result.diff_percent
);
}
#[test]
fn perf_parse_mouse_events() {
use opentui::input::InputParser;
let baseline = load_baseline();
let threshold = regression_threshold(baseline.as_ref());
let expected = baseline.as_ref().map_or(30, |b| {
b.baselines.input_processing.parse_1000_mouse_events_ms
});
let mut parser = InputParser::new();
let mouse_sequences: Vec<Vec<u8>> = (0..100)
.map(|i| {
let x = (i % 80) + 1;
let y = (i % 24) + 1;
format!("\x1b[<0;{x};{y}M").into_bytes()
})
.collect();
let actual = time_ms(
|| {
for seq in &mouse_sequences {
let _ = parser.parse(seq);
}
},
10,
);
let result = PerfResult::new("parse_1000_mouse_events", actual, expected, threshold);
println!(
"parse_1000_mouse_events: {} ms (baseline: {} ms, diff: {:.1}%)",
actual, expected, result.diff_percent
);
if should_update_baselines() {
println!(" Suggested baseline: {actual}");
}
assert!(
result.status != PerfStatus::Regression,
"REGRESSION: {} ms vs {} ms baseline ({:.1}% slower)",
actual,
expected,
result.diff_percent
);
}
#[test]
fn perf_parse_large_paste() {
use opentui::input::InputParser;
let baseline = load_baseline();
let threshold = regression_threshold(baseline.as_ref());
let expected = baseline.as_ref().map_or(50, |b| {
b.baselines.input_processing.parse_large_paste_10kb_ms
});
let mut parser = InputParser::new();
let paste_content: String = (0usize..10240)
.map(|i| {
let offset = u8::try_from(i % 26).expect("alphabet index fits u8");
char::from(b'a' + offset)
})
.collect();
let paste_sequence = format!("\x1b[200~{paste_content}\x1b[201~");
let paste_bytes = paste_sequence.as_bytes();
let actual = time_ms(
|| {
let _ = parser.parse(paste_bytes);
},
10,
);
let result = PerfResult::new("parse_large_paste_10kb", actual, expected, threshold);
println!(
"parse_large_paste_10kb: {} ms (baseline: {} ms, diff: {:.1}%)",
actual, expected, result.diff_percent
);
if should_update_baselines() {
println!(" Suggested baseline: {actual}");
}
assert!(
result.status != PerfStatus::Regression,
"REGRESSION: {} ms vs {} ms baseline ({:.1}% slower)",
actual,
expected,
result.diff_percent
);
}
#[test]
fn perf_comprehensive_report() {
let baseline = load_baseline();
let threshold = regression_threshold(baseline.as_ref());
let mut report = PerfReport::new();
{
let expected = baseline
.as_ref()
.map_or(50, |b| b.baselines.rendering.full_screen_clear_100x_ms);
let mut buffer = OptimizedBuffer::new(200, 50);
let actual = time_ms(|| buffer.clear(Rgba::BLACK), 100);
report.add(PerfResult::new(
"full_screen_clear_100x",
actual,
expected,
threshold,
));
}
{
let expected = baseline.as_ref().map_or(10, |b| {
b.baselines.buffer_operations.large_buffer_create_200x60_ms
});
let actual = time_ms(|| drop(OptimizedBuffer::new(200, 60)), 100);
report.add(PerfResult::new(
"large_buffer_create_100x",
actual,
expected,
threshold,
));
}
{
let mut buffer = OptimizedBuffer::new(200, 50);
buffer.clear(Rgba::WHITE);
let cell = Cell::new('X', Style::fg(Rgba::new(1.0, 0.0, 0.0, 0.5)));
let actual = time_ms(|| buffer.set_blended(50, 25, cell), 1000);
report.add(PerfResult::new(
"blended_set_1000x",
actual,
20, threshold,
));
}
report.finalize();
println!("\n{}", report.to_summary());
if let Ok(artifacts_dir) = std::env::var("TEST_ARTIFACTS_DIR") {
let report_path = format!("{artifacts_dir}/perf_report.json");
if let Err(e) = fs::write(&report_path, report.to_json()) {
eprintln!("Warning: Failed to save perf report: {e}");
}
}
assert!(
report.all_passed,
"Performance regressions detected!\n{}",
report.to_summary()
);
}
#[test]
fn perf_alpha_blending() {
let mut buffer = OptimizedBuffer::new(200, 50);
buffer.clear(Rgba::WHITE);
let semi_transparent = Rgba::new(1.0, 0.0, 0.0, 0.5);
let cell = Cell::new('X', Style::fg(semi_transparent));
for _ in 0..100 {
buffer.set_blended(50, 25, cell);
}
let start = Instant::now();
for _ in 0..10000 {
buffer.set_blended(50, 25, cell);
}
let elapsed = start.elapsed();
let ops_per_ms = 10000.0 / (elapsed.as_secs_f64() * 1000.0);
println!(
"alpha_blending_10k: {:?} ({:.2} ops/ms)",
elapsed, ops_per_ms
);
assert!(
elapsed < Duration::from_millis(100),
"Alpha blending too slow: {elapsed:?}"
);
}
#[test]
fn perf_diff_rendering_simulation() {
let mut front = OptimizedBuffer::new(200, 50);
let mut back = OptimizedBuffer::new(200, 50);
front.clear(Rgba::BLACK);
back.clear(Rgba::BLACK);
let style = Style::fg(Rgba::WHITE);
let changed_cells = (200u32 * 50) / 10;
for i in 0..changed_cells {
let x = (i * 7) % 200;
let y = (i * 3) % 50;
back.set(x, y, Cell::new('X', style));
}
let start = Instant::now();
let mut diff_count = 0;
for y in 0..50 {
for x in 0..200 {
let front_cell = front.get(x, y);
let back_cell = back.get(x, y);
if let (Some(f), Some(b)) = (front_cell, back_cell) {
if !f.bits_eq(b) {
diff_count += 1;
}
}
}
}
let elapsed = start.elapsed();
println!(
"diff_detection (10% changes): {:?}, found {} diffs",
elapsed, diff_count
);
assert!(
elapsed < Duration::from_millis(50),
"Diff detection too slow: {elapsed:?}"
);
}