242 lines
9.0 KiB
Rust
242 lines
9.0 KiB
Rust
//! Integration tests — compile every `.rpg` sample and validate stdout
|
|
//! against the accompanying `.rpg.golden` file.
|
|
//!
|
|
//! Each test follows the same three-step pipeline:
|
|
//! 1. `rust-langrpg -o <tmp> <sample>.rpg` — compile
|
|
//! 2. `<tmp>` — execute (optionally with stdin)
|
|
//! 3. compare stdout with `<sample>.rpg.golden`
|
|
//!
|
|
//! # Adding a new sample
|
|
//!
|
|
//! Drop the `.rpg` source and a `.rpg.golden` file into `samples/` and add a
|
|
//! `#[test]` that calls either:
|
|
//! * `run_sample("your_file.rpg")` — no stdin input
|
|
//! * `run_sample_stdin("your_file.rpg", b"input\n")` — bytes fed to stdin
|
|
|
|
use std::{
|
|
fs,
|
|
io::Write as _,
|
|
path::PathBuf,
|
|
process::{Command, Stdio},
|
|
};
|
|
|
|
/// 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");
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Shared driver
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// Compile `sample_name` from `samples/`, run it with no stdin, and assert
|
|
/// that its stdout matches `samples/<sample_name>.golden` line-for-line.
|
|
fn run_sample(sample_name: &str) {
|
|
run_sample_inner(sample_name, &[]);
|
|
}
|
|
|
|
/// Like [`run_sample`] but feeds `stdin_bytes` to the program's standard input.
|
|
///
|
|
/// Use this for programs that read operator responses via the `DSPLY … response`
|
|
/// opcode. Pass every line the program will request, each terminated by `\n`.
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```no_run
|
|
/// run_sample_stdin("3np1.rpg", b"8\n0\n");
|
|
/// ```
|
|
fn run_sample_stdin(sample_name: &str, stdin_bytes: &[u8]) {
|
|
run_sample_inner(sample_name, stdin_bytes);
|
|
}
|
|
|
|
/// Internal driver shared by [`run_sample`] and [`run_sample_stdin`].
|
|
fn run_sample_inner(sample_name: &str, stdin_bytes: &[u8]) {
|
|
let src = PathBuf::from(SAMPLES_DIR).join(sample_name);
|
|
let golden_path = PathBuf::from(SAMPLES_DIR).join(format!("{}.golden", sample_name));
|
|
|
|
assert!(
|
|
src.exists(),
|
|
"source file not found: {}",
|
|
src.display()
|
|
);
|
|
assert!(
|
|
golden_path.exists(),
|
|
"golden file not found: {}\n\
|
|
Create it with the expected stdout to enable this test.",
|
|
golden_path.display()
|
|
);
|
|
|
|
// ── 1. Compile ────────────────────────────────────────────────────────────
|
|
|
|
// Sanitise the name so it is safe to use in a file path (e.g. "for.rpg"
|
|
// becomes "for_rpg") and prefix so artefacts are easy to identify.
|
|
let safe_stem = sample_name
|
|
.replace('.', "_")
|
|
.replace(std::path::MAIN_SEPARATOR, "_");
|
|
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()
|
|
.unwrap_or_else(|e| panic!("failed to spawn compiler for '{}': {e}", sample_name));
|
|
|
|
assert!(
|
|
compile_out.status.success(),
|
|
"compilation of '{}' failed (exit {})\nstderr:\n{}",
|
|
sample_name,
|
|
compile_out.status,
|
|
String::from_utf8_lossy(&compile_out.stderr),
|
|
);
|
|
|
|
assert!(
|
|
exe.exists(),
|
|
"compiler exited 0 for '{}' but no executable was produced at '{}'",
|
|
sample_name,
|
|
exe.display(),
|
|
);
|
|
|
|
// ── 2. Execute ────────────────────────────────────────────────────────────
|
|
|
|
let run_out = if stdin_bytes.is_empty() {
|
|
// No input needed — let stdin inherit /dev/null so the process never
|
|
// blocks waiting for a read that will never come.
|
|
Command::new(&exe)
|
|
.stdin(Stdio::null())
|
|
.output()
|
|
.unwrap_or_else(|e| panic!("failed to run '{}': {e}", sample_name))
|
|
} else {
|
|
// Pipe the provided bytes to the program's stdin.
|
|
let mut child = Command::new(&exe)
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.unwrap_or_else(|e| panic!("failed to spawn '{}': {e}", sample_name));
|
|
|
|
// Write all input at once; the programs we test read only a handful of
|
|
// lines so this never fills the OS pipe buffer.
|
|
child
|
|
.stdin
|
|
.take()
|
|
.expect("stdin was piped")
|
|
.write_all(stdin_bytes)
|
|
.unwrap_or_else(|e| panic!("failed to write stdin for '{}': {e}", sample_name));
|
|
|
|
child
|
|
.wait_with_output()
|
|
.unwrap_or_else(|e| panic!("failed to wait on '{}': {e}", sample_name))
|
|
};
|
|
|
|
assert!(
|
|
run_out.status.success(),
|
|
"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),
|
|
);
|
|
|
|
// ── 3. Compare against golden ─────────────────────────────────────────────
|
|
|
|
let actual_raw = String::from_utf8_lossy(&run_out.stdout);
|
|
let expected_raw = fs::read_to_string(&golden_path)
|
|
.unwrap_or_else(|e| panic!("could not read golden '{}': {e}", golden_path.display()));
|
|
|
|
// Normalise both sides: trim trailing whitespace per line and ignore a
|
|
// lone trailing blank line so golden files don't need a precise final newline.
|
|
let normalise = |s: &str| -> Vec<String> {
|
|
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
|
|
};
|
|
|
|
let actual_lines = normalise(&actual_raw);
|
|
let expected_lines = normalise(&expected_raw);
|
|
|
|
if actual_lines == expected_lines {
|
|
return;
|
|
}
|
|
|
|
// ── 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');
|
|
}
|
|
|
|
// Point at the first diverging line to make the failure easy to locate.
|
|
for (i, (exp, act)) in expected_lines.iter().zip(actual_lines.iter()).enumerate() {
|
|
if exp != act {
|
|
msg.push_str(&format!(
|
|
"\nfirst difference at line {}:\n expected: {:?}\n actual: {:?}\n",
|
|
i + 1,
|
|
exp,
|
|
act,
|
|
));
|
|
break;
|
|
}
|
|
}
|
|
if actual_lines.len() != expected_lines.len() {
|
|
msg.push_str(&format!(
|
|
"\nline count: expected {}, got {}\n",
|
|
expected_lines.len(),
|
|
actual_lines.len(),
|
|
));
|
|
}
|
|
|
|
panic!("{}", msg);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// One #[test] per sample
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn sample_hello() {
|
|
run_sample("hello.rpg");
|
|
}
|
|
|
|
#[test]
|
|
fn sample_for() {
|
|
run_sample("for.rpg");
|
|
}
|
|
|
|
#[test]
|
|
fn sample_fib() {
|
|
run_sample("fib.rpg");
|
|
}
|
|
|
|
#[test]
|
|
fn sample_fizzbuzz() {
|
|
run_sample("fizzbuzz.rpg");
|
|
}
|
|
|
|
#[test]
|
|
fn sample_3np1() {
|
|
// Test the Collatz (3n+1) program with a starting value of 8.
|
|
// The sequence 8 → 4 → 2 → 1 takes 3 steps.
|
|
// A second input of 0 tells the outer loop to exit.
|
|
run_sample_stdin("3np1.rpg", b"8\n0\n");
|
|
}
|