diff --git a/CHANGELOG.md b/CHANGELOG.md index fb878a9c4..c8f7c3807 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,10 @@ and is not reconstructed here. the client cast Gates, declares the next creature movement tick, admits a Character to a party instance and records a finished Transfer in the character-to-shard index. A Package names it only from a file gated on `debug_reducers`, and the lint refuses it anywhere else. +- **Package tests.** A Module test build adds `crate::package_test`, a Package API root for a + Package's own unit tests. It runs shipped Runtime Scripts on a fresh Runtime Script Host and + returns their Script Answer, and it gives the source-scan helpers Core's Architecture Tests use. A + Package names it only from a file gated on `#![cfg(test)]`, and the lint refuses it anywhere else. - **Package-owned Accounts.** `package_account::create_package_character` lets a Package create a Character with no Session. It applies the name, race and class Refusals of client creation, and puts the Character on an Account without credentials. `game_package_account` records which diff --git a/CONTEXT.md b/CONTEXT.md index b8fb3a68b..2272fdc1b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -912,14 +912,15 @@ _Avoid_: plugin, addon (when meaning the whole folder), mod, extension The part of the Module a Package may name, versioned and written down at `docs/package-api.md`: the marker macros, the hook catalogue, the encounter kernel, the actor verbs and helpers, the Package Config seam, the Package Event seam, the table accessor conventions, and the list of module roots -everything else hangs under. The build lints every Package file against it and fails on a path +everything else hangs under. Two roots are gated: `package_fixture` exists only with +`debug_reducers`, and `package_test` only in a test build. The build lints every Package file against it and fails on a path outside it, so a core refactor breaks a Package at compile time rather than on a live realm. It is a compatibility contract, never a sandbox: compiled Package code is trusted either way. _Avoid_: SDK, plugin API, public API, allowlist **Package Fixture**: Package code that stages or observes state for the Package's own durable tests. It compiles only -with `debug_reducers` and reaches Core through `crate::package_fixture`, the one Package API root a +with `debug_reducers` and reaches Core through `crate::package_fixture`, a Package API root a release build does not have. _Avoid_: test harness, debug hook, test helper diff --git a/docs/package-api.md b/docs/package-api.md index 499ecb7e5..e267bc955 100644 --- a/docs/package-api.md +++ b/docs/package-api.md @@ -222,6 +222,26 @@ Name this root only from a Package file whose first non-blank line is `#![cfg(feature = "debug_reducers")]`. The lint refuses it anywhere else, and no exemption clears it, because a release build would compile that file without the root. +### Package tests (test only) + +`crate::package_test` exists only in a Module test build. It holds what a Package's own unit tests +need. + +| operation | does | +|---|---| +| `ask_offline(event, actor, target, scripts)` | runs `scripts` in order on a fresh Runtime Script Host and returns the Script Answer as `script_binding::ask` reads it; discards Staged Effects and returns any Script Diagnostic as an error | +| `EntityView`, `RuntimeScript` | the event entity and script values `ask_offline` takes | +| `read_scanned(rel)` | reads a repository-relative source file; `None` when its optional directory is not installed | +| `code_of(src, signature)` | the body after `signature`, comments removed | +| `shape_of(src, signature)` | `code_of` with whitespace collapsed, for an exact comparison | + +A source scan pins a chokepoint that no unit test can reach. Prefer a pure function and assert on +it wherever one exists. + +Name this root only from a Package file whose first non-blank line is `#![cfg(test)]`. The lint +refuses it anywhere else, and no exemption clears it, because an ordinary build would compile that +file without the root. + ### Tables A Package declares its own tables with `#[table(accessor = pkg__, ..)]`, the naming @@ -247,6 +267,7 @@ terrain transfer world xp ``` Debug only: `package_fixture`, in a file gated on `debug_reducers` (see Package fixtures above). +Test only: `package_test`, in a file gated on `#![cfg(test)]` (see Package tests above). Plus, at the crate root: any `game_*` name (a table accessor or registration marker), any `pkg_*` name (a Package's own generated root module), any type name in UpperCamelCase (a row or diff --git a/module/build.rs b/module/build.rs index 8c3dbac16..d8eb3e250 100644 --- a/module/build.rs +++ b/module/build.rs @@ -62,9 +62,9 @@ //! The same pass also lints each package file against the Package API surface //! (`PACKAGE_API_ROOTS`, documented at `docs/package-api.md`): a path that reaches the crate root //! outside it fails the build naming the Package, the file, the line and the path, unless the line -//! carries `// package-api: exempt `. A debug root (`PACKAGE_API_DEBUG_ROOTS`) is on the -//! surface only in a file that starts with the debug cfg above, and no exemption clears it. Core -//! `src/` is never linted. +//! carries `// package-api: exempt `. A gated root (`PACKAGE_API_GATED_ROOTS`) is on the +//! surface only in a file that starts with its gate, the debug cfg above or `#![cfg(test)]`, and no +//! exemption clears it. Core `src/` is never linted. //! //! EVALUATED AND REJECTED: replacing the marker scan with an explicit per-package `register()` //! convention. The registries are const fn-pointer arrays (no allocator-dependent init order in @@ -289,10 +289,13 @@ const PACKAGE_API_ROOTS: &[&str] = &[ "xp", ]; -/// Roots on the surface only in a Package file gated by `DEBUG_REDUCERS_FILE_CFG`. Core compiles -/// them only with `debug_reducers`, so naming one anywhere else would break a release build. An -/// exemption cannot clear one for the same reason. -const PACKAGE_API_DEBUG_ROOTS: &[&str] = &["package_fixture"]; +/// Roots on the surface only in a Package file whose first non-blank line is the paired gate. Core +/// compiles each root only under that cfg, so naming one in any other file would break a build +/// without it. An exemption cannot clear one for the same reason. +const PACKAGE_API_GATED_ROOTS: &[(&str, &str)] = &[ + ("package_fixture", DEBUG_REDUCERS_FILE_CFG), + ("package_test", TEST_FILE_CFG), +]; /// Crate-root names on the surface that are neither a module nor a type: the two marker macros the /// `game_` prefix below does not already cover, and the generated character-owned table manifest. @@ -1252,6 +1255,7 @@ fn scan_file(file: &Path, scan_root: &Path, in_package: bool, prefix: &str, reg: } const DEBUG_REDUCERS_FILE_CFG: &str = "#![cfg(feature = \"debug_reducers\")]"; +const TEST_FILE_CFG: &str = "#![cfg(test)]"; /// Keep registry discovery on the same whole-file feature boundary as rustc. /// @@ -1264,10 +1268,20 @@ fn registry_file_enabled(source: &str, debug_reducers: bool) -> bool { /// Whether the file's first non-blank line is `DEBUG_REDUCERS_FILE_CFG`. fn debug_only_file(source: &str) -> bool { - source - .lines() - .find(|line| !line.trim().is_empty()) - .is_some_and(|line| line.trim() == DEBUG_REDUCERS_FILE_CFG) + file_gate(source) == Some(DEBUG_REDUCERS_FILE_CFG) +} + +/// The file's first non-blank line, trimmed: the whole-file gate when it is one. +fn file_gate(source: &str) -> Option<&str> { + source.lines().map(str::trim).find(|line| !line.is_empty()) +} + +/// The gate a gated root needs, or `None` when `root` is not gated. +fn root_gate(root: &str) -> Option<&'static str> { + PACKAGE_API_GATED_ROOTS + .iter() + .find(|(gated, _)| *gated == root) + .map(|(_, gate)| *gate) } /// Whether `root`, the first segment of a crate-root path, is on the Package API surface. @@ -1275,8 +1289,8 @@ fn debug_only_file(source: &str) -> bool { /// Three families beyond the listed roots: `game_*` covers the table accessor traits and the /// `game_hook!`/`game_tick_pass!` markers, `pkg_*` covers a Package's own generated root (and its /// siblings'), and an UpperCamelCase name is a row or payload type re-exported at the crate root. -/// A debug root is on the surface only in a debug-only file. -fn on_package_api(root: &str, debug_only_file: bool) -> bool { +/// A gated root is on the surface only in a file that starts with its gate. +fn on_package_api(root: &str, file_gate: Option<&str>) -> bool { let upper_camel = root.starts_with(|c: char| c.is_ascii_uppercase()) && root.chars().any(|c| c.is_ascii_lowercase()); PACKAGE_API_ROOTS.contains(&root) @@ -1284,7 +1298,7 @@ fn on_package_api(root: &str, debug_only_file: bool) -> bool { || root.starts_with("game_") || root.starts_with("pkg_") || upper_camel - || (debug_only_file && PACKAGE_API_DEBUG_ROOTS.contains(&root)) + || root_gate(root).is_some_and(|gate| file_gate == Some(gate)) } #[derive(Clone, Copy)] @@ -1656,17 +1670,17 @@ fn record_rooted_paths( prefix: &str, stripped: &str, exempt_lines: &[usize], - debug_only_file: bool, + file_gate: Option<&str>, found: &mut Vec<(usize, usize, String)>, ) { for (root_index, root, written) in roots_after_separator(tokens, separator, prefix) { let normalized_root = root.strip_prefix("r#").unwrap_or(&root); - if normalized_root == "self" || on_package_api(normalized_root, debug_only_file) { + if normalized_root == "self" || on_package_api(normalized_root, file_gate) { continue; } let offset = tokens[root_index].start; let line = stripped[..offset].matches('\n').count() + 1; - if !exempt_lines.contains(&line) || PACKAGE_API_DEBUG_ROOTS.contains(&normalized_root) { + if !exempt_lines.contains(&line) || root_gate(normalized_root).is_some() { found.push((offset, line, written)); } } @@ -1682,7 +1696,7 @@ fn record_rooted_paths( fn out_of_surface_paths(source: &str, file_depth: usize) -> Vec<(usize, String)> { let stripped_source = strip_source(source); let stripped = stripped_source.code; - let debug_only_file = debug_only_file(source); + let file_gate = file_gate(source); let tokens = source_tokens(&stripped); let pairs = brace_pairs(&tokens); let modules = inline_modules(&tokens, &pairs); @@ -1704,7 +1718,7 @@ fn out_of_surface_paths(source: &str, file_depth: usize) -> Vec<(usize, String)> "$crate", &stripped, &stripped_source.package_api_exempt_lines, - debug_only_file, + file_gate, &mut found, ); index += 3; @@ -1722,7 +1736,7 @@ fn out_of_surface_paths(source: &str, file_depth: usize) -> Vec<(usize, String)> "crate", &stripped, &stripped_source.package_api_exempt_lines, - debug_only_file, + file_gate, &mut found, ); } else if tokens[index].text == "super" && (index == 0 || tokens[index - 1].text != "::") { @@ -1752,7 +1766,7 @@ fn out_of_surface_paths(source: &str, file_depth: usize) -> Vec<(usize, String)> &prefix, &stripped, &stripped_source.package_api_exempt_lines, - debug_only_file, + file_gate, &mut found, ); } @@ -1775,11 +1789,11 @@ fn out_of_surface_message(package: &str, file: &Path, line: usize, path: &str) - .split("::") .find(|segment| !matches!(*segment, "crate" | "$crate" | "super" | "self")) .map(|segment| segment.strip_prefix("r#").unwrap_or(segment)); - if root.is_some_and(|root| PACKAGE_API_DEBUG_ROOTS.contains(&root)) { + if let Some(gate) = root.and_then(root_gate) { return format!( - "build.rs: Package `{package}` names `{path}` at {}:{line}, a Package API root that \ - exists only with `debug_reducers` (docs/package-api.md, version 1). Name it only from \ - a file whose first non-blank line is `{DEBUG_REDUCERS_FILE_CFG}`.", + "build.rs: Package `{package}` names `{path}` at {}:{line}, a gated Package API root \ + (docs/package-api.md, version 1). Name it only from a file whose first non-blank line \ + is `{gate}`.", file.display() ); } @@ -2009,6 +2023,60 @@ mod package_api_lint_tests { assert!(!message.contains(PACKAGE_API_EXEMPT), "{message}"); } + #[test] + fn a_test_root_is_refused_outside_a_test_only_file() { + // An ordinary build compiles this file, and an item gate does not change what the lint + // sees. + let source = "#[cfg(test)]\nmod tests {\n use crate::package_test::shape_of;\n}\n"; + assert_eq!(reported(source), vec!["3:crate::package_test::shape_of"]); + } + + #[test] + fn a_test_root_is_on_the_surface_in_a_test_only_file() { + let source = "#![cfg(test)]\nuse crate::package_test::{ask_offline, read_scanned};\nfn f() {\n super::super::package_test::shape_of();\n}\n"; + assert!( + reported_at_depth(source, 1).is_empty(), + "{:?}", + reported_at_depth(source, 1) + ); + } + + #[test] + fn each_gated_root_needs_its_own_gate() { + let test_file = "#![cfg(test)]\ncrate::package_fixture::apply_damage();\n"; + let debug_file = + "#![cfg(feature = \"debug_reducers\")]\ncrate::package_test::ask_offline();\n"; + assert_eq!( + reported(test_file), + vec!["2:crate::package_fixture::apply_damage"] + ); + assert_eq!( + reported(debug_file), + vec!["2:crate::package_test::ask_offline"] + ); + } + + #[test] + fn an_exemption_cannot_clear_a_test_root() { + let source = "use crate::package_test::read_scanned; // package-api: exempt scan Core\n"; + assert_eq!( + reported(source), + vec!["1:crate::package_test::read_scanned"] + ); + } + + #[test] + fn a_test_root_failure_names_the_test_gate() { + let message = out_of_surface_message( + "bots", + Path::new("packages/bots/src/goals.rs"), + 9, + "crate::package_test::shape_of", + ); + assert!(message.contains(TEST_FILE_CFG), "{message}"); + assert!(!message.contains(PACKAGE_API_EXEMPT), "{message}"); + } + #[test] fn an_exemption_without_a_reason_does_not_clear() { let source = "crate::auth::Account; // package-api: exempt\n"; diff --git a/module/src/lib.rs b/module/src/lib.rs index f28612f04..8ac8f0203 100644 --- a/module/src/lib.rs +++ b/module/src/lib.rs @@ -381,6 +381,10 @@ mod package_fixture; /// per-Package provenance that records what landed. Not re-exported below, for the same reason /// `import_meta` is not: nothing outside this module reads its table. mod package_import; +/// The test-only Package API root for Package unit tests. Not re-exported below, for the same +/// reason as `package_fixture`. +#[cfg(test)] +mod package_test; mod professions; /// Deploy-safety tripwire: source-scans `scripts/**` + `tools/**` for a destructive /// `spacetime publish -c`, and pins the sanctioned deploy script's own argv guard and required diff --git a/module/src/package_test.rs b/module/src/package_test.rs new file mode 100644 index 000000000..03ebb1aa9 --- /dev/null +++ b/module/src/package_test.rs @@ -0,0 +1,83 @@ +#![cfg_attr(not(has_packages), allow(dead_code, unused_imports))] + +//! Steps a Package's own unit tests need. The root exists only under `cfg(test)`, and the Package +//! API lint accepts it only in a Package file gated on `#![cfg(test)]`. + +use crate::runtime_script::{ask_event, EffectSink, RuntimeScriptHost, ScriptEvent}; + +pub(crate) use crate::runtime_script::{EntityView, RuntimeScript}; +pub(crate) use crate::test_scan::{code_of, read_scanned, shape_of}; + +/// Run `scripts` in order for one event on a fresh Runtime Script Host and return the Script +/// Answer, read as `script_binding::ask` reads it. Staged Effects are discarded. Any Script +/// Diagnostic is an error, one diagnostic per line. +pub(crate) fn ask_offline( + event: &str, + actor: Option, + target: Option, + scripts: &[RuntimeScript<'_>], +) -> Result, String> { + let event = ScriptEvent { + name: event.to_string(), + actor, + target, + }; + let (diagnostics, answer) = ask_event( + &mut RuntimeScriptHost::new(), + &mut NoEffects, + &event, + scripts, + ); + if diagnostics.is_empty() { + Ok(answer) + } else { + Err(diagnostics + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n")) + } +} + +struct NoEffects; + +impl EffectSink for NoEffects { + fn grant_xp(&mut self, _character_guid: u64, _amount: u32) {} + fn heal(&mut self, _healer_guid: u64, _target_guid: u64, _amount: u32) {} + fn send_chat(&mut self, _recipient_guid: u64, _message: &str) {} +} + +#[cfg(test)] +mod tests { + use super::*; + + fn script(source: &str) -> RuntimeScript<'_> { + RuntimeScript { + name: "probe", + source, + } + } + + #[test] + fn an_offline_ask_returns_the_first_answer() { + let scripts = [ + script("return nil"), + script("return 15"), + script("return 40"), + ]; + assert_eq!( + ask_offline("pkg.flee_at", None, None, &scripts), + Ok(Some(15.0)) + ); + } + + #[test] + fn an_offline_ask_refuses_a_failing_script() { + let failure = ask_offline("pkg.flee_at", None, None, &[script("error('broken')")]) + .expect_err("a raising script is a diagnostic"); + assert!( + failure.contains("runtime script `probe` on `pkg.flee_at`"), + "{failure}" + ); + } +}