diff --git a/CLAUDE.md b/CLAUDE.md index 36dbd9a..c8e05f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -147,7 +147,8 @@ Claude MUST: - Use naming conventions from the guide (section 3): `def_`, `_pr`, `_cprg`, `effect_`, `cf_` prefixes. - Use idempotent SQL patterns: `MERGE`, `IF NOT EXISTS`, or `DELETE + INSERT` as appropriate per table. - Generate full-chain content when possible — avoid partial generation. -- Run the validation checklist (section 26) before declaring content complete. +- Run the validation checklist (section 26) before declaring content complete. Most of it is executable: apply the content to a local database and run `ContentInvariantTests` in `Perpetuum.Tests.Integration`. It skips when `PERPETUUM_GAMEROOT` is unset, so confirm it ran rather than assuming a pass. +- Report what the invariants said, and state separately what they do not cover — balance, cost, tiering and sibling-matched flags are judgements no query makes. - Ask the user for existing database values when dynamic resolution requires live data not available in docs. Claude MUST NOT: diff --git a/docs/codebase/TESTING.md b/docs/codebase/TESTING.md index 40d0456..eb4535d 100644 --- a/docs/codebase/TESTING.md +++ b/docs/codebase/TESTING.md @@ -11,7 +11,7 @@ coverage map below states what is covered and what is not. |------|---------|-------|-------| | 1 — smoke | `tools/smoke-test.ps1` | 1 end-to-end run | A configured `GameRoot` and a live database | | 2 — unit | `src/Perpetuum.Tests` | 58 tests | Nothing. Runs anywhere the solution builds | -| 3 — integration | `src/Perpetuum.Tests.Integration` | 8 tests | A configured `GameRoot` and a live database | +| 3 — integration | `src/Perpetuum.Tests.Integration` | 10 tests | A configured `GameRoot` and a live database | Tier 2 is the tier that runs in CI. Tiers 1 and 3 run on a developer machine that already has the standard server environment, and skip rather than fail when it is absent. @@ -205,6 +205,19 @@ planned order of attack. 5. Production code is not restructured to make a test possible. The four seams above have been enough so far; if a test genuinely cannot be written without a new seam, that is a discussion to have in the pull request, not a refactor to slip in. +6. **Game content is tested differently, because it is data rather than code.** New items, robots, + effects, modules or tech tree nodes are rows, so tier 2 has nothing to say about them — a faked + data layer cannot tell you whether a recipe names a component that exists. They belong in + `src/Perpetuum.Tests.Integration/Content/ContentInvariantTests.cs`, which turns the validation + checklist in `docs/content/claude_game_content_guide.md` section 26 into queries. Adding a new + kind of content usually means adding an invariant there rather than a test per item: the + invariant holds for every row of that kind, including the ones nobody has written yet. + +An invariant test passes by counting zero, which means a query that can never match passes for the +wrong reason and stays green through any amount of broken content. Write the new invariant, run it, +and confirm it reports a violation you know is there before trusting a zero. `ContentInvariantTests` +carries a test that does this for the shape the others use, including the NULL case that silently +breaks it. --- diff --git a/docs/content/claude_game_content_guide.md b/docs/content/claude_game_content_guide.md index 7e07e8a..94a2dea 100644 --- a/docs/content/claude_game_content_guide.md +++ b/docs/content/claude_game_content_guide.md @@ -1086,6 +1086,28 @@ Support: # 26. Validation Checklist +**Most of this checklist is now executable.** Apply your content to a local database, then run: + +```bash +dotnet test src/Perpetuum.Tests.Integration/Perpetuum.Tests.Integration.csproj -c Release -p:Platform=x64 --filter "FullyQualifiedName~ContentInvariantTests" +``` + +`ContentInvariantTests` checks the structural half of the list below against the real database: +recipe definitions and components resolve, no definition is a component of itself, tech tree parents +and children exist, no two nodes share a coordinate in a group, enabler extensions resolve, robot +template relations resolve on both sides, and no two definitions share a name. It needs +`PERPETUUM_GAMEROOT` set, and it skips rather than fails when the environment is absent — so +**a skipped run is not a pass.** Check that it actually ran. + +Reporting a violation is the whole of what it does; it changes nothing and writes nothing. + +**What it cannot check, and what therefore stays yours:** whether a robot is balanced, whether a +recipe's cost is sensible, whether tiering is coherent, whether an item is worth having, and +everything under Modules / Ammoable Equipment below — `moduleFlag`, `ammoType` and `attributeflags` +are verified against sibling items by reading, because "matches a verified sibling" is a judgement +about which sibling is the right one. A green run means the content hangs together. It does not mean +the content is good. + Before considering content complete, Claude should validate: ## Definitions diff --git a/src/Perpetuum.Tests.Integration/Content/ContentInvariantTests.cs b/src/Perpetuum.Tests.Integration/Content/ContentInvariantTests.cs new file mode 100644 index 0000000..df6c4f8 --- /dev/null +++ b/src/Perpetuum.Tests.Integration/Content/ContentInvariantTests.cs @@ -0,0 +1,165 @@ +using Microsoft.Data.SqlClient; +using Perpetuum.Tests.Integration.Infrastructure; +using Xunit; + +namespace Perpetuum.Tests.Integration.Content +{ + /// + /// Section 26 of docs/content/claude_game_content_guide.md is a validation checklist: unique + /// names, referenced fields exist, all recipe components exist, no circular dependency, tech + /// tree parents exist, coordinates do not overlap, extensions resolve, robot parts exist. Every + /// item on it is a statement about the database that a query can settle, and until now every + /// one was checked by reading. + /// + /// These tests make that checklist executable. They are read-only and run against the real + /// perpetuumsa, because the thing being checked is the content itself rather than any code path + /// — a faked data layer has nothing to say about whether a recipe names a component that exists. + /// + /// What this can prove is structural: nothing dangles, nothing duplicates, nothing points at + /// itself. What it cannot prove is that content is any good. Whether a robot is balanced, + /// whether a recipe costs a sensible amount, whether an item is worth having — none of that is + /// visible from here, and a green run must not be read as saying otherwise. + /// + [Collection(DatabaseCollection.Name)] + public class ContentInvariantTests + { + private sealed record Invariant(string Name, string Sql); + + /// + /// Each query counts violations, so zero is the passing answer for all of them. + /// + /// techtree.parentdefinition is the one that needs a qualification, and it was measured + /// rather than assumed: 21 rows carry parentdefinition = 0, which is the root-node marker + /// and not a broken reference. entitydefaults.definition is IDENTITY(1,1) and its lowest + /// live value is 1, so 0 cannot ever name a real definition. Writing that exclusion in + /// without checking would have hidden a genuine dangling parent; leaving it out reports 21 + /// healthy roots as damage. + /// + private static readonly Invariant[] Invariants = + [ + new("every production recipe belongs to a definition that exists", + """ + SELECT COUNT(*) FROM dbo.components c + WHERE NOT EXISTS (SELECT 1 FROM dbo.entitydefaults e WHERE e.definition = c.definition) + """), + + new("every production recipe component is a definition that exists", + """ + SELECT COUNT(*) FROM dbo.components c + WHERE NOT EXISTS (SELECT 1 FROM dbo.entitydefaults e WHERE e.definition = c.componentdefinition) + """), + + new("no definition is a component of itself", + """ + SELECT COUNT(*) FROM dbo.components c WHERE c.definition = c.componentdefinition + """), + + new("every tech tree parent that is not a root exists", + """ + SELECT COUNT(*) FROM dbo.techtree t + WHERE t.parentdefinition <> 0 + AND NOT EXISTS (SELECT 1 FROM dbo.entitydefaults e WHERE e.definition = t.parentdefinition) + """), + + new("every tech tree child exists", + """ + SELECT COUNT(*) FROM dbo.techtree t + WHERE NOT EXISTS (SELECT 1 FROM dbo.entitydefaults e WHERE e.definition = t.childdefinition) + """), + + new("no two tech tree nodes share a coordinate within a group", + """ + SELECT COUNT(*) FROM ( + SELECT groupID, x, y FROM dbo.techtree GROUP BY groupID, x, y HAVING COUNT(*) > 1 + ) duplicated + """), + + new("every tech tree enabler extension exists", + """ + SELECT COUNT(*) FROM dbo.techtree t + WHERE t.enablerextensionid IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM dbo.extensions x WHERE x.extensionid = t.enablerextensionid) + """), + + new("every robot template relation names a definition that exists", + """ + SELECT COUNT(*) FROM dbo.robottemplaterelation r + WHERE NOT EXISTS (SELECT 1 FROM dbo.entitydefaults e WHERE e.definition = r.definition) + """), + + new("every robot template relation names a template that exists", + """ + SELECT COUNT(*) FROM dbo.robottemplaterelation r + WHERE NOT EXISTS (SELECT 1 FROM dbo.robottemplates t WHERE t.id = r.templateid) + """), + + new("no two definitions share a name", + """ + SELECT COUNT(*) FROM ( + SELECT definitionname FROM dbo.entitydefaults GROUP BY definitionname HAVING COUNT(*) > 1 + ) duplicated + """), + ]; + + private static int Count(SqlConnection connection, string sql) + { + using SqlCommand command = connection.CreateCommand(); + command.CommandText = sql; + + return Convert.ToInt32(command.ExecuteScalar()); + } + + [RequiresGameRootFact] + public void Every_documented_content_invariant_holds() + { + DatabaseFixture fixture = new(); + using SqlConnection connection = fixture.OpenConnection(); + + List broken = []; + + foreach (Invariant invariant in Invariants) + { + int violations = Count(connection, invariant.Sql); + if (violations > 0) + { + broken.Add($"{invariant.Name}: {violations} row(s)"); + } + } + + Assert.True( + broken.Count == 0, + "Content invariants from section 26 of the content guide are violated in this database. " + + "Each count is rows, not definitions: " + + string.Join("; ", broken)); + } + + /// + /// The invariants above pass by returning zero, so a query that can never match would pass + /// for the wrong reason and stay green through any amount of broken content. The obvious way + /// for that to happen is a NULL in the referencing column: NULL = anything is never true, so + /// a NOT EXISTS written without thinking about it reports a violation that is not there, and + /// the fix people reach for — filtering NULLs out — can just as easily be written to filter + /// everything out. + /// + /// This runs the same shape against a set built in the query, holding one dangling + /// reference and one NULL, and asserts it finds exactly the dangling one. + /// + [RequiresGameRootFact] + public void The_shape_these_invariants_use_really_does_detect_a_dangling_reference() + { + DatabaseFixture fixture = new(); + using SqlConnection connection = fixture.OpenConnection(); + + int violations = Count( + connection, + """ + SELECT COUNT(*) + FROM (VALUES (1), (2), (99), (NULL)) AS child(parent) + WHERE child.parent IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM (VALUES (1), (2)) AS parent(id) WHERE parent.id = child.parent) + """); + + Assert.Equal(1, violations); + } + } +}