From dc9bb41ccec6b47bb4d33914babe0303651d220d Mon Sep 17 00:00:00 2001 From: charles Date: Thu, 12 Mar 2026 22:57:37 -0700 Subject: [PATCH] fix: refactor samples to make running easier --- samples/3np1.rpg.stdin | 2 + samples/{3np1.rpg.golden => 3np1.rpg.stdout} | 0 samples/{fib.rpg.golden => fib.rpg.stdout} | 0 ...izzbuzz.rpg.golden => fizzbuzz.rpg.stdout} | 0 samples/{for.rpg.golden => for.rpg.stdout} | 0 .../{hello.rpg.golden => hello.rpg.stdout} | 0 tests/samples.rs | 253 ++++++++++-------- 7 files changed, 146 insertions(+), 109 deletions(-) create mode 100644 samples/3np1.rpg.stdin rename samples/{3np1.rpg.golden => 3np1.rpg.stdout} (100%) rename samples/{fib.rpg.golden => fib.rpg.stdout} (100%) rename samples/{fizzbuzz.rpg.golden => fizzbuzz.rpg.stdout} (100%) rename samples/{for.rpg.golden => for.rpg.stdout} (100%) rename samples/{hello.rpg.golden => hello.rpg.stdout} (100%) diff --git a/samples/3np1.rpg.stdin b/samples/3np1.rpg.stdin new file mode 100644 index 0000000..9807191 --- /dev/null +++ b/samples/3np1.rpg.stdin @@ -0,0 +1,2 @@ +8 +0 diff --git a/samples/3np1.rpg.golden b/samples/3np1.rpg.stdout similarity index 100% rename from samples/3np1.rpg.golden rename to samples/3np1.rpg.stdout diff --git a/samples/fib.rpg.golden b/samples/fib.rpg.stdout similarity index 100% rename from samples/fib.rpg.golden rename to samples/fib.rpg.stdout diff --git a/samples/fizzbuzz.rpg.golden b/samples/fizzbuzz.rpg.stdout similarity index 100% rename from samples/fizzbuzz.rpg.golden rename to samples/fizzbuzz.rpg.stdout diff --git a/samples/for.rpg.golden b/samples/for.rpg.stdout similarity index 100% rename from samples/for.rpg.golden rename to samples/for.rpg.stdout diff --git a/samples/hello.rpg.golden b/samples/hello.rpg.stdout similarity index 100% rename from samples/hello.rpg.golden rename to samples/hello.rpg.stdout diff --git a/tests/samples.rs b/tests/samples.rs index aec6d35..12adfdd 100644 --- a/tests/samples.rs +++ b/tests/samples.rs @@ -1,17 +1,20 @@ -//! Integration tests — compile every `.rpg` sample and validate stdout -//! against the accompanying `.rpg.golden` file. +//! Integration tests — compile every `.rpg` sample and validate stdout. //! -//! Each test follows the same three-step pipeline: -//! 1. `rust-langrpg -o .rpg` — compile -//! 2. `` — execute (optionally with stdin) -//! 3. compare stdout with `.rpg.golden` +//! Tests are discovered automatically: every file in `samples/` whose name +//! ends with `.rpg` becomes a test case. Companion files control behaviour: +//! +//! | file | purpose | +//! |-----------------------|--------------------------------------------| +//! | `.rpg.stdout` | expected stdout (required to run the test) | +//! | `.rpg.stdin` | bytes piped to stdin (optional) | //! //! # 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 +//! 1. Drop `samples/.rpg` into the directory. +//! 2. Create `samples/.rpg.stdout` with the expected output. +//! 3. Optionally create `samples/.rpg.stdin` with any required input. +//! +//! No changes to this file are needed. use std::{ fs, @@ -27,50 +30,62 @@ const BIN: &str = env!("CARGO_BIN_EXE_rust-langrpg"); 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 -/// that its stdout matches `samples/.golden` line-for-line. -fn run_sample(sample_name: &str) { - run_sample_inner(sample_name, &[]); +/// Returns a sorted list of `.rpg` file names found in `SAMPLES_DIR`. +fn discover_samples() -> Vec { + let dir = fs::read_dir(SAMPLES_DIR) + .unwrap_or_else(|e| panic!("cannot read samples dir '{}': {e}", SAMPLES_DIR)); + + let mut names: Vec = 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 `.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. -/// -/// 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); -} +// ───────────────────────────────────────────────────────────────────────────── +// Driver +// ───────────────────────────────────────────────────────────────────────────── -/// 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)); +/// 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)); - 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() - ); + // 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(), + )); + } + + let stdin_bytes: Vec = 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 ──────────────────────────────────────────────────────────── - // 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, "_"); @@ -79,72 +94,70 @@ fn run_sample_inner(sample_name: &str, stdin_bytes: &[u8]) { 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)); + .map_err(|e| format!("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), - ); + 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), + )); + } - assert!( - exe.exists(), - "compiler exited 0 for '{}' but no executable was produced at '{}'", - sample_name, - exe.display(), - ); + if !exe.exists() { + return Err(format!( + "compiler exited 0 for '{}' but produced no executable 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)) + .map_err(|e| format!("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)); + .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 .stdin .take() .expect("stdin was piped") - .write_all(stdin_bytes) - .unwrap_or_else(|e| panic!("failed to write stdin for '{}': {e}", sample_name)); + .write_all(&stdin_bytes) + .map_err(|e| format!("failed to write stdin for '{}': {e}", sample_name))?; child .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!( - 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), - ); + 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), + )); + } - // ── 3. Compare against golden ───────────────────────────────────────────── + // ── 3. Compare against expected output ─────────────────────────────────── - 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())); + 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()))?; - // 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. + // Trim trailing whitespace per line; ignore a lone trailing blank line so + // `.rpg.stdout` files don't need a precise final newline. let normalise = |s: &str| -> Vec { let mut lines: Vec = s.lines().map(|l| l.trim_end().to_string()).collect(); if lines.last().map(|l| l.is_empty()).unwrap_or(false) { @@ -153,11 +166,11 @@ fn run_sample_inner(sample_name: &str, stdin_bytes: &[u8]) { lines }; - let actual_lines = normalise(&actual_raw); + let actual_lines = normalise(&actual_raw); let expected_lines = normalise(&expected_raw); if actual_lines == expected_lines { - return; + return Ok(()); } // ── Build a readable diff-style failure message ─────────────────────────── @@ -185,7 +198,6 @@ fn run_sample_inner(sample_name: &str, stdin_bytes: &[u8]) { 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!( @@ -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] -fn sample_hello() { - run_sample("hello.rpg"); -} +fn samples() { + let names = discover_samples(); -#[test] -fn sample_for() { - run_sample("for.rpg"); -} + assert!( + !names.is_empty(), + "no .rpg files found in '{}'", + SAMPLES_DIR, + ); -#[test] -fn sample_fib() { - run_sample("fib.rpg"); -} + let mut failures: Vec = Vec::new(); + let mut skipped: Vec = Vec::new(); -#[test] -fn sample_fizzbuzz() { - run_sample("fizzbuzz.rpg"); -} + 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); + } + } + } -#[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"); + if !skipped.is_empty() { + eprintln!("\n{} sample(s) skipped (no .rpg.stdout file):", skipped.len()); + for s in &skipped { + eprintln!(" {}", s); + } + } + + 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); }