#![cfg(unix)]
use std::collections::HashSet;
use std::env;
use std::fs::{self, File};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::Instant;
use ftui_pty::virtual_terminal::{CellStyle, Color, VirtualTerminal};
use serde::{Deserialize, Serialize};
use serde_json::json;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
const FIXTURE_ROOT_REL: &str = "../../tests/fixtures/vt-conformance";
const KNOWN_MISMATCHES_REL: &str =
"../../tests/fixtures/vt-conformance/differential/known_mismatches.tsv";
const JSONL_PATH_ENV: &str = "FTUI_VT_CONFORMANCE_JSONL";
const SUMMARY_PATH_ENV: &str = "FTUI_VT_CONFORMANCE_SUMMARY_JSON";
#[derive(Clone, Debug, Deserialize)]
struct Fixture {
name: String,
initial_size: [u16; 2],
input_bytes_hex: String,
expected: FixtureExpected,
}
#[derive(Clone, Debug, Deserialize)]
struct FixtureExpected {
cursor: ExpectedCursor,
#[serde(default)]
cells: Vec<ExpectedCell>,
}
#[derive(Clone, Debug, Deserialize)]
struct ExpectedCursor {
row: u16,
col: u16,
}
#[derive(Clone, Debug, Deserialize)]
struct ExpectedCell {
row: u16,
col: u16,
#[serde(rename = "char")]
ch: String,
#[serde(default)]
attrs: ExpectedAttrs,
}
#[derive(Clone, Debug, Default, Deserialize)]
struct ExpectedAttrs {
#[serde(default)]
bold: Option<bool>,
#[serde(default)]
dim: Option<bool>,
#[serde(default)]
italic: Option<bool>,
#[serde(default)]
underline: Option<bool>,
#[serde(default)]
blink: Option<bool>,
#[serde(default)]
inverse: Option<bool>,
#[serde(default)]
strikethrough: Option<bool>,
#[serde(default)]
hidden: Option<bool>,
#[serde(default)]
overline: Option<bool>,
#[serde(default)]
fg_color: Option<ColorExpectation>,
#[serde(default)]
bg_color: Option<ColorExpectation>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(untagged)]
enum ColorExpectation {
Default(String),
Named { named: u16 },
Indexed { indexed: u16 },
Rgb { rgb: [u8; 3] },
}
#[derive(Clone, Debug, Serialize)]
struct FixtureMismatch {
field: String,
row: Option<u16>,
col: Option<u16>,
expected: String,
actual: String,
}
#[derive(Debug, Serialize)]
struct FixtureResult {
fixture_id: String,
fixture_path: String,
correlation_id: String,
status: String,
duration_ms: u128,
mismatch_count: usize,
mismatches: Vec<FixtureMismatch>,
}
#[derive(Debug, Serialize)]
struct Summary {
run_id: String,
started_at: String,
finished_at: String,
total: usize,
passed: usize,
known_mismatch: usize,
failed: usize,
}
#[test]
fn vt_support_matrix_runner_matches_fixtures() {
let started_at = timestamp_utc();
let run_id = format!(
"vt-support-matrix-{}",
OffsetDateTime::now_utc().unix_timestamp_nanos()
);
let fixture_root = fixture_root();
let known_mismatch_ids = load_known_mismatch_ids(&known_mismatches_path());
let fixture_paths = collect_fixture_paths(&fixture_root);
assert!(
!fixture_paths.is_empty(),
"no fixtures discovered under {}",
fixture_root.display()
);
let mut jsonl = open_optional_log(JSONL_PATH_ENV);
write_jsonl_event(
&mut jsonl,
json!({
"event": "run_start",
"ts": timestamp_utc(),
"run_id": run_id,
"fixture_root": fixture_root.display().to_string(),
"fixture_count": fixture_paths.len(),
}),
);
let mut passed = 0usize;
let mut known_mismatch = 0usize;
let mut failed = 0usize;
let mut failures = Vec::new();
for fixture_path in fixture_paths {
let fixture = load_fixture(&fixture_path);
let result = evaluate_fixture(&fixture, &fixture_path, &known_mismatch_ids, &run_id, None);
match result.status.as_str() {
"pass" => passed += 1,
"known_mismatch" => known_mismatch += 1,
_ => {
failed += 1;
failures.push(result_to_failure_line(&result));
}
}
write_jsonl_event(
&mut jsonl,
json!({
"event": "fixture_result",
"ts": timestamp_utc(),
"run_id": run_id,
"correlation_id": result.correlation_id,
"fixture": result.fixture_id,
"fixture_path": result.fixture_path,
"status": result.status,
"duration_ms": result.duration_ms,
"mismatch_count": result.mismatch_count,
"mismatches": result.mismatches,
}),
);
}
let finished_at = timestamp_utc();
let summary = Summary {
run_id: run_id.clone(),
started_at,
finished_at: finished_at.clone(),
total: passed + known_mismatch + failed,
passed,
known_mismatch,
failed,
};
write_jsonl_event(
&mut jsonl,
json!({
"event": "run_summary",
"ts": finished_at,
"run_id": run_id,
"total": summary.total,
"passed": summary.passed,
"known_mismatch": summary.known_mismatch,
"failed": summary.failed,
}),
);
write_optional_summary(&summary);
assert!(
failures.is_empty(),
"VT support-matrix failures ({}):\n{}",
failures.len(),
failures.join("\n")
);
}
#[test]
fn vt_support_matrix_failure_injection_emits_actionable_diagnostics() {
let path = fixture_root().join("cursor/cup_basic.json");
let fixture = load_fixture(&path);
let result = evaluate_fixture(
&fixture,
&path,
&HashSet::new(),
"vt-support-matrix-injected",
Some((0, 1)),
);
assert_eq!(result.status, "fail", "injected run should fail");
let cursor_mismatch = result
.mismatches
.iter()
.find(|m| m.field == "cursor")
.expect("injected mismatch should include cursor context");
assert!(
cursor_mismatch.expected.contains("col="),
"expected cursor mismatch should include expected col"
);
assert!(
cursor_mismatch.actual.contains("col="),
"expected cursor mismatch should include actual col"
);
assert!(
result.correlation_id.contains("vt-support-matrix-injected"),
"correlation id should include run id"
);
}
fn evaluate_fixture(
fixture: &Fixture,
fixture_path: &Path,
known_mismatch_ids: &HashSet<String>,
run_id: &str,
cursor_injection: Option<(i16, i16)>,
) -> FixtureResult {
let start = Instant::now();
let mut mismatches = Vec::new();
let width = fixture.initial_size[0];
let height = fixture.initial_size[1];
let mut vt = VirtualTerminal::new(width, height);
match decode_hex_stream(&fixture.input_bytes_hex) {
Ok(input) => vt.feed(&input),
Err(error) => mismatches.push(FixtureMismatch {
field: "input_bytes_hex".to_string(),
row: None,
col: None,
expected: "valid hex stream".to_string(),
actual: error,
}),
}
if mismatches.is_empty() {
let expected_row = adjust_u16(
fixture.expected.cursor.row,
cursor_injection.map_or(0, |v| v.0),
);
let expected_col = adjust_u16(
fixture.expected.cursor.col,
cursor_injection.map_or(0, |v| v.1),
);
let (actual_col, actual_row) = vt.cursor();
if expected_row != actual_row || expected_col != actual_col {
mismatches.push(FixtureMismatch {
field: "cursor".to_string(),
row: Some(expected_row),
col: Some(expected_col),
expected: format!("row={expected_row}, col={expected_col}"),
actual: format!("row={actual_row}, col={actual_col}"),
});
}
for expected_cell in &fixture.expected.cells {
match vt.cell_at(expected_cell.col, expected_cell.row) {
None => mismatches.push(FixtureMismatch {
field: "cell_presence".to_string(),
row: Some(expected_cell.row),
col: Some(expected_cell.col),
expected: "cell in bounds".to_string(),
actual: "cell out of bounds".to_string(),
}),
Some(actual_cell) => {
compare_cell_char(expected_cell, actual_cell.ch, &mut mismatches);
compare_attrs(
&expected_cell.attrs,
&actual_cell.style,
expected_cell.row,
expected_cell.col,
&mut mismatches,
);
}
}
}
}
let is_known_mismatch = known_mismatch_ids.contains(&fixture.name);
let status = if mismatches.is_empty() {
"pass"
} else if is_known_mismatch {
"known_mismatch"
} else {
"fail"
};
let fixture_path_str = fixture_path.display().to_string();
let relative_path = fixture_path_str
.split_once("/tests/fixtures/vt-conformance/")
.map_or(fixture_path_str.clone(), |(_, rel)| rel.to_string());
FixtureResult {
fixture_id: fixture.name.clone(),
fixture_path: relative_path,
correlation_id: format!("{run_id}:{}", fixture.name),
status: status.to_string(),
duration_ms: start.elapsed().as_millis(),
mismatch_count: mismatches.len(),
mismatches,
}
}
fn compare_cell_char(
expected_cell: &ExpectedCell,
actual: char,
mismatches: &mut Vec<FixtureMismatch>,
) {
let expected = expected_cell.ch.chars().next().unwrap_or(' ');
if expected != actual {
mismatches.push(FixtureMismatch {
field: "char".to_string(),
row: Some(expected_cell.row),
col: Some(expected_cell.col),
expected: expected.to_string(),
actual: actual.to_string(),
});
}
}
fn compare_attrs(
expected: &ExpectedAttrs,
actual: &CellStyle,
row: u16,
col: u16,
mismatches: &mut Vec<FixtureMismatch>,
) {
compare_bool_attr("bold", expected.bold, actual.bold, row, col, mismatches);
compare_bool_attr("dim", expected.dim, actual.dim, row, col, mismatches);
compare_bool_attr(
"italic",
expected.italic,
actual.italic,
row,
col,
mismatches,
);
compare_bool_attr(
"underline",
expected.underline,
actual.underline,
row,
col,
mismatches,
);
compare_bool_attr("blink", expected.blink, actual.blink, row, col, mismatches);
compare_bool_attr(
"inverse",
expected.inverse,
actual.reverse,
row,
col,
mismatches,
);
compare_bool_attr(
"strikethrough",
expected.strikethrough,
actual.strikethrough,
row,
col,
mismatches,
);
compare_bool_attr(
"hidden",
expected.hidden,
actual.hidden,
row,
col,
mismatches,
);
compare_bool_attr(
"overline",
expected.overline,
actual.overline,
row,
col,
mismatches,
);
compare_color_attr(
"fg_color",
expected.fg_color.as_ref(),
actual.fg,
row,
col,
mismatches,
);
compare_color_attr(
"bg_color",
expected.bg_color.as_ref(),
actual.bg,
row,
col,
mismatches,
);
}
fn compare_bool_attr(
field: &str,
expected: Option<bool>,
actual: bool,
row: u16,
col: u16,
mismatches: &mut Vec<FixtureMismatch>,
) {
if let Some(expect) = expected
&& expect != actual
{
mismatches.push(FixtureMismatch {
field: field.to_string(),
row: Some(row),
col: Some(col),
expected: expect.to_string(),
actual: actual.to_string(),
});
}
}
fn compare_color_attr(
field: &str,
expected: Option<&ColorExpectation>,
actual: Option<Color>,
row: u16,
col: u16,
mismatches: &mut Vec<FixtureMismatch>,
) {
let Some(expectation) = expected else {
return;
};
match parse_expected_color(expectation) {
Ok(expected_color) => {
if expected_color != actual {
mismatches.push(FixtureMismatch {
field: field.to_string(),
row: Some(row),
col: Some(col),
expected: color_to_string(expected_color),
actual: color_to_string(actual),
});
}
}
Err(error) => mismatches.push(FixtureMismatch {
field: field.to_string(),
row: Some(row),
col: Some(col),
expected: "valid color expectation".to_string(),
actual: error,
}),
}
}
fn parse_expected_color(expectation: &ColorExpectation) -> Result<Option<Color>, String> {
match expectation {
ColorExpectation::Default(value) => {
if value.eq_ignore_ascii_case("default") {
Ok(None)
} else {
Err(format!("unsupported default token '{value}'"))
}
}
ColorExpectation::Named { named } => named_to_color(*named)
.ok_or_else(|| format!("unsupported named color index {named}"))
.map(Some),
ColorExpectation::Indexed { indexed } => {
if *indexed > 255 {
Err(format!("indexed color out of range: {indexed}"))
} else {
Ok(Some(color_256(*indexed as u8)))
}
}
ColorExpectation::Rgb { rgb } => Ok(Some(Color::new(rgb[0], rgb[1], rgb[2]))),
}
}
fn named_to_color(index: u16) -> Option<Color> {
match index {
0..=7 => Some(ansi_color(index as u8)),
8..=15 => Some(ansi_bright_color((index - 8) as u8)),
_ => None,
}
}
fn ansi_color(idx: u8) -> Color {
match idx {
0 => Color::new(0, 0, 0),
1 => Color::new(170, 0, 0),
2 => Color::new(0, 170, 0),
3 => Color::new(170, 170, 0),
4 => Color::new(0, 0, 170),
5 => Color::new(170, 0, 170),
6 => Color::new(0, 170, 170),
7 => Color::new(170, 170, 170),
_ => Color::new(0, 0, 0),
}
}
fn ansi_bright_color(idx: u8) -> Color {
match idx {
0 => Color::new(85, 85, 85),
1 => Color::new(255, 85, 85),
2 => Color::new(85, 255, 85),
3 => Color::new(255, 255, 85),
4 => Color::new(85, 85, 255),
5 => Color::new(255, 85, 255),
6 => Color::new(85, 255, 255),
7 => Color::new(255, 255, 255),
_ => Color::new(0, 0, 0),
}
}
fn color_256(idx: u8) -> Color {
match idx {
0..=7 => ansi_color(idx),
8..=15 => ansi_bright_color(idx - 8),
16..=231 => {
let value = idx - 16;
let blue = value % 6;
let green = (value / 6) % 6;
let red = value / 36;
let to_rgb = |component: u8| {
if component == 0 {
0
} else {
55 + component * 40
}
};
Color::new(to_rgb(red), to_rgb(green), to_rgb(blue))
}
232..=255 => {
let value = 8 + (idx - 232) * 10;
Color::new(value, value, value)
}
}
}
fn decode_hex_stream(hex: &str) -> Result<Vec<u8>, String> {
let compact: Vec<u8> = hex
.as_bytes()
.iter()
.copied()
.filter(|byte| !byte.is_ascii_whitespace())
.collect();
if !compact.len().is_multiple_of(2) {
return Err(format!("odd hex length: {}", compact.len()));
}
let mut out = Vec::with_capacity(compact.len() / 2);
for pair in compact.chunks_exact(2) {
let high = nibble(pair[0]).ok_or_else(|| format!("invalid hex byte: {}", pair[0]))?;
let low = nibble(pair[1]).ok_or_else(|| format!("invalid hex byte: {}", pair[1]))?;
out.push((high << 4) | low);
}
Ok(out)
}
fn nibble(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(10 + (byte - b'a')),
b'A'..=b'F' => Some(10 + (byte - b'A')),
_ => None,
}
}
fn color_to_string(color: Option<Color>) -> String {
match color {
Some(value) => format!("rgb({}, {}, {})", value.r, value.g, value.b),
None => "default".to_string(),
}
}
fn result_to_failure_line(result: &FixtureResult) -> String {
let detail = result
.mismatches
.first()
.map(|mismatch| {
format!(
"{} at row={:?}, col={:?}: expected={}, actual={}",
mismatch.field, mismatch.row, mismatch.col, mismatch.expected, mismatch.actual
)
})
.unwrap_or_else(|| "unknown mismatch".to_string());
format!("{} [{}] {}", result.fixture_id, result.fixture_path, detail)
}
fn collect_fixture_paths(root: &Path) -> Vec<PathBuf> {
let mut stack = vec![root.to_path_buf()];
let mut files = Vec::new();
while let Some(dir) = stack.pop() {
let entries = fs::read_dir(&dir)
.unwrap_or_else(|error| panic!("failed to read {}: {error}", dir.display()));
for entry in entries {
let path = entry
.unwrap_or_else(|error| {
panic!("failed to read dir entry in {}: {error}", dir.display())
})
.path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|ext| ext == "json") {
files.push(path);
}
}
}
files.sort();
files
}
fn load_fixture(path: &Path) -> Fixture {
let bytes =
fs::read(path).unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()));
serde_json::from_slice::<Fixture>(&bytes)
.unwrap_or_else(|error| panic!("failed to parse {}: {error}", path.display()))
}
fn load_known_mismatch_ids(path: &Path) -> HashSet<String> {
let Ok(contents) = fs::read_to_string(path) else {
return HashSet::new();
};
contents
.lines()
.filter_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
return None;
}
trimmed.split('|').next().map(str::to_string)
})
.collect()
}
fn write_optional_summary(summary: &Summary) {
let Some(path) = env::var_os(SUMMARY_PATH_ENV) else {
return;
};
let path = PathBuf::from(path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap_or_else(|error| {
panic!(
"failed to create summary directory {}: {error}",
parent.display()
)
});
}
let file = File::create(&path)
.unwrap_or_else(|error| panic!("failed to create summary {}: {error}", path.display()));
serde_json::to_writer_pretty(file, summary)
.unwrap_or_else(|error| panic!("failed to write summary {}: {error}", path.display()));
}
fn open_optional_log(var_name: &str) -> Option<File> {
let path = env::var_os(var_name)?;
let path = PathBuf::from(path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap_or_else(|error| {
panic!("failed to create log dir {}: {error}", parent.display())
});
}
Some(
File::create(&path)
.unwrap_or_else(|error| panic!("failed to create log {}: {error}", path.display())),
)
}
fn write_jsonl_event(file: &mut Option<File>, event: serde_json::Value) {
let Some(writer) = file else {
return;
};
serde_json::to_writer(&mut *writer, &event).expect("failed to serialize JSONL event");
writer.write_all(b"\n").expect("failed to append newline");
writer.flush().expect("failed to flush JSONL event");
}
fn timestamp_utc() -> String {
OffsetDateTime::now_utc()
.format(&Rfc3339)
.expect("Rfc3339 formatting should succeed")
}
fn fixture_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(FIXTURE_ROOT_REL)
}
fn known_mismatches_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(KNOWN_MISMATCHES_REL)
}
fn adjust_u16(value: u16, delta: i16) -> u16 {
if delta >= 0 {
value.saturating_add(delta as u16)
} else {
value.saturating_sub(delta.unsigned_abs())
}
}