use std::time::{Duration, Instant};
use termlens::{Error, FrameTiming, Key, Terminal};
mod common;
use common as util;
fn spawn_form_echo() -> termlens::Result<Terminal> {
Terminal::builder()
.size(80, 24)
.timeout(Duration::from_secs(10))
.env_clear()
.spawn(util::fixture_bin("form-echo"))
}
#[test]
fn the_frame_completed_before_the_call_is_evaluated() -> termlens::Result<()> {
let mut t = spawn_form_echo()?;
t.wait_frame(|s| s.contains("form-echo ready"))?;
t.send(Key::Esc)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn a_frame_is_internally_consistent() -> termlens::Result<()> {
let mut t = spawn_form_echo()?;
t.wait_frame(|s| s.contains("form-echo ready"))?;
t.send_str("hi")?;
let frame = t.wait_frame(|s| s.contains("input: hi"))?;
assert!(
frame.contains("last: char:i"),
"frame satisfied one row but not its sibling:\n{frame}"
);
t.send(Key::Esc)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn a_torn_repaint_is_never_observed() -> termlens::Result<()> {
let mut t = spawn_form_echo()?;
t.wait_frame(|s| s.contains("form-echo ready"))?;
t.send(Key::F(2))?;
let row = t.wait_frame(|s| s.contains("torn: left"))?.row_text(5);
assert!(
row.contains("torn: left right"),
"wait_frame observed a torn frame: {row:?}"
);
t.send(Key::Esc)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn apps_without_synchronized_output_time_out_with_guidance() {
let mut t = Terminal::builder()
.size(80, 24)
.timeout(Duration::from_secs(10))
.env_clear()
.spawn(util::fixture_bin("hello-tui"))
.unwrap();
t.wait_until(|s| s.contains("╯")).unwrap();
let start = Instant::now();
let err = t
.wait_frame_for(|_| true, Duration::from_millis(500))
.unwrap_err();
assert!(start.elapsed() >= Duration::from_millis(500));
let msg = err.to_string();
assert!(msg.contains("2026"), "no guidance in: {msg}");
assert!(msg.contains("wait_until"), "no alternative named in: {msg}");
assert!(err.screen().is_some(), "timeout must still embed a screen");
t.send(Key::Char('q')).unwrap();
assert!(t.wait_exit().unwrap().success());
}
#[test]
fn wait_frame_timeouts_embed_the_live_screen_not_the_last_frame() {
let mut t = Terminal::builder()
.timeout(Duration::from_millis(400))
.args([
"-c",
r"printf '\033[?2026h\033[HOLD FRAME\033[?2026l'; printf '\r\nLIVE SCREEN'; read quit",
])
.spawn("sh")
.unwrap();
t.wait_frame(|s| s.contains("OLD FRAME")).unwrap();
t.wait_until(|s| s.contains("LIVE SCREEN")).unwrap();
let err = t.wait_frame(|s| s.contains("never painted")).unwrap_err();
let screen = err.screen().expect("timeouts embed a screen");
assert!(
screen.contains("LIVE SCREEN"),
"the embedded screen is stale — it must match the header:\n{screen}"
);
let msg = err.to_string();
assert!(
msg.contains("has not completed a repaint") && msg.contains("1 complete frame in total"),
"the frame count and the reason belong in the message: {msg}"
);
}
#[test]
fn every_frame_of_a_burst_is_observable_in_order() -> termlens::Result<()> {
let mut t = Terminal::builder()
.timeout(Duration::from_secs(10))
.args([
"-c",
concat!(
r"printf '\033[?2026h\033[HSTEP 1\033[?2026l",
r"\033[?2026h\033[HSTEP 2\033[?2026l",
r"\033[?2026h\033[HSTEP 3\033[?2026l'; read guard"
),
])
.spawn("sh")?;
t.wait_until(|s| s.contains("STEP 3"))?;
assert!(t.wait_frame(|s| s.contains("STEP 1"))?.contains("STEP 1"));
assert!(t.wait_frame(|s| s.contains("STEP 2"))?.contains("STEP 2"));
assert!(t.wait_frame(|s| s.contains("STEP 3"))?.contains("STEP 3"));
t.send(Key::Enter)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn a_burst_longer_than_the_retention_bound_drops_its_oldest_frames() -> termlens::Result<()> {
let mut script = String::new();
for n in 1..=12 {
script.push_str(&format!(r"\033[?2026h\033[HFRAME {n:02}\033[?2026l"));
}
let mut t = Terminal::builder()
.timeout(Duration::from_millis(600))
.args(["-c", &format!("printf '{script}'; read guard")])
.spawn("sh")?;
t.wait_until(|s| s.contains("FRAME 12"))?;
t.wait_frame(|s| s.contains("FRAME 05"))?;
let err = t.wait_frame(|s| s.contains("FRAME 01")).unwrap_err();
assert!(
err.to_string().contains("12 in total"),
"the frame count belongs in the message: {err}"
);
Ok(())
}
#[test]
fn wait_frame_fails_fast_on_eof() {
let mut t = Terminal::builder()
.timeout(Duration::from_secs(30))
.args(["-c", r"printf '\033[?2026hdone\033[?2026l'; read guard"])
.spawn("sh")
.unwrap();
t.wait_frame(|s| s.contains("done")).unwrap();
t.send(Key::Enter).unwrap();
let start = Instant::now();
let err = t.wait_frame(|s| s.contains("never painted")).unwrap_err();
assert!(matches!(err, Error::Eof { .. }), "expected Eof, got: {err}");
assert!(
start.elapsed() < Duration::from_secs(10),
"EOF should fail fast"
);
}
#[test]
fn wait_idle_does_not_resolve_inside_an_open_synchronized_update() {
let mut t = Terminal::builder()
.timeout(Duration::from_millis(600))
.args(["-c", r"printf '\033[?2026hhalf a frame'; read guard"])
.spawn("sh")
.unwrap();
t.wait_until(|s| s.contains("half a frame")).unwrap();
let err = t.wait_idle(Duration::from_millis(100)).unwrap_err();
assert!(
matches!(err, Error::Timeout { .. }),
"an open synchronized update must not count as idle: {err}"
);
}
#[test]
fn an_unmatched_end_publishes_no_frame() {
let mut t = Terminal::builder()
.timeout(Duration::from_millis(600))
.args([
"-c",
r"printf '\033[2J\033[HNO-BEGIN\033[?2026l'; read guard",
])
.spawn("sh")
.unwrap();
t.wait_until(|s| s.contains("NO-BEGIN")).unwrap();
let err = t.wait_frame(|s| s.contains("NO-BEGIN")).unwrap_err();
assert!(
err.to_string().contains("never emitted"),
"a phantom frame would both match and suppress the diagnosis: {err}"
);
}
#[test]
fn a_defensive_mode_reset_keeps_the_never_emitted_diagnosis() {
let mut t = Terminal::builder()
.timeout(Duration::from_millis(600))
.args([
"-c",
concat!(
r"printf '\033[?2026l\033[?25h\033[?1000l\033[?1002l",
r"\033[?1003l\033[?2004l\033[?1049l'; ",
r"printf '\033[2J\033[HPLAIN-PAINT'; read guard"
),
])
.spawn("sh")
.unwrap();
t.wait_until(|s| s.contains("PLAIN-PAINT")).unwrap();
let err = t.wait_frame(|s| s.contains("NEVER-DRAWN")).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("never emitted a DEC 2026 synchronized update"),
"the reset must not look like a repaint: {msg}"
);
assert!(
!msg.contains("complete frames observed"),
"no frame was drawn, so no count should be claimed: {msg}"
);
}
#[test]
fn a_begin_end_pair_that_drew_nothing_is_still_a_frame() {
let mut t = Terminal::builder()
.timeout(Duration::from_secs(5))
.args([
"-c",
r"printf '\033[2J\033[HSTATIC'; printf '\033[?2026h\033[?2026l'; read guard",
])
.spawn("sh")
.unwrap();
t.wait_frame(|s| s.contains("STATIC")).unwrap();
t.send(Key::Enter).unwrap();
}
#[test]
fn a_superseded_frame_no_longer_satisfies_a_wait() -> termlens::Result<()> {
let mut t = Terminal::builder()
.timeout(Duration::from_secs(10))
.args([
"-c",
concat!(
r"printf '\033[?2026h\033[2J\033[HSTATE-A\033[?2026l'; read a; ",
r"printf '\033[?2026h\033[2J\033[HSTATE-B\033[?2026l'; read b"
),
])
.spawn("sh")?;
assert!(t.wait_frame(|s| s.contains("STATE-A"))?.contains("STATE-A"));
t.send(Key::Enter)?;
let stale = t.wait_frame_for(|s| s.contains("STATE-A"), Duration::from_millis(700));
assert!(
matches!(stale, Err(Error::Timeout { .. })),
"waiting for the superseded state must fail: {stale:?}"
);
assert!(t.wait_frame(|s| s.contains("STATE-B"))?.contains("STATE-B"));
t.send(Key::Enter)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn one_frame_cannot_satisfy_two_waits() -> termlens::Result<()> {
let mut t = Terminal::builder()
.timeout(Duration::from_secs(10))
.args([
"-c",
r"printf '\033[?2026h\033[2J\033[HONLY-FRAME\033[?2026l'; read guard",
])
.spawn("sh")?;
t.wait_frame(|s| s.contains("ONLY-FRAME"))?;
let again = t.wait_frame_for(|s| s.contains("ONLY-FRAME"), Duration::from_millis(700));
assert!(
matches!(again, Err(Error::Timeout { .. })),
"one repaint must not satisfy two waits: {again:?}"
);
t.send(Key::Enter)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn a_burst_frame_asked_for_out_of_order_is_gone() -> termlens::Result<()> {
let mut t = Terminal::builder()
.timeout(Duration::from_millis(700))
.args([
"-c",
concat!(
r"printf '\033[?2026h\033[HSTEP 1\033[?2026l",
r"\033[?2026h\033[HSTEP 2\033[?2026l",
r"\033[?2026h\033[HSTEP 3\033[?2026l'; read guard"
),
])
.spawn("sh")?;
t.wait_until(|s| s.contains("STEP 3"))?;
t.wait_frame(|s| s.contains("STEP 3"))?;
let backwards = t.wait_frame(|s| s.contains("STEP 1"));
assert!(
matches!(backwards, Err(Error::Timeout { .. })),
"STEP 1 was already passed over: {backwards:?}"
);
Ok(())
}
#[test]
fn the_returned_frame_is_the_matched_instant_not_the_live_screen() -> termlens::Result<()> {
let mut t = Terminal::builder()
.timeout(Duration::from_secs(10))
.args([
"-c",
r"printf '\033[?2026h\033[2J\033[HFRAMED\033[?2026l'; printf '\r\nLIVE'; read guard",
])
.spawn("sh")?;
let frame = t.wait_frame(|s| s.contains("FRAMED"))?;
t.wait_until(|s| s.contains("LIVE"))?;
assert!(
!frame.contains("LIVE"),
"the returned frame must be the instant the update ended:\n{frame}"
);
assert!(
t.screen().contains("LIVE"),
"the live screen has moved on:\n{}",
t.screen()
);
t.send(Key::Enter)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn a_resize_stops_offering_frames_drawn_at_the_old_size() -> termlens::Result<()> {
let mut t = Terminal::builder()
.size(80, 24)
.timeout(Duration::from_millis(700))
.args([
"-c",
r"printf '\033[?2026h\033[2J\033[HBEFORE-RESIZE\033[?2026l'; read guard",
])
.spawn("sh")?;
t.wait_until(|s| s.contains("BEFORE-RESIZE"))?;
t.resize(40, 10)?;
let stale = t.wait_frame(|s| s.contains("BEFORE-RESIZE"));
assert!(
matches!(stale, Err(Error::Timeout { .. })),
"a pre-resize frame must not answer a post-resize wait: {stale:?}"
);
Ok(())
}
#[test]
fn the_repaint_answering_a_resize_is_offered_to_wait_frame() -> termlens::Result<()> {
let mut t = spawn_form_echo()?;
t.wait_frame(|s| s.contains("form-echo ready"))?;
for (cols, rows) in [(60u16, 12u16), (40, 8), (100, 30)] {
t.resize(cols, rows)?;
let frame = t.wait_frame(|s| s.contains(&format!("last: resize:{cols}x{rows}")))?;
assert_eq!(frame.size(), (cols, rows), "{frame}");
}
t.send(Key::Esc)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn a_snapshot_can_be_mid_frame_for_a_synchronized_application() -> termlens::Result<()> {
let mut t = Terminal::builder()
.size(80, 24)
.timeout(Duration::from_secs(10))
.args([
"-c",
concat!(
r"printf '\033[?2026h\033[2J\033[HROW-ONE'; read a; ",
r"printf '\033[2;1HROW-TWO\033[?2026l'; read b"
),
])
.spawn("sh")?;
t.wait_until(|s| s.contains("ROW-ONE"))?;
let torn = t.screen();
assert!(torn.contains("ROW-ONE"), "row 0 is painted:\n{torn}");
assert!(
torn.row_text(1).trim_end().is_empty(),
"the frame is not finished, and screen() says so:\n{torn}"
);
t.send(Key::Enter)?;
let frame = t.wait_frame(|s| s.contains("ROW-TWO"))?;
assert!(
frame.contains("ROW-ONE") && frame.contains("ROW-TWO"),
"{frame}"
);
t.send(Key::Enter)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn a_wait_idle_timeout_names_an_unfinished_frame() {
let mut t = Terminal::builder()
.timeout(Duration::from_millis(600))
.args(["-c", r"printf '\033[?2026hhalf a frame'; read guard"])
.spawn("sh")
.unwrap();
t.wait_until(|s| s.contains("half a frame")).unwrap();
let err = t.wait_idle(Duration::from_millis(100)).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("unfinished DEC 2026 synchronized update"),
"the real state must be named: {msg}"
);
assert!(
msg.contains("half-painted frame"),
"and what the embedded screen is: {msg}"
);
}
#[test]
fn the_timing_series_shows_a_deliberately_slow_repaint() -> termlens::Result<()> {
let mut t = spawn_form_echo()?;
t.wait_frame(|s| s.contains("form-echo ready"))?;
for c in ['a', 'b', 'c'] {
t.send(Key::Char(c))?;
t.wait_frame(|s| s.contains(&format!("input: {}", "abc".split(c).next().unwrap_or(""))))?;
}
let quick = t.frame_timings();
assert!(quick.len() >= 4, "one per repaint so far: {}", quick.len());
let slowest_quick = quick.iter().map(|f| f.duration()).max().unwrap();
t.send(Key::F(2))?;
let torn = t.wait_frame(|s| s.contains("torn: left") && s.contains("right"))?;
assert!(torn.contains("torn: left right"), "{torn}");
let all = t.frame_timings();
let slow = all.last().expect("the torn frame");
assert_eq!(
slow.index(),
torn.repaints(),
"the series and the frame count agree on which repaint this was"
);
assert!(
slow.duration() >= Duration::from_millis(140),
"the deliberate 150ms sleep must be inside the span: {:?}",
slow.duration()
);
assert!(
slow.duration() > slowest_quick,
"the slowed repaint must still be the slowest: {:?} vs {slowest_quick:?}",
slow.duration()
);
assert!(
all.iter().all(|f| f.printable_chars() > 0),
"each repaint drew something: {:?}",
all.iter()
.map(FrameTiming::printable_chars)
.collect::<Vec<_>>()
);
assert_eq!(slow.printable_chars(), 16, "{slow:?}");
t.send(Key::Esc)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn a_burst_in_one_read_is_timed_per_frame() -> termlens::Result<()> {
let mut t = Terminal::builder()
.size(40, 6)
.timeout(Duration::from_secs(10))
.args([
"-c",
concat!(
r"printf READY; read a; ",
r"printf '\033[?2026hone\033[?2026l\033[?2026htwotwo\033[?2026l\033[?2026hthree!\033[?2026l'; ",
r"printf ' DONE'; read b"
),
])
.spawn("/bin/sh")?;
t.wait_until(|s| s.contains("READY"))?;
assert!(t.frame_timings().is_empty(), "nothing has repainted yet");
t.send(Key::Enter)?;
t.wait_until(|s| s.contains("DONE"))?;
let timings = t.frame_timings();
assert_eq!(timings.len(), 3, "three frames, three timings: {timings:?}");
assert_eq!(
timings.iter().map(FrameTiming::index).collect::<Vec<_>>(),
vec![1, 2, 3],
"numbered in emission order"
);
assert_eq!(
timings
.iter()
.map(FrameTiming::printable_chars)
.collect::<Vec<_>>(),
vec![3, 6, 6],
"one/twotwo/three! — per frame, not per read"
);
t.send(Key::Enter)?;
assert!(t.wait_exit()?.success());
Ok(())
}