use std::path::{Path, PathBuf};
use std::time::Duration;
use std::{env, fs};
use termlens::{Key, Terminal};
const TIMEOUT: Duration = Duration::from_secs(10);
fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name)
}
fn fixture_run() -> PathBuf {
fixture("run-flip")
}
fn golden_path(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/golden")
.join(name)
}
fn normalize(frame: &str) -> String {
frame
.lines()
.map(str::trim_end)
.collect::<Vec<_>>()
.join("\n")
}
fn assert_golden(name: &str, screen: &str, context: &str) {
let path = golden_path(name);
let actual = normalize(screen);
if env::var_os("LAUNCHBOUND_BLESS").is_some() {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, format!("{actual}\n")).unwrap();
}
let expected = fs::read_to_string(&path)
.unwrap_or_else(|_| panic!("missing golden {name}; bless with LAUNCHBOUND_BLESS=1"));
assert_eq!(
normalize(&expected),
actual,
"{context}: frame differs from golden {name}\n--- rendered ---\n{screen}"
);
}
fn assert_styled_golden(name: &str, screen: &termlens::Screen, context: &str) -> String {
let path = golden_path(name);
let actual = screen.with_styles().to_string();
if env::var_os("LAUNCHBOUND_BLESS").is_some() {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, format!("{actual}\n")).unwrap();
}
let expected = fs::read_to_string(&path)
.unwrap_or_else(|_| panic!("missing golden {name}; bless with LAUNCHBOUND_BLESS=1"));
assert_eq!(
expected.trim_end_matches('\n'),
actual,
"{context}: styled frame differs from golden {name}"
);
expected
}
fn spawn_in(run_dir: PathBuf, size: (u16, u16)) -> Terminal {
let mut t = Terminal::builder()
.size(size.0, size.1)
.env_clear()
.timeout(TIMEOUT)
.arg(run_dir)
.spawn(env!("CARGO_BIN_EXE_launchbound-tui"))
.expect("failed to spawn the TUI in a PTY");
t.wait_until(|s| s.to_string().contains("launchbound"))
.expect("first frame");
t
}
fn spawn(size: (u16, u16)) -> Terminal {
spawn_in(fixture_run(), size)
}
fn spawn_in_with_env(run_dir: PathBuf, size: (u16, u16), env: &[(&str, &str)]) -> Terminal {
let mut builder = Terminal::builder()
.size(size.0, size.1)
.env_clear()
.timeout(TIMEOUT)
.arg(run_dir);
for (k, v) in env {
builder = builder.env(k, v);
}
let mut t = builder
.spawn(env!("CARGO_BIN_EXE_launchbound-tui"))
.expect("failed to spawn the TUI in a PTY");
t.wait_until(|s| s.to_string().contains("launchbound"))
.expect("first frame");
t
}
fn coloured_cells(screen: &termlens::Screen) -> Vec<String> {
let mut out = Vec::new();
for row in 0..screen.rows() {
for col in 0..screen.cols() {
let Some(cell) = screen.cell(row, col) else {
continue;
};
let style = cell.style();
if style.fg != termlens::Color::Default || style.bg != termlens::Color::Default {
out.push(format!("{row}:{col} fg={:?} bg={:?}", style.fg, style.bg));
}
}
}
out
}
fn quit(mut t: Terminal, context: &str) {
t.send(Key::Char('q')).expect("send q");
let status = t.wait_exit().expect("TUI did not exit after q");
assert!(status.success(), "{context}: exited with {status:?}");
assert!(
!t.screen().alternate_screen(),
"{context}: the TUI exited without leaving the alternate screen"
);
}
fn ready(screen: &termlens::Screen) -> bool {
screen.to_string().contains("q quit")
}
#[test]
fn overview_at_80x24() {
let mut t = spawn((80, 24));
let frame = t.wait_frame(ready).expect("the first complete frame");
assert_golden("overview-80x24.txt", &frame.to_string(), "overview");
quit(t, "overview");
}
#[test]
fn resize_relayouts_the_frame() {
let mut t = spawn((80, 24));
let before = t
.wait_frame(|s| {
let frame = s.to_string();
frame.contains("q quit") && !frame.contains("[0.0398, 0.0402]")
})
.expect("the 80-column frame");
t.resize(110, 32).expect("resize");
let frame = t
.wait_frame(|s| s.to_string().contains("[0.0398, 0.0402]"))
.expect("the relaid-out frame");
assert_golden("overview-110x32.txt", &frame.to_string(), "resized");
let diff = before.diff(&frame);
assert!(
diff.cells().count() > 0,
"the 110-column frame re-laid out nothing inside the old width:\n{diff}"
);
quit(t, "resized");
}
#[test]
fn ranking_scrolls_a_long_candidate_list() {
let mut t = spawn((80, 24));
t.wait_frame(ready).expect("the first complete frame");
t.send(Key::Char('2')).expect("send 2");
t.wait_frame(|s| s.to_string().contains("ranking ("))
.expect("ranking view");
for _ in 0..5 {
t.send(Key::Char('j')).expect("send j");
}
let frame = t
.wait_frame(|s| {
let frame = s.to_string();
frame.contains("c1-0000000000000004") && !frame.contains("c1-0000000000000003")
})
.expect("scroll applied");
assert_golden("ranking-scrolled-80x24.txt", &frame.to_string(), "ranking");
quit(t, "ranking");
}
#[test]
fn rejection_view_names_rules_and_spans() {
let mut t = spawn((80, 24));
t.wait_frame(ready).expect("the first complete frame");
t.send(Key::Char('3')).expect("send 3");
let screen = t
.wait_frame(|s| s.to_string().contains("all refused configurations:"))
.expect("rejections view")
.to_string();
assert!(screen.contains("RC001"), "rule id visible");
assert!(screen.contains("src/lib.rs:33:13"), "span visible");
assert_golden("rejections-80x24.txt", &screen, "rejections");
quit(t, "rejections");
}
#[test]
fn progress_view_shows_measured_of_planned() {
let mut t = spawn((80, 24));
t.wait_frame(ready).expect("the first complete frame");
t.send(Key::Char('4')).expect("send 4");
let screen = t
.wait_frame(|s| s.to_string().contains("measured 11 of"))
.expect("progress view")
.to_string();
assert!(screen.contains("measured 11 of"), "progress counter");
assert_golden("progress-80x24.txt", &screen, "progress");
quit(t, "progress");
}
#[test]
fn stress_100_runs_at_80x24() {
for run in 0..100 {
let mut t = spawn((80, 24));
let frame = t
.wait_frame(ready)
.unwrap_or_else(|e| panic!("run {run}: waiting for the first frame: {e}"));
assert_golden(
"overview-80x24.txt",
&frame.to_string(),
&format!("run {run}"),
);
quit(t, &format!("run {run}"));
}
}
#[test]
fn no_golden_line_is_cut_at_the_panel_border() {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/golden");
let mut checked = 0;
for entry in fs::read_dir(&dir).expect("tests/golden must exist") {
let path = entry.unwrap().path();
let name = path.file_name().unwrap().to_string_lossy().into_owned();
let text = fs::read_to_string(&path).unwrap();
for (number, line) in text.lines().enumerate() {
let Some(inner) = line.strip_suffix('│') else {
continue;
};
let Some(inner) = inner.strip_prefix('│') else {
continue;
};
let Some(last) = inner.chars().next_back() else {
continue;
};
if inner.chars().all(|c| c == '─' || c == ' ') {
continue;
}
if last == '…' {
continue;
}
let cut = last.is_ascii_digit() || matches!(last, ',' | '[' | '(' | '=' | '-');
assert!(
!cut,
"{name}:{}: a value is cut at the panel border (ends {last:?}):\n{line}",
number + 1
);
let full = inner.chars().count() >= 76;
let tail = inner.split_whitespace().next_back().unwrap_or("");
assert!(
!(full && last.is_alphabetic() && tail.len() > 6),
"{name}:{}: a word is cut at the panel border ({tail:?}):\n{line}",
number + 1
);
}
checked += 1;
}
assert!(checked > 0, "no goldens found in {dir:?}");
}
#[test]
fn a_refusal_reason_survives_a_narrow_terminal_whole() {
let mut t = spawn((60, 30));
let first = t
.wait_frame(|s| s.to_string().contains("candidates ·"))
.expect("the first complete frame");
let footer = first.rows() - 1;
let separators = first
.find_all("·")
.into_iter()
.filter(|(row, _)| *row == footer)
.count();
assert_eq!(
separators, 4,
"the sixty-column footer is cut mid-list:\n{first}"
);
assert!(
first.locate("q quit").is_none(),
"`ready` would hold at sixty columns after all — reread the comment \
above and the one in AGENTS.md:\n{first}"
);
t.send(Key::Char('3')).expect("send 3");
let frame = t
.wait_frame(|s| s.to_string().contains("all refused configurations:"))
.expect("the rejections view");
let joined = frame
.to_string()
.lines()
.map(|line| line.trim_matches(['│', ' ']))
.collect::<Vec<_>>()
.join(" ");
let prose: String = joined.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(
prose.contains("safe only at one warp (<= 32 threads)"),
"the actionable clause must reach the reader at 60 columns:\n{frame}"
);
assert!(
prose.contains("divergence source `warp_id()` splits a 64-thread block"),
"and so must the rest of the reason:\n{frame}"
);
for row in 0..frame.rows() {
let text = frame.row_text(row);
let Some(inner) = text.strip_suffix('│').and_then(|t| t.strip_prefix('│')) else {
continue;
};
if inner.chars().all(|c| c == '─' || c == ' ') {
continue;
}
if let Some(last) = inner.chars().next_back() {
assert!(
last == '…' || !(last.is_ascii_digit() || matches!(last, ',' | '[' | '(' | '=')),
"row {row} is cut at the border (ends {last:?}):\n{frame}"
);
}
}
quit(t, "narrow rejections");
}
#[test]
fn no_view_of_the_tui_uses_colour() {
let mut t = spawn((80, 24));
t.wait_frame(ready).expect("the first complete frame");
for (key, view, marker) in [
(Key::Char('2'), "ranking", "ranking ("),
(Key::Char('3'), "rejections", "all refused configurations:"),
(Key::Char('4'), "progress", "measured 11 of"),
(Key::Char('1'), "overview", "the field, fastest first"),
] {
t.send(key).expect("switch view");
let screen = t
.wait_frame(|s| s.to_string().contains(marker))
.unwrap_or_else(|e| panic!("{view}: waiting for the view body: {e}"));
let coloured = coloured_cells(&screen);
assert!(
coloured.is_empty(),
"{view}: {} cell(s) carry colour, which a NO_COLOR reader would lose; \
add a cue that survives without it (bold, reverse, or a glyph): {:?}",
coloured.len(),
&coloured[..coloured.len().min(8)]
);
}
quit(t, "no_view_of_the_tui_uses_colour");
}
#[test]
fn the_metal_banner_keeps_its_emphasis_under_no_color() {
const BANNER: &str =
"NO convergence gate exists on the Metal path: the same bug class is NOT checked";
let mut t = spawn_in_with_env(fixture("run-metal"), (80, 24), &[("NO_COLOR", "1")]);
let frame = t.wait_frame(ready).expect("the first complete frame");
let at = frame
.find(BANNER)
.unwrap_or_else(|| panic!("the no-gate banner is missing under NO_COLOR:\n{frame}"));
assert_eq!(
at,
(1, 0),
"the banner is still the second line of the header"
);
for col in 0..BANNER.chars().count() as u16 {
let style = frame
.cell(1, col)
.unwrap_or_else(|| panic!("cell (1, {col}) is off the grid"))
.style();
assert!(
style.bold && style.reverse,
"banner cell (1, {col}) {style:?} lost its emphasis under NO_COLOR \
— the notice can be read past:\n{frame}"
);
}
assert!(
coloured_cells(&frame).is_empty(),
"nothing is coloured under NO_COLOR either"
);
quit(t, "the_metal_banner_keeps_its_emphasis_under_no_color");
}
#[test]
fn no_color_changes_not_one_cell() {
let mut plain = spawn_in_with_env(fixture_run(), (80, 24), &[]);
let a = plain
.wait_frame(ready)
.expect("a whole frame without NO_COLOR")
.with_styles()
.to_string();
quit(plain, "no_color_changes_not_one_cell (plain)");
let mut flagged = spawn_in_with_env(fixture_run(), (80, 24), &[("NO_COLOR", "1")]);
let b = flagged
.wait_frame(ready)
.expect("a whole frame with NO_COLOR")
.with_styles()
.to_string();
quit(flagged, "no_color_changes_not_one_cell (NO_COLOR)");
assert_eq!(
a, b,
"NO_COLOR changed the frame; it should have nothing to change"
);
}
#[test]
fn the_metal_banner_is_bold_and_reversed_and_nothing_else_is() {
const BANNER: &str =
"NO convergence gate exists on the Metal path: the same bug class is NOT checked";
let mut t = spawn_in(fixture("run-metal"), (80, 24));
let frame = t.wait_frame(ready).expect("the first complete frame");
let at = frame.find(BANNER).unwrap_or_else(|| {
panic!("the no-gate banner is not on the gate=none frame at all:\n{frame}")
});
assert_eq!(at, (1, 0), "the banner is the second line of the header");
for col in 0..BANNER.chars().count() as u16 {
let cell = frame
.cell(1, col)
.unwrap_or_else(|| panic!("cell (1, {col}) is off the grid"));
let style = cell.style();
assert!(
style.bold && style.reverse,
"banner cell (1, {col}) {:?} is not bold+reverse — the notice can \
be read past:\n{frame}",
cell.contents()
);
}
for row in 0..frame.rows() {
if row == 1 {
continue;
}
for col in 0..frame.cols() {
let Some(cell) = frame.cell(row, col) else {
continue;
};
assert!(
!cell.style().reverse,
"row {row} col {col} is also reversed, which dilutes the \
banner:\n{frame}"
);
}
}
assert_styled_golden("overview-metal-80x24.styled.txt", &frame, "metal overview");
quit(t, "metal overview");
}
#[test]
fn every_repaint_is_bracketed_so_wait_frame_sees_whole_frames() {
let mut t = spawn((80, 24));
let first = t.wait_frame(ready).expect("the first complete frame");
assert_eq!(
first.repaints(),
1,
"the first draw is one bracketed repaint, not zero (unbracketed) and \
not several (bracketed per widget):\n{first}"
);
let mut expected = 1;
for (key, needle) in [
('2', "ranking ("),
('3', "all refused configurations:"),
('4', "measured 11 of"),
] {
t.send(Key::Char(key)).expect("send a view key");
let frame = t
.wait_frame(|s| s.to_string().contains(needle))
.expect("the view's frame");
expected += 1;
assert_eq!(
frame.repaints(),
expected,
"one keystroke is one whole frame ({needle}):\n{frame}"
);
}
quit(t, "repaints");
}
#[test]
fn the_footer_reaches_the_reader_whole_at_eighty_columns() {
let mut t = spawn((80, 24));
let frame = t.wait_frame(ready).expect("the first complete frame");
let footer = frame.rows() - 1;
let separators: Vec<(u16, u16)> = frame
.find_all("·")
.into_iter()
.filter(|(row, _)| *row == footer)
.collect();
assert_eq!(
separators.len(),
5,
"the footer lists six hints separated by five `·`; it is cut:\n{frame}"
);
let Some(termlens::Location::Screen { row, col }) = frame.locate("q quit") else {
panic!("the last hint `q quit` is not on the grid:\n{frame}");
};
assert_eq!(row, footer, "and it is on the footer row");
assert!(
col > separators.last().unwrap().1,
"after the last separator:\n{frame}"
);
quit(t, "footer");
}