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_run() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/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 spawn(size: (u16, u16)) -> Terminal {
let mut t = Terminal::builder()
.size(size.0, size.1)
.env_clear()
.timeout(TIMEOUT)
.arg(fixture_run())
.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 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:?}");
}
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));
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");
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));
t.wait_frame(|s| s.to_string().contains("candidates ·"))
.expect("the first complete frame");
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");
}