#![forbid(unsafe_code)]
use ftui_render::buffer::Buffer;
use ftui_render::cell::{Cell, PackedRgba};
use ftui_render::diff::BufferDiff;
use ftui_render::presenter::{Presenter, TerminalCapabilities};
use ftui_harness::flicker_detection::{FlickerDetector, analyze_stream, analyze_stream_with_id};
use ftui_harness::golden::compute_text_checksum;
fn caps_with_sync() -> TerminalCapabilities {
let mut caps = TerminalCapabilities::basic();
caps.sync_output = true;
caps
}
fn caps_without_sync() -> TerminalCapabilities {
TerminalCapabilities::basic()
}
fn present_to_bytes(buffer: &Buffer, diff: &BufferDiff, caps: TerminalCapabilities) -> Vec<u8> {
let mut sink = Vec::new();
let mut presenter = Presenter::new(&mut sink, caps);
presenter.present(buffer, diff).unwrap();
drop(presenter);
sink
}
fn present_frame_synced(buffer: &Buffer) -> Vec<u8> {
let blank = Buffer::new(buffer.width(), buffer.height());
let diff = BufferDiff::compute(&blank, buffer);
present_to_bytes(buffer, &diff, caps_with_sync())
}
fn present_incremental_synced(prev: &Buffer, next: &Buffer) -> Vec<u8> {
let diff = BufferDiff::compute(prev, next);
present_to_bytes(next, &diff, caps_with_sync())
}
fn build_test_buffer(width: u16, height: u16, seed: u64) -> Buffer {
let mut buf = Buffer::new(width, height);
let num_cells = (width as u64 * height as u64).min(200);
for i in 0..num_cells {
let x = ((i.wrapping_mul(13).wrapping_add(seed.wrapping_mul(7))) % width as u64) as u16;
let y = ((i.wrapping_mul(17).wrapping_add(seed.wrapping_mul(3))) % height as u64) as u16;
let ch = char::from_u32(b'A' as u32 + (i % 26) as u32).unwrap();
let r = ((i.wrapping_mul(31)) % 256) as u8;
let g = ((i.wrapping_mul(47)) % 256) as u8;
let b_val = ((i.wrapping_mul(71)) % 256) as u8;
buf.set_raw(
x,
y,
Cell::from_char(ch).with_fg(PackedRgba::rgb(r, g, b_val)),
);
}
buf
}
#[test]
fn synced_single_frame_is_flicker_free() {
let mut buf = Buffer::new(80, 24);
for (i, ch) in "Hello, FrankenTUI!".chars().enumerate() {
buf.set_raw(i as u16, 0, Cell::from_char(ch));
}
let output = present_frame_synced(&buf);
let analysis = analyze_stream(&output);
analysis.assert_flicker_free();
assert_eq!(analysis.stats.total_frames, 1);
assert_eq!(analysis.stats.complete_frames, 1);
}
#[test]
fn synced_multi_frame_render_loop() {
let mut all_output = Vec::new();
let mut prev = Buffer::new(40, 10);
for frame_idx in 0u32..10 {
let mut next = prev.clone();
let msg = format!("Frame {frame_idx}");
for (i, ch) in msg.chars().enumerate() {
next.set_raw(i as u16, 0, Cell::from_char(ch));
}
let status = format!("Status: OK #{frame_idx}");
for (i, ch) in status.chars().enumerate() {
next.set_raw(i as u16, 9, Cell::from_char(ch));
}
let output = present_incremental_synced(&prev, &next);
all_output.extend_from_slice(&output);
prev = next;
}
let analysis = analyze_stream(&all_output);
analysis.assert_flicker_free();
assert_eq!(analysis.stats.total_frames, 10);
assert_eq!(analysis.stats.complete_frames, 10);
}
#[test]
fn synced_empty_diff_is_flicker_free() {
let buf = Buffer::new(80, 24);
let diff = BufferDiff::new();
let output = present_to_bytes(&buf, &diff, caps_with_sync());
let analysis = analyze_stream(&output);
analysis.assert_flicker_free();
}
#[test]
fn synced_full_screen_update_is_flicker_free() {
let old = Buffer::new(40, 12);
let mut new = Buffer::new(40, 12);
for y in 0..12u16 {
for x in 0..40u16 {
let ch = char::from_u32(b'A' as u32 + ((x + y) % 26) as u32).unwrap();
new.set_raw(x, y, Cell::from_char(ch));
}
}
let diff = BufferDiff::compute(&old, &new);
let output = present_to_bytes(&new, &diff, caps_with_sync());
let analysis = analyze_stream(&output);
analysis.assert_flicker_free();
assert!(analysis.stats.sync_coverage() > 70.0);
}
#[test]
fn synced_styled_content_is_flicker_free() {
let old = Buffer::new(30, 5);
let mut new = Buffer::new(30, 5);
let red = PackedRgba::rgb(255, 0, 0);
let green = PackedRgba::rgb(0, 255, 0);
let blue = PackedRgba::rgb(0, 0, 255);
for (i, ch) in "Red text".chars().enumerate() {
new.set_raw(i as u16, 0, Cell::from_char(ch).with_fg(red));
}
for (i, ch) in "Green text".chars().enumerate() {
new.set_raw(i as u16, 1, Cell::from_char(ch).with_fg(green));
}
for (i, ch) in "Blue text".chars().enumerate() {
new.set_raw(i as u16, 2, Cell::from_char(ch).with_fg(blue));
}
let diff = BufferDiff::compute(&old, &new);
let output = present_to_bytes(&new, &diff, caps_with_sync());
let analysis = analyze_stream(&output);
analysis.assert_flicker_free();
}
#[test]
fn unsynced_output_detected_as_gap() {
let mut buf = Buffer::new(20, 5);
for (i, ch) in "Visible content".chars().enumerate() {
buf.set_raw(i as u16, 0, Cell::from_char(ch));
}
let old = Buffer::new(20, 5);
let diff = BufferDiff::compute(&old, &buf);
let output = present_to_bytes(&buf, &diff, caps_without_sync());
let analysis = analyze_stream(&output);
assert!(!analysis.flicker_free);
assert!(analysis.stats.sync_gaps > 0);
}
struct Lcg(u64);
impl Lcg {
fn new(seed: u64) -> Self {
Self(seed)
}
fn next_u32(&mut self) -> u32 {
self.0 = self
.0
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1);
(self.0 >> 32) as u32
}
fn next_range(&mut self, max: usize) -> usize {
if max == 0 {
return 0;
}
(self.next_u32() as usize) % max
}
fn next_char(&mut self) -> char {
char::from_u32(b'A' as u32 + (self.next_u32() % 26)).unwrap()
}
fn next_color(&mut self) -> PackedRgba {
let r = (self.next_u32() % 256) as u8;
let g = (self.next_u32() % 256) as u8;
let b = (self.next_u32() % 256) as u8;
PackedRgba::rgb(r, g, b)
}
}
#[test]
fn property_synced_presenter_always_flicker_free() {
for seed in 0..16u64 {
let mut rng = Lcg::new(seed);
let width = 10 + rng.next_range(71) as u16; let height = 5 + rng.next_range(26) as u16; let num_changes = 1 + rng.next_range(200);
let old = Buffer::new(width, height);
let mut new = Buffer::new(width, height);
for _ in 0..num_changes {
let x = rng.next_range(width as usize) as u16;
let y = rng.next_range(height as usize) as u16;
new.set_raw(
x,
y,
Cell::from_char(rng.next_char()).with_fg(rng.next_color()),
);
}
let diff = BufferDiff::compute(&old, &new);
let output = present_to_bytes(&new, &diff, caps_with_sync());
let analysis = analyze_stream_with_id(&format!("prop-seed-{seed}"), &output);
assert!(
analysis.flicker_free,
"seed {seed} ({}x{}, {} changes): not flicker-free\n\
sync_gaps={}, partial_clears={}, incomplete={}",
width,
height,
num_changes,
analysis.stats.sync_gaps,
analysis.stats.partial_clears,
analysis.stats.total_frames - analysis.stats.complete_frames,
);
}
}
#[test]
fn property_incremental_frames_always_flicker_free() {
for seed in 0..8u64 {
let mut rng = Lcg::new(seed ^ 0xdead_beef);
let width = 20 + rng.next_range(61) as u16;
let height = 8 + rng.next_range(23) as u16;
let num_frames = 5 + rng.next_range(8);
let mut all_output = Vec::new();
let mut prev = Buffer::new(width, height);
for _ in 0..num_frames {
let mut next = prev.clone();
let changes = 1 + rng.next_range(50);
for _ in 0..changes {
let x = rng.next_range(width as usize) as u16;
let y = rng.next_range(height as usize) as u16;
next.set_raw(
x,
y,
Cell::from_char(rng.next_char()).with_fg(rng.next_color()),
);
}
let output = present_incremental_synced(&prev, &next);
all_output.extend_from_slice(&output);
prev = next;
}
let analysis = analyze_stream_with_id(&format!("incr-seed-{seed}"), &all_output);
assert!(
analysis.flicker_free,
"seed {seed}: incremental frames not flicker-free"
);
assert_eq!(
analysis.stats.total_frames, num_frames as u64,
"seed {seed}: frame count mismatch"
);
}
}
#[test]
fn property_diff_completeness_through_presenter() {
for seed in 0..8u64 {
let mut rng = Lcg::new(seed ^ 0xbabe_face);
let width = 10 + rng.next_range(30) as u16;
let height = 5 + rng.next_range(15) as u16;
let old = Buffer::new(width, height);
let mut new = Buffer::new(width, height);
let mut changed_positions = Vec::new();
let num_changes = 1 + rng.next_range(50);
for _ in 0..num_changes {
let x = rng.next_range(width as usize) as u16;
let y = rng.next_range(height as usize) as u16;
let ch = rng.next_char();
new.set_raw(x, y, Cell::from_char(ch));
changed_positions.push((x, y, ch));
}
let diff = BufferDiff::compute(&old, &new);
for &(x, y, _ch) in &changed_positions {
let old_cell = old.get_unchecked(x, y);
let new_cell = new.get_unchecked(x, y);
if !old_cell.bits_eq(new_cell) {
let in_diff = diff.iter().any(|(dx, dy)| dx == x && dy == y);
assert!(
in_diff,
"seed {seed}: changed cell at ({x},{y}) missing from diff — would ghost"
);
}
}
}
}
#[test]
fn no_ghosting_after_shrink() {
let mut large = Buffer::new(80, 24);
for y in 0..24u16 {
for x in 0..80u16 {
large.set_raw(x, y, Cell::from_char('#'));
}
}
let mut small = Buffer::new(40, 12);
for y in 0..12u16 {
for x in 0..40u16 {
small.set_raw(x, y, Cell::from_char('.'));
}
}
let blank = Buffer::new(40, 12);
let diff = BufferDiff::compute(&blank, &small);
assert_eq!(
diff.len(),
40 * 12,
"After resize to blank, all cells should be in diff"
);
let output = present_to_bytes(&small, &diff, caps_with_sync());
let analysis = analyze_stream(&output);
analysis.assert_flicker_free();
}
#[test]
fn no_ghosting_after_grow() {
let mut small = Buffer::new(20, 8);
for (i, ch) in "Small content".chars().enumerate() {
small.set_raw(i as u16, 0, Cell::from_char(ch));
}
let mut large = Buffer::new(80, 24);
for (i, ch) in "Large content".chars().enumerate() {
large.set_raw(i as u16, 0, Cell::from_char(ch));
}
for (i, ch) in "Bottom row".chars().enumerate() {
large.set_raw(i as u16, 23, Cell::from_char(ch));
}
let blank = Buffer::new(80, 24);
let diff = BufferDiff::compute(&blank, &large);
let output = present_to_bytes(&large, &diff, caps_with_sync());
let analysis = analyze_stream(&output);
analysis.assert_flicker_free();
}
#[test]
fn property_resize_oscillation_always_flicker_free() {
let sizes: [(u16, u16); 4] = [(80, 24), (40, 12), (120, 40), (20, 8)];
for seed in 0..4u64 {
let mut rng = Lcg::new(seed ^ 0xcafe_d00d);
let mut all_output = Vec::new();
for cycle in 0..6usize {
let (width, height) = sizes[cycle % sizes.len()];
let mut buf = Buffer::new(width, height);
let num_changes = 5 + rng.next_range(30);
for _ in 0..num_changes {
let x = rng.next_range(width as usize) as u16;
let y = rng.next_range(height as usize) as u16;
buf.set_raw(x, y, Cell::from_char(rng.next_char()));
}
let output = present_frame_synced(&buf);
all_output.extend_from_slice(&output);
}
let analysis = analyze_stream_with_id(&format!("resize-osc-{seed}"), &all_output);
assert!(
analysis.flicker_free,
"seed {seed}: resize oscillation not flicker-free"
);
assert_eq!(analysis.stats.total_frames, 6);
}
}
#[test]
fn golden_checksum_80x24_synced_frame() {
let buf = build_test_buffer(80, 24, 42);
let output = present_frame_synced(&buf);
let analysis = analyze_stream_with_id("golden-80x24", &output);
analysis.assert_flicker_free();
let checksum = compute_text_checksum(&analysis.jsonl);
assert!(
!checksum.is_empty(),
"Checksum should be non-empty for golden test"
);
}
#[test]
fn golden_checksum_120x40_synced_frame() {
let buf = build_test_buffer(120, 40, 42);
let output = present_frame_synced(&buf);
let analysis = analyze_stream_with_id("golden-120x40", &output);
analysis.assert_flicker_free();
let checksum = compute_text_checksum(&analysis.jsonl);
assert!(
!checksum.is_empty(),
"Checksum should be non-empty for golden test"
);
}
#[test]
fn golden_jsonl_schema_stability() {
let mut buf = Buffer::new(20, 5);
buf.set_raw(0, 0, Cell::from_char('X'));
let output = present_frame_synced(&buf);
let mut detector = FlickerDetector::new("schema-test");
detector.feed(&output);
detector.finalize();
let jsonl = detector.to_jsonl();
for line in jsonl.lines() {
assert!(line.starts_with('{'), "JSONL line should start with {{");
assert!(line.ends_with('}'), "JSONL line should end with }}");
assert!(
line.contains("\"run_id\":\"schema-test\""),
"Missing run_id in JSONL"
);
assert!(
line.contains("\"event_type\":"),
"Missing event_type in JSONL"
);
assert!(line.contains("\"severity\":"), "Missing severity in JSONL");
assert!(line.contains("\"context\":"), "Missing context in JSONL");
assert!(line.contains("\"details\":"), "Missing details in JSONL");
}
}
#[test]
fn realistic_tui_session_simulation() {
let mut all_output = Vec::new();
let width = 80u16;
let height = 24u16;
let mut buf = Buffer::new(width, height);
for (i, ch) in "=== FrankenTUI Demo ===".chars().enumerate() {
buf.set_raw(i as u16, 0, Cell::from_char(ch));
}
for (i, ch) in "[q] Quit [h] Help".chars().enumerate() {
buf.set_raw(i as u16, height - 1, Cell::from_char(ch));
}
all_output.extend_from_slice(&present_frame_synced(&buf));
let mut prev = buf;
for frame in 0..5u32 {
let mut next = prev.clone();
let status = format!("Frame: {frame} Time: {:.1}s", frame as f64 * 0.1);
for (i, ch) in status.chars().enumerate() {
if (i as u16) < width {
next.set_raw(i as u16, 1, Cell::from_char(ch));
}
}
all_output.extend_from_slice(&present_incremental_synced(&prev, &next));
prev = next;
}
let new_width = 60u16;
let new_height = 20u16;
let mut resized = Buffer::new(new_width, new_height);
for (i, ch) in "=== FrankenTUI Demo ===".chars().enumerate() {
if (i as u16) < new_width {
resized.set_raw(i as u16, 0, Cell::from_char(ch));
}
}
for (i, ch) in "[q] Quit".chars().enumerate() {
resized.set_raw(i as u16, new_height - 1, Cell::from_char(ch));
}
all_output.extend_from_slice(&present_frame_synced(&resized));
prev = resized;
for frame in 5..8u32 {
let mut next = prev.clone();
let status = format!("Post-resize frame: {frame}");
for (i, ch) in status.chars().enumerate() {
if (i as u16) < new_width {
next.set_raw(i as u16, 1, Cell::from_char(ch));
}
}
all_output.extend_from_slice(&present_incremental_synced(&prev, &next));
prev = next;
}
let analysis = analyze_stream(&all_output);
analysis.assert_flicker_free();
assert_eq!(analysis.stats.total_frames, 10);
assert_eq!(analysis.stats.complete_frames, 10);
assert!(analysis.stats.sync_coverage() > 70.0);
}
#[test]
fn rapid_content_updates_no_flicker() {
let width = 40u16;
let height = 5u16;
let mut all_output = Vec::new();
let mut prev = Buffer::new(width, height);
let text = "The quick brown fox jumps over the lazy dog";
for (frame_idx, ch) in text.chars().enumerate() {
let mut next = prev.clone();
let x = frame_idx as u16 % width;
let y = frame_idx as u16 / width;
if y < height {
next.set_raw(x, y, Cell::from_char(ch));
}
all_output.extend_from_slice(&present_incremental_synced(&prev, &next));
prev = next;
}
let analysis = analyze_stream(&all_output);
analysis.assert_flicker_free();
assert_eq!(analysis.stats.total_frames as usize, text.len());
}
#[test]
fn frame_with_sparse_changes_across_rows() {
let width = 80u16;
let height = 40u16;
let old = Buffer::new(width, height);
let mut new = Buffer::new(width, height);
for y in 0..height {
let x = (y * 7 + 3) % width;
new.set_raw(x, y, Cell::from_char('*'));
}
let diff = BufferDiff::compute(&old, &new);
assert_eq!(diff.len(), height as usize);
let output = present_to_bytes(&new, &diff, caps_with_sync());
let analysis = analyze_stream(&output);
analysis.assert_flicker_free();
}
#[test]
fn alternating_styled_rows() {
let width = 60u16;
let height = 20u16;
let old = Buffer::new(width, height);
let mut new = Buffer::new(width, height);
let style_a = PackedRgba::rgb(255, 100, 100);
let style_b = PackedRgba::rgb(100, 100, 255);
for y in 0..height {
let fg = if y % 2 == 0 { style_a } else { style_b };
for x in 0..width {
let ch = if y % 2 == 0 { '=' } else { '-' };
new.set_raw(x, y, Cell::from_char(ch).with_fg(fg));
}
}
let diff = BufferDiff::compute(&old, &new);
let output = present_to_bytes(&new, &diff, caps_with_sync());
let analysis = analyze_stream(&output);
analysis.assert_flicker_free();
}
#[test]
fn single_cell_buffer_synced() {
let mut buf = Buffer::new(1, 1);
buf.set_raw(0, 0, Cell::from_char('X'));
let output = present_frame_synced(&buf);
let analysis = analyze_stream(&output);
analysis.assert_flicker_free();
}
#[test]
fn maximum_practical_buffer_synced() {
let width = 120u16;
let height = 40u16;
let old = Buffer::new(width, height);
let mut new = Buffer::new(width, height);
for y in 0..height {
for x in 0..width {
new.set_raw(x, y, Cell::from_char('.'));
}
}
let diff = BufferDiff::compute(&old, &new);
let output = present_to_bytes(&new, &diff, caps_with_sync());
let analysis = analyze_stream(&output);
analysis.assert_flicker_free();
}
#[test]
fn empty_to_empty_is_flicker_free() {
let buf = Buffer::new(80, 24);
let diff = BufferDiff::new();
let output = present_to_bytes(&buf, &diff, caps_with_sync());
let analysis = analyze_stream(&output);
analysis.assert_flicker_free();
}
#[test]
fn no_change_frame_produces_complete_sync_brackets() {
let prev = Buffer::new(20, 5);
let next = prev.clone();
let diff = BufferDiff::compute(&prev, &next);
assert!(diff.is_empty());
let output = present_to_bytes(&next, &diff, caps_with_sync());
let analysis = analyze_stream(&output);
analysis.assert_flicker_free();
assert_eq!(analysis.stats.total_frames, 1);
assert_eq!(analysis.stats.complete_frames, 1);
}
#[test]
fn flicker_detector_jsonl_write_roundtrip() {
let buf = build_test_buffer(40, 10, 99);
let output = present_frame_synced(&buf);
let mut detector = FlickerDetector::new("roundtrip-test");
detector.feed(&output);
detector.finalize();
let mut jsonl_buf = Vec::new();
detector.write_jsonl(&mut jsonl_buf).unwrap();
let jsonl = String::from_utf8(jsonl_buf).unwrap();
let lines: Vec<&str> = jsonl.lines().collect();
assert!(!lines.is_empty(), "Should have at least one JSONL line");
let event_types: Vec<&str> = lines
.iter()
.filter_map(|line| {
let start = line.find("\"event_type\":\"")?;
let rest = &line[start + 14..];
let end = rest.find('"')?;
Some(&rest[..end])
})
.collect();
assert!(
event_types.contains(&"frame_start"),
"Missing frame_start in JSONL"
);
assert!(
event_types.contains(&"frame_end"),
"Missing frame_end in JSONL"
);
assert!(
event_types.contains(&"analysis_complete"),
"Missing analysis_complete in JSONL"
);
}
#[test]
fn flicker_stats_sync_coverage_realistic() {
let buf = build_test_buffer(80, 24, 42);
let output = present_frame_synced(&buf);
let analysis = analyze_stream(&output);
assert!(
analysis.stats.sync_coverage() > 50.0,
"Expected >50% sync coverage, got {:.1}%",
analysis.stats.sync_coverage()
);
assert!(
analysis.stats.bytes_in_sync > 0,
"Should have bytes inside sync"
);
assert!(analysis.stats.bytes_total > 0, "Should have total bytes");
}