diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 3a8ab1e9d..94aa055bc 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -22,6 +22,8 @@ The commands CI runs, in `.github/workflows/rust.yml`: - [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` - [ ] `cargo test -p lyracore-importer` - [ ] `cargo test -p lyracore-module --lib --features=debug_reducers` +- [ ] `cargo test -p lyracore-module --test package_api_lint` +- [ ] `cargo test -p lyracore-package-delta` - [ ] `cargo test -p lyracore-gateway` - [ ] `cargo test -p lyracore-shared` - [ ] `cargo test -p lyracore-test-support` @@ -34,7 +36,7 @@ In `.github/workflows/module-durable.yml`, when the change reaches the Module or In `.github/workflows/runtime-scripts.yml`, when the change reaches `datascripts/`: -- [ ] `bun test tests/runtime-scripts.test.ts` in `datascripts/` +- [ ] `bun test` in `datascripts/` Other checks: diff --git a/.github/workflows/runtime-scripts.yml b/.github/workflows/runtime-scripts.yml index 9471c10c9..8b6171fb6 100644 --- a/.github/workflows/runtime-scripts.yml +++ b/.github/workflows/runtime-scripts.yml @@ -20,5 +20,5 @@ jobs: with: bun-version: 1.3.7 - run: bun install --frozen-lockfile - - name: Typecheck and verify Runtime Script output - run: bun test tests/runtime-scripts.test.ts + - name: Test Datascripts and compile Runtime Scripts + run: bun test diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 9dfafc4ec..83fcdd00c 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -47,9 +47,11 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - # One job, not four: the crates share a target directory, and a failed step names its crate. + # Share compiled dependencies while keeping each crate's result separate. - run: cargo test -p lyracore-importer - run: cargo test -p lyracore-module --lib --features=debug_reducers + - run: cargo test -p lyracore-module --test package_api_lint + - run: cargo test -p lyracore-package-delta - run: cargo test -p lyracore-gateway - run: cargo test -p lyracore-shared - run: cargo test -p lyracore-test-support diff --git a/Cargo.lock b/Cargo.lock index 077766239..458edae41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1869,6 +1869,7 @@ version = "0.1.0" name = "lyracore-test-support" version = "0.1.0" dependencies = [ + "rustc_lexer", "serde_json", ] @@ -2715,6 +2716,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc_lexer" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86aae0c77166108c01305ee1a36a1e77289d7dc6ca0a3cd91ff4992de2d16a5" +dependencies = [ + "unicode-xid", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -3753,6 +3763,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "url" version = "2.5.8" diff --git a/crates/lyracore-package-delta/tests/common/mod.rs b/crates/lyracore-package-delta/tests/common/mod.rs index 70ffa6f41..c151c1655 100644 --- a/crates/lyracore-package-delta/tests/common/mod.rs +++ b/crates/lyracore-package-delta/tests/common/mod.rs @@ -199,7 +199,8 @@ pub const WHOLE_QUEST_ROW: &str = r#"{ "repeatable": { "type": "bool", "value": false }, "next_quest_id": { "type": "u32", "value": 0 }, "limit_time": { "type": "u32", "value": 0 }, - "reward_money_max_level": { "type": "u32", "value": 0 } + "reward_money_max_level": { "type": "u32", "value": 0 }, + "quest_type": { "type": "u32", "value": 0 } }"#; /// One `game_quest_template` claim. diff --git a/crates/lyracore-package-delta/tests/quest_identifiers.rs b/crates/lyracore-package-delta/tests/quest_identifiers.rs index 7a5c239e6..19170b7d9 100644 --- a/crates/lyracore-package-delta/tests/quest_identifiers.rs +++ b/crates/lyracore-package-delta/tests/quest_identifiers.rs @@ -113,17 +113,24 @@ fn the_package_range_is_disjoint_from_every_reserved_identifier() { #[test] fn an_incomplete_quest_insert_is_refused() { + let mut fields: serde_json::Map = + serde_json::from_str(WHOLE_QUEST_ROW).unwrap(); + fields.remove("reward_money"); + fields.remove("quest_type"); let json = artifact( "example.pkg", - &quest_claim(PACKAGE_QUEST_ID_FLOOR, "insert", A_TITLE), + &quest_claim( + PACKAGE_QUEST_ID_FLOOR, + "insert", + &serde_json::to_string(&fields).unwrap(), + ), ); match PackageDelta::parse(&json).expect_err("the insert must be refused") { - DeltaError::IncompleteInsert { table, missing } => { + DeltaError::IncompleteInsert { table, mut missing } => { assert_eq!(table, Table::Quest); - assert_eq!(missing.len(), 18); - assert!(missing.contains(&"reward_money".to_owned())); - assert!(!missing.contains(&"title".to_owned())); + missing.sort(); + assert_eq!(missing, ["quest_type", "reward_money"]); } other => panic!("expected an incomplete insert, got {other}"), } diff --git a/crates/lyracore-test-support/Cargo.toml b/crates/lyracore-test-support/Cargo.toml index a81f9f298..8e948e4d9 100644 --- a/crates/lyracore-test-support/Cargo.toml +++ b/crates/lyracore-test-support/Cargo.toml @@ -9,6 +9,7 @@ publish.workspace = true description = "Private Standalone fixtures shared by Core and Package integration tests." [dependencies] +rustc_lexer = "0.1.0" serde_json = "1" [lints] diff --git a/crates/lyracore-test-support/src/source_scan.rs b/crates/lyracore-test-support/src/source_scan.rs index 34d6512af..756313aae 100644 --- a/crates/lyracore-test-support/src/source_scan.rs +++ b/crates/lyracore-test-support/src/source_scan.rs @@ -1,60 +1,220 @@ -//! Rust source-scan primitives for Architecture Tests and the Package API's `package_test` root. +//! Lexical helpers for architecture tests. These scans do not prove runtime control flow. -/// Isolate the brace-matched body that follows the first occurrence of `signature`. Panics if the -/// signature or a balanced `{...}` is missing, because a scan that cannot find its target has lost -/// its pin. -pub fn body_of(src: &str, signature: &str) -> String { - let start = src - .find(signature) - .unwrap_or_else(|| panic!("`{signature}` no longer exists in this source")); - let rest = &src[start..]; - let open = rest.find('{').expect("fn has a body"); - let mut depth = 0i32; - for (i, c) in rest[open..].char_indices() { - match c { - '{' => depth += 1, - '}' => { +use std::ops::Range; + +use rustc_lexer::{tokenize, TokenKind}; + +fn is_comment(kind: TokenKind) -> bool { + matches!( + kind, + TokenKind::LineComment | TokenKind::BlockComment { .. } + ) +} + +fn masked(src: &str, omit: impl Fn(TokenKind) -> bool) -> String { + let mut bytes = src.as_bytes().to_vec(); + let mut start = 0; + for token in tokenize(src) { + let end = start + token.len; + if omit(token.kind) { + for byte in &mut bytes[start..end] { + if !matches!(*byte, b'\n' | b'\r') { + *byte = b' '; + } + } + } + start = end; + } + String::from_utf8(bytes).expect("masking whole tokens preserves UTF-8") +} + +/// Mask comments, literals, and test-only items or blocks, preserving byte offsets. +/// Handles outer `#[cfg(...)]` predicates. Inner `#![cfg(...)]` attributes remain in the scan. +/// This does not expand macros or `cfg_attr`. +pub fn code_only(src: &str) -> String { + without_test_code(&masked(src, |kind| { + is_comment(kind) || matches!(kind, TokenKind::Literal { .. }) + })) +} + +// Unknown configuration predicates can hold in production. Only remove code when +// setting `test = false` proves its cfg false. +fn cfg_without_test(tokens: &[(&str, Range)]) -> Option { + if tokens.len() == 1 && tokens[0].0 == "test" { + return Some(false); + } + if tokens.len() < 3 || tokens[1].0 != "(" || tokens.last()?.0 != ")" { + return None; + } + let mut args = Vec::new(); + let mut start = 2; + let mut i = start; + while i < tokens.len() - 1 { + if tokens[i].0 == "," { + args.push(cfg_without_test(&tokens[start..i])); + start = i + 1; + } else if matches!(tokens[i].0, "(" | "[") { + i = group_end(tokens, i); + } + i += 1; + } + if start < i { + args.push(cfg_without_test(&tokens[start..i])); + } + match tokens[0].0 { + "all" if args.contains(&Some(false)) => Some(false), + "all" if args.iter().all(|arg| *arg == Some(true)) => Some(true), + "any" if args.contains(&Some(true)) => Some(true), + "any" if args.iter().all(|arg| *arg == Some(false)) => Some(false), + "not" if args.len() == 1 => args[0].map(|value| !value), + _ => None, + } +} + +fn group_end(tokens: &[(&str, Range)], start: usize) -> usize { + let mut depth = 0; + for (i, (token, _)) in tokens.iter().enumerate().skip(start) { + match *token { + "(" | "[" | "{" => depth += 1, + ")" | "]" | "}" => { depth -= 1; if depth == 0 { - return rest[open..=open + i].to_string(); + return i; } } _ => {} } } - panic!("unterminated body for `{signature}`"); + panic!("unterminated group in source scan"); } -/// Strip a `//` comment from `line`, respecting double-quoted string literals. Raw strings and -/// block comments are not understood. -fn strip_line_comment(line: &str) -> &str { - let bytes = line.as_bytes(); - let mut in_string = false; +fn without_test_code(src: &str) -> String { + let mut offset = 0; + let tokens: Vec<_> = tokenize(src) + .filter_map(|token| { + let range = offset..offset + token.len; + offset = range.end; + (!is_comment(token.kind) && token.kind != TokenKind::Whitespace) + .then(|| (&src[range.clone()], range)) + }) + .collect(); + let mut bytes = src.as_bytes().to_vec(); let mut i = 0; - while i < bytes.len() { - match bytes[i] { - b'\\' if in_string => i += 1, - b'"' => in_string = !in_string, - b'/' if !in_string && bytes.get(i + 1) == Some(&b'/') => return &line[..i], - _ => {} + while i + 2 < tokens.len() { + if tokens[i].0 != "#" || tokens[i + 1].0 != "[" || tokens[i + 2].0 != "cfg" { + i += 1; + continue; + } + let attr_end = group_end(&tokens, i + 1); + if cfg_without_test(&tokens[i + 4..attr_end - 1]) != Some(false) { + i = attr_end + 1; + continue; + } + let start = tokens[i].1.start; + i = attr_end + 1; + let mut generic_depth = 0usize; + let mut function = false; + let mut field_type = false; + let mut unmatched_close = false; + while i < tokens.len() { + match tokens[i].0 { + ";" => break, + "}" | ")" | "]" => { + unmatched_close = true; + break; + } + "," if generic_depth == 0 && !function => break, + "fn" if !field_type => function = true, + ":" if !function => field_type = true, + "<" => generic_depth += 1, + ">" if tokens[i - 1].0 != "-" => { + generic_depth = generic_depth.saturating_sub(1); + } + "{" if generic_depth == 0 => { + i = group_end(&tokens, i); + break; + } + "(" | "[" | "{" => i = group_end(&tokens, i), + _ => {} + } + i += 1; + } + assert!(i < tokens.len(), "test-only item has no end"); + let end = if unmatched_close { + tokens[i].1.start + } else { + tokens[i].1.end + }; + for byte in &mut bytes[start..end] { + if !matches!(*byte, b'\n' | b'\r') { + *byte = b' '; + } } i += 1; } - line + String::from_utf8(bytes).expect("masking whole items preserves UTF-8") +} + +fn token_at(src: &str, byte_idx: usize) -> TokenKind { + assert!( + byte_idx < src.len(), + "token offset must be inside the source" + ); + let mut end = 0; + for token in tokenize(src) { + end += token.len; + if byte_idx < end { + return token.kind; + } + } + unreachable!("the lexer covers every source byte") +} + +/// Whether this byte belongs to a line or block comment. +pub fn in_comment(src: &str, byte_idx: usize) -> bool { + is_comment(token_at(src, byte_idx)) +} + +fn body_range(src: &str, signature: &str) -> Range { + let code = masked(src, |kind| { + is_comment(kind) || matches!(kind, TokenKind::Literal { .. }) + }); + let uncommented = masked(src, is_comment); + let start = uncommented + .match_indices(signature) + .find(|(start, _)| !matches!(token_at(src, *start), TokenKind::Literal { .. })) + .map(|(start, _)| start) + .unwrap_or_else(|| panic!("`{signature}` no longer exists in this source")); + let open = start + code[start..].find('{').expect("item has a body"); + let mut depth = 0; + for (offset, byte) in code.as_bytes()[open..].iter().enumerate() { + match byte { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + return open..open + offset + 1; + } + } + _ => {} + } + } + panic!("unterminated body for `{signature}`"); +} + +/// Find a body without matching signatures or braces inside comments and literals. +/// Returns the original body, including comments. Use `code_of` for content checks. +pub fn body_of(src: &str, signature: &str) -> String { + src[body_range(src, signature)].to_string() } -/// [`body_of`] with every comment removed: comment-only lines dropped, trailing comments cut. +/// Extract a body with comments removed, preserving literal values and test-only code. +/// Use `code_only` when a check must exclude non-production tokens. pub fn code_of(src: &str, signature: &str) -> String { - body_of(src, signature) - .lines() - .map(strip_line_comment) - .map(str::trim_end) - .filter(|l| !l.trim().is_empty()) - .collect::>() - .join("\n") + compact(&masked(&body_of(src, signature), is_comment)) } -/// [`code_of`] with every whitespace run collapsed to one space, for an equality comparison. +/// Collapse whitespace in `code_of` for the Package API's source comparisons. pub fn shape_of(src: &str, signature: &str) -> String { code_of(src, signature) .split_whitespace() @@ -62,30 +222,127 @@ pub fn shape_of(src: &str, signature: &str) -> String { .join(" ") } +fn compact(src: &str) -> String { + src.lines() + .map(str::trim_end) + .filter(|line| !line.trim().is_empty()) + .collect::>() + .join("\n") +} + #[cfg(test)] mod tests { use super::*; #[test] - fn code_of_strips_a_trailing_comment_and_keeps_the_live_half() { - let src = "fn f() {\n let _ = ctx; // bump(ctx, g);\n // bump in prose\n}"; - let code = code_of(src, "fn f("); - assert!(!code.contains("bump"), "{code}"); - assert!(code.contains("let _ = ctx;"), "{code}"); + fn commented_calls_do_not_satisfy_presence_checks() { + let source = "fn update() { require_operator(ctx)?; publish(ctx, &row); }"; + for call in ["require_operator(ctx)?;", "publish(ctx, &row);"] { + assert!(code_only(source).contains(call)); + for comment in [ + format!("/* {call} */"), + format!("// {call}\n"), + format!("let _ = r#\"{call}\"#;"), + format!("#[cfg(test)] {{ {call} }}"), + ] { + let changed = source.replace(call, &comment); + assert!(!code_only(&changed).contains(call)); + } + } + } + + #[test] + fn commented_and_quoted_signatures_do_not_select_the_body() { + let source = r###" + // fn target() { wrong_line(); } + /* fn target() { wrong_block(); } */ + const DECOY: &str = r#"fn target() { wrong_string(); }"#; + #[cfg(test)] fn target() { actual(); } + "###; + assert_eq!(body_of(source, "fn target("), "{ actual(); }"); } #[test] - fn code_of_keeps_a_slash_slash_inside_a_string_literal() { - let src = "fn f() {\n Err(\"see https://example.com/docs\")?;\n}"; - assert!(code_of(src, "fn f(").contains("https://example.com/docs")); + fn nested_comments_cannot_hide_the_end_of_a_body() { + let source = "fn f() { before(); /* } /* nested { */ } */ after(); } fn g() {}"; + let body = code_of(source, "fn f("); + assert!(body.contains("before();")); + assert!(body.contains("after();")); + assert!(!body.contains("nested")); + assert!(!body.contains("fn g")); } #[test] - fn code_of_handles_an_escaped_quote_inside_a_string_literal() { - let src = "fn f() {\n let s = \"a \\\"q\\\" // kept\";\n let t = 1; // cut\n}"; - let code = code_of(src, "fn f("); - assert!(code.contains("// kept"), "{code}"); - assert!(!code.contains("cut"), "{code}"); + fn braces_and_comment_markers_in_literals_are_preserved() { + let source = r###"fn f<'a>(s: &'a str) -> [u8; 3] { + let url = "https://example.com/} /* literal */"; + let escaped = "\" } // literal"; + let raw = br##"\" } /* raw */ // literal"##; + let ch = b'}'; + let unicode = 'é'; + /* remove this { */ after(); + } fn g() {}"###; + let body = code_of(source, "fn f<'a>(s: &'a str) -> [u8; 3]"); + assert!(body.contains("https://example.com/} /* literal */")); + assert!(body.contains(r###"br##"\" } /* raw */ // literal"##"###)); + assert!(body.contains("let ch = b'}';")); + assert!(body.contains("let unicode = 'é';")); + assert!(body.contains("after();")); + assert!(!body.contains("remove this")); + assert!(!body.contains("fn g")); + } + + #[test] + fn masking_preserves_offsets_and_distinguishes_lifetimes_from_characters() { + let source = "fn f<'a>() { /* é\ncomment */ let x = 'é'; read(); }"; + let code = code_only(source); + assert_eq!(code.len(), source.len()); + assert_eq!(code.find("read()"), source.find("read()")); + assert_eq!(code.find('\n'), source.find('\n')); + assert!(code.contains("fn f<'a>()")); + assert!(!code.contains('é')); + assert!(in_comment(source, source.find("comment").unwrap())); + assert!(!in_comment(source, source.find("read").unwrap())); + } + + #[test] + fn production_scans_exclude_test_configuration_and_resume_after_items() { + let source = r#" + #[cfg(all(unix, test))] #[allow(dead_code)] fn hidden() { hidden_call(); } + #[cfg(test)] mod external; + #[cfg(test)] fn generic() { hidden_call(); } + #[cfg(test)] fn fallible() -> Result<(), ()> { hidden_call(); Ok(()) } + #[cfg(test)] fn callable (), T>() { hidden_call(); } + #[cfg(test)] fn bounded() where S: Read, St: Store { hidden_call(); } + #[cfg(test)] fn constant() { hidden_call(); } + #[cfg(not(not(test)))] fn also_hidden() { hidden_call(); } + #[cfg(any(test, unix))] fn possible() { possible_call(); } + #[cfg(not(test))] fn production() { production_call(); } + fn last() { #[cfg(test)] { hidden_call(); } last_call(); } + fn local() { #[cfg(test)] fn helper() { hidden_call(); } after_helper(); } + struct S { #[cfg(test)] a: u8 } + fn after_field() { field_successor(); } + enum E { #[cfg(test)] A } + fn after_variant() { variant_successor(); } + fn matched() { match n { #[cfg(test)] 1 => hidden_call() } } + fn after_match() { match_successor(); } + "#; + let code = code_only(source); + assert!(!code.contains("hidden_call")); + assert!(!code.contains("external")); + assert!(body_of(&code, "fn last(").contains("last_call()")); + assert!(body_of(&code, "fn local(").contains("after_helper()")); + for call in [ + "possible_call()", + "production_call()", + "last_call()", + "field_successor()", + "variant_successor()", + "match_successor()", + ] { + assert!(code.contains(call)); + assert_eq!(code.find(call), source.find(call)); + } } #[test] @@ -96,7 +353,13 @@ mod tests { #[test] #[should_panic(expected = "no longer exists")] - fn body_of_panics_when_the_signature_is_gone() { - body_of("fn g() {}", "fn f("); + fn a_commented_out_function_is_missing() { + code_of("/* fn gone() {} */", "fn gone("); + } + + #[test] + #[should_panic(expected = "unterminated body")] + fn a_brace_in_a_comment_cannot_close_a_function() { + body_of("fn broken() { /* } */", "fn broken("); } } diff --git a/datascripts/tests/emit.test.ts b/datascripts/tests/emit.test.ts index 9477498b3..ca5678165 100644 --- a/datascripts/tests/emit.test.ts +++ b/datascripts/tests/emit.test.ts @@ -14,7 +14,7 @@ // Datascript. import { expect, test } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -55,11 +55,20 @@ test("the Fire Nova Datascript emits the frozen artifact, byte for byte", () => }); test("emitting twice produces the same bytes", () => { - expect(emit().artifact).toBe(emit().artifact); + const first = emit(); + const second = emit(); + expect(first.code).toBe(0); + expect(second.code).toBe(0); + expect(first.artifact).toBeDefined(); + expect(second.artifact).toBeDefined(); + expect(first.artifact).toBe(second.artifact); }); test("the artifact claims five ranks, each with its cloned effects", () => { - const artifact = JSON.parse(readFileSync(FROZEN, "utf8")); + const result = emit(); + expect(result.code).toBe(0); + expect(result.artifact).toBeDefined(); + const artifact = JSON.parse(result.artifact!); const spells = artifact.claims.filter((c: { table: string }) => c.table === "game_spell"); const effects = artifact.claims.filter( @@ -77,7 +86,7 @@ test("the artifact claims five ranks, each with its cloned effects", () => { test("a Datascript that is refused writes nothing and exits non-zero", () => { const root = mkdtempSync(join(tmpdir(), "lyracore-datascript-")); const script = join(root, "refused.ts"); - Bun.write( + writeFileSync( script, `import { run } from ${JSON.stringify(join(REPO, "datascripts", "lib", "index.ts"))};\n` + `await run("fire_nova", (data) => { data.spell(133).clone(200000); });\n`, diff --git a/docs/testing.md b/docs/testing.md index ddee16216..b46a9bc49 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -27,6 +27,8 @@ cargo fmt --all -- --check cargo clippy --workspace --all-targets --all-features -- -D warnings cargo test -p lyracore-importer cargo test -p lyracore-module --lib --features=debug_reducers +cargo test -p lyracore-module --test package_api_lint +cargo test -p lyracore-package-delta cargo test -p lyracore-gateway cargo test -p lyracore-shared cargo test -p lyracore-test-support @@ -83,7 +85,7 @@ From `.github/workflows/runtime-scripts.yml`: ```bash cd datascripts bun install --frozen-lockfile -bun test tests/runtime-scripts.test.ts +bun test ``` ## Client data diff --git a/gateway/src/stdb/connection.rs b/gateway/src/stdb/connection.rs index e902af469..b003638cb 100644 --- a/gateway/src/stdb/connection.rs +++ b/gateway/src/stdb/connection.rs @@ -1756,6 +1756,22 @@ pub(crate) fn recv_reducer_on( mod coordinator_query_tests { use super::{call_pipe_queries, coordinator_queries, CALL_PIPE_LIVENESS_QUERY}; + #[test] + fn coordinator_subscribes_bot_intents_in_each_topology() { + for sharded in [false, true] { + let queries = coordinator_queries(sharded); + for query in [ + "SELECT * FROM game_bot_invite_intent", + "SELECT * FROM game_bot_transfer_intent", + ] { + assert!( + queries.contains(&query), + "missing {query}, sharded={sharded}" + ); + } + } + } + /// Every table that only exists on a MULTI-DATABASE deployment. A subscription to one of these /// against a module that predates it FAILS TO APPLY and takes the whole gateway down on /// restart (`coordinator_queries`' doc comment) — so an unconfigured gateway must ask for none. diff --git a/importer/src/main.rs b/importer/src/main.rs index db11ef8c9..5d41532ba 100644 --- a/importer/src/main.rs +++ b/importer/src/main.rs @@ -8583,19 +8583,18 @@ mod tests { format!("({})", cols.join(",")) } - /// A `quest_template` row wide enough to reach `qt::REW_MAIL_DELAY_SECS` (col 103), with a reward - /// mail template + delay stamped. Mirrors `quest_template_row_chained_timed`'s pattern. + /// Source columns from the pinned ClassicDB schema, independent of the importer offsets. fn quest_template_row_with_reward_mail( entry: u64, title: &str, mail_template: u32, mail_delay: u32, ) -> String { - let mut cols = vec!["0".to_string(); qt::REW_MAIL_DELAY_SECS + 1]; - cols[qt::ENTRY] = entry.to_string(); - cols[qt::TITLE] = format!("'{title}'"); - cols[qt::REW_MAIL_TEMPLATE_ID] = mail_template.to_string(); - cols[qt::REW_MAIL_DELAY_SECS] = mail_delay.to_string(); + let mut cols = vec!["0".to_string(); 104]; + cols[0] = entry.to_string(); + cols[30] = format!("'{title}'"); + cols[102] = mail_template.to_string(); + cols[103] = mail_delay.to_string(); format!("({})", cols.join(",")) } @@ -9549,15 +9548,15 @@ mod tests { fn quest_type_column_reads_into_quest_type_and_counts_raid_quests() { let normal_row = { let mut row = vec!["0".to_string(); 102]; - row[qt::ENTRY] = "500".to_string(); - row[qt::TITLE] = "'Normal quest'".to_string(); + row[0] = "500".to_string(); + row[30] = "'Normal quest'".to_string(); format!("({})", row.join(",")) }; let raid_row = { let mut row = vec!["0".to_string(); 102]; - row[qt::ENTRY] = "501".to_string(); - row[qt::TITLE] = "'Raid quest'".to_string(); - row[qt::TYPE] = "62".to_string(); + row[0] = "501".to_string(); + row[30] = "'Raid quest'".to_string(); + row[6] = "62".to_string(); format!("({})", row.join(",")) }; let dump = format!( @@ -9596,8 +9595,8 @@ mod tests { for (index, value) in [ (0, "3645"), (30, "'Membership Card Renewal'"), - (qt::REW_MAIL_TEMPLATE_ID, "99"), - (qt::REW_MAIL_DELAY_SECS, "86400"), + (102, "99"), + (103, "86400"), ] { cols[index] = value.to_string(); } diff --git a/module/src/creatures/ai.rs b/module/src/creatures/ai.rs index 141c808b3..53a1c0276 100644 --- a/module/src/creatures/ai.rs +++ b/module/src/creatures/ai.rs @@ -603,7 +603,7 @@ mod tests { // sibling `spawn` module, reached via the parent `creatures` re-export. use crate::creatures::{ rank_hp_multiplier_permille, rolled_creature_stats, scale_creature_damage_for_level, - scale_health_for_rank, CreatureFamily, + scale_health_for_rank, }; #[test] @@ -858,23 +858,6 @@ mod tests { assert_eq!(scale_health_for_rank(2_000_000_000, 4), 3_600_000_000); // ×1.8 < u32::MAX } - /// Schema shape guard, follows `loot.rs`'s `new_loot_family_tables_construct_with_ - /// the_documented_shape` convention): `game_creature_family` constructs with named fields exactly - /// like the importer's positional SQL INSERT column list (`family_id,name,pet_food_mask, - /// pet_talent_type,category`) — a compile-time guard against a silently reordered/renamed column. - /// Purely compile-time: the runtime gates (feeding + tameable) don't exist yet, so there is - /// nothing behavioral to assert. - #[test] - fn new_creature_family_table_constructs_with_the_documented_shape() { - let _wolf = CreatureFamily { - family_id: 1, - name: "Wolf".to_string(), - pet_food_mask: 0x41, // MEAT | RAW_MEAT - pet_talent_type: 0, // >= 0 = tameable; -1 marks non-pet families - category: 8, - }; - } - #[test] fn flee_threshold_is_alive_and_low_health() { // 5/42 → 5*100=500 < 42*15=630 → flee. diff --git a/module/src/go_model.rs b/module/src/go_model.rs index 3e309159d..334db41e7 100644 --- a/module/src/go_model.rs +++ b/module/src/go_model.rs @@ -121,7 +121,6 @@ pub fn import_go_models_append(ctx: &ReducerContext, packed: String) -> Result<( #[cfg(test)] mod tests { use super::*; - use std::collections::BTreeMap; #[test] fn parse_row_round_trips_a_well_formed_row() { @@ -161,80 +160,4 @@ mod tests { } assert!(parse_row("1,1,1,€a").is_err()); } - - // ------------------------------------------------------------------------------------- - // Reducer idempotency — a pure in-memory stand-in for the entry-keyed clear+load / - // replace-on-append semantics `load_go_model_batch` implements against a real table, - // mirroring `vmap.rs`'s `LifecycleHarness` test style (no live ReducerContext needed). - // ------------------------------------------------------------------------------------- - - #[derive(Default)] - struct GoModelHarness { - rows: BTreeMap)>, - } - - impl GoModelHarness { - fn import(&mut self, packed: &str) -> Result { - self.rows.clear(); - self.append(packed) - } - - fn append(&mut self, packed: &str) -> Result { - let mut loaded = 0u32; - for row in packed.split(';').filter(|r| !r.is_empty()) { - let (entry, scale, radius, blob) = parse_row(row)?; - self.rows.insert(entry, (scale, radius, blob)); - loaded += 1; - } - Ok(loaded) - } - } - - fn packed_row(entry: u32, scale: f32, radius: f32) -> String { - let blob = lyracore_shared::vmap::encode(&[]); - let hex: String = blob.iter().map(|b| format!("{b:02x}")).collect(); - format!("{entry},{scale},{radius},{hex}") - } - - #[test] - fn importing_the_same_batch_twice_is_idempotent() { - let mut h = GoModelHarness::default(); - let batch = format!("{};{}", packed_row(1, 1.0, 2.0), packed_row(2, 1.0, 3.0)); - h.import(&batch).unwrap(); - let after_first: Vec<_> = h.rows.keys().copied().collect(); - h.import(&batch).unwrap(); - let after_second: Vec<_> = h.rows.keys().copied().collect(); - assert_eq!(after_first, after_second); - assert_eq!( - h.rows.len(), - 2, - "re-importing the same batch must not duplicate rows" - ); - } - - #[test] - fn re_appending_the_same_entry_replaces_rather_than_duplicates() { - let mut h = GoModelHarness::default(); - h.import(&packed_row(1, 1.0, 2.0)).unwrap(); - h.append(&packed_row(1, 1.0, 5.0)).unwrap(); // corrected re-extract, same entry - assert_eq!(h.rows.len(), 1); - assert_eq!( - h.rows[&1].1, 5.0, - "the newer radius must win, not error or duplicate" - ); - } - - #[test] - fn a_fresh_import_clears_a_prior_generation() { - let mut h = GoModelHarness::default(); - h.import(&packed_row(1, 1.0, 2.0)).unwrap(); - h.import(&packed_row(2, 1.0, 3.0)).unwrap(); - assert_eq!( - h.rows.len(), - 1, - "import (not append) must clear the prior batch" - ); - assert!(h.rows.contains_key(&2)); - assert!(!h.rows.contains_key(&1)); - } } diff --git a/module/src/import_meta.rs b/module/src/import_meta.rs index 4257e9cbd..1186ce365 100644 --- a/module/src/import_meta.rs +++ b/module/src/import_meta.rs @@ -77,24 +77,3 @@ pub fn stamp_import_meta( stamp(ctx, &family, &source_sha, &file_hash, row_count); Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn stamp_is_upsert_by_family() { - // Pure-logic shape check (no live ReducerContext in a native unit test — the module's other - // table-touching tests are exercised via the wire suite / gateway integration, not here). - // This test documents the row shape stays what `stamp`/`stamp_import_meta` produce. - let row = ImportMeta { - family: "quests".to_string(), - source_sha: "deadbeef".to_string(), - file_hash: "abc123".to_string(), - row_count: 42, - imported_at: Timestamp::UNIX_EPOCH, - }; - assert_eq!(row.family, "quests"); - assert_eq!(row.row_count, 42); - } -} diff --git a/module/src/instance.rs b/module/src/instance.rs index 0f2c825f8..e2c5cfae2 100644 --- a/module/src/instance.rs +++ b/module/src/instance.rs @@ -1253,13 +1253,6 @@ pub(crate) fn fire_instance_removal(ctx: &ReducerContext, removal: &InstanceRemo mod tests { use super::*; - #[test] - fn dungeon_map_set_contains_deadmines_and_no_open_world_map() { - assert!(is_dungeon_map(36), "Deadmines is the one imported dungeon"); - assert!(!is_dungeon_map(0), "Eastern Kingdoms is open world"); - assert!(!is_dungeon_map(1), "Kalimdor is open world"); - } - #[test] fn every_dungeon_map_has_an_entrance_fallback_arm() { // The stranding guard's invariant: a reaped-instance login on ANY dungeon map must have an diff --git a/module/src/loot/mod.rs b/module/src/loot/mod.rs index e1118957c..130c0c9c5 100644 --- a/module/src/loot/mod.rs +++ b/module/src/loot/mod.rs @@ -1125,110 +1125,6 @@ mod tests { assert!(!needs_item_pure(&active, &objectives, 55, 11)); } - /// Schema round-trip guard: `CreatureLoot.quest_only` defaults to `false` (additive migration — - /// every existing row keeps rolling unconditionally), and the new tables' row shapes construct with - /// named fields exactly like `CreatureLoot` (a compile-time guard against a silently reordered / - /// renamed column — the importer's positional SQL INSERT depends on this order matching its - /// column list verbatim). `CorpseLoot` now END-carries `quest_only`/`reserved_for` too. - #[test] - fn new_loot_family_tables_construct_with_the_documented_shape() { - let creature = CreatureLoot { - id: 0, - creature_entry: 1, - item_entry: 2, - chance_bp: 3, - count: 4, - group_id: 0, - quest_only: false, - }; - assert!( - !creature.quest_only, - "quest_only must default/construct false for an ordinary row" - ); - - let pickpocket = GamePickpocketLoot { - id: 0, - creature_entry: 1, - item_entry: 2, - chance_bp: 3, - count: 4, - group_id: 0, - quest_only: true, - }; - assert!(pickpocket.quest_only); - - let go_loot = GameObjectLoot { - id: 0, - loot_id: 1, - item_entry: 2, - chance_bp: 3, - count: 4, - group_id: 0, - quest_only: false, - }; - assert_eq!(go_loot.loot_id, 1); - - let skin = GameSkinningLoot { - id: 0, - skin_loot_id: 1, - item_entry: 2, - chance_bp: 3, - count: 4, - group_id: 0, - }; - assert_eq!(skin.skin_loot_id, 1); - - let fish = GameFishingLoot { - id: 0, - zone_id: 12, - item_entry: 6291, - chance_bp: 3, - count: 4, - group_id: 0, - }; - assert_eq!(fish.zone_id, 12); - - let corpse_row = CorpseLoot { - id: 0, - corpse_guid: 1, - slot: 0, - item_entry: 2, - count: 1, - quest_only: true, - reserved_for: 42, - designated_looter_guid: 0, - master_only: false, - withheld: false, - random_property_id: 0, - }; - assert!(corpse_row.quest_only); - assert_eq!(corpse_row.reserved_for, 42); - let shared_row = CorpseLoot { - id: 0, - corpse_guid: 1, - slot: 0, - item_entry: 2, - count: 1, - quest_only: false, - reserved_for: 0, - designated_looter_guid: 7, - master_only: true, - withheld: true, - random_property_id: 0, - }; - assert!( - !shared_row.quest_only, - "quest_only must default/construct false for an ordinary row" - ); - assert_eq!( - shared_row.reserved_for, 0, - "reserved_for must default/construct 0 (unclaimed)" - ); - assert_eq!(shared_row.designated_looter_guid, 7); - assert!(shared_row.master_only); - assert!(shared_row.withheld); - } - #[test] fn quest_take_allowed_admits_the_reservee_unconditionally_and_needing_takers_on_shared_rows() { // Unreserved (shared) row: any needing taker is admitted; a non-needing one is rejected. diff --git a/module/src/loot/tag.rs b/module/src/loot/tag.rs index 4290abaf7..8cc1bb79f 100644 --- a/module/src/loot/tag.rs +++ b/module/src/loot/tag.rs @@ -981,24 +981,6 @@ fn entity_xp(ctx: &ReducerContext, guid: u64) -> Result { mod tests { use super::*; - #[test] - fn tag_tables_keep_the_pinned_row_shapes() { - let tag = CreatureQuestTap { - creature_guid: 10, - character_guid: 20, - }; - let member = CreatureQuestTapMember { - id: 30, - creature_guid: 10, - character_guid: 20, - }; - assert_eq!((tag.creature_guid, tag.character_guid), (10, 20)); - assert_eq!( - (member.id, member.creature_guid, member.character_guid), - (30, 10, 20) - ); - } - #[test] fn corpse_access_requires_a_resolved_eligibility_row() { assert!(!corpse_eligible_for_access(&[], 7)); diff --git a/module/src/motion.rs b/module/src/motion.rs index d9df99f05..d5a9a77da 100644 --- a/module/src/motion.rs +++ b/module/src/motion.rs @@ -418,14 +418,13 @@ pub fn set_motion_tick_ms(ctx: &ReducerContext, tick_ms: u64) -> Result<(), Stri } // =========================================================================================== -// Tests — the pure decisions above, plus a model of the queue built out of them +// Tests for the movement coalescing decisions // =========================================================================================== #[cfg(test)] mod tests { use super::*; use lyracore_shared::opcodes::movement as m; - use std::collections::{HashMap, HashSet}; const HEARTBEAT: u16 = m::MSG_MOVE_HEARTBEAT as u16; const FACING: u16 = m::MSG_MOVE_SET_FACING as u16; @@ -472,189 +471,4 @@ mod tests { ever delete again" ); } - - // ---- A model of the queue, built out of the SAME pure fns the reducers execute -------------- - // - // The module crate has no `ReducerContext` harness by design (playbook §7), so the ALGORITHM is - // exercised here against an in-memory stand-in for the two tables. Every decision in the model is a - // call into `coalesce` / `tick_action`; the model itself only moves rows between two maps, - // exactly as the reducers do. - - #[derive(Default)] - struct Model { - pending: HashMap)>, - /// The public relay: guid -> (opcode, movement_info, seq). - published: HashMap, u32)>, - alive: HashSet, - /// How many times a row was written into the public table (the thing that costs a sweep). - publishes: usize, - } - - impl Model { - fn publish(&mut self, guid: u64, payload: (u16, Vec)) { - let seq = self.published.get(&guid).map_or(0, |(_, _, s)| s + 1); - self.published.insert(guid, (payload.0, payload.1, seq)); - self.publishes += 1; - } - - /// `movement_update`'s relay block. - fn movement(&mut self, guid: u64, opcode: u16, info: &[u8]) { - let incoming = (opcode, info.to_vec()); - match self.pending.get(&guid).cloned() { - None => { - self.pending.insert(guid, incoming); - } - Some(prev) => match coalesce(prev.0, opcode) { - Coalesce::Replace => { - self.pending.insert(guid, incoming); - } - Coalesce::KeepQueued => {} - Coalesce::FlushThenReplace => { - self.publish(guid, prev); - self.pending.insert(guid, incoming); - } - }, - } - } - - /// `publish_motion`. - fn tick(&mut self) { - for (guid, payload) in std::mem::take(&mut self.pending) { - match tick_action(self.alive.contains(&guid)) { - TickAction::Publish => self.publish(guid, payload), - TickAction::DropStale => {} - } - } - } - } - - #[test] - fn the_tick_republishes_a_changed_row() { - let mut w = Model::default(); - w.alive.insert(7); - w.movement(7, HEARTBEAT, &[1, 2, 3]); - w.tick(); - assert_eq!( - w.published.get(&7), - Some(&(HEARTBEAT, vec![1, 2, 3], 0)), - "a staged heartbeat must reach the public relay on the next firing" - ); - w.movement(7, HEARTBEAT, &[4, 5, 6]); - w.tick(); - assert_eq!(w.published.get(&7), Some(&(HEARTBEAT, vec![4, 5, 6], 1))); - } - - #[test] - fn the_tick_does_not_republish_an_unchanged_row() { - let mut w = Model::default(); - w.alive.insert(7); - w.movement(7, HEARTBEAT, &[1, 2, 3]); - w.tick(); - let after_first = w.publishes; - // Ten idle firings with no movement in between. - for _ in 0..10 { - w.tick(); - } - assert_eq!( - w.publishes, after_first, - "an entity that did not move has no queued row, so the tick must not touch its public \ - row at all — rewriting every entity every tick would just move the cost, not remove it" - ); - assert_eq!( - w.published.get(&7).map(|(_, _, s)| *s), - Some(0), - "seq must not advance while nothing moved" - ); - } - - #[test] - fn the_tick_does_not_resurrect_a_departed_entity() { - let mut w = Model::default(); - w.alive.insert(7); - w.movement(7, HEARTBEAT, &[1, 2, 3]); - // The mover logs out AFTER staging: the lifecycle path deletes both rows... - w.alive.remove(&7); - w.pending.remove(&7); - w.tick(); - assert!(!w.published.contains_key(&7)); - // missed, a race), the tick's own liveness gate still refuses to write the public row. - w.movement(7, HEARTBEAT, &[9]); - w.tick(); - assert!( - !w.published.contains_key(&7), - "the liveness gate is the backstop that makes a missed delete site degrade to a \ - dropped packet instead of an immortal ghost row" - ); - assert!(w.pending.is_empty(), "the stale row is drained either way"); - } - - #[test] - fn a_discrete_transition_survives_a_window_it_shares_with_a_heartbeat() { - let mut w = Model::default(); - w.alive.insert(7); - // Jump, then a heartbeat 10ms later — both inside one 50ms window. - w.movement(7, JUMP, &[0xAA]); - w.movement(7, HEARTBEAT, &[0xBB]); - w.tick(); - assert_eq!( - w.published.get(&7).map(|(op, info, _)| (*op, info.clone())), - Some((JUMP, vec![0xAA])), - "the JUMP opcode AND its movement_info (which carries the jump's own flags) must \ - survive — 'latest position wins' would publish the heartbeat and the peer would never \ - play the jump" - ); - - w.movement(7, HEARTBEAT, &[0xCC]); - w.tick(); - assert_eq!( - w.published.get(&7).map(|(op, info, _)| (*op, info.clone())), - Some((HEARTBEAT, vec![0xCC])) - ); - } - - #[test] - fn two_discrete_transitions_in_one_window_are_both_relayed_in_order() { - let mut w = Model::default(); - w.alive.insert(7); - w.movement(7, START, &[0x01]); - w.movement(7, JUMP, &[0x02]); - // The START went out immediately (it could not be expressed by the same public row as the - // JUMP in one transaction), so it is already published BEFORE the firing. - assert_eq!( - w.published.get(&7).map(|(op, _, _)| *op), - Some(START), - "the older transition is flushed, never overwritten" - ); - w.tick(); - assert_eq!( - w.published - .get(&7) - .map(|(op, info, s)| (*op, info.clone(), *s)), - Some((JUMP, vec![0x02], 1)), - "and the newer one follows on the next firing, with a bumped seq — order preserved" - ); - } - - #[test] - fn a_crowd_of_heartbeats_costs_one_public_write_each_per_firing() { - let mut w = Model::default(); - for guid in 0..100u64 { - w.alive.insert(guid); - } - // Three heartbeats per player inside one window (the earlier cost: 300 transactions). - for _ in 0..3 { - for guid in 0..100u64 { - w.movement(guid, HEARTBEAT, &[0x01]); - } - } - assert_eq!( - w.publishes, 0, - "staging costs no public write at all — that is the whole item" - ); - w.tick(); - assert_eq!( - w.publishes, 100, - "one write per MOVER per firing, all inside the single tick transaction" - ); - } } diff --git a/module/src/mount.rs b/module/src/mount.rs index 5d98e8f71..934e2ee8b 100644 --- a/module/src/mount.rs +++ b/module/src/mount.rs @@ -307,21 +307,6 @@ mod tests { assert_eq!(projected_mount_display([(A_MOUNTED, -3)]), 0); } - /// Idempotence, at the level the recompute actually decides it: the projection is a pure function - /// of the aura set, so re-deriving it from an unchanged set yields the same value — which is why - /// `recompute_mount` can skip the write and why a second `dismount` changes nothing. Removing the - /// mount spell's rows (both effects together, as `dismount` does) converges on 0. - #[test] - fn projection_is_stable_and_converges_on_removal() { - let mounted = [(A_MOUNTED, 1147), (A_MOD_SPEED, SPEED_MOUNTED as i32)]; - assert_eq!(projected_mount_display(mounted), 1147); - assert_eq!(projected_mount_display(mounted), 1147); - - let after_dismount: [(u8, i32); 0] = []; - assert_eq!(projected_mount_display(after_dismount), 0); - assert_eq!(projected_mount_display(after_dismount), 0); - } - /// Mount replacement (story 28): applying a second mount dismounts the first, so the aura set is /// never allowed to hold two `A_MOUNTED` rows. Should one appear anyway, the projection stays /// deterministic rather than flickering between displays. diff --git a/module/src/spell/tests.rs b/module/src/spell/tests.rs index 0e2ab011d..11d5789c6 100644 --- a/module/src/spell/tests.rs +++ b/module/src/spell/tests.rs @@ -795,17 +795,6 @@ fn mana_potion_energizes_and_clamps_to_max_power() { assert_eq!(energized_value(500, 500, POTION_MANA), 500); } -/// CONSUMABLE BREADTH — the WELL-FED buff (Spiced Wolf Meat 2680→50116) is an `A_MOD_STAT` whose +Stamina -/// effect MOVES the max-health pool: `aura_moves_vitals(A_MOD_STAT, STAT_STA)` is true, so applying the -/// buff re-derives max HP (the Cooking payoff is mechanically live). The +Spirit effect is summed by -/// `stat_bonus` but does NOT move vitals (no max-pool consumer for SPI) — staged/inert, like Mark of the -/// Wild's non-STA stats. This guards the kind/p0 the Well-Fed seed must carry to be a real HP buff. -#[test] -fn well_fed_stamina_moves_max_health_spirit_is_inert() { - assert!(aura_moves_vitals(A_MOD_STAT, STAT_STA as i32)); // +Sta grows max HP on apply - assert!(!aura_moves_vitals(A_MOD_STAT, STAT_SPI as i32)); // +Spi is summed but moves no pool (inert) -} - /// CONSUMABLE BREADTH — the DRINK (water 159/5350→50114) SCHEDULES a periodic energize: an /// `A_PERIODIC_ENERGIZE` aura that the scheduler folds `energized_value` into `power` each tick. Modeling /// the 6 ticks (40 mana / 5s × 6 = 240 over 30s), power climbs +40 per tick and CLAMPS at max — exactly diff --git a/module/src/test_scan.rs b/module/src/test_scan.rs index bf2033794..4812389e9 100644 --- a/module/src/test_scan.rs +++ b/module/src/test_scan.rs @@ -8,29 +8,7 @@ use std::io::Write; // The engine behind the spatial-scan and transfer-fence Architecture Tests. The two callers differ // only in which accessors they watch and what counts as opening a read. -/// `byte_idx` sits on a `//` line, i.e. it is prose rather than code. -pub(crate) fn on_comment_line(content: &str, byte_idx: usize) -> bool { - let line_start = content[..byte_idx].rfind('\n').map(|i| i + 1).unwrap_or(0); - content[line_start..byte_idx].trim_start().starts_with("//") -} - -/// `byte_idx` sits inside an ordinary double-quoted string literal on its line. Source scanners -/// must not treat assertion needles as live table reads. -fn in_string_literal(content: &str, byte_idx: usize) -> bool { - let line_start = content[..byte_idx].rfind('\n').map(|i| i + 1).unwrap_or(0); - let bytes = &content.as_bytes()[line_start..byte_idx]; - let mut in_string = false; - let mut i = 0; - while i < bytes.len() { - match bytes[i] { - b'\\' if in_string => i += 1, - b'"' => in_string = !in_string, - _ => {} - } - i += 1; - } - in_string -} +pub(crate) use lyracore_test_support::source_scan::in_comment as on_comment_line; /// 1-based line number of `byte_idx`. pub(crate) fn line_of(content: &str, byte_idx: usize) -> usize { @@ -71,6 +49,8 @@ pub(crate) fn raw_table_reads( accessors: &[&'static str], opens: impl Fn(&str, usize) -> bool, ) -> Vec<(usize, &'static str)> { + let code = lyracore_test_support::source_scan::code_only(content); + let content = code.as_str(); let mut out = Vec::new(); // `(local handle name, accessor it was bound from)`, deduped by name so a file that binds // `entities` a dozen times doesn't count each downstream read a dozen times. @@ -78,18 +58,12 @@ pub(crate) fn raw_table_reads( for accessor in accessors { let call = format!("{accessor}()"); for (idx, _) in content.match_indices(&call) { - if on_comment_line(content, idx) || in_string_literal(content, idx) { - continue; - } if opens(content, idx + call.len()) { out.push((line_of(content, idx), *accessor)); } } let bind = format!("ctx.db.{accessor}();"); for (idx, _) in content.match_indices(&bind) { - if on_comment_line(content, idx) || in_string_literal(content, idx) { - continue; - } // Walk back over `let [mut] NAME =` on the same line to name the handle. let line_start = content[..idx].rfind('\n').map(|i| i + 1).unwrap_or(0); let Some(head) = content[line_start..idx].trim_end().strip_suffix('=') else { @@ -112,10 +86,7 @@ pub(crate) fn raw_table_reads( } for (name, accessor) in &handles { for (idx, _) in content.match_indices(name.as_str()) { - if on_comment_line(content, idx) - || in_string_literal(content, idx) - || !is_standalone_ident(content, idx, name) - { + if !is_standalone_ident(content, idx, name) { continue; } if opens(content, idx + name.len()) { @@ -208,17 +179,18 @@ mod tests { use super::*; #[test] - fn raw_table_reads_ignores_a_table_scan_named_inside_a_string_literal() { - let src = r#"fn f() { + fn raw_table_reads_ignores_comments_and_literals_but_keeps_live_reads() { + let src = r###"fn f() { assert!(!tick.contains("game_world_entity().iter()")); -}"#; + /* game_world_entity().iter(); /* nested */ */ + let example = r#"game_world_entity().iter()"#; + ctx.db.game_world_entity().iter(); + #[cfg(test)] { ctx.db.game_world_entity().iter(); } +}"###; let found = raw_table_reads(src, &["game_world_entity"], |content, byte_idx| { content[byte_idx..].starts_with(".iter()") }); - assert!( - found.is_empty(), - "an assertion's forbidden-pattern needle is not a live table read: {found:?}" - ); + assert_eq!(found, [(5, "game_world_entity")]); } // ---- the optional-tree resolver ------------------------------------------------------------ diff --git a/module/src/vmap.rs b/module/src/vmap.rs index e0dc2a2fb..5a6012e47 100644 --- a/module/src/vmap.rs +++ b/module/src/vmap.rs @@ -1062,132 +1062,6 @@ pub fn is_indoor(ctx: &ReducerContext, map_id: u32, x: f32, y: f32, z: f32) -> b #[cfg(test)] mod tests { use super::*; - use std::collections::BTreeMap; - - /// (base_z, walk, obs) for one coverage cell. - type CoverageCell = (f32, Vec, Vec); - - #[derive(Default)] - struct LifecycleHarness { - generations: BTreeMap, - chunks: BTreeMap<(u64, u64, u32), Vec>, - /// (generation_id, cell_key) -> (base_z, walk, obs) — mirrors `VmapNavCoverage`. - coverage: BTreeMap<(u64, u64), CoverageCell>, - /// generation_id -> (cell_count, digest, complete) — mirrors `VmapNavCoverageManifest`. - coverage_manifests: BTreeMap, - } - - impl LifecycleHarness { - fn stage(&mut self, id: u64, map: u32, expected: u32) { - self.generations.insert(id, (map, STAGING, expected, 0)); - } - - fn append(&mut self, id: u64, key: u64, shard: u32, blob: Vec) -> Result<(), String> { - let generation = self.generations.get_mut(&id).ok_or("missing generation")?; - require_staging(generation.1)?; - match self.chunks.get(&(id, key, shard)) { - Some(prior) if prior == &blob => Ok(()), - Some(_) => Err("conflicting retry".to_string()), - None => { - self.chunks.insert((id, key, shard), blob); - generation.3 += 1; - Ok(()) - } - } - } - - fn verify(&mut self, id: u64) -> Result<(), String> { - let generation = self.generations.get_mut(&id).ok_or("missing generation")?; - require_staging(generation.1)?; - require_complete(generation.2, generation.3)?; - generation.1 = VERIFIED; - Ok(()) - } - - fn activate(&mut self, id: u64) -> Result<(), String> { - let map = self.generations.get(&id).ok_or("missing generation")?.0; - require_verified(self.generations[&id].1)?; - for generation in self.generations.values_mut() { - if generation.0 == map && generation.1 == ACTIVE { - generation.1 = DISCARDED; - } - } - self.generations.get_mut(&id).unwrap().1 = ACTIVE; - Ok(()) - } - - fn discard(&mut self, id: u64) -> Result<(), String> { - let generation = self.generations.get_mut(&id).ok_or("missing generation")?; - if generation.1 == ACTIVE { - return Err("cannot discard active".to_string()); - } - generation.1 = DISCARDED; - self.chunks - .retain(|(generation_id, _, _), _| *generation_id != id); - self.coverage - .retain(|(generation_id, _), _| *generation_id != id); - self.coverage_manifests.remove(&id); - Ok(()) - } - - fn active(&self, map: u32) -> Option { - self.generations.iter().find_map(|(&id, generation)| { - (generation.0 == map && generation.1 == ACTIVE).then_some(id) - }) - } - - fn coverage_preparable(&self, id: u64) -> Result<(), String> { - let state = self.generations.get(&id).ok_or("missing generation")?.1; - require_coverage_preparable(state) - } - - /// Mirrors `prepare_vmap_nav_coverage`: skips a cell key that already has a row - /// (idempotent retry), writes nothing when `derive` returns `None` (the clear-cell case). - fn prepare_coverage( - &mut self, - id: u64, - keys: &[u64], - mut derive: impl FnMut(u64) -> Option<(f32, Vec, Vec)>, - ) -> Result<(), String> { - self.coverage_preparable(id)?; - for &key in keys { - if self.coverage.contains_key(&(id, key)) { - continue; - } - if let Some(cell) = derive(key) { - self.coverage.insert((id, key), cell); - } - } - Ok(()) - } - - /// Mirrors `finalize_vmap_nav_coverage`: cell count + digest over the sorted current rows. - fn finalize_coverage(&mut self, id: u64) -> Result<(), String> { - self.coverage_preparable(id)?; - let mut cells: Vec<(u64, &CoverageCell)> = self - .coverage - .iter() - .filter(|((generation_id, _), _)| *generation_id == id) - .map(|((_, key), cell)| (*key, cell)) - .collect(); - cells.sort_unstable_by_key(|(key, _)| *key); - let digest = coverage_digest(cells.iter().map(|(key, (base_z, walk, obs))| { - (*key, *base_z, walk.as_slice(), obs.as_slice()) - })); - self.coverage_manifests - .insert(id, (cells.len() as u32, digest, true)); - Ok(()) - } - - /// Mirrors `active_vmap_nav_coverage_manifest`: the active generation's manifest, but - /// only when it is finalized. - fn active_coverage_manifest(&self, map: u32) -> Option<(u32, [u8; 32])> { - let id = self.active(map)?; - let (count, digest, complete) = self.coverage_manifests.get(&id)?; - complete.then_some((*count, *digest)) - } - } - #[test] fn only_a_complete_staging_generation_can_be_verified() { assert!(require_staging(STAGING).is_ok()); @@ -1238,50 +1112,6 @@ mod tests { ); } - #[test] - fn lifecycle_harness_retries_activation_and_map_selection() { - let mut h = LifecycleHarness::default(); - h.stage(1, 0, 1); - h.append(1, 7, 0, vec![1]).unwrap(); - h.append(1, 7, 0, vec![1]).unwrap(); - assert_eq!(h.generations[&1].3, 1, "retry must not duplicate a chunk"); - h.verify(1).unwrap(); - h.activate(1).unwrap(); - assert_eq!(h.active(0), Some(1)); - assert_eq!(h.active(1), None, "another map remains baseline"); - - h.stage(2, 0, 1); - assert!( - h.verify(2).is_err(), - "incomplete replacement cannot activate" - ); - assert_eq!( - h.active(0), - Some(1), - "prior active generation survives failure" - ); - h.append(2, 8, 0, vec![2]).unwrap(); - h.verify(2).unwrap(); - h.activate(2).unwrap(); - assert_eq!(h.active(0), Some(2)); - assert_eq!(h.generations[&1].1, DISCARDED); - } - - #[test] - fn lifecycle_harness_discards_staging_without_touching_active() { - let mut h = LifecycleHarness::default(); - h.stage(1, 0, 1); - h.append(1, 7, 0, vec![1]).unwrap(); - h.verify(1).unwrap(); - h.activate(1).unwrap(); - h.stage(2, 0, 1); - h.append(2, 8, 0, vec![2]).unwrap(); - h.discard(2).unwrap(); - assert_eq!(h.active(0), Some(1)); - assert!(!h.chunks.keys().any(|(generation, _, _)| *generation == 2)); - assert!(h.discard(1).is_err(), "active data cannot be discarded"); - } - /// The presence table's classifier. A WMO group is indoor by the SHARED mangos rule (outdoor /// only when `0x8000` is set); a doodad never is, whatever its geometry looks like. #[test] @@ -1349,109 +1179,4 @@ mod tests { "a different coverage selection cannot resume the generation" ); } - - #[test] - fn coverage_prepares_in_batches_and_finalizes_idempotently() { - let mut h = LifecycleHarness::default(); - h.stage(1, 0, 1); - h.append(1, 7, 0, vec![1]).unwrap(); - h.verify(1).unwrap(); - - let derive = |key: u64| (key != 11).then_some((80.0, vec![0xAA], vec![0xBB])); - h.prepare_coverage(1, &[10, 11, 12], derive).unwrap(); - assert_eq!(h.coverage.len(), 2, "the fully-clear cell writes no row"); - - h.finalize_coverage(1).unwrap(); - let first = *h.coverage_manifests.get(&1).unwrap(); - assert_eq!(first.0, 2, "cell count only counts cells with coverage"); - - // Retry the exact same batch, including the already-prepared keys and the empty one. - h.prepare_coverage(1, &[10, 11, 12], derive).unwrap(); - assert_eq!( - h.coverage.len(), - 2, - "retry does not duplicate a prepared cell" - ); - h.finalize_coverage(1).unwrap(); - assert_eq!( - *h.coverage_manifests.get(&1).unwrap(), - first, - "re-finalizing an unchanged batch reproduces the same manifest" - ); - } - - #[test] - fn coverage_preparation_is_refused_outside_verified_or_active() { - let mut h = LifecycleHarness::default(); - h.stage(1, 0, 1); - assert!( - h.prepare_coverage(1, &[10], |_| Some((80.0, vec![], vec![]))) - .is_err(), - "a staging generation cannot prepare coverage" - ); - h.append(1, 7, 0, vec![1]).unwrap(); - h.verify(1).unwrap(); - h.activate(1).unwrap(); - - h.stage(2, 0, 1); - h.discard(2).unwrap(); - assert!( - h.prepare_coverage(2, &[10], |_| Some((80.0, vec![], vec![]))) - .is_err(), - "a discarded generation cannot prepare coverage" - ); - } - - #[test] - fn discarding_a_generation_removes_its_coverage_and_manifest() { - let mut h = LifecycleHarness::default(); - h.stage(1, 0, 1); - h.append(1, 7, 0, vec![1]).unwrap(); - h.verify(1).unwrap(); - h.prepare_coverage(1, &[10], |_| Some((80.0, vec![0xAA], vec![0xBB]))) - .unwrap(); - h.finalize_coverage(1).unwrap(); - assert!(!h.coverage.is_empty() && h.coverage_manifests.contains_key(&1)); - - h.discard(1).unwrap(); - assert!(!h - .coverage - .keys() - .any(|(generation_id, _)| *generation_id == 1)); - assert!(!h.coverage_manifests.contains_key(&1)); - } - - #[test] - fn activation_selects_coverage_only_when_finalized_for_that_exact_generation() { - let mut h = LifecycleHarness::default(); - // Generation 1: fully prepared, finalized, then activated. - h.stage(1, 0, 1); - h.append(1, 7, 0, vec![1]).unwrap(); - h.verify(1).unwrap(); - h.prepare_coverage(1, &[10], |_| Some((80.0, vec![0xAA], vec![0xBB]))) - .unwrap(); - h.finalize_coverage(1).unwrap(); - h.activate(1).unwrap(); - assert!( - h.active_coverage_manifest(0).is_some(), - "the active generation's finalized coverage is selected" - ); - - // Generation 2 replaces it but its coverage prep never finalizes — a failed/partial run. - h.stage(2, 0, 1); - h.append(2, 8, 0, vec![2]).unwrap(); - h.verify(2).unwrap(); - h.prepare_coverage(2, &[20], |_| Some((80.0, vec![0xCC], vec![0xDD]))) - .unwrap(); - h.activate(2).unwrap(); - - assert_eq!(h.active(0), Some(2), "activation succeeds without coverage"); - assert!( - h.active_coverage_manifest(0).is_none(), - "an unfinalized generation activates fine but selects no coverage" - ); - // Generation 1's own finalized manifest survives untouched — only an explicit discard - // clears it, and generation 1 was never discarded here. - assert!(h.coverage_manifests.contains_key(&1)); - } }