add: more samples and make them work

This commit is contained in:
2026-03-12 22:55:14 -07:00
parent 6c4118c489
commit 46935005f7
8 changed files with 525 additions and 90 deletions

View File

@@ -3,15 +3,22 @@
//!
//! Each test follows the same three-step pipeline:
//! 1. `rust-langrpg -o <tmp> <sample>.rpg` — compile
//! 2. `<tmp>` — execute
//! 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
//! one-line `#[test]` that calls `run_sample("your_file.rpg")`.
//! `#[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, path::PathBuf, process::Command};
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");
@@ -23,12 +30,28 @@ const SAMPLES_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/samples");
// Shared driver
// ─────────────────────────────────────────────────────────────────────────────
/// Compile `sample_name` from `samples/`, run the resulting binary, and assert
/// Compile `sample_name` from `samples/`, run it with no stdin, and assert
/// that its stdout matches `samples/<sample_name>.golden` line-for-line.
///
/// Temp binaries are written to the OS temp directory with a name derived from
/// the sample so parallel test execution doesn't cause collisions.
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));
@@ -46,9 +69,11 @@ fn run_sample(sample_name: &str) {
// ── 1. Compile ────────────────────────────────────────────────────────────
// Sanitise the stem so it is safe to embed in a file name (e.g. "for.rpg"
// "for_rpg") and prefix with a fixed string to make it easy to identify.
let safe_stem = sample_name.replace('.', "_").replace(std::path::MAIN_SEPARATOR, "_");
// 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)
@@ -73,9 +98,35 @@ fn run_sample(sample_name: &str) {
// ── 2. Execute ────────────────────────────────────────────────────────────
let run_out = Command::new(&exe)
.output()
.unwrap_or_else(|e| panic!("failed to run compiled binary for '{}': {e}", sample_name));
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(),
@@ -88,66 +139,73 @@ fn run_sample(sample_name: &str) {
// ── 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 file '{}': {e}", golden_path.display())
});
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 from every line and ignore
// a trailing blank line, so golden files don't need a precise final newline.
// 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> {
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) {
lines.pop();
}
lines
};
let actual_lines = normalise(&actual_raw);
let actual_lines = normalise(&actual_raw);
let expected_lines = normalise(&expected_raw);
if actual_lines != expected_lines {
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');
}
// Highlight the first diverging line to make failures easy to spot.
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 differs: expected {}, got {}\n",
expected_lines.len(),
actual_lines.len(),
));
}
panic!("{}", msg);
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);
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -168,3 +226,16 @@ fn sample_for() {
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");
}