fix: refactor samples to make running easier

This commit is contained in:
2026-03-12 22:57:37 -07:00
parent 46935005f7
commit dc9bb41cce
7 changed files with 146 additions and 109 deletions

2
samples/3np1.rpg.stdin Normal file
View File

@@ -0,0 +1,2 @@
8
0

View File

@@ -1,17 +1,20 @@
//! Integration tests — compile every `.rpg` sample and validate stdout //! Integration tests — compile every `.rpg` sample and validate stdout.
//! against the accompanying `.rpg.golden` file.
//! //!
//! Each test follows the same three-step pipeline: //! Tests are discovered automatically: every file in `samples/` whose name
//! 1. `rust-langrpg -o <tmp> <sample>.rpg` — compile //! ends with `.rpg` becomes a test case. Companion files control behaviour:
//! 2. `<tmp>` — execute (optionally with stdin) //!
//! 3. compare stdout with `<sample>.rpg.golden` //! | file | purpose |
//! |-----------------------|--------------------------------------------|
//! | `<name>.rpg.stdout` | expected stdout (required to run the test) |
//! | `<name>.rpg.stdin` | bytes piped to stdin (optional) |
//! //!
//! # Adding a new sample //! # Adding a new sample
//! //!
//! Drop the `.rpg` source and a `.rpg.golden` file into `samples/` and add a //! 1. Drop `samples/<name>.rpg` into the directory.
//! `#[test]` that calls either: //! 2. Create `samples/<name>.rpg.stdout` with the expected output.
//! * `run_sample("your_file.rpg")` — no stdin input //! 3. Optionally create `samples/<name>.rpg.stdin` with any required input.
//! * `run_sample_stdin("your_file.rpg", b"input\n")` — bytes fed to stdin //!
//! No changes to this file are needed.
use std::{ use std::{
fs, fs,
@@ -27,50 +30,62 @@ const BIN: &str = env!("CARGO_BIN_EXE_rust-langrpg");
const SAMPLES_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/samples"); const SAMPLES_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/samples");
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Shared driver // Discovery
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
/// Compile `sample_name` from `samples/`, run it with no stdin, and assert /// Returns a sorted list of `.rpg` file names found in `SAMPLES_DIR`.
/// that its stdout matches `samples/<sample_name>.golden` line-for-line. fn discover_samples() -> Vec<String> {
fn run_sample(sample_name: &str) { let dir = fs::read_dir(SAMPLES_DIR)
run_sample_inner(sample_name, &[]); .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
} }
/// Like [`run_sample`] but feeds `stdin_bytes` to the program's standard input. // ─────────────────────────────────────────────────────────────────────────────
/// // Driver
/// 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`]. /// Compile and run one sample, returning `Err(message)` on any failure.
fn run_sample_inner(sample_name: &str, stdin_bytes: &[u8]) { fn run_one(sample_name: &str) -> Result<(), String> {
let src = PathBuf::from(SAMPLES_DIR).join(sample_name); let base = PathBuf::from(SAMPLES_DIR);
let golden_path = PathBuf::from(SAMPLES_DIR).join(format!("{}.golden", sample_name)); let src = base.join(sample_name);
let stdout_path = base.join(format!("{}.stdout", sample_name));
let stdin_path = base.join(format!("{}.stdin", sample_name));
assert!( // Skip samples that have no golden file yet — they are works in progress.
src.exists(), if !stdout_path.exists() {
"source file not found: {}", return Err(format!(
src.display() "SKIP '{}': no expected-output file '{}'",
); sample_name,
assert!( stdout_path.display(),
golden_path.exists(), ));
"golden file not found: {}\n\ }
Create it with the expected stdout to enable this test.",
golden_path.display() 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()
};
// ── 1. Compile ──────────────────────────────────────────────────────────── // ── 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 let safe_stem = sample_name
.replace('.', "_") .replace('.', "_")
.replace(std::path::MAIN_SEPARATOR, "_"); .replace(std::path::MAIN_SEPARATOR, "_");
@@ -79,72 +94,70 @@ fn run_sample_inner(sample_name: &str, stdin_bytes: &[u8]) {
let compile_out = Command::new(BIN) let compile_out = Command::new(BIN)
.args(["-o", exe.to_str().unwrap(), src.to_str().unwrap()]) .args(["-o", exe.to_str().unwrap(), src.to_str().unwrap()])
.output() .output()
.unwrap_or_else(|e| panic!("failed to spawn compiler for '{}': {e}", sample_name)); .map_err(|e| format!("failed to spawn compiler for '{}': {e}", sample_name))?;
assert!( if !compile_out.status.success() {
compile_out.status.success(), return Err(format!(
"compilation of '{}' failed (exit {})\nstderr:\n{}", "compilation of '{}' failed (exit {})\nstderr:\n{}",
sample_name, sample_name,
compile_out.status, compile_out.status,
String::from_utf8_lossy(&compile_out.stderr), String::from_utf8_lossy(&compile_out.stderr),
); ));
}
assert!( if !exe.exists() {
exe.exists(), return Err(format!(
"compiler exited 0 for '{}' but no executable was produced at '{}'", "compiler exited 0 for '{}' but produced no executable at '{}'",
sample_name, sample_name,
exe.display(), exe.display(),
); ));
}
// ── 2. Execute ──────────────────────────────────────────────────────────── // ── 2. Execute ────────────────────────────────────────────────────────────
let run_out = if stdin_bytes.is_empty() { 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) Command::new(&exe)
.stdin(Stdio::null()) .stdin(Stdio::null())
.output() .output()
.unwrap_or_else(|e| panic!("failed to run '{}': {e}", sample_name)) .map_err(|e| format!("failed to run '{}': {e}", sample_name))?
} else { } else {
// Pipe the provided bytes to the program's stdin.
let mut child = Command::new(&exe) let mut child = Command::new(&exe)
.stdin(Stdio::piped()) .stdin(Stdio::piped())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.spawn() .spawn()
.unwrap_or_else(|e| panic!("failed to spawn '{}': {e}", sample_name)); .map_err(|e| format!("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 child
.stdin .stdin
.take() .take()
.expect("stdin was piped") .expect("stdin was piped")
.write_all(stdin_bytes) .write_all(&stdin_bytes)
.unwrap_or_else(|e| panic!("failed to write stdin for '{}': {e}", sample_name)); .map_err(|e| format!("failed to write stdin for '{}': {e}", sample_name))?;
child child
.wait_with_output() .wait_with_output()
.unwrap_or_else(|e| panic!("failed to wait on '{}': {e}", sample_name)) .map_err(|e| format!("failed to wait on '{}': {e}", sample_name))?
}; };
assert!( if !run_out.status.success() {
run_out.status.success(), return Err(format!(
"binary for '{}' exited non-zero ({})\nstdout:\n{}\nstderr:\n{}", "binary for '{}' exited non-zero ({})\nstdout:\n{}\nstderr:\n{}",
sample_name, sample_name,
run_out.status, run_out.status,
String::from_utf8_lossy(&run_out.stdout), String::from_utf8_lossy(&run_out.stdout),
String::from_utf8_lossy(&run_out.stderr), String::from_utf8_lossy(&run_out.stderr),
); ));
}
// ── 3. Compare against golden ───────────────────────────────────────────── // ── 3. Compare against expected output ───────────────────────────────────
let actual_raw = String::from_utf8_lossy(&run_out.stdout); let actual_raw = String::from_utf8_lossy(&run_out.stdout);
let expected_raw = fs::read_to_string(&golden_path) let expected_raw = fs::read_to_string(&stdout_path)
.unwrap_or_else(|e| panic!("could not read golden '{}': {e}", golden_path.display())); .map_err(|e| format!("could not read '{}': {e}", stdout_path.display()))?;
// Normalise both sides: trim trailing whitespace per line and ignore a // Trim trailing whitespace per line; ignore a lone trailing blank line so
// lone trailing blank line so golden files don't need a precise final newline. // `.rpg.stdout` files don't need a precise final newline.
let normalise = |s: &str| -> Vec<String> { let normalise = |s: &str| -> Vec<String> {
let mut lines: Vec<String> = s.lines().map(|l| l.trim_end().to_string()).collect(); 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) { if lines.last().map(|l| l.is_empty()).unwrap_or(false) {
@@ -157,7 +170,7 @@ fn run_sample_inner(sample_name: &str, stdin_bytes: &[u8]) {
let expected_lines = normalise(&expected_raw); let expected_lines = normalise(&expected_raw);
if actual_lines == expected_lines { if actual_lines == expected_lines {
return; return Ok(());
} }
// ── Build a readable diff-style failure message ─────────────────────────── // ── Build a readable diff-style failure message ───────────────────────────
@@ -185,7 +198,6 @@ fn run_sample_inner(sample_name: &str, stdin_bytes: &[u8]) {
msg.push('\n'); 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() { for (i, (exp, act)) in expected_lines.iter().zip(actual_lines.iter()).enumerate() {
if exp != act { if exp != act {
msg.push_str(&format!( msg.push_str(&format!(
@@ -205,37 +217,60 @@ fn run_sample_inner(sample_name: &str, stdin_bytes: &[u8]) {
)); ));
} }
panic!("{}", msg); Err(msg)
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// One #[test] per sample // Single test entry-point
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
#[test] #[test]
fn sample_hello() { fn samples() {
run_sample("hello.rpg"); let names = discover_samples();
}
#[test] assert!(
fn sample_for() { !names.is_empty(),
run_sample("for.rpg"); "no .rpg files found in '{}'",
} SAMPLES_DIR,
);
#[test] let mut failures: Vec<String> = Vec::new();
fn sample_fib() { let mut skipped: Vec<String> = Vec::new();
run_sample("fib.rpg");
}
#[test] for name in &names {
fn sample_fizzbuzz() { eprint!(" sample '{}' … ", name);
run_sample("fizzbuzz.rpg"); 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);
}
}
}
#[test] if !skipped.is_empty() {
fn sample_3np1() { eprintln!("\n{} sample(s) skipped (no .rpg.stdout file):", skipped.len());
// Test the Collatz (3n+1) program with a starting value of 8. for s in &skipped {
// The sequence 8 → 4 → 2 → 1 takes 3 steps. eprintln!(" {}", s);
// A second input of 0 tells the outer loop to exit. }
run_sample_stdin("3np1.rpg", b"8\n0\n"); }
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);
} }