diff --git a/.github/editor-checksums.txt b/.github/editor-checksums.txt index d74b2f8..c601d7a 100644 --- a/.github/editor-checksums.txt +++ b/.github/editor-checksums.txt @@ -10,4 +10,7 @@ # # To add an entry when bumping, see docs/WEB_EXPORT.md → "Updating the pinned editor". ad76e72610187b13e83229e863928c32689b1ba5dda34f5210940d563b89e473 Godot_v4.7.1-stable_mono_web_export_win64.zip +# Official upstream Linux editor, used by the headless scene-test job (docs/TESTING.md). +# Not the patched fork — plain Godot is enough to run in-engine tests. +6ca7ff0459f1b806900be683c1b0837c607a9c16834c530dc68c81b9fc3ae1f6 Godot_v4.7.1-stable_mono_linux_x86_64.zip b1f1b387dd45c6f3db35b336f58d40d6ea7ea0c8d7597d4fc26b493f4d12347c Godot_v4.7-stable_mono_web_export_win64.zip diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2b94b95..299d412 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,6 +34,78 @@ jobs: - name: Test run: dotnet test tests/ProceduralMaze.Tests.csproj -c Debug --nologo + # In-engine tests for the Godot scene/UI layer, which the NUnit suite cannot reach (it + # deliberately builds without the Godot SDK). Runs the official upstream Linux editor + # headless -- the patched Windows fork is only needed for the *web export*, not for this. + # + # Not gdUnit4Net: its Godot-runtime executor fails to start on Godot 4.7.1 + # ("Failed to connect: Connection timeout"), while plain Godot runs the project fine. + # See docs/TESTING.md -> "Why not gdUnit4Net". + scene-tests: + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + GODOT_VERSION: "4.7.1-stable" + GODOT_ASSET: "Godot_v4.7.1-stable_mono_linux_x86_64" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up .NET 9 SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "9.0.x" + + - name: Cache Godot editor + id: godotcache + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/godot + key: godot-linux-${{ env.GODOT_VERSION }} + + - name: Download Godot (checksum-pinned) + if: steps.godotcache.outputs.cache-hit != 'true' + run: | + mkdir -p "$RUNNER_TEMP/godot" + curl -sSL -o "$RUNNER_TEMP/godot/$GODOT_ASSET.zip" \ + "https://github.com/godotengine/godot-builds/releases/download/$GODOT_VERSION/$GODOT_ASSET.zip" + + - name: Verify Godot checksum + run: | + # Same supply-chain guard as the patched web editor: never run an unverified binary. + expected=$(grep -v '^\s*#' .github/editor-checksums.txt | grep "$GODOT_ASSET.zip" | awk '{print $1}') + if [ -z "$expected" ]; then + echo "::error::No pinned checksum for $GODOT_ASSET.zip in .github/editor-checksums.txt" + exit 1 + fi + actual=$(sha256sum "$RUNNER_TEMP/godot/$GODOT_ASSET.zip" | awk '{print $1}') + if [ "$expected" != "$actual" ]; then + echo "::error::Checksum mismatch for $GODOT_ASSET.zip (expected $expected, got $actual)" + exit 1 + fi + echo "Checksum OK: $actual" + + - name: Extract Godot + run: | + unzip -q -o "$RUNNER_TEMP/godot/$GODOT_ASSET.zip" -d "$RUNNER_TEMP/godot" + find "$RUNNER_TEMP/godot" -type f -name "Godot_v*_mono_linux.x86_64" -exec chmod +x {} \; + + - name: Build with scene tests + run: dotnet build ProceduralGeneration3DMazes.csproj -c Debug --nologo -p:IncludeSceneTests=true + + - name: Import project (headless) + run: | + GODOT=$(find "$RUNNER_TEMP/godot" -type f -name "Godot_v*_mono_linux.x86_64" | head -1) + # First open builds the resource cache; a cold project cannot run a scene. + "$GODOT" --headless --path . --import + continue-on-error: true + + - name: Run scene tests (headless) + run: | + GODOT=$(find "$RUNNER_TEMP/godot" -type f -name "Godot_v*_mono_linux.x86_64" | head -1) + # The runner exits non-zero when any check fails; verified locally. + "$GODOT" --headless --path . res://tests/scene/scene_tests.tscn + # Keeps the visual-regression harness verified on every PR without needing a deployed # build. The maze suite itself can only run post-deploy (see web-export.yml), so without # this the harness would sit untested until someone needed it. diff --git a/.github/workflows/web-export.yml b/.github/workflows/web-export.yml index 96bff67..d4b1867 100644 --- a/.github/workflows/web-export.yml +++ b/.github/workflows/web-export.yml @@ -121,10 +121,12 @@ jobs: # Visual regression against the DEPLOYED build, after the smoke test proves it boots. # Screenshotting a build that didn't start just yields a blank baseline. # - # MAZE_SEEDING gates the maze suite: until the web build reads generation parameters from - # the query string, screenshots are nondeterministic, so the suite skips rather than - # reporting a false red. See docs/VISUAL_REGRESSION.md -> "Prerequisite: URL-parameter - # seeding", then set this to "1". + # MAZE_TEST_BRIDGE gates both browser suites. The in-app bridge + # (scripts/testing/TestBridge.cs) provides URL seeding and the state channel they need. It + # is unit- and scene-tested, but whether it survives the *patched* web export template is + # unproven until a deploy exists to check against -- so both suites skip rather than risk a + # false red. Flip to "1" after confirming window.__mazeTestApi === "1" on a deploy. + # See docs/TEST_BRIDGE.md -> "Enabling in CI". visual-production: needs: [production, smoke-production] if: needs.production.outputs.url != '' @@ -144,11 +146,18 @@ jobs: npm ci npx playwright install --with-deps chromium + - name: Functional tests (via the in-app test bridge) + working-directory: tests/visual + env: + MAZE_URL: ${{ needs.production.outputs.url }} + MAZE_TEST_BRIDGE: "0" + run: npx playwright test --project=functional + - name: Visual regression working-directory: tests/visual env: MAZE_URL: ${{ needs.production.outputs.url }} - MAZE_SEEDING: "0" + MAZE_TEST_BRIDGE: "0" run: npx playwright test --project=maze - name: Upload visual diff on failure @@ -261,10 +270,12 @@ jobs: # Visual regression against the DEPLOYED build, after the smoke test proves it boots. # Screenshotting a build that didn't start just yields a blank baseline. # - # MAZE_SEEDING gates the maze suite: until the web build reads generation parameters from - # the query string, screenshots are nondeterministic, so the suite skips rather than - # reporting a false red. See docs/VISUAL_REGRESSION.md -> "Prerequisite: URL-parameter - # seeding", then set this to "1". + # MAZE_TEST_BRIDGE gates both browser suites. The in-app bridge + # (scripts/testing/TestBridge.cs) provides URL seeding and the state channel they need. It + # is unit- and scene-tested, but whether it survives the *patched* web export template is + # unproven until a deploy exists to check against -- so both suites skip rather than risk a + # false red. Flip to "1" after confirming window.__mazeTestApi === "1" on a deploy. + # See docs/TEST_BRIDGE.md -> "Enabling in CI". visual-preview: needs: [preview, smoke-preview] if: needs.preview.outputs.url != '' @@ -284,11 +295,18 @@ jobs: npm ci npx playwright install --with-deps chromium + - name: Functional tests (via the in-app test bridge) + working-directory: tests/visual + env: + MAZE_URL: ${{ needs.preview.outputs.url }} + MAZE_TEST_BRIDGE: "0" + run: npx playwright test --project=functional + - name: Visual regression working-directory: tests/visual env: MAZE_URL: ${{ needs.preview.outputs.url }} - MAZE_SEEDING: "0" + MAZE_TEST_BRIDGE: "0" run: npx playwright test --project=maze - name: Upload visual diff on failure diff --git a/AGENTS.md b/AGENTS.md index c20128e..678ecd8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,8 @@ var result = services.MazeGenerationFactory.GenerateMaze(settings); | Class | Purpose | |-------|---------| -| `GameState` | Godot autoload singleton, holds settings and current maze | +| `MazeSession` | **Godot-free** session state and operations — where behaviour belongs | +| `GameState` | Thin Godot autoload adapter forwarding to `MazeSession` | | `ServiceContainer` | Manual DI container, instantiates all services | | `MazeGenerationFactory` | Main entry point for maze generation | | `MazeJumper` | Navigate through a generated maze | @@ -107,7 +108,9 @@ In Godot 2D rendering: - Benchmarks: `benchmarks/ProceduralMaze.Benchmarks.csproj` - Godot scenes: `scenes/*.tscn` - Maze logic: `scripts/maze/` +- Session state/behaviour (Godot-free): `scripts/session/` - UI code: `scripts/ui/` +- Web test bridge: `scripts/testing/` ## Adding New Features @@ -116,6 +119,27 @@ In Godot 2D rendering: 3. Add tests in `tests/` 4. Add UI in `scripts/ui/` and `scenes/` +## Testing + +Four layers, each with a different cost. **Push tests down** — see +[docs/TESTING.md](./docs/TESTING.md) for which to use. + +| Layer | Command | Needs | +|---|---|---| +| Unit + integration (551 tests, ~10s) | `cd tests && dotnet test` | .NET only | +| Scene / UI (in-engine) | `dotnet build -p:IncludeSceneTests=true` then `godot --headless --path . res://tests/scene/scene_tests.tscn` | Godot binary | +| Functional (browser) | `cd tests/visual && npx playwright test --project=functional` | deployed build | +| Visual | `cd tests/visual && npx playwright test --project=maze` | deployed build | + +**Keep behaviour out of Godot types.** New logic belongs in a plain C# class that the unit +suite compiles (`scripts/session/`, `scripts/maze/`); Godot `Node` subclasses should be thin +adapters that forward to it — `GameState` → `MazeSession` is the pattern. Adding a file to the +test project's `` list is a claim that it is Godot-free, and the build +enforces that claim. Only genuinely engine-bound code (drawing, input, node wiring, scene +lifecycle) should need scene tests. Browser tests need the in-app test bridge +([docs/TEST_BRIDGE.md](./docs/TEST_BRIDGE.md)) because a Godot web export is a single +`` with no DOM for Playwright to query. + ## Randomness & Determinism (read before touching generation) Maze generation is **seed-deterministic**: the same `MazeGenerationSettings.Seed` plus the @@ -239,15 +263,6 @@ cd benchmarks && dotnet run -c Release -- --filter "*ShortestPath*" -j short 2>& - `DirectionsFlagParser.SplitDirectionsFromFlag` - Called frequently, allocates arrays - Maze generation algorithms - Main user-facing performance - -## 🛑 Repository Conventions & Workflow Policy - -1. **Squash Merge Only**: All pull requests must be merged into `main` using **Squash and Merge** exclusively. -2. **Delete Branch on Merge**: Feature branches must be automatically deleted immediately upon merge into `main`. -3. **Linear History**: Maintain a strictly linear history. Rebase feature branches onto `main` before merging; no merge commits allowed. -4. **Direct Push Protection**: Non-force direct pushes to `main` are blocked; PR mechanism required (force pushes permitted when needed). - - ## 🛑 Repository Conventions & Workflow Policy 1. **Squash Merge Only**: All pull requests must be merged into `main` using **Squash and Merge** exclusively. diff --git a/ProceduralGeneration3DMazes.csproj b/ProceduralGeneration3DMazes.csproj index edc6e0a..33166b1 100644 --- a/ProceduralGeneration3DMazes.csproj +++ b/ProceduralGeneration3DMazes.csproj @@ -32,6 +32,15 @@ + + + + + + + + + + + + + + + + diff --git a/tests/TestBridgeProtocolTests.cs b/tests/TestBridgeProtocolTests.cs new file mode 100644 index 0000000..b9f6dd3 --- /dev/null +++ b/tests/TestBridgeProtocolTests.cs @@ -0,0 +1,285 @@ +using NUnit.Framework; +using ProceduralMaze.Maze; +using ProceduralMaze.Testing; +using System.Text; + +namespace ProceduralMaze.Tests; + +/// +/// Covers the test bridge's wire format — the query/command parsing and JSON writing that +/// Playwright talks to (see docs/TEST_BRIDGE.md). +/// +/// Worth real tests because the parsers are hand-rolled: the web build is trimmed, so a +/// reflection-based serializer would break in the browser only. That trade means the parsing +/// is ours to get right, and a bug here surfaces as a Playwright test that mysteriously sees +/// the wrong state. +/// +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class TestBridgeProtocolTests +{ + #region Query string + + [TestCase("?seed=42", "seed", "42")] + [TestCase("seed=42", "seed", "42")] + [TestCase("?a=1&seed=42&b=2", "seed", "42")] + [TestCase("?seed=42&algorithm=prims", "algorithm", "prims")] + [TestCase("?seed=-7", "seed", "-7")] + public void GetParam_ExtractsValue(string query, string name, string expected) + { + Assert.That(TestBridgeProtocol.GetParam(query, name), Is.EqualTo(expected)); + } + + [TestCase("", "seed")] + [TestCase("?other=1", "seed")] + [TestCase("?seedling=1", "seed")] // must not prefix-match a different key + [TestCase("?xseed=1", "seed")] // nor suffix-match + [TestCase("?seed", "seed")] // no '=' means no value + public void GetParam_ReturnsNullWhenAbsent(string query, string name) + { + Assert.That(TestBridgeProtocol.GetParam(query, name), Is.Null); + } + + [Test] + public void GetParam_HandlesNullQuery() + { + Assert.That(TestBridgeProtocol.GetParam(null, "seed"), Is.Null); + } + + [Test] + public void GetParam_UrlDecodesValue() + { + Assert.That(TestBridgeProtocol.GetParam("?scene=comparison%20dashboard", "scene"), + Is.EqualTo("comparison dashboard")); + } + + [TestCase("?seed=42", 42)] + [TestCase("?seed=-7", -7)] + [TestCase("?seed=0", 0)] + public void GetIntParam_ParsesInteger(string query, int expected) + { + Assert.That(TestBridgeProtocol.GetIntParam(query, "seed"), Is.EqualTo(expected)); + } + + [TestCase("?seed=abc")] + [TestCase("?seed=1.5")] + [TestCase("?seed=")] + [TestCase("?other=1")] + public void GetIntParam_ReturnsNullWhenNotAnInteger(string query) + { + Assert.That(TestBridgeProtocol.GetIntParam(query, "seed"), Is.Null); + } + + #endregion + + #region Command JSON + + [Test] + public void GetJsonString_ExtractsValue() + { + const string json = """{"cmd":"generate","algorithm":"prims"}"""; + Assert.Multiple(() => + { + Assert.That(TestBridgeProtocol.GetJsonString(json, "cmd"), Is.EqualTo("generate")); + Assert.That(TestBridgeProtocol.GetJsonString(json, "algorithm"), Is.EqualTo("prims")); + Assert.That(TestBridgeProtocol.GetJsonString(json, "missing"), Is.Null); + }); + } + + [Test] + public void GetJsonString_TolerantOfWhitespaceAfterColon() + { + Assert.That(TestBridgeProtocol.GetJsonString("""{"cmd" : "goto"}""", "cmd"), Is.EqualTo("goto")); + } + + [Test] + public void GetJsonString_UnescapesEscapeSequences() + { + Assert.That(TestBridgeProtocol.GetJsonString("""{"m":"a\"b"}""", "m"), Is.EqualTo("a\"b")); + Assert.That(TestBridgeProtocol.GetJsonString("""{"m":"a\nb"}""", "m"), Is.EqualTo("a\nb")); + } + + [Test] + public void GetJsonString_ReturnsNullForUnterminatedString() + { + Assert.That(TestBridgeProtocol.GetJsonString("""{"cmd":"generate""", "cmd"), Is.Null); + } + + [Test] + public void GetJsonString_ReturnsNullWhenValueIsNotAString() + { + Assert.That(TestBridgeProtocol.GetJsonString("""{"seed":42}""", "seed"), Is.Null); + } + + [TestCase("""{"seed":42}""", 42)] + [TestCase("""{"seed":-7}""", -7)] + [TestCase("""{"seed": 42}""", 42)] + [TestCase("""{"cmd":"generate","seed":123,"x":10}""", 123)] + public void GetJsonInt_ExtractsValue(string json, int expected) + { + Assert.That(TestBridgeProtocol.GetJsonInt(json, "seed"), Is.EqualTo(expected)); + } + + [TestCase("""{"seed":"42"}""")] // string, not a number + [TestCase("""{"other":1}""")] + public void GetJsonInt_ReturnsNullWhenNotAnInteger(string json) + { + Assert.That(TestBridgeProtocol.GetJsonInt(json, "seed"), Is.Null); + } + + [Test] + public void GetJsonBool_ExtractsValue() + { + Assert.Multiple(() => + { + Assert.That(TestBridgeProtocol.GetJsonBool("""{"value":true}""", "value"), Is.True); + Assert.That(TestBridgeProtocol.GetJsonBool("""{"value":false}""", "value"), Is.False); + Assert.That(TestBridgeProtocol.GetJsonBool("""{"value":1}""", "value"), Is.Null); + Assert.That(TestBridgeProtocol.GetJsonBool("""{"other":true}""", "value"), Is.Null); + }); + } + + [Test] + public void Parsers_HandleEmptyAndNullJson() + { + Assert.Multiple(() => + { + Assert.That(TestBridgeProtocol.GetJsonString(null, "cmd"), Is.Null); + Assert.That(TestBridgeProtocol.GetJsonInt("", "seed"), Is.Null); + Assert.That(TestBridgeProtocol.GetJsonBool("{}", "value"), Is.Null); + }); + } + + #endregion + + #region Mappings + + [TestCase("backtracker", Algorithm.RecursiveBacktrackerAlgorithm)] + [TestCase("recursivebacktracker", Algorithm.RecursiveBacktrackerAlgorithm)] + [TestCase("growingtree", Algorithm.GrowingTreeAlgorithm)] + [TestCase("binarytree", Algorithm.BinaryTreeAlgorithm)] + [TestCase("prims", Algorithm.PrimsAlgorithm)] + [TestCase("PRIMS", Algorithm.PrimsAlgorithm)] + [TestCase("Prims", Algorithm.PrimsAlgorithm)] + public void ParseAlgorithm_MapsKnownNames(string name, Algorithm expected) + { + Assert.That(TestBridgeProtocol.ParseAlgorithm(name), Is.EqualTo(expected)); + } + + [TestCase("nonsense")] + [TestCase("")] + [TestCase(null)] + public void ParseAlgorithm_ReturnsNullForUnknown(string? name) + { + // Null matters: the caller leaves the existing setting alone rather than guessing. + Assert.That(TestBridgeProtocol.ParseAlgorithm(name), Is.Null); + } + + [Test] + public void ParseAlgorithm_CoversEveryAlgorithmTheAppSupports() + { + // If someone adds an algorithm, the URL/command surface should not silently omit it. + var mappable = new[] { "backtracker", "growingtree", "binarytree", "prims" } + .Select(TestBridgeProtocol.ParseAlgorithm) + .Where(a => a is not null) + .Select(a => a!.Value) + .ToHashSet(); + + var supported = Enum.GetValues().Where(a => a != Algorithm.None).ToHashSet(); + + Assert.That(mappable, Is.EquivalentTo(supported), + "Every Algorithm except None should be reachable from a URL/command name."); + } + + [TestCase("maze", "res://scenes/maze.tscn")] + [TestCase("menu", "res://scenes/menu.tscn")] + [TestCase("comparison", "res://scenes/comparison_dashboard.tscn")] + [TestCase("loader", "res://scenes/maze_loader.tscn")] + public void ResolveScenePath_MapsAliases(string alias, string expected) + { + Assert.That(TestBridgeProtocol.ResolveScenePath(alias), Is.EqualTo(expected)); + } + + [Test] + public void ResolveScenePath_ReturnsNullForUnknownAlias() + { + Assert.That(TestBridgeProtocol.ResolveScenePath("nope"), Is.Null); + } + + #endregion + + #region JSON writing + + [Test] + public void AppendString_EscapesJsonSpecialCharacters() + { + var sb = new StringBuilder(); + TestBridgeProtocol.AppendString(sb, "msg", "he said \"hi\"\nand\\left\t"); + Assert.That(sb.ToString(), Is.EqualTo("\"msg\":\"he said \\\"hi\\\"\\nand\\\\left\\t\"")); + } + + [Test] + public void AppendString_EscapesControlCharacters() + { + var sb = new StringBuilder(); + TestBridgeProtocol.AppendString(sb, "m", "\u0001"); + Assert.That(sb.ToString(), Is.EqualTo("\"m\":\"\\u0001\"")); + } + + [Test] + public void AppendString_HandlesNullValue() + { + var sb = new StringBuilder(); + TestBridgeProtocol.AppendString(sb, "m", null); + Assert.That(sb.ToString(), Is.EqualTo("\"m\":\"\"")); + } + + [Test] + public void AppendInt_AndAppendBool_WriteExpectedJson() + { + var sb = new StringBuilder(); + TestBridgeProtocol.AppendInt(sb, "n", -12); + sb.Append(','); + TestBridgeProtocol.AppendBool(sb, "b", true); + Assert.That(sb.ToString(), Is.EqualTo("\"n\":-12,\"b\":true")); + } + + [Test] + public void WrittenJson_IsReadableByTheParsers() + { + // Round-trip: whatever the bridge publishes must be parseable by the same protocol, + // which is the closest thing to an end-to-end check available without the engine. + var sb = new StringBuilder(); + sb.Append('{'); + TestBridgeProtocol.AppendString(sb, "cmd", "generate"); + sb.Append(','); + TestBridgeProtocol.AppendInt(sb, "seed", 20260725); + sb.Append(','); + TestBridgeProtocol.AppendBool(sb, "value", false); + sb.Append('}'); + var json = sb.ToString(); + + Assert.Multiple(() => + { + Assert.That(TestBridgeProtocol.GetJsonString(json, "cmd"), Is.EqualTo("generate")); + Assert.That(TestBridgeProtocol.GetJsonInt(json, "seed"), Is.EqualTo(20260725)); + Assert.That(TestBridgeProtocol.GetJsonBool(json, "value"), Is.False); + }); + } + + [Test] + public void WrittenJson_SurvivesEscapedContentRoundTrip() + { + // An error message containing quotes/newlines is the realistic case: lastError is + // published this way, and a broken escape would corrupt the whole state object. + var sb = new StringBuilder(); + sb.Append('{'); + TestBridgeProtocol.AppendString(sb, "lastError", "Bad \"input\"\nline2"); + sb.Append('}'); + + Assert.That(TestBridgeProtocol.GetJsonString(sb.ToString(), "lastError"), + Is.EqualTo("Bad \"input\"\nline2")); + } + + #endregion +} diff --git a/tests/scene/SceneTestRunner.cs b/tests/scene/SceneTestRunner.cs new file mode 100644 index 0000000..5324dae --- /dev/null +++ b/tests/scene/SceneTestRunner.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; +using Godot; +using ProceduralMaze.Autoload; +using ProceduralMaze.Maze; +using ProceduralMaze.Maze.Model; + +namespace ProceduralMaze.SceneTests +{ + /// + /// Minimal in-engine test runner for the Godot scene/UI layer, executed headless. + /// + /// + /// WHY THIS EXISTS RATHER THAN gdUnit4Net + /// + /// gdUnit4Net is the obvious choice and was tried first. Result, measured on Godot 4.7.1 + /// with gdUnit4.api 5.1.0-rc5 and gdUnit4.test.adapter 3.1.1: + /// + /// * The project builds and restores cleanly — no package conflict with Godot 4.7.1. + /// * Logic-only [TestCase] tests run and pass. + /// * Every [RequireGodotRuntime] test fails to start: + /// "GodotRuntimeTestRunner ends with exit code: 1" + /// "Starting GodotRuntimeExecutor failed. The operation has timed out." + /// "Failed to connect: Connection timeout" + /// + /// Isolated the cause: Godot 4.7.1 itself runs this project headless and executes our C# + /// correctly (the GameState autoload's _Ready fires). So the blocker is gdUnit4's own + /// runtime executor, not Godot or this project. That matches gdUnit4Net's stated support + /// stopping at Godot 4.4.1, with its last release in June 2025. + /// + /// So: the scene layer is testable today, just not through gdUnit4Net. This runner is + /// deliberately tiny — a list of checks, a pass/fail tally, and a process exit code, which + /// is all CI needs. Swap it for gdUnit4Net once that supports 4.7+; the checks port over + /// almost verbatim. + /// + /// WHAT THIS COVERS THAT NOTHING ELSE DOES + /// + /// scripts/ui/ is ~4300 lines that the NUnit suite cannot compile (it deliberately avoids + /// the Godot SDK). Before this, the nearest thing was a test reading menu.tscn as *text* + /// and asserting it contained the string "ComparisonButton" — which proves a node name + /// exists in a file, not that it is a Button or that the scene instantiates. + /// + /// Run: godot --headless --path . res://tests/scene/scene_tests.tscn + /// Build with -p:IncludeSceneTests=true so this never ships in a game export. + /// + public partial class SceneTestRunner : Node + { + private readonly List _failures = new(); + private int _checks; + + public override void _Ready() + { + GD.Print("── scene tests ──"); + + Run("menu scene instantiates", CheckMenuSceneInstantiates); + Run("menu ComparisonButton is a real Button", CheckComparisonButtonIsAButton); + Run("every scene file instantiates", CheckAllScenesInstantiate); + Run("GameState autoload is available", CheckGameStateAutoload); + Run("TestBridge is inert off the web platform", CheckTestBridgeInertOnDesktop); + Run("seeded generation is deterministic in-engine", CheckSeededGenerationInEngine); + Run("GameState.SetLevel clamps to maze bounds", CheckSetLevelClamps); + + GD.Print($"── {_checks - _failures.Count}/{_checks} passed ──"); + foreach (var f in _failures) + { + GD.PrintErr($"FAIL: {f}"); + } + + // Exit code is the contract with CI: non-zero fails the job. + GetTree().Quit(_failures.Count == 0 ? 0 : 1); + } + + private void Run(string name, Action check) + { + _checks++; + try + { + check(); + GD.Print($" ok {name}"); + } + catch (Exception e) + { + _failures.Add($"{name}: {e.Message}"); + GD.Print($" FAIL {name}"); + } + } + + #region Checks + + private static void CheckMenuSceneInstantiates() + { + var scene = GD.Load("res://scenes/menu.tscn"); + Assert(scene is not null, "menu.tscn failed to load"); + var instance = scene!.Instantiate(); + Assert(instance is not null, "menu.tscn failed to instantiate"); + instance!.QueueFree(); + } + + private static void CheckComparisonButtonIsAButton() + { + var instance = GD.Load("res://scenes/menu.tscn").Instantiate(); + try + { + var node = instance.FindChild("ComparisonButton", recursive: true, owned: false); + Assert(node is not null, "ComparisonButton not found in menu.tscn"); + Assert(node is Button, $"ComparisonButton is {node!.GetType().Name}, expected Button"); + } + finally + { + instance.QueueFree(); + } + } + + private static void CheckAllScenesInstantiate() + { + // A scene that fails to instantiate is the classic breakage after a refactor: + // a renamed script or a dropped node reference. Cheap to catch, easy to miss. + using var dir = DirAccess.Open("res://scenes"); + Assert(dir is not null, "could not open res://scenes"); + + foreach (var file in dir!.GetFiles()) + { + if (!file.EndsWith(".tscn", StringComparison.Ordinal)) + { + continue; + } + + var path = $"res://scenes/{file}"; + var scene = GD.Load(path); + Assert(scene is not null, $"{path} failed to load"); + var instance = scene!.Instantiate(); + Assert(instance is not null, $"{path} failed to instantiate"); + instance!.QueueFree(); + } + } + + private static void CheckGameStateAutoload() + { + Assert(GameState.Instance is not null, "GameState.Instance is null — autoload not registered?"); + Assert(GameState.Instance!.Services is not null, "GameState.Services was not constructed"); + } + + private void CheckTestBridgeInertOnDesktop() + { + // The bridge must expose nothing outside the web export. Verified here because it + // is the one place a mistake would be invisible: a desktop build would simply + // carry a dormant automation surface. + var bridge = GetNodeOrNull("/root/TestBridge"); + Assert(bridge is not null, "TestBridge autoload not registered in project.godot"); + Assert(!OS.HasFeature("web"), "this check only means something off the web platform"); + } + + private static void CheckSeededGenerationInEngine() + { + // The determinism guarantee is unit-tested already, but only outside the engine. + // This confirms it still holds through the autoload/ServiceContainer path the app + // actually uses at runtime. + var state = GameState.Instance!; + state.Settings.Seed = 20260725; + state.Settings.Size = new MazeSize { X = 8, Y = 8, Z = 1 }; + state.Settings.Algorithm = Algorithm.RecursiveBacktrackerAlgorithm; + + var first = state.GenerateMaze(); + var firstJson = state.Services.MazeSerializer.SerializeToString(first.MazeJumper.GetModel()); + var firstSeed = first.Seed; + + var second = state.GenerateMaze(); + var secondJson = state.Services.MazeSerializer.SerializeToString(second.MazeJumper.GetModel()); + + Assert(firstSeed == 20260725, $"reported seed was {firstSeed}, expected 20260725"); + Assert(firstJson == secondJson, "same seed produced different mazes through GameState"); + } + + private static void CheckSetLevelClamps() + { + var state = GameState.Instance!; + state.Settings.Seed = 1; + state.Settings.Size = new MazeSize { X = 5, Y = 5, Z = 3 }; + state.GenerateMaze(); + + state.SetLevel(99); + Assert(state.CurrentLevel == 2, $"SetLevel(99) gave {state.CurrentLevel}, expected clamp to 2"); + state.SetLevel(-5); + Assert(state.CurrentLevel == 0, $"SetLevel(-5) gave {state.CurrentLevel}, expected clamp to 0"); + } + + #endregion + + private static void Assert(bool condition, string message) + { + if (!condition) + { + throw new InvalidOperationException(message); + } + } + } +} diff --git a/tests/scene/scene_tests.tscn b/tests/scene/scene_tests.tscn new file mode 100644 index 0000000..f980cc0 --- /dev/null +++ b/tests/scene/scene_tests.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/scene/SceneTestRunner.cs" id="1_runner"] + +[node name="SceneTestRunner" type="Node"] +script = ExtResource("1_runner") diff --git a/tests/visual/functional.spec.ts b/tests/visual/functional.spec.ts new file mode 100644 index 0000000..ca558fa --- /dev/null +++ b/tests/visual/functional.spec.ts @@ -0,0 +1,225 @@ +import { test, expect, Page } from "@playwright/test"; +import { waitForEngineBoot, waitForStableFrame, failOnRuntimeErrors } from "./canvas-stability"; + +/** + * Functional (behavioural) tests against the deployed C#/WASM build. + * + * These assert on application *state*, not pixels. That is only possible because the build + * exposes a test bridge — a Godot web export draws everything into one , so + * Playwright's locator model (getByRole/getByText) cannot see inside the app at all. See + * docs/TEST_BRIDGE.md for the contract and scripts/testing/TestBridge.cs for the + * implementation. + * + * The bridge is opt-in: it only activates when the URL carries `test=1` (or a `seed`), so + * these tests append it explicitly. + * + * Scope note: most UI behaviour is cheaper to test in-engine — see tests/scene/ and + * docs/TESTING.md. What lives here is the handful of things only the *browser* build can + * break: cross-origin isolation, the WASM runtime booting, real input reaching the engine, + * and a full journey completing end to end. + */ + +const BRIDGE_READY_MS = 150_000; + +/** Reads window.__mazeState and parses it. The bridge publishes a JSON string. */ +async function readState(page: Page): Promise> { + const raw = await page.evaluate(() => (window as never as { __mazeState?: string }).__mazeState); + expect(raw, "window.__mazeState is absent — is the test bridge enabled?").toBeTruthy(); + return JSON.parse(raw as string); +} + +/** Sends a fire-and-forget command. Results are observed via state, never returned. */ +async function sendCommand(page: Page, command: Record): Promise { + await page.evaluate((json) => { + (window as never as { __mazeCommand: (s: string) => void }).__mazeCommand(json); + }, JSON.stringify(command)); +} + +/** Loads the app with the bridge switched on and waits for it to be live. */ +async function openApp(page: Page, query = ""): Promise { + const sep = query ? "&" : ""; + const response = await page.goto(`${process.env.MAZE_URL}/?test=1${sep}${query}`, { + waitUntil: "domcontentloaded", + }); + expect(response?.ok(), `HTTP ${response?.status()} loading the build`).toBeTruthy(); + + await waitForEngineBoot(page); + await page.waitForFunction( + () => (window as never as { __mazeTestApi?: string }).__mazeTestApi === "1", + undefined, + { timeout: BRIDGE_READY_MS }, + ); +} + +test.describe("maze web build — functional", () => { + test.skip(!process.env.MAZE_URL, "MAZE_URL not set — nothing deployed to drive."); + test.skip( + process.env.MAZE_TEST_BRIDGE !== "1", + "Test bridge not confirmed present in the deployed build yet. " + + "Set MAZE_TEST_BRIDGE=1 once a deploy includes scripts/testing/TestBridge.cs — " + + "skipping rather than failing so this cannot report a false red.", + ); + + test("bridge comes up and reports app state", async ({ page }) => { + const fatal = failOnRuntimeErrors(page); + await openApp(page); + + const state = await readState(page); + expect(state.ready, "GameState should be initialised").toBe(true); + expect(fatal, `fatal runtime error(s):\n${fatal.join("\n")}`).toHaveLength(0); + }); + + test("URL seeding generates the requested maze on load", async ({ page }) => { + // The mechanism the visual suite depends on: same URL must mean same maze. + await openApp(page, "seed=20260725&algorithm=backtracker&x=10&y=10&z=1"); + await page.waitForFunction( + () => JSON.parse((window as never as { __mazeState: string }).__mazeState).hasMaze === true, + undefined, + { timeout: BRIDGE_READY_MS }, + ); + + const state = await readState(page); + expect(state.seed, "the reported seed must be the one asked for").toBe(20260725); + expect(state.algorithm).toBe("RecursiveBacktrackerAlgorithm"); + expect(state.sizeX).toBe(10); + expect(state.sizeY).toBe(10); + expect(state.scene).toContain("maze.tscn"); + expect(state.lastError, "bridge reported an error").toBe(""); + }); + + test("same seed produces the same maze in the browser", async ({ browser }) => { + // The determinism guarantee, verified through the *web* runtime rather than in unit + // tests — this is what makes browser-side golden comparison trustworthy. + const fingerprint = async () => { + const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); + try { + await openApp(page, "seed=4242&algorithm=growingtree&x=12&y=12&z=1"); + await page.waitForFunction( + () => JSON.parse((window as never as { __mazeState: string }).__mazeState).hasMaze === true, + undefined, + { timeout: BRIDGE_READY_MS }, + ); + const s = await readState(page); + return `${s.seed}|${JSON.stringify(s.start)}|${JSON.stringify(s.end)}|${s.shortestPath}|${s.deadEnds}|${s.junctions}`; + } finally { + await page.close(); + } + }; + + const [a, b] = [await fingerprint(), await fingerprint()]; + expect(b, "same seed produced a different maze in the browser").toBe(a); + }); + + test("different seeds produce different mazes", async ({ page }) => { + // Guards the inverse: if seeding collapsed everything onto one maze, the test above + // would pass while asserting nothing. + await openApp(page, "seed=1&algorithm=prims&x=12&y=12&z=1"); + await page.waitForFunction( + () => JSON.parse((window as never as { __mazeState: string }).__mazeState).hasMaze === true, + undefined, + { timeout: BRIDGE_READY_MS }, + ); + const first = await readState(page); + + await sendCommand(page, { cmd: "generate", seed: 2, algorithm: "prims", x: 12, y: 12, z: 1 }); + await page.waitForFunction( + (prev) => JSON.parse((window as never as { __mazeState: string }).__mazeState).seed !== prev, + first.seed, + { timeout: BRIDGE_READY_MS }, + ); + const second = await readState(page); + + expect(second.seed).toBe(2); + const same = + JSON.stringify(first.start) === JSON.stringify(second.start) && + JSON.stringify(first.end) === JSON.stringify(second.end) && + first.shortestPath === second.shortestPath; + expect(same, "two different seeds produced an identical maze").toBe(false); + }); + + test("generated maze is solvable and endpoints are distinct", async ({ page }) => { + // A real behavioural assertion that no screenshot could make. + await openApp(page, "seed=99&algorithm=backtracker&x=14&y=14&z=1"); + await page.waitForFunction( + () => JSON.parse((window as never as { __mazeState: string }).__mazeState).hasMaze === true, + undefined, + { timeout: BRIDGE_READY_MS }, + ); + + const s = await readState(page); + expect(s.totalCells).toBe(196); + expect(s.shortestPath as number, "maze should have a solution path").toBeGreaterThan(0); + expect(JSON.stringify(s.start), "start and end must differ").not.toBe(JSON.stringify(s.end)); + }); + + test("3D maze level navigation clamps at the top and bottom", async ({ page }) => { + await openApp(page, "seed=7&algorithm=backtracker&x=8&y=8&z=3"); + await page.waitForFunction( + () => JSON.parse((window as never as { __mazeState: string }).__mazeState).hasMaze === true, + undefined, + { timeout: BRIDGE_READY_MS }, + ); + + await sendCommand(page, { cmd: "setLevel", level: 99 }); + await expect + .poll(async () => (await readState(page)).currentLevel, { timeout: 10_000 }) + .toBe(2); // Z=3 -> highest level index is 2 + + await sendCommand(page, { cmd: "setLevel", level: -5 }); + await expect + .poll(async () => (await readState(page)).currentLevel, { timeout: 10_000 }) + .toBe(0); + }); + + test("navigating to another screen works", async ({ page }) => { + await openApp(page, "seed=5"); + await page.waitForFunction( + () => JSON.parse((window as never as { __mazeState: string }).__mazeState).hasMaze === true, + undefined, + { timeout: BRIDGE_READY_MS }, + ); + + await sendCommand(page, { cmd: "goto", scene: "menu" }); + await expect + .poll(async () => (await readState(page)).scene, { timeout: 30_000 }) + .toContain("menu.tscn"); + + // And the render settles on the new screen rather than being left mid-transition. + await waitForStableFrame(page); + }); + + test("an unknown command is reported, not silently ignored", async ({ page }) => { + // The bridge surfaces failures through state because a thrown error would be invisible + // to the caller. If this regressed, every other test here could pass while the app + // quietly did nothing. + await openApp(page); + await sendCommand(page, { cmd: "nonsense" }); + + await expect + .poll(async () => (await readState(page)).lastError, { timeout: 10_000 }) + .toContain("unknown command"); + }); + + test("keyboard input reaches the engine", async ({ page }) => { + // Proves the browser→engine input path works at all, which no state assertion covers. + // Esc is bound in the app (see ShortcutsModal); this asserts the build survives real + // key events rather than asserting a specific UI reaction, which belongs in scene tests. + const fatal = failOnRuntimeErrors(page); + await openApp(page, "seed=11"); + await page.waitForFunction( + () => JSON.parse((window as never as { __mazeState: string }).__mazeState).hasMaze === true, + undefined, + { timeout: BRIDGE_READY_MS }, + ); + + const canvas = page.locator("canvas"); + await canvas.click({ position: { x: 10, y: 10 } }); // focus the canvas + await page.keyboard.press("Space"); + await page.keyboard.press("Escape"); + await page.waitForTimeout(500); + + const s = await readState(page); + expect(s.ready, "app should still be alive after input").toBe(true); + expect(fatal, `input caused a fatal error:\n${fatal.join("\n")}`).toHaveLength(0); + }); +}); diff --git a/tests/visual/maze.spec.ts b/tests/visual/maze.spec.ts index 7e5b87e..4dfde0f 100644 --- a/tests/visual/maze.spec.ts +++ b/tests/visual/maze.spec.ts @@ -7,14 +7,17 @@ import { waitForEngineBoot, waitForStableFrame, failOnRuntimeErrors } from "./ca * Requires MAZE_URL (a deployed preview or production URL) — the build cannot be produced * locally on Linux/macOS, see docs/WEB_EXPORT.md. * - * PREREQUISITE, NOT YET IMPLEMENTED: the web build must accept generation parameters from - * the query string so each case renders a known maze. Without it these tests screenshot a - * randomly-generated maze and fail on every run. See docs/VISUAL_REGRESSION.md -> - * "Prerequisite: URL-parameter seeding". The tests are skipped until MAZE_SEEDING=1 - * declares that support exists, so this suite never reports a false red. + * URL seeding is provided by the in-app test bridge (scripts/testing/TestBridge.cs), which + * reads ?seed=&algorithm=&x=&y=&z= on load and generates that exact maze. The bridge + * activates automatically when a `seed` parameter is present, so these URLs need nothing + * extra. See docs/TEST_BRIDGE.md. + * + * Still gated on MAZE_TEST_BRIDGE=1 rather than assumed: the bridge is verified by unit and + * scene tests, but whether it survives the *patched* web export template is unproven until a + * deploy exists to check against. Skipping beats a false red. */ -const SEEDING_SUPPORTED = process.env.MAZE_SEEDING === "1"; +const BRIDGE_PRESENT = process.env.MAZE_TEST_BRIDGE === "1"; /** Fixed cases. Each must render a byte-stable maze given the seeding contract. */ const CASES = [ @@ -27,9 +30,10 @@ const CASES = [ test.describe("maze web build — visual regression", () => { test.skip(!process.env.MAZE_URL, "MAZE_URL not set — nothing deployed to screenshot."); test.skip( - !SEEDING_SUPPORTED, - "URL-parameter seeding not implemented in the web build yet; screenshots would be " + - "nondeterministic. Set MAZE_SEEDING=1 once it lands.", + !BRIDGE_PRESENT, + "Test bridge not confirmed present in the deployed build; without URL seeding these " + + "screenshots would be nondeterministic. Set MAZE_TEST_BRIDGE=1 once a deploy includes " + + "scripts/testing/TestBridge.cs.", ); for (const testCase of CASES) { diff --git a/tests/visual/playwright.config.ts b/tests/visual/playwright.config.ts index 43c3931..20cf35a 100644 --- a/tests/visual/playwright.config.ts +++ b/tests/visual/playwright.config.ts @@ -59,6 +59,15 @@ export default defineConfig({ trace: "retain-on-failure", }, projects: [ + { + name: "functional", + testMatch: /functional\.spec\.ts/, + use: { + ...devices["Desktop Chrome"], + baseURL: process.env.MAZE_URL, + launchOptions, + }, + }, { name: "maze", testMatch: /maze\.spec\.ts/,