//! 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 .rpg` — compile //! 2. `` — execute (optionally with stdin) //! 3. compare stdout with `.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/.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 { 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) { 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"); }