diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000..816356a --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,5 @@ +[profile.default] +# Keep running the rest of the suite after a failure, so one broken test +# doesn't hide the results of every test that would have run after it. +# (Process-per-test isolation is nextest's own default, not set here.) +fail-fast = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16a6971..8ad6512 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,8 +27,10 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo fmt --all --check - run: cargo clippy --workspace -- -D warnings - # Corpus-driven tests skip gracefully when the sample data is absent. - - run: cargo test --workspace + # Corpus/archive tests are #[ignore]d and need real data; run with --ignored locally. + - uses: taiki-e/install-action@nextest + - run: cargo nextest run --workspace + - run: cargo test --workspace --doc wasm-core: runs-on: ubuntu-latest @@ -52,3 +54,34 @@ jobs: working-directory: webui - run: npm run build working-directory: webui + + coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + # Instrumented builds use different RUSTFLAGS, so they get their own cache key. + - uses: Swatinem/rust-cache@v2 + with: + key: llvm-cov + - uses: taiki-e/install-action@cargo-llvm-cov + - uses: taiki-e/install-action@nextest + - run: cargo llvm-cov nextest --workspace --lcov --output-path lcov.info + - run: cargo llvm-cov report --summary-only >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@v4 + with: + name: coverage-lcov + path: lcov.info + + no-silent-skips: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Fail if a test silently returns on missing corpus + run: | + if grep -rEn 'corpus_root\(\)\s*else\s*\{|(std::)?env::var\("XAERO_[A-Z_]*CORPUS"\)\s*else\s*\{' crates; then + echo "::error::Found a silent corpus/env else-skip. Use #[ignore] + .expect(), not a silent return." + exit 1 + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4bbf410..42dcc02 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,22 @@ permissions: attestations: write jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@nextest + - run: cargo fmt --all --check + - run: cargo clippy --workspace -- -D warnings + - run: cargo nextest run --workspace + - run: cargo test --workspace --doc + build: + needs: test strategy: fail-fast: false matrix: diff --git a/.gitignore b/.gitignore index f7a3ebc..8c0c1af 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ vault.db pyramid.db *.db-shm *.db-wal + +# Coverage output (cargo llvm-cov) +lcov.info diff --git a/Cargo.lock b/Cargo.lock index 153456c..8e7899d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,6 +20,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "axum" version = "0.8.9" @@ -87,6 +93,21 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.13.1" @@ -233,6 +254,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "fdeflate" version = "0.3.7" @@ -269,6 +296,12 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -596,6 +629,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "lock_api" version = "0.4.14" @@ -687,6 +726,15 @@ dependencies = [ "bitflags", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -765,6 +813,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.47" @@ -815,6 +888,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + [[package]] name = "rayon" version = "1.12.0" @@ -844,6 +926,12 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "ring" version = "0.17.14" @@ -872,6 +960,19 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.43" @@ -907,6 +1008,18 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -1089,6 +1202,27 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "test-support" +version = "0.4.0" +dependencies = [ + "tempfile", + "xaero-core", +] + [[package]] name = "thiserror" version = "2.0.20" @@ -1225,6 +1359,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1284,6 +1424,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -1510,8 +1659,10 @@ dependencies = [ name = "xaero-core" version = "0.4.0" dependencies = [ + "proptest", "rayon", "smallvec", + "test-support", "thiserror", "walkdir", "zip", @@ -1524,6 +1675,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "test-support", "xaero-core", ] @@ -1534,6 +1686,7 @@ dependencies = [ "filetime", "rayon", "serde", + "test-support", "xaero-core", "xaero-db", "xaero-scan", @@ -1543,6 +1696,7 @@ dependencies = [ name = "xaero-scan" version = "0.4.0" dependencies = [ + "test-support", "xaero-core", ] @@ -1553,6 +1707,7 @@ dependencies = [ "png", "rayon", "serde_json", + "test-support", "tokio", "xaero-core", "xaero-db", @@ -1576,6 +1731,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "test-support", "tokio", "xaero-core", "xaero-db", diff --git a/Cargo.toml b/Cargo.toml index 91a5540..05e3388 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,11 @@ [workspace] resolver = "2" -members = ["crates/xaero-core", "crates/xaero-db", "crates/xaero-merge", "crates/xaero-scan", "crates/xaerotools-server", "crates/xaerotools-cli", "tools/xaero-colorgen"] +members = ["crates/xaero-core", "crates/xaero-db", "crates/xaero-merge", "crates/xaero-scan", "crates/xaerotools-server", "crates/xaerotools-cli", "tools/xaero-colorgen", "crates/test-support"] [workspace.package] version = "0.4.0" edition = "2021" +rust-version = "1.89" license = "MIT" authors = ["XaeroTools contributors"] repository = "https://github.com/dekrom/xaerotools" diff --git a/crates/test-support/Cargo.toml b/crates/test-support/Cargo.toml new file mode 100644 index 0000000..67fe734 --- /dev/null +++ b/crates/test-support/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "test-support" +version.workspace = true +edition = "2021" +publish = false + +[dependencies] +xaero-core = { path = "../xaero-core" } +tempfile = "3" diff --git a/crates/test-support/src/corpus.rs b/crates/test-support/src/corpus.rs new file mode 100644 index 0000000..6d424ed --- /dev/null +++ b/crates/test-support/src/corpus.rs @@ -0,0 +1,85 @@ +use std::path::{Path, PathBuf}; + +/// Pure core: decides the corpus path from inputs. `required` forces a hard +/// error (not a skip) when data is missing. `var` names the environment +/// variable the caller reads, so the message points at that variable and not +/// at whichever one this helper happened to be written for first. +pub fn resolve_corpus( + var: &str, + env_value: Option, + required: bool, + directory_exists: impl Fn(&Path) -> bool, +) -> Result, String> { + if let Some(p) = env_value { + let p = PathBuf::from(p); + if directory_exists(&p) { + return Ok(Some(p)); + } + if required { + return Err(format!( + "{var} points at a missing directory: {}", + p.display() + )); + } + return Ok(None); + } + if required { + return Err(format!("{var}_REQUIRED=1 but {var} is unset")); + } + Ok(None) +} + +/// Reads real env for one corpus variable. Panics loudly in required mode so a +/// misconfigured corpus job fails instead of silently passing. Callers still +/// `.expect()` the `None`, so neither mode can turn into a silent skip. +fn root_for(var: &str) -> Option { + let required = std::env::var(format!("{var}_REQUIRED")).ok().as_deref() == Some("1"); + match resolve_corpus(var, std::env::var(var).ok(), required, |p| p.is_dir()) { + Ok(v) => v, + Err(e) => panic!("{e}"), + } +} + +/// The public sample corpus (`XAERO_CORPUS`), shared by most corpus tests. +pub fn corpus_root() -> Option { + root_for("XAERO_CORPUS") +} + +/// The private legacy archive (`XAERO_LEGACY_CORPUS`). Same contract as +/// [`corpus_root`]; it is a separate variable because the two data sets have +/// different availability, not because they need different rules. +pub fn legacy_corpus_root() -> Option { + root_for("XAERO_LEGACY_CORPUS") +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn required_but_absent_is_an_error() { + let r = resolve_corpus("XAERO_CORPUS", None, true, |_| false); + assert!(r.is_err(), "required + no data must fail, not skip"); + } + #[test] + fn absent_and_not_required_is_none() { + assert_eq!( + resolve_corpus("XAERO_CORPUS", None, false, |_| false).unwrap(), + None + ); + } + #[test] + fn explicit_env_dir_is_used() { + let r = resolve_corpus("XAERO_CORPUS", Some("/x".into()), false, |p| { + p == std::path::Path::new("/x") + }); + assert_eq!(r.unwrap(), Some(PathBuf::from("/x"))); + } + #[test] + fn the_message_names_the_variable_the_caller_reads() { + let e = resolve_corpus("XAERO_LEGACY_CORPUS", None, true, |_| false).unwrap_err(); + assert!( + e.contains("XAERO_LEGACY_CORPUS_REQUIRED") && !e.contains("XAERO_CORPUS_REQUIRED=1"), + "message must name the legacy variable, got: {e}" + ); + } +} diff --git a/crates/test-support/src/lib.rs b/crates/test-support/src/lib.rs new file mode 100644 index 0000000..be9d5bf --- /dev/null +++ b/crates/test-support/src/lib.rs @@ -0,0 +1,4 @@ +pub mod corpus; +pub mod synth; +pub mod trees; +pub use corpus::{corpus_root, legacy_corpus_root, resolve_corpus}; diff --git a/crates/test-support/src/synth.rs b/crates/test-support/src/synth.rs new file mode 100644 index 0000000..979ee44 --- /dev/null +++ b/crates/test-support/src/synth.rs @@ -0,0 +1,40 @@ +use xaero_core::{encode_region, DecodedRegion}; + +/// Re-encode a decoded region to the canonical 7.8 stream. +pub fn reencode(region: &DecodedRegion) -> Vec { + encode_region(region) +} + +/// First `keep` bytes of `bytes`, an error-injection or seed input. +pub fn truncate_prefix(bytes: &[u8], keep: usize) -> Vec { + bytes[..keep.min(bytes.len())].to_vec() +} + +#[cfg(test)] +mod tests { + use super::*; + + // The committed legacy fixture the core crate already ships. + const FIXTURE: &[u8] = + include_bytes!("../../xaero-core/tests/fixtures/legacy/v0.7_-10000_-1.zip"); + + #[test] + fn reencode_of_a_decoded_region_decodes_again() { + let stream = xaero_core::read_region_container(FIXTURE).expect("unzip"); + let region = xaero_core::decode_region(&stream).expect("decode"); + let bytes = reencode(®ion); + assert!( + xaero_core::decode_region(&bytes).is_ok(), + "re-encoded region must decode" + ); + } + + #[test] + fn truncate_prefix_never_panics_on_decode() { + let stream = xaero_core::read_region_container(FIXTURE).expect("unzip"); + for keep in [0usize, 1, 8, stream.len() / 2] { + let t = truncate_prefix(&stream, keep); + let _ = xaero_core::decode_region(&t); // must not panic (Ok or Err both fine) + } + } +} diff --git a/crates/test-support/src/trees.rs b/crates/test-support/src/trees.rs new file mode 100644 index 0000000..76688d9 --- /dev/null +++ b/crates/test-support/src/trees.rs @@ -0,0 +1,26 @@ +use std::fs; +use tempfile::TempDir; + +/// Build a minimal world-map tree. Each region = (world_id, rx, rz, bytes), +/// placed at world-map//null/0/_.zip (dim "null", mw "0"). +pub fn world_map_tree(regions: &[(&str, i32, i32, &[u8])]) -> TempDir { + let directory = TempDir::new().expect("tempdir"); + for (world, rx, rz, bytes) in regions { + let region_directory = directory.path().join(format!("world-map/{world}/null/0")); + fs::create_dir_all(®ion_directory).unwrap(); + fs::write(region_directory.join(format!("{rx}_{rz}.zip")), bytes).unwrap(); + } + directory +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn builds_the_expected_layout() { + let directory = world_map_tree(&[("srv", -10, -1, b"zzz")]); + let p = directory.path().join("world-map/srv/null/0/-10_-1.zip"); + assert!(p.is_file(), "expected region file at {}", p.display()); + assert_eq!(std::fs::read(p).unwrap(), b"zzz"); + } +} diff --git a/crates/xaero-core/Cargo.toml b/crates/xaero-core/Cargo.toml index 17c9f41..e395cbd 100644 --- a/crates/xaero-core/Cargo.toml +++ b/crates/xaero-core/Cargo.toml @@ -11,3 +11,5 @@ zip = { version = "8.6.0", default-features = false, features = ["deflate"] } [dev-dependencies] rayon = "1.12.0" walkdir = "2.5.0" +test-support = { path = "../test-support" } +proptest = "1" diff --git a/crates/xaero-core/tests/corpus.rs b/crates/xaero-core/tests/corpus.rs index b3c50c6..7059245 100644 --- a/crates/xaero-core/tests/corpus.rs +++ b/crates/xaero-core/tests/corpus.rs @@ -7,8 +7,9 @@ //! 7.8 share the same body layout), and survive decode(encode(x)) with //! semantic equality //! -//! The corpus lives outside the repo; the test is skipped (with a notice) -//! when it cannot be found. Override with XAERO_CORPUS=/path/to/sample-data. +//! The corpus lives outside the repo. These tests are `#[ignore]`d, so a plain +//! `cargo test` reports them as skipped and never as passed. Run them with +//! `--ignored` and XAERO_CORPUS=/path/to/sample-data. use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -16,16 +17,6 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use rayon::prelude::*; use xaero_core::{decode_region, encode_region, read_region_container}; -fn corpus_root() -> Option { - if let Ok(p) = std::env::var("XAERO_CORPUS") { - let p = PathBuf::from(p); - return p.is_dir().then_some(p); - } - // repo layout: /xaerotools/crates/xaero-core ; corpus at /sample data - let fallback = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../sample data"); - fallback.is_dir().then(|| fallback.canonicalize().unwrap()) -} - fn is_region_file(path: &Path) -> bool { if path.extension().and_then(|e| e.to_str()) != Some("zip") { return false; @@ -48,11 +39,9 @@ fn in_cache_dir(path: &Path) -> bool { } #[test] +#[ignore = "requires corpus (XAERO_CORPUS)"] fn corpus_round_trip() { - let Some(root) = corpus_root() else { - eprintln!("corpus not found; set XAERO_CORPUS to run this test"); - return; - }; + let root = test_support::corpus_root().expect("XAERO_CORPUS"); let mut files: Vec = walkdir::WalkDir::new(&root) .into_iter() .filter_map(|e| e.ok()) @@ -160,10 +149,9 @@ fn corpus_round_trip() { } #[test] +#[ignore = "requires corpus (XAERO_CORPUS)"] fn truncation_never_panics() { - let Some(root) = corpus_root() else { - return; - }; + let root = test_support::corpus_root().expect("XAERO_CORPUS"); // One small region per major version. let picks = [ "xaero1.21.4/world-map/Multiplayer_2b2t/DIM-1/mw$default/0_-24.zip", @@ -171,9 +159,16 @@ fn truncation_never_panics() { ]; for rel in picks { let path = root.join(rel); - let Ok(bytes) = std::fs::read(&path) else { - continue; - }; + // Reading these two is the whole test. Skipping a missing one leaves + // the loop body unexecuted and the test green, so name it and stop. + let bytes = std::fs::read(&path).unwrap_or_else(|e| { + panic!( + "corpus pick is missing: {} ({e}). If the corpus layout moved, \ + update the pick rather than letting this test pass without \ + decoding anything.", + path.display() + ) + }); let stream = read_region_container(&bytes).unwrap(); for cut in 0..stream.len().min(4000) { let _ = decode_region(&stream[..cut]); diff --git a/crates/xaero-core/tests/legacy.rs b/crates/xaero-core/tests/legacy.rs index cb24d96..91f02f1 100644 --- a/crates/xaero-core/tests/legacy.rs +++ b/crates/xaero-core/tests/legacy.rs @@ -7,7 +7,9 @@ //! (0.4 and 0.7). //! //! Point `XAERO_LEGACY_CORPUS` at a directory of region zips to run the same -//! assertions across a larger private archive; the test is a no-op without it. +//! assertions across a larger private archive. That sweep is `#[ignore]`d, so +//! it never runs by accident, and it stops with a clear error rather than +//! reporting a pass when the archive is absent. use std::path::{Path, PathBuf}; @@ -166,12 +168,22 @@ fn legacy_regions_re_encode_to_modern_and_survive_a_round_trip() { } /// Opt-in sweep over a private archive: every region must decode to exact EOF. +/// +/// The name below is quoted in `docs/START-HERE.md` and in the panic message, +/// because running the ignored set against the public corpus alone means +/// excluding this one by name. Renaming it means updating both. #[test] +#[ignore = "requires XAERO_LEGACY_CORPUS"] fn optional_corpus_decodes_to_exact_eof() { - let Ok(dir) = std::env::var("XAERO_LEGACY_CORPUS") else { - eprintln!("XAERO_LEGACY_CORPUS unset — skipping"); - return; - }; + let dir = test_support::legacy_corpus_root().unwrap_or_else(|| { + panic!( + "XAERO_LEGACY_CORPUS is unset. This sweep reads a private archive \ + that is not part of the public sample corpus, so `--ignored` on \ + the public data alone cannot satisfy it. Either set the variable, \ + or exclude this one test with \ + `--skip optional_corpus_decodes_to_exact_eof`." + ) + }); let mut checked = 0usize; let mut exact = 0usize; let mut by_version: std::collections::BTreeMap = Default::default(); @@ -207,6 +219,10 @@ fn optional_corpus_decodes_to_exact_eof() { *by_version.entry(dr.version.to_string()).or_default() += 1; checked += 1; } - assert!(checked > 0, "corpus dir {dir} had no region zips"); + assert!( + checked > 0, + "corpus dir {} had no region zips", + dir.display() + ); eprintln!("decoded {checked} regions cleanly ({exact} byte-identical): {by_version:?}"); } diff --git a/crates/xaero-core/tests/parsers.rs b/crates/xaero-core/tests/parsers.rs index fd9c020..2afa2bb 100644 --- a/crates/xaero-core/tests/parsers.rs +++ b/crates/xaero-core/tests/parsers.rs @@ -1,24 +1,13 @@ -//! Parser checks against the real sample corpus (skipped when absent). - -use std::path::{Path, PathBuf}; +//! Parser checks against the real sample corpus. `#[ignore]`d, so they report +//! as skipped rather than as passed when XAERO_CORPUS is unset. use xaero_core::dimconfig::{parse_dimension_config, parse_minimap_config}; use xaero_core::waypoints::parse_waypoints_file; -fn corpus_root() -> Option { - if let Ok(p) = std::env::var("XAERO_CORPUS") { - let p = PathBuf::from(p); - return p.is_dir().then_some(p); - } - let fallback = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../sample data"); - fallback.is_dir().then(|| fallback.canonicalize().unwrap()) -} - #[test] +#[ignore = "requires corpus (XAERO_CORPUS)"] fn parses_all_sample_waypoint_files() { - let Some(root) = corpus_root() else { - return; - }; + let root = test_support::corpus_root().expect("XAERO_CORPUS"); let mut files = 0; let mut total = 0; for entry in walkdir::WalkDir::new(&root) @@ -54,10 +43,9 @@ fn parses_all_sample_waypoint_files() { } #[test] +#[ignore = "requires corpus (XAERO_CORPUS)"] fn parses_all_sample_configs() { - let Some(root) = corpus_root() else { - return; - }; + let root = test_support::corpus_root().expect("XAERO_CORPUS"); let mut dimcfgs = 0; let mut mmcfgs = 0; for entry in walkdir::WalkDir::new(&root) diff --git a/crates/xaero-core/tests/proptest_roundtrip.rs b/crates/xaero-core/tests/proptest_roundtrip.rs new file mode 100644 index 0000000..1d75a53 --- /dev/null +++ b/crates/xaero-core/tests/proptest_roundtrip.rs @@ -0,0 +1,18 @@ +use proptest::prelude::*; +use xaero_core::{decode_region, encode_region}; + +proptest! { + #[test] + fn decode_never_panics(bytes in proptest::collection::vec(any::(), 0..4096)) { + let _ = decode_region(&bytes); + } + + #[test] + fn encode_is_a_fixed_point(bytes in proptest::collection::vec(any::(), 0..4096)) { + if let Ok(region) = decode_region(&bytes) { + let once = encode_region(®ion); + let twice = encode_region(&decode_region(&once).expect("re-decode canonical")); + prop_assert_eq!(once, twice); + } + } +} diff --git a/crates/xaero-db/Cargo.toml b/crates/xaero-db/Cargo.toml index 51b39cd..2d572ec 100644 --- a/crates/xaero-db/Cargo.toml +++ b/crates/xaero-db/Cargo.toml @@ -9,3 +9,6 @@ rusqlite = { version = "0.37", features = ["bundled", "hooks"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" xaero-core = { path = "../xaero-core" } + +[dev-dependencies] +test-support = { path = "../test-support" } diff --git a/crates/xaero-db/src/drawing.rs b/crates/xaero-db/src/drawing.rs index c683d77..66bc49d 100644 --- a/crates/xaero-db/src/drawing.rs +++ b/crates/xaero-db/src/drawing.rs @@ -685,10 +685,9 @@ mod tests { } #[test] + #[ignore = "requires corpus (XAERO_CORPUS)"] fn reads_corpus_drawing_dbs() { - let Some(root) = crate::tests::corpus_root() else { - return; - }; + let root = test_support::corpus_root().expect("XAERO_CORPUS"); let mut checked = 0; for version_dir in ["xaero1.21.4", "xaero1.21.8"] { let wm = root.join(version_dir).join("world-map"); diff --git a/crates/xaero-db/src/lib.rs b/crates/xaero-db/src/lib.rs index 937095a..0ab5e74 100644 --- a/crates/xaero-db/src/lib.rs +++ b/crates/xaero-db/src/lib.rs @@ -651,18 +651,10 @@ mod tests { use super::*; use std::path::PathBuf; - pub(crate) fn corpus_root() -> Option { - if let Ok(p) = std::env::var("XAERO_CORPUS") { - let p = PathBuf::from(p); - return p.is_dir().then_some(p); - } - let fallback = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../sample data"); - fallback.is_dir().then(|| fallback.canonicalize().unwrap()) - } - #[test] + #[ignore = "requires corpus (XAERO_CORPUS)"] fn reads_sample_newchunks() { - let Some(root) = corpus_root() else { return }; + let root = test_support::corpus_root().expect("XAERO_CORPUS"); let db = open_readonly( &root.join("xaero1.21.8/world-map/Multiplayer_2b2t/XaeroPlusNewChunks.db"), ) @@ -679,8 +671,9 @@ mod tests { } #[test] + #[ignore = "requires corpus (XAERO_CORPUS)"] fn reads_all_sample_dbs() { - let Some(root) = corpus_root() else { return }; + let root = test_support::corpus_root().expect("XAERO_CORPUS"); let mut checked = 0; for version_dir in ["xaero1.21.4", "xaero1.21.8"] { let wm = root.join(version_dir).join("world-map"); diff --git a/crates/xaero-merge/Cargo.toml b/crates/xaero-merge/Cargo.toml index efdfefb..f8fe966 100644 --- a/crates/xaero-merge/Cargo.toml +++ b/crates/xaero-merge/Cargo.toml @@ -14,3 +14,4 @@ serde = { version = "1", features = ["derive"] } [dev-dependencies] xaero-core = { path = "../xaero-core" } +test-support = { path = "../test-support" } diff --git a/crates/xaero-merge/tests/fixture.rs b/crates/xaero-merge/tests/fixture.rs index 960407a..341f54a 100644 --- a/crates/xaero-merge/tests/fixture.rs +++ b/crates/xaero-merge/tests/fixture.rs @@ -3,19 +3,8 @@ //! overlap — null 307 vs 90 regions (20 conflicts), DIM-1 296 vs 794 (71), //! DIM1 0 vs 4. Expected merged totals: null 377, DIM-1 1019, DIM1 4. -use std::path::{Path, PathBuf}; - use xaero_merge::{merge_to_output, MergeOptions}; -fn corpus_root() -> Option { - if let Ok(p) = std::env::var("XAERO_CORPUS") { - let p = PathBuf::from(p); - return p.is_dir().then_some(p); - } - let fallback = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../sample data"); - fallback.is_dir().then(|| fallback.canonicalize().unwrap()) -} - fn unit<'a>( report: &'a xaero_merge::MergeReport, dim: &str, @@ -29,11 +18,9 @@ fn unit<'a>( } #[test] +#[ignore = "requires corpus (XAERO_CORPUS)"] fn merges_the_2b2t_fixture() { - let Some(root) = corpus_root() else { - eprintln!("corpus not found; skipping"); - return; - }; + let root = test_support::corpus_root().expect("XAERO_CORPUS"); let a = root.join("xaero1.21.4"); let b = root.join("xaero1.21.8"); let out = std::env::temp_dir().join(format!("xt-merge-fixture-{}", std::process::id())); diff --git a/crates/xaero-scan/Cargo.toml b/crates/xaero-scan/Cargo.toml index 9986519..a72aa50 100644 --- a/crates/xaero-scan/Cargo.toml +++ b/crates/xaero-scan/Cargo.toml @@ -6,3 +6,6 @@ license.workspace = true [dependencies] xaero-core = { path = "../xaero-core" } + +[dev-dependencies] +test-support = { path = "../test-support" } diff --git a/crates/xaero-scan/src/lib.rs b/crates/xaero-scan/src/lib.rs index bcef769..7a7cd44 100644 --- a/crates/xaero-scan/src/lib.rs +++ b/crates/xaero-scan/src/lib.rs @@ -749,18 +749,10 @@ fn scan_minimap_world( mod tests { use super::*; - fn corpus_root() -> Option { - if let Ok(p) = std::env::var("XAERO_CORPUS") { - let p = PathBuf::from(p); - return p.is_dir().then_some(p); - } - let fallback = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../sample data"); - fallback.is_dir().then(|| fallback.canonicalize().unwrap()) - } - #[test] + #[ignore = "requires corpus (XAERO_CORPUS)"] fn discovers_sample_worlds() { - let Some(root) = corpus_root() else { return }; + let root = test_support::corpus_root().expect("XAERO_CORPUS"); let worlds = discover_root(&root.join("xaero1.21.8")); assert!(worlds.iter().any(|w| w.id == "Multiplayer_2b2t")); let w2 = worlds.iter().find(|w| w.id == "Multiplayer_2b2t").unwrap(); @@ -789,8 +781,9 @@ mod tests { } #[test] + #[ignore = "requires corpus (XAERO_CORPUS)"] fn discovers_minimap_only_worlds() { - let Some(root) = corpus_root() else { return }; + let root = test_support::corpus_root().expect("XAERO_CORPUS"); let worlds = discover_root(&root.join("xaero1.21.4")); // Worlds the player only ever had the minimap for: waypoints, no map. let pvp = worlds @@ -824,8 +817,9 @@ mod tests { } #[test] + #[ignore = "requires corpus (XAERO_CORPUS)"] fn scans_every_waypoint_file_layout() { - let Some(root) = corpus_root() else { return }; + let root = test_support::corpus_root().expect("XAERO_CORPUS"); let files = scan_waypoint_files(&root.join("xaero1.21.4")); assert!(files.iter().all(|f| f.kind == WaypointSourceKind::Live)); // Every mw$*_.txt under a dim% folder of the corpus. @@ -936,8 +930,9 @@ mod tests { } #[test] + #[ignore = "requires corpus (XAERO_CORPUS)"] fn indexes_sample_regions() { - let Some(root) = corpus_root() else { return }; + let root = test_support::corpus_root().expect("XAERO_CORPUS"); let dir = root.join("xaero1.21.8/world-map/Multiplayer_2b2t/DIM-1/mw$default"); let idx = index_regions(&dir).unwrap(); assert_eq!(idx.entries.len(), 794); diff --git a/crates/xaerotools-cli/Cargo.toml b/crates/xaerotools-cli/Cargo.toml index 896d12a..b102336 100644 --- a/crates/xaerotools-cli/Cargo.toml +++ b/crates/xaerotools-cli/Cargo.toml @@ -13,3 +13,6 @@ xaero-db = { path = "../xaero-db" } xaero-merge = { path = "../xaero-merge" } xaero-scan = { path = "../xaero-scan" } xaerotools-server = { path = "../xaerotools-server" } + +[dev-dependencies] +test-support = { path = "../test-support" } diff --git a/crates/xaerotools-cli/tests/cli.rs b/crates/xaerotools-cli/tests/cli.rs index 0ac43fb..4a2100b 100644 --- a/crates/xaerotools-cli/tests/cli.rs +++ b/crates/xaerotools-cli/tests/cli.rs @@ -3,27 +3,19 @@ //! `render` is asserted pixel-for-pixel against `render-region` so the stitch //! path can never silently drift from the single-region renderer, and `doctor` //! is pointed at a scratch tree of deliberately broken files. The corpus lives -//! outside the repo; corpus tests are skipped (with a notice) when it is -//! absent. Override with XAERO_CORPUS=/path/to/sample-data. +//! outside the repo; the corpus tests are `#[ignore]`d, so they report as +//! skipped and never as passed. Point XAERO_CORPUS at a copy to run them. //! //! Nothing here ever writes inside the corpus. use std::path::{Path, PathBuf}; use std::process::Command; -fn corpus_root() -> Option { - if let Ok(p) = std::env::var("XAERO_CORPUS") { - let p = PathBuf::from(p); - return p.is_dir().then_some(p); - } - let fallback = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../sample data"); - fallback.is_dir().then(|| fallback.canonicalize().unwrap()) -} - /// The 1.21.8 half of the corpus: one world with a 4-region End dimension. -fn corpus_root_1218() -> Option { - let p = corpus_root()?.join("xaero1.21.8"); - p.is_dir().then_some(p) +fn corpus_root_1218() -> PathBuf { + test_support::corpus_root() + .expect("XAERO_CORPUS") + .join("xaero1.21.8") } fn scratch(name: &str) -> PathBuf { @@ -51,11 +43,9 @@ fn read_png(path: &Path) -> (u32, u32, Vec) { } #[test] +#[ignore = "requires corpus (XAERO_CORPUS)"] fn render_stitch_matches_render_region() { - let Some(root) = corpus_root_1218() else { - eprintln!("skipping: sample corpus not found"); - return; - }; + let root = corpus_root_1218(); let dir = scratch("render"); let stitched = dir.join("end.png"); let single = dir.join("region.png"); @@ -100,11 +90,9 @@ fn render_stitch_matches_render_region() { } #[test] +#[ignore = "requires corpus (XAERO_CORPUS)"] fn render_refuses_an_oversized_box() { - let Some(root) = corpus_root_1218() else { - eprintln!("skipping: sample corpus not found"); - return; - }; + let root = corpus_root_1218(); let dir = scratch("cap"); let out = xt(&[ "render", @@ -167,11 +155,9 @@ fn render_leaves_a_hole_for_a_region_it_cannot_decode() { } #[test] +#[ignore = "requires corpus (XAERO_CORPUS)"] fn stats_json_describes_the_corpus() { - let Some(root) = corpus_root_1218() else { - eprintln!("skipping: sample corpus not found"); - return; - }; + let root = corpus_root_1218(); let out = xt(&[ "stats", "--root", @@ -221,11 +207,9 @@ fn stats_json_describes_the_corpus() { } #[test] +#[ignore = "requires corpus (XAERO_CORPUS)"] fn doctor_is_quiet_on_a_healthy_corpus() { - let Some(root) = corpus_root_1218() else { - eprintln!("skipping: sample corpus not found"); - return; - }; + let root = corpus_root_1218(); let out = xt(&[ "doctor", "--root", diff --git a/crates/xaerotools-server/Cargo.toml b/crates/xaerotools-server/Cargo.toml index 183fc02..88549bc 100644 --- a/crates/xaerotools-server/Cargo.toml +++ b/crates/xaerotools-server/Cargo.toml @@ -21,3 +21,6 @@ xaero-merge = { path = "../xaero-merge" } xaero-db = { path = "../xaero-db" } getrandom = "0.4.3" rusqlite = { version = "0.37", features = ["bundled"] } + +[dev-dependencies] +test-support = { path = "../test-support" } diff --git a/crates/xaerotools-server/src/ingest.rs b/crates/xaerotools-server/src/ingest.rs index 593580e..5eef81e 100644 --- a/crates/xaerotools-server/src/ingest.rs +++ b/crates/xaerotools-server/src/ingest.rs @@ -428,21 +428,10 @@ mod tests { ); } - fn corpus_root() -> Option { - if let Ok(p) = std::env::var("XAERO_CORPUS") { - let p = PathBuf::from(p); - return p.is_dir().then_some(p); - } - let fallback = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../sample data"); - fallback.is_dir().then(|| fallback.canonicalize().unwrap()) - } - #[test] + #[ignore = "requires corpus (XAERO_CORPUS)"] fn stores_backup_and_merges_shared_from_corpus() { - let Some(root) = corpus_root() else { - eprintln!("corpus not found; skipping"); - return; - }; + let root = test_support::corpus_root().expect("XAERO_CORPUS"); // Two real copies of the same coordinate: major-6 (1.21.4) and // major-7 (1.21.8) — a genuine cross-version merge. let a = std::fs::read( diff --git a/docs/START-HERE.md b/docs/START-HERE.md index 762986f..b7395d3 100644 --- a/docs/START-HERE.md +++ b/docs/START-HERE.md @@ -52,12 +52,28 @@ vault backup of every account's waypoints. ## Developing -- `cargo test --workspace` — the full suite. The byte-identical codec - round-trip runs against the 2b2t sample corpus, which is not part of this - repo: point `XAERO_CORPUS` at a copy - (`XAERO_CORPUS=/path/to/sample-data cargo test --workspace`). Without it - those tests **skip rather than fail**, so a green run does not by itself - prove the corpus was read. +- `cargo test --workspace` — the full suite. The corpus-backed tests (the + byte-identical codec round-trip and friends) are `#[ignore]`d, so this run + reports them as **skipped**, never as passed. A green run therefore makes no + claim about the corpus either way. +- To actually run them, point `XAERO_CORPUS` at a copy of the 2b2t sample + corpus, which is not part of this repo, and ask for the ignored tests: + + ``` + XAERO_CORPUS=/path/to/sample-data cargo test --workspace -- --ignored \ + --skip optional_corpus_decodes_to_exact_eof + ``` + + Or under nextest, which is what CI runs: + + ``` + XAERO_CORPUS=/path/to/sample-data cargo nextest run --workspace \ + --run-ignored ignored-only -- --skip optional_corpus_decodes_to_exact_eof + ``` + + Without `XAERO_CORPUS` they stop with an error rather than passing quietly. + The `--skip` excludes one sweep over a **private** legacy archive that is not + part of the public corpus; drop it if you have set `XAERO_LEGACY_CORPUS`. - If you change the web UI: `cd webui && npm install && npm run build`, then `cargo clean -p xaerotools-server && cargo build` (the UI is embedded at compile time).