use std::fs;
use std::path::{Path, PathBuf};
use termlens::Screen;
fn versions() -> Vec<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/compat");
let mut dirs: Vec<PathBuf> = fs::read_dir(&root)
.expect("tests/compat exists")
.map(|entry| entry.expect("a directory entry").path())
.filter(|path| path.is_dir())
.collect();
dirs.sort();
assert!(
!dirs.is_empty(),
"no version directories under {}",
root.display()
);
dirs
}
const SHAPES: [&str; 6] = [
"plain",
"styled",
"hidden",
"wide",
"masked",
"text-of-styled",
];
fn read(path: &Path) -> String {
fs::read_to_string(path)
.unwrap_or_else(|e| panic!("{}: {e}", path.display()))
.replace("\r\n", "\n")
}
fn round_trip(text: &str) -> (Screen, String) {
let screen = Screen::parse(text).expect("a corpus file parses");
let rows = usize::from(screen.rows());
let after_grid = text
.lines()
.skip(1 + rows)
.any(|line| !line.trim().is_empty());
let rendered = if after_grid {
format!("{}\n", screen.with_styles())
} else {
format!("{screen}\n")
};
(screen, rendered)
}
#[test]
fn every_text_file_written_by_a_published_release_round_trips_byte_for_byte() {
for dir in versions() {
for shape in SHAPES {
let path = dir.join(format!("{shape}.txt"));
let text = read(&path);
let (_, rendered) = round_trip(&text);
assert_eq!(
rendered,
text,
"{} does not survive parse -> render",
path.display()
);
}
}
}
#[test]
fn a_hand_edited_corpus_file_would_fail() {
let dir = &versions()[0];
let text = read(&dir.join("styled.txt"));
let (_, rendered) = round_trip(&text);
assert_eq!(rendered, text, "the unedited file round-trips");
for (from, to) in [
("cursor: 2,17", "cursor: 2,18"),
("myapp", "myapq"),
("fg=6", "fg=7"),
] {
let edited = text.replacen(from, to, 1);
assert_ne!(edited, text, "{from} is in the file");
let (_, rendered) = round_trip(&edited);
assert_eq!(
rendered, edited,
"an edit that is itself valid still round-trips"
);
assert_ne!(rendered, text, "…and no longer equals the frozen file");
}
let truncated = text.replacen("styles:", "style:", 1);
assert!(Screen::parse(&truncated).is_err());
}
#[cfg(feature = "serde")]
mod json {
use super::*;
#[test]
fn every_json_twin_reads_as_the_same_picture_as_its_text() {
for dir in versions() {
for shape in SHAPES {
let text_path = dir.join(format!("{shape}.txt"));
let json_path = dir.join(format!("{shape}.json"));
let (from_text, _) = round_trip(&read(&text_path));
let from_json: Screen = serde_json::from_str(&read(&json_path))
.unwrap_or_else(|e| panic!("{}: {e}", json_path.display()));
let diff = from_text.diff(&from_json);
if shape == "text-of-styled" {
assert_eq!(from_json.to_string(), from_text.to_string());
assert_eq!(diff.changed_rows().collect::<Vec<_>>(), [0], "{diff}");
assert_eq!(
diff.style_changes().collect::<Vec<_>>(),
[(0, "(none)", "0-2 fg=1")],
"{diff}"
);
continue;
}
assert!(
diff.is_empty(),
"{} and {} show different pictures:\n{diff}",
text_path.display(),
json_path.display()
);
}
}
}
#[test]
fn every_json_twin_re_serialises_to_itself_plus_the_format_field() {
for dir in versions() {
for shape in SHAPES {
let path = dir.join(format!("{shape}.json"));
let mut file: serde_json::Value = serde_json::from_str(&read(&path)).unwrap();
let screen: Screen = serde_json::from_value(file.clone()).unwrap();
let object = file.as_object_mut().expect("a JSON object");
object.entry("format").or_insert(serde_json::json!(1));
assert_eq!(
serde_json::to_value(&screen).unwrap(),
file,
"{} does not survive deserialize -> serialize",
path.display()
);
}
}
}
#[test]
fn a_0_10_json_file_has_no_format_field_and_a_0_11_one_does() {
for dir in versions() {
let version = dir.file_name().unwrap().to_string_lossy().into_owned();
let file: serde_json::Value =
serde_json::from_str(&read(&dir.join("plain.json"))).unwrap();
let one = serde_json::json!(1);
let expected = (!version.starts_with("0.10.")).then_some(&one);
assert_eq!(file.get("format"), expected, "{version}");
}
}
}
#[cfg(all(unix, feature = "serde"))]
#[test]
#[ignore = "writes tests/compat/<version>/; run by hand at release time with TERMLENS_WRITE_CORPUS set"]
fn write_corpus() -> termlens::Result<()> {
use std::time::Duration;
use termlens::Terminal;
let Ok(version) = std::env::var("TERMLENS_WRITE_CORPUS") else {
panic!("set TERMLENS_WRITE_CORPUS=<version> to write a corpus directory");
};
let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/compat")
.join(&version);
assert!(
!dir.exists(),
"{} exists; a published release's corpus is never rewritten",
dir.display()
);
fs::create_dir_all(&dir).expect("create the version directory");
let sh = |size: (u16, u16), script: &str| -> termlens::Result<Terminal> {
Terminal::builder()
.size(size.0, size.1)
.env_clear()
.timeout(Duration::from_secs(20))
.arg("-c")
.arg(format!("{script}; read _"))
.spawn("/bin/sh")
};
let write = |name: &str, text: String, screen: &Screen| {
fs::write(dir.join(format!("{name}.txt")), format!("{text}\n")).expect("write text");
let json = serde_json::to_string_pretty(screen).expect("serialize");
fs::write(dir.join(format!("{name}.json")), format!("{json}\n")).expect("write json");
};
let mut t = sh((20, 4), "printf 'hello, world\\nsecond line'")?;
t.wait_until(|s| s.contains("second line"))?;
let s = t.screen();
write("plain", s.to_string(), &s);
let mut t = sh((30, 4), "printf '\\033[1;36mmyapp\\033[0m \\033[7m> Alpha\\033[0m\\n\\033[2mnote\\033[0m \\033[3mit\\033[0m \\033[4;33mul\\033[0m \\033[5mbl\\033[0m \\033[8mhid\\033[0m \\033[9mst\\033[0m\\n\\033[48;2;30;30;46mrgb bg\\033[0m \\033[38;5;208mfg208\\033[0m DONE'")?;
t.wait_until(|s| s.contains("DONE"))?;
let s = t.screen();
write("styled", s.with_styles().to_string(), &s);
let mut t = sh((20, 3), "printf 'cursor gone\\033[?25l'")?;
t.wait_until(|s| s.contains("cursor gone") && !s.cursor_visible())?;
let s = t.screen();
write("hidden", s.with_styles().to_string(), &s);
let mut t = sh((12, 3), "printf '東京 e\u{301} 😀!\\nabcdefghij東\\nEND'")?;
t.wait_until(|s| s.contains("END"))?;
let s = t.screen();
write("wide", s.with_styles().to_string(), &s);
let mut t = sh(
(30, 3),
"printf 'pw: \\033[8mhunter2\\033[28m at 12:34:56\\n\\033[1mid=\\033[0m 4711 DONE'",
)?;
t.wait_until(|s| s.contains("DONE"))?;
let s = t
.screen()
.mask_matching("12:34:56", '▒')
.mask_rect(4..11, 0..1);
write("masked", s.with_styles().to_string(), &s);
let mut t = sh((16, 2), "printf '\\033[31mred\\033[0m and plain'")?;
t.wait_until(|s| s.contains("plain"))?;
let s = t.screen();
write("text-of-styled", s.to_string(), &s);
Ok(())
}