2026-03-12 22:57:37 -07:00
|
|
|
//! Integration tests — compile every `.rpg` sample and validate stdout.
|
2026-03-12 22:39:30 -07:00
|
|
|
//!
|
2026-03-12 22:57:37 -07:00
|
|
|
//! Tests are discovered automatically: every file in `samples/` whose name
|
|
|
|
|
//! ends with `.rpg` becomes a test case. Companion files control behaviour:
|
|
|
|
|
//!
|
|
|
|
|
//! | file | purpose |
|
|
|
|
|
//! |-----------------------|--------------------------------------------|
|
|
|
|
|
//! | `<name>.rpg.stdout` | expected stdout (required to run the test) |
|
|
|
|
|
//! | `<name>.rpg.stdin` | bytes piped to stdin (optional) |
|
2026-03-12 22:39:30 -07:00
|
|
|
//!
|
|
|
|
|
//! # Adding a new sample
|
|
|
|
|
//!
|
2026-03-12 22:57:37 -07:00
|
|
|
//! 1. Drop `samples/<name>.rpg` into the directory.
|
|
|
|
|
//! 2. Create `samples/<name>.rpg.stdout` with the expected output.
|
|
|
|
|
//! 3. Optionally create `samples/<name>.rpg.stdin` with any required input.
|
|
|
|
|
//!
|
|
|
|
|
//! No changes to this file are needed.
|
2026-03-12 22:39:30 -07:00
|
|
|
|
2026-03-12 22:55:14 -07:00
|
|
|
use std::{
|
|
|
|
|
fs,
|
|
|
|
|
io::Write as _,
|
|
|
|
|
path::PathBuf,
|
|
|
|
|
process::{Command, Stdio},
|
|
|
|
|
};
|
2026-03-12 22:39:30 -07:00
|
|
|
|
|
|
|
|
/// Path to the freshly-built compiler binary, injected by Cargo.
|
|
|
|
|
const BIN: &str = env!("CARGO_BIN_EXE_rust-langrpg");
|
|
|
|
|
|
|
|
|
|
/// Absolute path to the `samples/` directory, resolved at compile time.
|
|
|
|
|
const SAMPLES_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/samples");
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
2026-03-12 22:57:37 -07:00
|
|
|
// Discovery
|
2026-03-12 22:39:30 -07:00
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-03-12 22:57:37 -07:00
|
|
|
/// Returns a sorted list of `.rpg` file names found in `SAMPLES_DIR`.
|
|
|
|
|
fn discover_samples() -> Vec<String> {
|
|
|
|
|
let dir = fs::read_dir(SAMPLES_DIR)
|
|
|
|
|
.unwrap_or_else(|e| panic!("cannot read samples dir '{}': {e}", SAMPLES_DIR));
|
|
|
|
|
|
|
|
|
|
let mut names: Vec<String> = dir
|
|
|
|
|
.filter_map(|entry| {
|
|
|
|
|
let entry = entry.expect("dir entry error");
|
|
|
|
|
let name = entry.file_name().into_string().expect("non-UTF-8 filename");
|
|
|
|
|
if name.ends_with(".rpg") && !name.contains('.') == false {
|
|
|
|
|
// Accept only plain `<stem>.rpg` (no extra dots apart from the extension).
|
|
|
|
|
let stem = &name[..name.len() - 4]; // strip ".rpg"
|
|
|
|
|
if !stem.contains('.') {
|
|
|
|
|
return Some(name);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
None
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
names.sort();
|
|
|
|
|
names
|
2026-03-12 22:55:14 -07:00
|
|
|
}
|
|
|
|
|
|
2026-03-12 22:57:37 -07:00
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
// Driver
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
2026-03-12 22:55:14 -07:00
|
|
|
|
2026-03-12 22:57:37 -07:00
|
|
|
/// Compile and run one sample, returning `Err(message)` on any failure.
|
|
|
|
|
fn run_one(sample_name: &str) -> Result<(), String> {
|
|
|
|
|
let base = PathBuf::from(SAMPLES_DIR);
|
|
|
|
|
let src = base.join(sample_name);
|
|
|
|
|
let stdout_path = base.join(format!("{}.stdout", sample_name));
|
|
|
|
|
let stdin_path = base.join(format!("{}.stdin", sample_name));
|
|
|
|
|
|
|
|
|
|
// Skip samples that have no golden file yet — they are works in progress.
|
|
|
|
|
if !stdout_path.exists() {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"SKIP '{}': no expected-output file '{}'",
|
|
|
|
|
sample_name,
|
|
|
|
|
stdout_path.display(),
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-03-12 22:39:30 -07:00
|
|
|
|
2026-03-12 22:57:37 -07:00
|
|
|
let stdin_bytes: Vec<u8> = if stdin_path.exists() {
|
|
|
|
|
fs::read(&stdin_path)
|
|
|
|
|
.map_err(|e| format!("cannot read stdin file '{}': {e}", stdin_path.display()))?
|
|
|
|
|
} else {
|
|
|
|
|
Vec::new()
|
|
|
|
|
};
|
2026-03-12 22:39:30 -07:00
|
|
|
|
|
|
|
|
// ── 1. Compile ────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-03-12 22:55:14 -07:00
|
|
|
let safe_stem = sample_name
|
|
|
|
|
.replace('.', "_")
|
|
|
|
|
.replace(std::path::MAIN_SEPARATOR, "_");
|
2026-03-12 22:39:30 -07:00
|
|
|
let exe = std::env::temp_dir().join(format!("rpg_sample_test_{}.out", safe_stem));
|
|
|
|
|
|
|
|
|
|
let compile_out = Command::new(BIN)
|
|
|
|
|
.args(["-o", exe.to_str().unwrap(), src.to_str().unwrap()])
|
|
|
|
|
.output()
|
2026-03-12 22:57:37 -07:00
|
|
|
.map_err(|e| format!("failed to spawn compiler for '{}': {e}", sample_name))?;
|
|
|
|
|
|
|
|
|
|
if !compile_out.status.success() {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"compilation of '{}' failed (exit {})\nstderr:\n{}",
|
|
|
|
|
sample_name,
|
|
|
|
|
compile_out.status,
|
|
|
|
|
String::from_utf8_lossy(&compile_out.stderr),
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-03-12 22:39:30 -07:00
|
|
|
|
2026-03-12 22:57:37 -07:00
|
|
|
if !exe.exists() {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"compiler exited 0 for '{}' but produced no executable at '{}'",
|
|
|
|
|
sample_name,
|
|
|
|
|
exe.display(),
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-03-12 22:39:30 -07:00
|
|
|
|
|
|
|
|
// ── 2. Execute ────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-03-12 22:55:14 -07:00
|
|
|
let run_out = if stdin_bytes.is_empty() {
|
|
|
|
|
Command::new(&exe)
|
|
|
|
|
.stdin(Stdio::null())
|
|
|
|
|
.output()
|
2026-03-12 22:57:37 -07:00
|
|
|
.map_err(|e| format!("failed to run '{}': {e}", sample_name))?
|
2026-03-12 22:55:14 -07:00
|
|
|
} else {
|
|
|
|
|
let mut child = Command::new(&exe)
|
|
|
|
|
.stdin(Stdio::piped())
|
|
|
|
|
.stdout(Stdio::piped())
|
|
|
|
|
.stderr(Stdio::piped())
|
|
|
|
|
.spawn()
|
2026-03-12 22:57:37 -07:00
|
|
|
.map_err(|e| format!("failed to spawn '{}': {e}", sample_name))?;
|
2026-03-12 22:55:14 -07:00
|
|
|
|
|
|
|
|
child
|
|
|
|
|
.stdin
|
|
|
|
|
.take()
|
|
|
|
|
.expect("stdin was piped")
|
2026-03-12 22:57:37 -07:00
|
|
|
.write_all(&stdin_bytes)
|
|
|
|
|
.map_err(|e| format!("failed to write stdin for '{}': {e}", sample_name))?;
|
2026-03-12 22:55:14 -07:00
|
|
|
|
|
|
|
|
child
|
|
|
|
|
.wait_with_output()
|
2026-03-12 22:57:37 -07:00
|
|
|
.map_err(|e| format!("failed to wait on '{}': {e}", sample_name))?
|
2026-03-12 22:55:14 -07:00
|
|
|
};
|
2026-03-12 22:39:30 -07:00
|
|
|
|
2026-03-12 22:57:37 -07:00
|
|
|
if !run_out.status.success() {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"binary for '{}' exited non-zero ({})\nstdout:\n{}\nstderr:\n{}",
|
|
|
|
|
sample_name,
|
|
|
|
|
run_out.status,
|
|
|
|
|
String::from_utf8_lossy(&run_out.stdout),
|
|
|
|
|
String::from_utf8_lossy(&run_out.stderr),
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-03-12 22:39:30 -07:00
|
|
|
|
2026-03-12 22:57:37 -07:00
|
|
|
// ── 3. Compare against expected output ───────────────────────────────────
|
2026-03-12 22:39:30 -07:00
|
|
|
|
2026-03-12 22:57:37 -07:00
|
|
|
let actual_raw = String::from_utf8_lossy(&run_out.stdout);
|
|
|
|
|
let expected_raw = fs::read_to_string(&stdout_path)
|
|
|
|
|
.map_err(|e| format!("could not read '{}': {e}", stdout_path.display()))?;
|
2026-03-12 22:39:30 -07:00
|
|
|
|
2026-03-12 22:57:37 -07:00
|
|
|
// Trim trailing whitespace per line; ignore a lone trailing blank line so
|
|
|
|
|
// `.rpg.stdout` files don't need a precise final newline.
|
2026-03-12 22:39:30 -07:00
|
|
|
let normalise = |s: &str| -> Vec<String> {
|
2026-03-12 22:55:14 -07:00
|
|
|
let mut lines: Vec<String> = s.lines().map(|l| l.trim_end().to_string()).collect();
|
|
|
|
|
if lines.last().map(|l| l.is_empty()).unwrap_or(false) {
|
|
|
|
|
lines.pop();
|
|
|
|
|
}
|
|
|
|
|
lines
|
2026-03-12 22:39:30 -07:00
|
|
|
};
|
|
|
|
|
|
2026-03-12 22:57:37 -07:00
|
|
|
let actual_lines = normalise(&actual_raw);
|
2026-03-12 22:39:30 -07:00
|
|
|
let expected_lines = normalise(&expected_raw);
|
|
|
|
|
|
2026-03-12 22:55:14 -07:00
|
|
|
if actual_lines == expected_lines {
|
2026-03-12 22:57:37 -07:00
|
|
|
return Ok(());
|
2026-03-12 22:55:14 -07:00
|
|
|
}
|
2026-03-12 22:39:30 -07:00
|
|
|
|
2026-03-12 22:55:14 -07:00
|
|
|
// ── Build a readable diff-style failure message ───────────────────────────
|
|
|
|
|
|
|
|
|
|
let mut msg = format!(
|
|
|
|
|
"stdout mismatch for '{}'\n\n\
|
|
|
|
|
── expected ({} line{}) ─────────────────────────────────\n",
|
|
|
|
|
sample_name,
|
|
|
|
|
expected_lines.len(),
|
|
|
|
|
if expected_lines.len() == 1 { "" } else { "s" },
|
|
|
|
|
);
|
|
|
|
|
for line in &expected_lines {
|
|
|
|
|
msg.push_str(" ");
|
|
|
|
|
msg.push_str(line);
|
|
|
|
|
msg.push('\n');
|
|
|
|
|
}
|
|
|
|
|
msg.push_str(&format!(
|
|
|
|
|
"\n── actual ({} line{}) ─────────────────────────────────\n",
|
|
|
|
|
actual_lines.len(),
|
|
|
|
|
if actual_lines.len() == 1 { "" } else { "s" },
|
|
|
|
|
));
|
|
|
|
|
for line in &actual_lines {
|
|
|
|
|
msg.push_str(" ");
|
|
|
|
|
msg.push_str(line);
|
|
|
|
|
msg.push('\n');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (i, (exp, act)) in expected_lines.iter().zip(actual_lines.iter()).enumerate() {
|
|
|
|
|
if exp != act {
|
2026-03-12 22:39:30 -07:00
|
|
|
msg.push_str(&format!(
|
2026-03-12 22:55:14 -07:00
|
|
|
"\nfirst difference at line {}:\n expected: {:?}\n actual: {:?}\n",
|
|
|
|
|
i + 1,
|
|
|
|
|
exp,
|
|
|
|
|
act,
|
2026-03-12 22:39:30 -07:00
|
|
|
));
|
2026-03-12 22:55:14 -07:00
|
|
|
break;
|
2026-03-12 22:39:30 -07:00
|
|
|
}
|
|
|
|
|
}
|
2026-03-12 22:55:14 -07:00
|
|
|
if actual_lines.len() != expected_lines.len() {
|
|
|
|
|
msg.push_str(&format!(
|
|
|
|
|
"\nline count: expected {}, got {}\n",
|
|
|
|
|
expected_lines.len(),
|
|
|
|
|
actual_lines.len(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 22:57:37 -07:00
|
|
|
Err(msg)
|
2026-03-12 22:39:30 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
2026-03-12 22:57:37 -07:00
|
|
|
// Single test entry-point
|
2026-03-12 22:39:30 -07:00
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-03-12 22:57:37 -07:00
|
|
|
fn samples() {
|
|
|
|
|
let names = discover_samples();
|
2026-03-12 22:39:30 -07:00
|
|
|
|
2026-03-12 22:57:37 -07:00
|
|
|
assert!(
|
|
|
|
|
!names.is_empty(),
|
|
|
|
|
"no .rpg files found in '{}'",
|
|
|
|
|
SAMPLES_DIR,
|
|
|
|
|
);
|
2026-03-12 22:39:30 -07:00
|
|
|
|
2026-03-12 22:57:37 -07:00
|
|
|
let mut failures: Vec<String> = Vec::new();
|
|
|
|
|
let mut skipped: Vec<String> = Vec::new();
|
|
|
|
|
|
|
|
|
|
for name in &names {
|
|
|
|
|
eprint!(" sample '{}' … ", name);
|
|
|
|
|
match run_one(name) {
|
|
|
|
|
Ok(()) => eprintln!("ok"),
|
|
|
|
|
Err(msg) if msg.starts_with("SKIP") => {
|
|
|
|
|
eprintln!("skipped");
|
|
|
|
|
skipped.push(msg);
|
|
|
|
|
}
|
|
|
|
|
Err(msg) => {
|
|
|
|
|
eprintln!("FAILED");
|
|
|
|
|
failures.push(msg);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-12 22:55:14 -07:00
|
|
|
|
2026-03-12 22:57:37 -07:00
|
|
|
if !skipped.is_empty() {
|
|
|
|
|
eprintln!("\n{} sample(s) skipped (no .rpg.stdout file):", skipped.len());
|
|
|
|
|
for s in &skipped {
|
|
|
|
|
eprintln!(" {}", s);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-12 22:55:14 -07:00
|
|
|
|
2026-03-12 22:57:37 -07:00
|
|
|
if failures.is_empty() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut report = format!(
|
|
|
|
|
"\n{} of {} sample(s) failed:\n",
|
|
|
|
|
failures.len(),
|
|
|
|
|
names.len(),
|
|
|
|
|
);
|
|
|
|
|
for (i, f) in failures.iter().enumerate() {
|
|
|
|
|
report.push_str(&format!("\n── failure {} ──────────────────────────────────────────\n{}\n", i + 1, f));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
panic!("{}", report);
|
2026-03-12 22:55:14 -07:00
|
|
|
}
|