gameboy-rust/crates/desktop/tests/rom_suite_cli.rs

269 lines
9.7 KiB
Rust

//! File-backed suite tests. All ROM instructions are original and temporary.
use serde_json::{json, Value};
use std::{
fs::{self, File},
path::{Path, PathBuf},
process::{Command, Output},
sync::atomic::{AtomicUsize, Ordering},
};
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
static NEXT: AtomicUsize = AtomicUsize::new(0);
let path = std::env::temp_dir().join(format!(
"gba-suite-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir(&path).unwrap();
Self(path)
}
fn rom(&self, name: &str, code: &[u32]) {
fs::write(
self.0.join(name),
code.iter()
.flat_map(|word| word.to_le_bytes())
.collect::<Vec<_>>(),
)
.unwrap();
}
fn manifest(&self, cases: Vec<Value>) -> PathBuf {
let path = self.0.join("suite λ.json");
fs::write(
&path,
serde_json::to_vec(&json!({"version":1,"cases":cases})).unwrap(),
)
.unwrap();
path
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn case(name: &str, rom: &str) -> Value {
json!({"name":name,"rom":rom,"step_limit":100,
"completion":{"pc":"0x08000004","instruction_set":"arm"},
"checks":[{"kind":"register","index":0,"equals":42}]})
}
fn command() -> Command {
Command::new(env!("CARGO_BIN_EXE_gameboy-rust"))
}
fn run(path: &Path) -> Output {
command()
.arg("--test-suite")
.arg(path)
.current_dir(std::env::temp_dir())
.output()
.unwrap()
}
fn stderr(output: &Output) -> String {
String::from_utf8(output.stderr.clone()).unwrap()
}
fn report(output: &Output) -> Value {
serde_json::from_slice(&output.stdout).unwrap_or_else(|error| {
panic!(
"invalid JSON: {error}; stdout={:?}; stderr={}",
output.stdout,
stderr(output)
)
})
}
#[test]
fn relative_paths_resolve_from_suite_and_success_reports_are_repeatable() {
let fixture = Fixture::new();
fixture.rom("original λ.gba", &[0xe3a0_002a, 0xee00_0000]);
let path = fixture.manifest(vec![case("original \"ARM\"\ncase", "original λ.gba")]);
let first = run(&path);
let second = run(&path);
assert!(first.status.success(), "{}", stderr(&first));
assert!(first.stderr.is_empty());
assert_eq!(first.stdout, second.stdout);
let report = report(&first);
assert_eq!(report["format_version"], 1);
assert_eq!(report["bios"], "original");
assert_eq!(report["passed"], true);
assert_eq!(report["failed_count"], 0);
let result = &report["cases"][0];
assert_eq!(result["reason"], "checkpoint");
assert_eq!(result["name"], "original \"ARM\"\ncase");
assert_eq!(result["rom_bytes"], 8);
assert_eq!(result["state"]["pc"], 0x0800_0004u32);
assert_eq!(result["state"]["instruction_set"], "arm");
assert_eq!(result["state"]["registers"].as_array().unwrap().len(), 16);
assert_eq!(result["checks"][0]["actual"], 42);
}
#[test]
fn failures_do_not_skip_later_cases_and_never_count_as_passes() {
let fixture = Fixture::new();
fixture.rom("valid.gba", &[0xe3a0_002a, 0xeaff_fffe]);
fixture.rom("invalid.gba", &[0xee00_0000]);
fixture.rom("stop.gba", &[0xef03_0000, 0xeaff_fffe]);
let mut mismatch = case("mismatch", "valid.gba");
mismatch["checks"][0]["equals"] = json!(43);
let mut timeout = case("timeout", "valid.gba");
timeout["completion"]["pc"] = json!("0x08000008");
let mut bad_read = case("read-error", "valid.gba");
bad_read["checks"] = json!([{"kind":"memory32","address":"0x01000000","equals":0}]);
let path = fixture.manifest(vec![
mismatch,
timeout,
case("diagnostic", "invalid.gba"),
case("stop", "stop.gba"),
case("missing", "missing.gba"),
bad_read,
case("passes-last", "valid.gba"),
]);
let output = run(&path);
assert_eq!(output.status.code(), Some(1));
let report = report(&output);
assert_eq!(report["case_count"], 7);
assert_eq!(report["failed_count"], 6);
assert_eq!(report["passed"], false);
for (index, reason) in [
"assertion_failed",
"step_limit",
"emulation_error",
"stopped",
"load_error",
"assertion_failed",
"checkpoint",
]
.iter()
.enumerate()
{
let result = &report["cases"][index];
assert_eq!(result["reason"], *reason);
assert_eq!(result["passed"], index == 6);
}
assert_eq!(report["cases"][0]["checks"][0]["actual"], 42);
assert_eq!(report["cases"][1]["state"]["steps"], 100);
assert_eq!(report["cases"][1]["checks"], json!([]));
assert_eq!(report["cases"][2]["state"]["pc"], 0x0800_0000u32);
assert_eq!(report["cases"][3]["state"]["stopped"], true);
assert!(report["cases"][4]["state"].is_null());
assert!(report["cases"][5]["checks"][0]["error"].is_string());
assert!(stderr(&output).contains("6 case(s) failed"));
}
#[test]
fn every_case_starts_with_fresh_machine_state() {
let fixture = Fixture::new();
fixture.rom(
"write.gba",
&[0xe3a0_002a, 0xe3a0_1402, 0xe581_0000, 0xeaff_fffe],
);
fixture.rom("fresh.gba", &[0xe3a0_002a, 0xeaff_fffe]);
let mut writer = case("write", "write.gba");
writer["completion"]["pc"] = json!("0x0800000c");
writer["checks"] = json!([{"kind":"memory32","address":"0x02000000","equals":42}]);
let mut fresh = case("fresh", "fresh.gba");
fresh["checks"] = json!([{"kind":"memory32","address":"0x02000000","equals":0}, {"kind":"cpsr","equals":"0x1f"}]);
let output = run(&fixture.manifest(vec![writer, fresh]));
assert!(output.status.success(), "{}", stderr(&output));
assert_eq!(report(&output)["failed_count"], 0);
}
#[test]
fn malformed_or_oversized_suites_fail_before_any_case_execution() {
let fixture = Fixture::new();
let path = fixture.0.join("invalid.json");
for bytes in [
b"".as_slice(),
b"{",
br#"{"version":1,"cases":[]}"#,
br#"{"version":2,"cases":[]}"#,
] {
fs::write(&path, bytes).unwrap();
let output = run(&path);
assert_eq!(output.status.code(), Some(1));
assert!(output.stdout.is_empty());
assert!(stderr(&output).contains("cannot read test suite"));
}
File::create(&path)
.unwrap()
.set_len(1024 * 1024 + 1)
.unwrap();
let output = run(&path);
assert_eq!(output.status.code(), Some(1));
assert!(stderr(&output).contains("1 MiB"));
for path in [&fixture.0, &fixture.0.join("missing.json")] {
let output = run(path);
assert_eq!(output.status.code(), Some(1));
assert!(output.stdout.is_empty());
}
fixture.rom("valid.gba", &[0xe3a0_002a, 0xeaff_fffe]);
let mut invalid = case("invalid-later", "valid.gba");
invalid["checks"] = json!([]);
let output = run(&fixture.manifest(vec![case("would-pass", "valid.gba"), invalid]));
assert_eq!(output.status.code(), Some(1));
assert!(output.stdout.is_empty());
}
#[test]
fn gameplay_reports_inputs_captured_pixels_and_failures_repeatably() {
let fixture = Fixture::new();
fs::write(fixture.0.join("input.gba"), gba_demos::input_rom()).unwrap();
fixture.rom(
"bad-video.gba",
&[0xe3a0_1301, 0xe3a0_0007, 0xe1c1_00b0, 0xeaff_fffe],
);
let good = json!({"name":"gameplay", "rom":"input.gba", "step_limit":1000000,
"completion":{"vblanks":3},
"inputs":[{"vblank":0,"buttons":1},{"vblank":1,"buttons":17},{"vblank":2,"buttons":0}],
"checks":[{"kind":"pixel","x":239,"y":159,"equals":255}]});
let mut mismatch = good.clone();
mismatch["name"] = json!("mismatch");
mismatch["checks"][0]["equals"] = json!(0);
let mut render_error = good.clone();
render_error["name"] = json!("bad-video");
render_error["rom"] = json!("bad-video.gba");
let path = fixture.0.join("gameplay.json");
fs::write(
&path,
serde_json::to_vec(&json!({"version":2,"cases":[good,mismatch,render_error]})).unwrap(),
)
.unwrap();
let first = run(&path);
assert_eq!(first.status.code(), Some(1));
assert_eq!(first.stdout, run(&path).stdout);
let report = report(&first);
assert_eq!(report["format_version"], 2);
assert_eq!(report["failed_count"], 2);
let good = &report["cases"][0];
assert_eq!(good["passed"], true);
assert_eq!(good["state"]["vblanks"], 3);
assert_eq!(good["state"]["captured_vblank"], 3);
assert_eq!(good["inputs"][1]["buttons"], 17);
assert_eq!(good["checks"][0]["actual"], 255);
assert_eq!(report["cases"][1]["reason"], "assertion_failed");
assert_eq!(report["cases"][2]["reason"], "render_error");
assert_eq!(report["cases"][2]["state"]["vblanks"], 1);
assert_eq!(report["cases"][2]["checks"], json!([]));
assert!(report["cases"][2]["error"].is_string());
}
#[test]
fn suite_mode_rejects_other_modes_and_help_advertises_it() {
for args in [
vec!["--test-suite"],
vec!["--test-suite", "missing.json", "--steps", "10"],
vec!["--test-suite", "missing.json", "--window"],
vec!["--test-suite", "missing.json", "--cpu-demo"],
vec!["--test-suite", "missing.json", "--help"],
] {
let output = command().args(args).output().unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(output.stdout.is_empty());
assert!(stderr(&output).contains("usage:"));
}
let output = command().arg("--help").output().unwrap();
assert!(output.status.success());
assert!(String::from_utf8(output.stdout)
.unwrap()
.contains("--test-suite PATH.json"));
}