diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 6b4100a..2b94b95 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -18,6 +18,10 @@ concurrency:
jobs:
test:
runs-on: ubuntu-latest
+ # A pathological run once hung this job for 40 minutes before being cancelled with no
+ # diagnostic. The suite completes in ~10s; 15 minutes fails fast while leaving ample
+ # headroom for a cold restore on a slow runner.
+ timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -29,3 +33,38 @@ jobs:
- name: Test
run: dotnet test tests/ProceduralMaze.Tests.csproj -c Debug --nologo
+
+ # 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.
+ visual-harness:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+
+ - name: Install Playwright (chromium)
+ working-directory: tests/visual
+ run: |
+ npm ci
+ npx playwright install --with-deps chromium
+
+ - name: Visual harness self-test
+ working-directory: tests/visual
+ run: npx playwright test --project=harness-selftest
+
+ - name: Upload report on failure
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: visual-harness-report
+ path: |
+ tests/visual/playwright-report/
+ tests/visual/test-results/
+ if-no-files-found: ignore
diff --git a/.github/workflows/web-export.yml b/.github/workflows/web-export.yml
index 3d15b6d..96bff67 100644
--- a/.github/workflows/web-export.yml
+++ b/.github/workflows/web-export.yml
@@ -117,6 +117,50 @@ jobs:
SMOKE_URL: ${{ needs.production.outputs.url }}
run: node .github/smoke/smoke.mjs
+
+ # 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".
+ visual-production:
+ needs: [production, smoke-production]
+ if: needs.production.outputs.url != ''
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+
+ - name: Install Playwright (chromium)
+ working-directory: tests/visual
+ run: |
+ npm ci
+ npx playwright install --with-deps chromium
+
+ - name: Visual regression
+ working-directory: tests/visual
+ env:
+ MAZE_URL: ${{ needs.production.outputs.url }}
+ MAZE_SEEDING: "0"
+ run: npx playwright test --project=maze
+
+ - name: Upload visual diff on failure
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: visual-production-report
+ path: |
+ tests/visual/playwright-report/
+ tests/visual/test-results/
+ if-no-files-found: ignore
+
# ── Preview: on-demand via "/preview" comment on a PR (owner only) ──────────
preview:
if: >
@@ -213,3 +257,46 @@ jobs:
else
gh pr comment ${{ github.event.issue.number }} --repo ${{ github.repository }} --body "❌ Smoke test FAILED on the preview — the build served but did not boot correctly. Check the workflow logs."
fi
+
+ # 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".
+ visual-preview:
+ needs: [preview, smoke-preview]
+ if: needs.preview.outputs.url != ''
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+
+ - name: Install Playwright (chromium)
+ working-directory: tests/visual
+ run: |
+ npm ci
+ npx playwright install --with-deps chromium
+
+ - name: Visual regression
+ working-directory: tests/visual
+ env:
+ MAZE_URL: ${{ needs.preview.outputs.url }}
+ MAZE_SEEDING: "0"
+ run: npx playwright test --project=maze
+
+ - name: Upload visual diff on failure
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: visual-preview-report
+ path: |
+ tests/visual/playwright-report/
+ tests/visual/test-results/
+ if-no-files-found: ignore
diff --git a/.gitignore b/.gitignore
index e7f9a36..0af5f34 100644
--- a/.gitignore
+++ b/.gitignore
@@ -45,6 +45,7 @@ test-results/
playwright-report/
blob-report/
playwright/.cache/
+.last-run.json
# Local scratch / planning notes
todo.md
diff --git a/AGENTS.md b/AGENTS.md
index 2efa721..c20128e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -116,6 +116,39 @@ In Godot 2D rendering:
3. Add tests in `tests/`
4. Add UI in `scripts/ui/` and `scenes/`
+## Randomness & Determinism (read before touching generation)
+
+Maze generation is **seed-deterministic**: the same `MazeGenerationSettings.Seed` plus the
+same settings always produces the same maze. Golden-file regression testing depends on it.
+See [docs/REGRESSION_TESTING.md](./docs/REGRESSION_TESTING.md).
+
+**The rule: all randomness goes through an injected `IRandomValueGenerator`.**
+
+```csharp
+// Yes — injected, seeded, reproducible
+_randomValueGenerator.Shuffle(carvableDirections);
+var n = _randomValueGenerator.GetNext(0, size.X - 1); // INCLUSIVE range
+
+// No — process-global, unseedable, silently breaks reproducibility
+Random.Shared.Shuffle(directions);
+var r = new Random().Next(10);
+```
+
+Banned in `scripts/maze/`: `Random.Shared`, `new Random(`, `Guid.NewGuid`,
+`DateTime.Now/UtcNow` (use the injected `ISystemClock`). `RandomnessDisciplineTests`
+enforces this by scanning source and will fail the build with the offending line — it is not
+a style preference, it's the thing that keeps the seed meaningful.
+
+The only exempt files are `RandomValueGenerator.cs` and `SystemClock.cs`, the designated
+injected sources. Adding to that exemption list adds a global-state escape hatch.
+
+**Reproducing a bug:** every result carries `MazeGenerationResults.Seed`, including runs
+that didn't ask for a seed. Put that value in `settings.Seed` to regenerate the exact maze.
+
+**Threading:** a generator instance is deliberately not thread-safe — per-instance state is
+what makes seeding work. Give each concurrent pipeline its own `ServiceContainer`, as the
+test suite does. Nothing in the maze pipeline is currently concurrent.
+
## Web Export Constraints (read before adding BCL dependencies)
This project ships a browser build (`maze.ryankelly.dev`) via an **experimental**
diff --git a/benchmarks/HelperBenchmarks.cs b/benchmarks/HelperBenchmarks.cs
index a69e74d..ec5e389 100644
--- a/benchmarks/HelperBenchmarks.cs
+++ b/benchmarks/HelperBenchmarks.cs
@@ -6,7 +6,7 @@ namespace ProceduralMaze.Benchmarks;
///
/// Benchmarks for helper utility functions.
-/// Tests ArrayHelper.Shuffle and DirectionsFlagParser performance.
+/// Tests IRandomValueGenerator.Shuffle and DirectionsFlagParser performance.
///
[MemoryDiagnoser]
[ShortRunJob]
@@ -20,9 +20,14 @@ public class HelperBenchmarks
private List _largeList = null!;
private DirectionsFlagParser _parser = null!;
+ // The production shuffle path. Fixed seed so benchmark runs are comparable to each
+ // other rather than varying with whatever Random.Shared happened to produce.
+ private RandomValueGenerator _rng = null!;
+
[GlobalSetup]
public void Setup()
{
+ _rng = new RandomValueGenerator(seed: 1);
_smallArray = Enumerable.Range(0, 6).ToArray(); // Typical direction count
_mediumArray = Enumerable.Range(0, 100).ToArray();
_largeArray = Enumerable.Range(0, 10000).ToArray();
@@ -51,38 +56,38 @@ public void IterationSetup()
[Benchmark(Baseline = true)]
public void Shuffle_Array_Small_6()
{
- ArrayHelper.Shuffle(_smallArray);
+ _rng.Shuffle(_smallArray);
}
[Benchmark]
public void Shuffle_Array_Medium_100()
{
- ArrayHelper.Shuffle(_mediumArray);
+ _rng.Shuffle(_mediumArray);
}
[Benchmark]
public void Shuffle_Array_Large_10000()
{
- ArrayHelper.Shuffle(_largeArray);
+ _rng.Shuffle(_largeArray);
}
// List shuffle benchmarks
[Benchmark]
public void Shuffle_List_Small_6()
{
- ArrayHelper.Shuffle(_smallList);
+ _rng.Shuffle(_smallList);
}
[Benchmark]
public void Shuffle_List_Medium_100()
{
- ArrayHelper.Shuffle(_mediumList);
+ _rng.Shuffle(_mediumList);
}
[Benchmark]
public void Shuffle_List_Large_10000()
{
- ArrayHelper.Shuffle(_largeList);
+ _rng.Shuffle(_largeList);
}
// DirectionsFlagParser benchmarks
diff --git a/docs/REGRESSION_TESTING.md b/docs/REGRESSION_TESTING.md
new file mode 100644
index 0000000..bb9d9f2
--- /dev/null
+++ b/docs/REGRESSION_TESTING.md
@@ -0,0 +1,168 @@
+# Automated Regression Testing
+
+**Status:** Foundation landed (seeding + determinism guards). Golden-file suite designed
+below, **not yet built**.
+
+## Why the existing suite can't catch regressions
+
+The 421 tests before this work were all *invariant* tests: is the maze valid, is every cell
+reachable, is the path bidirectional. Valuable, but they share a blind spot — none of them
+can assert **what** was generated, only that whatever came out satisfies some property.
+
+That was not a gap in test-writing discipline. It was forced: generation was
+nondeterministic, so there was nothing stable to assert against. Measured on the
+pre-seeding code (12×12×2, identical settings, 8 runs each):
+
+| Algorithm | Distinct mazes / 8 runs |
+|---|---|
+| GrowingTree | 8/8 |
+| RecursiveBacktracker | 8/8 |
+| BinaryTree | 8/8 |
+| Prims | 8/8 |
+
+Distinct start points: 8/8. Distinct end points: 8/8. Shortest-path length across eight
+identical-setting runs: `[45, 1, 33, 25, 25, 104, 43, 151]` — **min 1, max 151**.
+
+Consequences:
+
+- **No output assertions.** An algorithm change that made mazes measurably worse — more
+ dead ends, shorter solution paths, biased carving — would pass every test.
+- **Runtime was unpredictable.** Unlucky shuffle orders send `PerfectAgent`'s exponential
+ DFS down enormous subtrees, so suite wall-time on identical code ranged from **~1s to a
+ 40-minute hang** (CI run 9), with **1002s on `main`** well before any of this work. That
+ reads as flaky infrastructure; it was unseeded input meeting an exponential algorithm.
+ Note this bit the *sample-maze* tests, which load mazes from disk — so the nondeterminism
+ there is the agent's own shuffle order, not generation. See "Open questions".
+- **Bugs weren't reportable.** A failure found by chance could not be reproduced, because
+ nothing recorded the random state that produced it.
+
+That `ShortestPath == 1` case is worth its own look: it means start and end landed adjacent,
+producing a trivially solvable maze. Whether that's acceptable is a product question, but it
+is currently *unobservable* — exactly what regression tests should surface.
+
+## What landed: the seeding foundation
+
+Three independent sources of ungoverned randomness fed generation, none of them seedable:
+
+1. `RandomValueGenerator` → a `[ThreadStatic]` `Random` seeded from `Environment.TickCount`.
+2. `ArrayHelper.Shuffle` → `Random.Shared`, a process-global. Called from the backtracker,
+ the growing-tree algorithm and the random carver — and reached as a *static*, so there
+ was no injection seam at all.
+3. `RandomAgent` and `PerfectAgent` → `Random.Shared.Shuffle` directly, bypassing even
+ `ArrayHelper`.
+
+Now:
+
+- **`IRandomValueGenerator` is the single source of randomness**, injected everywhere, with
+ `GetNext`, `Shuffle` and `Reseed`. Instances hold their own `Random` — no thread-static, no
+ shared global. (The thread-static indirection was also unnecessary: nothing in the maze
+ pipeline is concurrent.)
+- **`MazeGenerationSettings.Seed`** (`int?`) pins a run. `MazeGenerationFactory.GenerateMaze`
+ reseeds once, before anything consumes randomness, so the seed determines start/end
+ placement, carving order, wall removal and agent walks alike.
+- **`MazeGenerationResults.Seed`** always reports the seed used. An unseeded run still draws
+ a concrete seed and reports it, so a maze found by chance can be reproduced — feed the
+ reported seed back in.
+- **`ArrayHelper` is gone.** Its shuffle overloads were the trap; its `Average` was dead
+ code. Benchmarks now measure the production shuffle path with a fixed seed.
+- **`ISystemClock`** replaces a `DateTime.Now` read inside `MazeStatsSerializer`, which
+ would otherwise have made serialized stats differ on every run — a golden-file blocker
+ found by the discipline guard below, not by inspection.
+
+### Guards
+
+- **`DeterminismTests`** — same seed produces byte-identical mazes across all four
+ algorithms; different seeds still produce different mazes (so seeding can't silently
+ collapse the output space); an unseeded run's reported seed reproduces its maze; wall
+ removal and agent walks are seed-stable; and reusing one container across seeds doesn't
+ leak state between runs.
+- **`RandomnessDisciplineTests`** — scans `scripts/maze/` and fails on `Random.Shared`,
+ `new Random(`, `ArrayHelper.Shuffle`, `Guid.NewGuid` and `DateTime.Now/UtcNow`, with
+ `RandomValueGenerator.cs` and `SystemClock.cs` as the only exemptions. Determinism is a
+ whole-pipeline property: one stray global call breaks it, and the symptom appears as a
+ flaky golden test far from the cause. This catches it at the source.
+
+## The regression suite (designed, not built)
+
+### Layer 1 — Golden files
+
+Commit the serialized output of a fixed matrix of seeded generations; the test regenerates
+and byte-compares.
+
+- **Matrix:** 4 algorithms × 3 sizes (small 2D, medium 2D, small 3D) × 2 seeds ≈ 24 cases.
+ Enough to cover each algorithm and dimensionality without a large fixture set.
+- **Stored under** `tests/golden/--.maze`, using the existing
+ `.maze` format so goldens stay human-diffable and the serializer gets exercised too.
+- **On failure:** print the seed and a unified diff, plus the command to regenerate.
+- **Regeneration:** one opt-in switch (`UPDATE_GOLDENS=1`) that rewrites the fixtures.
+ Deliberately env-gated, because the whole value of a golden file is that updating it is a
+ reviewed act — a diff in the PR, not a silent overwrite.
+
+**The judgement call this layer forces:** a golden file fails on *any* output change,
+including a deliberate improvement. That's the point — it makes intent explicit — but it
+means algorithm work will routinely carry golden churn. If that proves annoying in practice,
+the answer is to narrow what's goldened (structure only, not stats), not to loosen the
+comparison.
+
+### Layer 2 — Metric assertions
+
+Golden files detect *change*; they don't say whether it's good. Layer 2 asserts on quality
+metrics from `GenerationMetrics` / `MazeStatsResult` — dead-end count, junction count,
+branching factor, solution-path length — as **ranges** over a sample of seeds.
+
+Ranges, not exact values, because these should express algorithm character ("a backtracker
+maze has long corridors and few junctions") and survive an unrelated refactor. This is the
+layer that catches "still valid, but measurably worse".
+
+### Layer 3 — Cross-seed invariants (property tests)
+
+Run the existing invariant checks across many seeds rather than one arbitrary one — a
+lightweight property test. Cheap to add now that a failing case is reportable by seed, and
+it's how the `ShortestPath == 1` class of finding gets caught systematically.
+
+### Layer 4 — Performance regressions
+
+BenchmarkDotNet already exists but isn't wired to CI. With seeded input its numbers become
+comparable run to run, which is the prerequisite. Deferring: benchmark-in-CI needs a
+stable-hardware story before threshold failures mean anything, and GitHub runners are noisy.
+
+### CI wiring
+
+Layers 1–3 are plain NUnit tests, so `test.yml` picks them up with no workflow change —
+which is the main argument for building them in that order. Layer 4 needs its own job and
+should wait.
+
+## Recommended order
+
+1. **Layer 1** on the 4 algorithms at one size — proves the harness, small fixture set.
+2. **Layer 3** — cheapest real bug-finding per line of test code.
+3. **Layer 2** — needs a baseline sample of what current metrics actually are, so it comes
+ after there's a stable way to generate them.
+4. **Layer 4** — only with a considered hardware/threshold story.
+
+## Open questions
+
+- **Golden scope:** maze structure only, or stats JSON too? Structure alone is more stable;
+ stats catch more. Recommendation: structure first, add stats if a real regression escapes.
+- **Should `Seed` be surfaced in the UI?** The plumbing now supports "regenerate this exact
+ maze" and "share a seed". That's a product feature, not a testing need — worth a separate
+ decision.
+- **Should the `.maze` format carry its seed?** An optional `SEED` header would make an
+ exported maze self-describing. It's a format change, so it needs a spec update in
+ `SerialisationSpecification.md` and a deserializer that tolerates the field's absence.
+- **Is `ShortestPath == 1` acceptable?** Reachable today via random start/end placement. If
+ not, minimum-separation becomes a generation constraint and a Layer 3 assertion.
+- **`BinaryTreeAlgorithm` is a placeholder** that delegates to `BacktrackerAlgorithm`
+ (see its own comment). Golden files would lock in that duplicate behaviour — worth
+ resolving before, not after, goldens are committed.
+- **`PerfectAgent`'s search is worst-case exponential** and should use a shared visited set
+ instead of scanning the current path (`previousPoints.Any(...)`) — it also copies the whole
+ path per branch. Measured on the 1200/1600-cell samples, 8 runs of two tests: 1.8s to
+ >120s (two runs unfinished), while RandomAgent stayed flat at 1.6-1.8s. This is what made
+ CI wall-time range from 29s to a 40-minute hang on identical code, including a 1002s run on
+ `main` before any of this work.
+
+ Mitigated for now by narrowing the PerfectAgent sample to <= 200 cells and capping those
+ tests at 60s (`SampleMazeTests`), plus `timeout-minutes: 15` on the CI job. **That bounds
+ the symptom; the algorithm is still exponential** and will resurface on any larger maze —
+ including in the app, where it would hang the UI rather than a test.
diff --git a/docs/VISUAL_REGRESSION.md b/docs/VISUAL_REGRESSION.md
new file mode 100644
index 0000000..cdd9e9d
--- /dev/null
+++ b/docs/VISUAL_REGRESSION.md
@@ -0,0 +1,164 @@
+# Visual Regression Testing (web build)
+
+Catches rendering regressions in the browser build that no C# test can see: a shader or
+material change, a camera/viewport shift, a UI theme break, a canvas that boots but draws
+nothing.
+
+**Tooling:** [`@playwright/test`](https://playwright.dev/docs/test-snapshots) and its built-in
+`toHaveScreenshot()` snapshot comparison — the standard for web visual regression. The repo
+already uses Playwright for the post-deploy smoke test, so this adds no new tool.
+
+**Status:** harness built and self-verified. The maze suite is **skipped** until the web
+build supports URL-parameter seeding (below) — deliberately skipped rather than failing, so
+it can't report a false red.
+
+## How this depends on the seeding work
+
+A screenshot test needs the same input to produce the same pixels. A procedurally generated
+maze does not, unless the generation seed is fixed — see
+[REGRESSION_TESTING.md](./REGRESSION_TESTING.md). Before seeding existed, every page load
+produced a different maze, so visual regression was impossible in principle, not just
+unimplemented.
+
+Seeding is now in place in the C# core (`MazeGenerationSettings.Seed`). What's missing is a
+way to *reach* it from a URL.
+
+## Prerequisite: URL-parameter seeding
+
+The suite loads `/?seed=20260725&algorithm=backtracker&x=10&y=10&z=1` and expects that exact
+maze. The web build must read those parameters at startup and apply them instead of using
+menu defaults or randomised settings.
+
+Sketch — Godot exposes the query string through `JavaScriptBridge`, which only exists on the
+web export, so it must be feature-guarded:
+
+```csharp
+// Web-only: JavaScriptBridge is not available on desktop builds.
+if (OS.HasFeature("web"))
+{
+ var search = JavaScriptBridge.Eval("window.location.search", true)?.ToString() ?? "";
+ // Parse ?seed=&algorithm=&x=&y=&z= and apply onto MazeGenerationSettings,
+ // then generate immediately, bypassing the menu.
+}
+```
+
+Requirements for the parameters to be regression-safe:
+
+1. **`seed` maps straight onto `MazeGenerationSettings.Seed`.** No re-randomising afterwards.
+2. **Generation happens once, on load**, with no menu interaction needed — the test can't
+ click through a UI reliably.
+3. **Invalid or absent parameters fall back to current behaviour**, so normal visitors are
+ unaffected.
+4. **The render settles.** Whatever intro animation or camera easing exists must reach a
+ fixed final frame; the harness waits for pixel stability and fails if it never settles.
+
+Once that ships, set `MAZE_SEEDING=1` in the workflow to un-skip the suite and generate
+baselines (below).
+
+> Not implemented here because building the web export requires the patched Windows editor
+> (see [WEB_EXPORT.md](./WEB_EXPORT.md)), so a Godot-side change could not be compiled or
+> verified in this environment. Specified rather than guessed at.
+
+## Layout
+
+| Path | Purpose |
+|---|---|
+| `tests/visual/playwright.config.ts` | Projects, thresholds, fixed viewport/locale/timezone |
+| `tests/visual/maze.spec.ts` | The real suite — screenshots the deployed build |
+| `tests/visual/harness.spec.ts` | Self-test proving the harness works without a Godot build |
+| `tests/visual/canvas-stability.ts` | Boot detection and pixel-stability polling |
+| `tests/visual/*-snapshots/` | Committed baseline PNGs (platform-keyed by Playwright) |
+
+## Why pixel-stability polling
+
+A canvas app has no event meaning "finished drawing". Screenshotting on `domcontentloaded`
+or after a fixed `waitForTimeout` captures a partially-drawn frame, and the resulting
+baseline flakes forever afterwards.
+
+`waitForStableFrame` instead downscales the canvas to 64×64, hashes the pixels, and waits
+until consecutive samples agree. It **throws** if the canvas never settles — a still-animating
+canvas is a reason to fail loudly, not to screenshot anyway and hope.
+
+`waitForEngineBoot` additionally asserts `crossOriginIsolated` and `SharedArrayBuffer`, because
+without COOP/COEP the canvas element appears but the .NET runtime never starts — and a
+screenshot of that is a plausible-looking blank image.
+
+## Thresholds, and the page-reuse trap
+
+`maxDiffPixelRatio: 0.01` with per-pixel `threshold: 0.2`.
+
+**Measured on this harness** (a seeded 2D-canvas fixture, Chromium 1194, one machine):
+
+| Comparison | Result |
+|---|---|
+| Two screenshots, same page, no redraw | byte-identical |
+| Two **fresh pages**, same seed | **byte-identical** |
+| Two separate **browser launches**, same seed | **byte-identical** |
+| Two `setContent` calls on **one reused page** | **3782 pixels differ (1.6%)** |
+
+The headline: canvas rendering is deterministic, including across browser launches — but
+**reusing a page across loads perturbs the raster**. The initial version of this suite reused
+one page and looked like rendering nondeterminism; it was the fixture. Every test therefore
+loads into a fresh page, and `canvas-stability.ts` callers must keep doing so.
+
+The retained tolerance is for what *hasn't* been measured: different CI machines and driver
+revisions, and the real build being a **WebGL/WASM** render rather than 2D canvas. If it
+proves byte-stable in practice, tighten toward zero — and if a real regression ever slips
+under 1%, tighten the ratio rather than adding per-test exceptions.
+
+## Baselines
+
+Playwright keys snapshots by platform (`...-linux.png`, `...-darwin.png`). CI runs Linux, so
+**baselines must be generated on Linux** or CI fails on missing snapshots.
+
+Generate/update them one of two ways:
+
+```sh
+# In CI (preferred): run the visual job with UPDATE_SNAPSHOTS=1 and commit the artifact.
+# Locally on Linux:
+cd tests/visual && npm ci && npx playwright test --update-snapshots
+```
+
+Do **not** generate baselines on macOS and commit them — they'll be named `-darwin` and CI
+will still have nothing to compare against.
+
+Updating a baseline is a reviewed act: the PR diff shows the old and new PNG side by side.
+That's the whole value, so there's no auto-update-on-failure switch.
+
+## CI wiring
+
+Runs after the existing post-deploy smoke test, against the same deployed URL — the smoke
+test proves it *boots*, this proves it *looks right*, and there's no point screenshotting a
+build that didn't start.
+
+Two jobs:
+
+- **`visual-harness`** in `test.yml` — runs the self-test on every PR. No deploy needed, runs
+ on Linux in seconds, and keeps the harness from rotting while the maze suite is skipped.
+- **`visual-production` / `visual-preview`** in `web-export.yml` — runs the maze suite against
+ the deployed URL, after the smoke test.
+
+GitHub runners need the browser downloaded, exactly as the existing smoke job does it:
+
+```yaml
+- run: npm ci && npx playwright install --with-deps chromium
+ working-directory: tests/visual
+```
+
+(The `CHROMIUM_PATH` env var in `playwright.config.ts` is only for constrained environments
+that ship a pinned Chromium of a mismatched build — leave it unset in CI.)
+
+On failure Playwright writes `expected`/`actual`/`diff` PNGs plus an HTML report — upload
+`tests/visual/playwright-report/` and `test-results/` as artifacts, or a red build gives a
+reviewer nothing to look at.
+
+## Scope and limits
+
+- **Covers** the deployed browser build's rendered output at fixed seeds and viewport.
+- **Does not cover** interaction (solving, camera movement, menu flows) — that's Playwright
+ functional testing, a separate suite if wanted.
+- **Not a substitute for** the C# golden-file work: byte-comparing a serialized maze localises
+ a regression to the algorithm, whereas a screenshot diff only says "the picture changed".
+ Structure tests fail with a precise cause; visual tests catch what structure tests can't see.
+- **Single browser.** Chromium only. Cross-browser rendering diffs are a different problem
+ and would triple baseline count for little value on a WASM canvas.
diff --git a/scripts/autoload/ServiceContainer.cs b/scripts/autoload/ServiceContainer.cs
index ebf537b..29c0692 100644
--- a/scripts/autoload/ServiceContainer.cs
+++ b/scripts/autoload/ServiceContainer.cs
@@ -21,6 +21,7 @@ public class ServiceContainer
public IMovementHelper MovementHelper { get; }
public IPointValidity PointValidity { get; }
public IRandomValueGenerator RandomValueGenerator { get; }
+ public ISystemClock SystemClock { get; }
public ITimeRecorder TimeRecorder { get; }
public IMazeHelper MazeHelper { get; }
@@ -74,6 +75,7 @@ public ServiceContainer()
DirectionsFlagParser = new DirectionsFlagParser();
PointValidity = new PointValidity();
RandomValueGenerator = new RandomValueGenerator();
+ SystemClock = new SystemClock();
TimeRecorder = new TimeRecorder();
// Model classes
@@ -97,12 +99,12 @@ public ServiceContainer()
// More generation classes
DeadEndFiller = new DeadEndFiller(DeadEndModelWrapperFactory, PointsAndDirectionsRetriever);
- RandomCarver = new RandomCarver(RandomPointGenerator, PointsAndDirectionsRetriever, DirectionsFlagParser);
+ RandomCarver = new RandomCarver(RandomPointGenerator, PointsAndDirectionsRetriever, DirectionsFlagParser, RandomValueGenerator);
// Algorithms
GrowingTreeAlgorithm = new GrowingTreeAlgorithmLinkedList(RandomPointGenerator, RandomValueGenerator, DirectionsFlagParser);
- RecursiveBacktrackerAlgorithm = new BacktrackerAlgorithm(DirectionsFlagParser, RandomPointGenerator);
- BinaryTreeAlgorithm = new BinaryTreeAlgorithm(DirectionsFlagParser, RandomPointGenerator);
+ RecursiveBacktrackerAlgorithm = new BacktrackerAlgorithm(DirectionsFlagParser, RandomPointGenerator, RandomValueGenerator);
+ BinaryTreeAlgorithm = new BinaryTreeAlgorithm(DirectionsFlagParser, RandomPointGenerator, RandomValueGenerator);
PrimsAlgorithm = new PrimsAlgorithm(DirectionsFlagParser, RandomPointGenerator, RandomValueGenerator);
// Solver classes
@@ -113,7 +115,7 @@ public ServiceContainer()
DijkstraAnimator = new DijkstraAnimator(GraphBuilder);
// Agent classes
- AgentFactory = new AgentFactory(DirectionsFlagParser, PointsAndDirectionsRetriever);
+ AgentFactory = new AgentFactory(DirectionsFlagParser, PointsAndDirectionsRetriever, RandomValueGenerator);
// Heuristics classes
MazeStatsGenerator = new MazeStatsGenerator(DirectionsFlagParser);
@@ -135,13 +137,14 @@ public ServiceContainer()
HeuristicsGenerator,
AgentFactory,
TimeRecorder,
- MazeHelper);
+ MazeHelper,
+ RandomValueGenerator);
// Serialization classes
MazeSerializer = new MazeSerializer();
MazeDeserializer = new MazeDeserializer();
MazeValidator = new MazeValidator(DirectionsFlagParser, MovementHelper);
- MazeStatsSerializer = new MazeStatsSerializer();
+ MazeStatsSerializer = new MazeStatsSerializer(SystemClock);
}
}
}
diff --git a/scripts/maze/agents/AgentFactory.cs b/scripts/maze/agents/AgentFactory.cs
index f34a3d5..a9b1468 100644
--- a/scripts/maze/agents/AgentFactory.cs
+++ b/scripts/maze/agents/AgentFactory.cs
@@ -8,11 +8,15 @@ public class AgentFactory : IAgentFactory
{
private readonly IDirectionsFlagParser _directionsFlagParser;
private readonly IPointsAndDirectionsRetriever _pointsAndDirectionsRetriever;
+ private readonly IRandomValueGenerator _randomValueGenerator;
- public AgentFactory(IDirectionsFlagParser directionsFlagParser, IPointsAndDirectionsRetriever pointsAndDirectionsRetriever)
+ public AgentFactory(IDirectionsFlagParser directionsFlagParser,
+ IPointsAndDirectionsRetriever pointsAndDirectionsRetriever,
+ IRandomValueGenerator randomValueGenerator)
{
_directionsFlagParser = directionsFlagParser;
_pointsAndDirectionsRetriever = pointsAndDirectionsRetriever;
+ _randomValueGenerator = randomValueGenerator;
}
public IAgent MakeAgent(AgentType type)
@@ -20,9 +24,9 @@ public IAgent MakeAgent(AgentType type)
switch (type)
{
case AgentType.Random:
- return new RandomAgent(_pointsAndDirectionsRetriever, _directionsFlagParser);
+ return new RandomAgent(_pointsAndDirectionsRetriever, _directionsFlagParser, _randomValueGenerator);
case AgentType.Perfect:
- return new PerfectAgent(_directionsFlagParser);
+ return new PerfectAgent(_directionsFlagParser, _randomValueGenerator);
default:
throw new ArgumentOutOfRangeException(nameof(type), type, null);
}
diff --git a/scripts/maze/agents/PerfectAgent.cs b/scripts/maze/agents/PerfectAgent.cs
index b56541f..7656fb8 100644
--- a/scripts/maze/agents/PerfectAgent.cs
+++ b/scripts/maze/agents/PerfectAgent.cs
@@ -9,10 +9,13 @@ namespace ProceduralMaze.Maze.Agents
public class PerfectAgent : AgentBase
{
private readonly IDirectionsFlagParser _directionsFlagParser;
+ private readonly IRandomValueGenerator _randomValueGenerator;
- public PerfectAgent(IDirectionsFlagParser directionsFlagParser)
+ public PerfectAgent(IDirectionsFlagParser directionsFlagParser,
+ IRandomValueGenerator randomValueGenerator)
{
_directionsFlagParser = directionsFlagParser;
+ _randomValueGenerator = randomValueGenerator;
}
public override AgentResults RunAgentBase(IMaze maze)
@@ -39,7 +42,7 @@ private List GetPathToLastPoint(List previ
return previousPoints;
}
var directions = maze.GetDirectionsFromPoint();
- Random.Shared.Shuffle(directions);
+ _randomValueGenerator.Shuffle(directions);
var currentPoint = maze.CurrentPoint;
// Check each direction for path to end
foreach (var direction in directions)
diff --git a/scripts/maze/agents/RandomAgent.cs b/scripts/maze/agents/RandomAgent.cs
index 86bb5d0..cfa692f 100644
--- a/scripts/maze/agents/RandomAgent.cs
+++ b/scripts/maze/agents/RandomAgent.cs
@@ -15,11 +15,15 @@ public class RandomAgent : AgentBase
{
private readonly IPointsAndDirectionsRetriever _pointsAndDirectionsRetriever;
private readonly IDirectionsFlagParser _directionsFlagParser;
+ private readonly IRandomValueGenerator _randomValueGenerator;
- public RandomAgent(IPointsAndDirectionsRetriever pointsAndDirectionsRetriever, IDirectionsFlagParser directionsFlagParser)
+ public RandomAgent(IPointsAndDirectionsRetriever pointsAndDirectionsRetriever,
+ IDirectionsFlagParser directionsFlagParser,
+ IRandomValueGenerator randomValueGenerator)
{
_pointsAndDirectionsRetriever = pointsAndDirectionsRetriever;
_directionsFlagParser = directionsFlagParser;
+ _randomValueGenerator = randomValueGenerator;
}
public override AgentResults RunAgentBase(IMaze maze)
@@ -28,7 +32,7 @@ public override AgentResults RunAgentBase(IMaze maze)
if (!maze.CurrentPoint.Equals(maze.EndPoint))
{
var firstDirections = maze.GetDirectionsFromPoint();
- Random.Shared.Shuffle(firstDirections);
+ _randomValueGenerator.Shuffle(firstDirections);
var first = firstDirections[0];
var currentPoint = maze.CurrentPoint;
maze.MoveInDirection(first);
@@ -39,7 +43,7 @@ public override AgentResults RunAgentBase(IMaze maze)
var directions = maze.GetDirectionsFromPoint();
var reverseDirection = _directionsFlagParser.OppositeDirection(lastDirectionMoved);
var filteredDirections = directions.Where(x => x != reverseDirection).ToArray();
- Random.Shared.Shuffle(filteredDirections);
+ _randomValueGenerator.Shuffle(filteredDirections);
if (_pointsAndDirectionsRetriever.IsJunction(directions))
{
var direction = filteredDirections[0];
diff --git a/scripts/maze/factory/MazeGenerationFactory.cs b/scripts/maze/factory/MazeGenerationFactory.cs
index 83eef2e..56b4a79 100644
--- a/scripts/maze/factory/MazeGenerationFactory.cs
+++ b/scripts/maze/factory/MazeGenerationFactory.cs
@@ -23,6 +23,7 @@ public class MazeGenerationFactory : IMazeGenerationFactory
private readonly IAgentFactory _agentFactory;
private readonly ITimeRecorder _timeRecorder;
private readonly IMazeHelper _mazeHelper;
+ private readonly IRandomValueGenerator _randomValueGenerator;
public MazeGenerationFactory(
IMazeModelFactory mazeModelFactory,
@@ -37,7 +38,8 @@ public MazeGenerationFactory(
IHeuristicsGenerator heuristicsGenerator,
IAgentFactory agentFactory,
ITimeRecorder timeRecorder,
- IMazeHelper mazeHelper)
+ IMazeHelper mazeHelper,
+ IRandomValueGenerator randomValueGenerator)
{
_mazeModelFactory = mazeModelFactory;
_growingTreeAlgorithm = growingTreeAlgorithm;
@@ -52,10 +54,19 @@ public MazeGenerationFactory(
_agentFactory = agentFactory;
_timeRecorder = timeRecorder;
_mazeHelper = mazeHelper;
+ _randomValueGenerator = randomValueGenerator;
}
public MazeGenerationResults GenerateMaze(MazeGenerationSettings settings)
{
+ // Reseed once, here, before anything consumes randomness. Every random decision
+ // downstream (start/end placement, carving order, wall removal, agent walks)
+ // draws from this one generator, so the seed fully determines the output.
+ // An unseeded run still gets a concrete seed, reported back on the results, so
+ // an interesting maze found by chance can always be reproduced.
+ var effectiveSeed = settings.Seed ?? RandomValueGenerator.NewRandomSeed();
+ _randomValueGenerator.Reseed(effectiveSeed);
+
IMazeCarver carver = null!;
var modelBuildTime = _timeRecorder.GetRunningTime(() =>
{
@@ -132,7 +143,8 @@ public MazeGenerationResults GenerateMaze(MazeGenerationSettings settings)
DeadEndFillerTime = deadEndFillerTime,
AgentGenerationTime = agentGenerationTime,
HeuristicsTime = heuristicsTime,
- TotalTime = totalTime
+ TotalTime = totalTime,
+ Seed = effectiveSeed
};
}
diff --git a/scripts/maze/factory/MazeGenerationResults.cs b/scripts/maze/factory/MazeGenerationResults.cs
index 0a2445d..34558e9 100644
--- a/scripts/maze/factory/MazeGenerationResults.cs
+++ b/scripts/maze/factory/MazeGenerationResults.cs
@@ -22,5 +22,12 @@ public class MazeGenerationResults
public List DirectionsCarvedIn { get; set; } = new();
public GenerationMetrics Metrics { get; set; } = new();
public Dictionary Heatmap { get; set; } = new();
+
+ ///
+ /// The seed that actually produced this maze — whether it came from
+ /// MazeGenerationSettings.Seed or was drawn automatically. Feed it back in
+ /// to reproduce this exact maze.
+ ///
+ public int Seed { get; set; }
}
}
diff --git a/scripts/maze/generation/BacktrackerAlgorithm.cs b/scripts/maze/generation/BacktrackerAlgorithm.cs
index bb5b45c..17d4524 100644
--- a/scripts/maze/generation/BacktrackerAlgorithm.cs
+++ b/scripts/maze/generation/BacktrackerAlgorithm.cs
@@ -10,12 +10,15 @@ public class BacktrackerAlgorithm : IRecursiveBacktrackerAlgorithm
{
private readonly IDirectionsFlagParser _directionsFlagParser;
private readonly IRandomPointGenerator _randomPointGenerator;
+ private readonly IRandomValueGenerator _randomValueGenerator;
public BacktrackerAlgorithm(IDirectionsFlagParser directionsFlagParser,
- IRandomPointGenerator randomPointGenerator)
+ IRandomPointGenerator randomPointGenerator,
+ IRandomValueGenerator randomValueGenerator)
{
_directionsFlagParser = directionsFlagParser;
_randomPointGenerator = randomPointGenerator;
+ _randomValueGenerator = randomValueGenerator;
}
public AlgorithmRunResults GenerateMaze(IMazeCarver maze, MazeGenerationSettings settings)
@@ -33,7 +36,7 @@ public AlgorithmRunResults GenerateMaze(IMazeCarver maze, MazeGenerationSettings
maze.JumpToPoint(currentPoint);
var carvableDirections = maze.CarvableDirections();
- ArrayHelper.Shuffle(carvableDirections);
+ _randomValueGenerator.Shuffle(carvableDirections);
var carved = false;
foreach (var direction in carvableDirections)
{
diff --git a/scripts/maze/generation/BinaryTreeAlgorithm.cs b/scripts/maze/generation/BinaryTreeAlgorithm.cs
index 2668767..c739046 100644
--- a/scripts/maze/generation/BinaryTreeAlgorithm.cs
+++ b/scripts/maze/generation/BinaryTreeAlgorithm.cs
@@ -11,18 +11,21 @@ public class BinaryTreeAlgorithm : IBinaryTreeAlgorithm
{
private readonly IDirectionsFlagParser _directionsFlagParser;
private readonly IRandomPointGenerator _randomPointGenerator;
+ private readonly IRandomValueGenerator _randomValueGenerator;
- public BinaryTreeAlgorithm(IDirectionsFlagParser directionsFlagParser,
- IRandomPointGenerator randomPointGenerator)
+ public BinaryTreeAlgorithm(IDirectionsFlagParser directionsFlagParser,
+ IRandomPointGenerator randomPointGenerator,
+ IRandomValueGenerator randomValueGenerator)
{
_directionsFlagParser = directionsFlagParser;
_randomPointGenerator = randomPointGenerator;
+ _randomValueGenerator = randomValueGenerator;
}
public AlgorithmRunResults GenerateMaze(IMazeCarver maze, MazeGenerationSettings settings)
{
// Use backtracker logic as placeholder for binary tree
- var backtracker = new BacktrackerAlgorithm(_directionsFlagParser, _randomPointGenerator);
+ var backtracker = new BacktrackerAlgorithm(_directionsFlagParser, _randomPointGenerator, _randomValueGenerator);
return backtracker.GenerateMaze(maze, settings);
}
}
diff --git a/scripts/maze/generation/GrowingTreeAlgorithmLinkedList.cs b/scripts/maze/generation/GrowingTreeAlgorithmLinkedList.cs
index 7822bf9..f422dd4 100644
--- a/scripts/maze/generation/GrowingTreeAlgorithmLinkedList.cs
+++ b/scripts/maze/generation/GrowingTreeAlgorithmLinkedList.cs
@@ -46,7 +46,7 @@ private AlgorithmRunResults GenerateMaze(IMazeCarver maze, List 0)
@@ -77,7 +80,7 @@ private int CheckPoint(MazePoint point, IMazeCarver carver, int numberOfWalls, D
{
carver.JumpToPoint(point);
var directions = carver.CarvableDirections();
- ArrayHelper.Shuffle(directions);
+ _randomValueGenerator.Shuffle(directions);
if (directions.Length > 0)
{
var selectedDirection = directions.Contains(preferredDirection)
diff --git a/scripts/maze/helper/ArrayHelper.cs b/scripts/maze/helper/ArrayHelper.cs
deleted file mode 100644
index aef0b24..0000000
--- a/scripts/maze/helper/ArrayHelper.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-
-namespace ProceduralMaze.Maze.Helper
-{
- public static class ArrayHelper
- {
- public static void Shuffle(T[] array)
- {
- Random.Shared.Shuffle(array);
- }
-
- public static void Shuffle(List list)
- {
- Random.Shared.Shuffle(System.Runtime.InteropServices.CollectionsMarshal.AsSpan(list));
- }
-
- public static double Average(IEnumerable items, Func func)
- {
- int count = 0;
- var total = items.Aggregate(0.0, (seed, item) =>
- {
- count++;
- return seed + func(item);
- });
- return total / count;
- }
- }
-}
diff --git a/scripts/maze/helper/IRandomValueGenerator.cs b/scripts/maze/helper/IRandomValueGenerator.cs
index 1071f8c..706e63f 100644
--- a/scripts/maze/helper/IRandomValueGenerator.cs
+++ b/scripts/maze/helper/IRandomValueGenerator.cs
@@ -1,7 +1,40 @@
+using System.Collections.Generic;
+
namespace ProceduralMaze.Maze.Helper
{
+ ///
+ /// The single source of randomness for maze generation.
+ ///
+ ///
+ /// Every random decision in generation MUST go through an injected instance of this
+ /// interface. Reaching for Random.Shared, new Random() or the static
+ /// ArrayHelper.Shuffle overloads inside generation code reintroduces global
+ /// state that cannot be seeded, which makes runs unreproducible and golden-file
+ /// regression tests impossible. RandomnessDisciplineTests enforces this.
+ ///
+ /// Instances are NOT thread-safe, deliberately: the maze pipeline is single-threaded,
+ /// and a per-instance generator is what makes a seeded run reproducible. Give each
+ /// concurrent pipeline its own ServiceContainer (as the test suite does)
+ /// rather than sharing one generator across threads.
+ ///
public interface IRandomValueGenerator
{
+ /// The seed currently driving this generator.
+ int Seed { get; }
+
+ /// Random integer in the INCLUSIVE range [min, max].
int GetNext(int min, int max);
+
+ /// Shuffles in place using this generator's sequence.
+ void Shuffle(T[] array);
+
+ /// Shuffles in place using this generator's sequence.
+ void Shuffle(IList list);
+
+ ///
+ /// Restarts the sequence from . Called once per generation
+ /// run so that the same seed always yields the same maze.
+ ///
+ void Reseed(int seed);
}
}
diff --git a/scripts/maze/helper/ISystemClock.cs b/scripts/maze/helper/ISystemClock.cs
new file mode 100644
index 0000000..6ba367d
--- /dev/null
+++ b/scripts/maze/helper/ISystemClock.cs
@@ -0,0 +1,18 @@
+using System;
+
+namespace ProceduralMaze.Maze.Helper
+{
+ ///
+ /// Injected source of wall-clock time.
+ ///
+ ///
+ /// Exists for the same reason as : a direct
+ /// DateTime.Now read inside serialization makes the output bytes differ on every
+ /// run, which defeats byte-comparison against a golden file. Regression tests inject a
+ /// fixed clock; production uses .
+ ///
+ public interface ISystemClock
+ {
+ DateTime Now { get; }
+ }
+}
diff --git a/scripts/maze/helper/RandomValueGenerator.cs b/scripts/maze/helper/RandomValueGenerator.cs
index 3ae56ff..fd0b9fe 100644
--- a/scripts/maze/helper/RandomValueGenerator.cs
+++ b/scripts/maze/helper/RandomValueGenerator.cs
@@ -1,24 +1,68 @@
using System;
-using System.Threading;
+using System.Collections.Generic;
namespace ProceduralMaze.Maze.Helper
{
+ ///
+ /// Seedable, per-instance source of randomness. See
+ /// for the discipline this exists to enforce.
+ ///
+ ///
+ /// Previously this delegated to a [ThreadStatic] seeded from
+ /// Environment.TickCount, which made every run unreproducible. The thread-static
+ /// indirection also wasn't buying anything — nothing in the maze pipeline is concurrent —
+ /// so a plain instance field is both simpler and seedable.
+ ///
public class RandomValueGenerator : IRandomValueGenerator
{
- public int GetNext(int min, int max)
+ private Random _random;
+
+ public int Seed { get; private set; }
+
+ ///
+ /// Fixed seed for reproducible output. When null, a seed is drawn from the system
+ /// clock and exposed via — so even an unseeded run can be
+ /// reproduced after the fact.
+ ///
+ public RandomValueGenerator(int? seed = null)
{
- return ThreadSafeRandom.ThisThreadsRandom.Next(min, max + 1); // +1 because Random.Next max is exclusive
+ Seed = seed ?? NewRandomSeed();
+ _random = new Random(Seed);
}
- }
- public static class ThreadSafeRandom
- {
- [ThreadStatic]
- private static Random? Local;
+ public void Reseed(int seed)
+ {
+ Seed = seed;
+ _random = new Random(seed);
+ }
+
+ // +1 because Random.Next's max is exclusive while this contract is inclusive.
+ public int GetNext(int min, int max) => _random.Next(min, max + 1);
+
+ public void Shuffle(T[] array) => _random.Shuffle(array);
- public static Random ThisThreadsRandom
+ public void Shuffle(IList list)
{
- get { return Local ??= new Random(unchecked(Environment.TickCount * 31 + Thread.CurrentThread.ManagedThreadId)); }
+ if (list is List concrete)
+ {
+ // Span path keeps the common case allocation-free, matching the old
+ // ArrayHelper.Shuffle(List) behaviour.
+ _random.Shuffle(System.Runtime.InteropServices.CollectionsMarshal.AsSpan(concrete));
+ return;
+ }
+
+ // Fisher-Yates for any other IList.
+ for (var i = list.Count - 1; i > 0; i--)
+ {
+ var j = _random.Next(i + 1);
+ (list[i], list[j]) = (list[j], list[i]);
+ }
}
+
+ ///
+ /// Draws a fresh seed for an unseeded run. Uses a throwaway
+ /// rather than Random.Shared so nothing here depends on shared global state.
+ ///
+ public static int NewRandomSeed() => new Random().Next();
}
}
diff --git a/scripts/maze/helper/SystemClock.cs b/scripts/maze/helper/SystemClock.cs
new file mode 100644
index 0000000..d7301e6
--- /dev/null
+++ b/scripts/maze/helper/SystemClock.cs
@@ -0,0 +1,10 @@
+using System;
+
+namespace ProceduralMaze.Maze.Helper
+{
+ /// Real wall-clock time. The only place in maze logic that reads the clock.
+ public class SystemClock : ISystemClock
+ {
+ public DateTime Now => DateTime.Now;
+ }
+}
diff --git a/scripts/maze/model/MazeGenerationSettings.cs b/scripts/maze/model/MazeGenerationSettings.cs
index f5b3e38..f1343b9 100644
--- a/scripts/maze/model/MazeGenerationSettings.cs
+++ b/scripts/maze/model/MazeGenerationSettings.cs
@@ -16,5 +16,15 @@ public class MazeGenerationSettings
public SolverType SolverType { get; set; }
public HeuristicType HeuristicType { get; set; }
public GrowingTreeSettings GrowingTreeSettings { get; set; } = new GrowingTreeSettings();
+
+ ///
+ /// Seed for this generation run. The same seed with otherwise identical settings
+ /// always produces the same maze.
+ ///
+ ///
+ /// Null means "pick one for me" — a seed is still drawn and reported back via
+ /// MazeGenerationResults.Seed, so any run can be reproduced after the fact.
+ ///
+ public int? Seed { get; set; }
}
}
diff --git a/scripts/maze/serialization/MazeStatsSerializer.cs b/scripts/maze/serialization/MazeStatsSerializer.cs
index cb4ffcf..9b0e7f8 100644
--- a/scripts/maze/serialization/MazeStatsSerializer.cs
+++ b/scripts/maze/serialization/MazeStatsSerializer.cs
@@ -3,6 +3,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ProceduralMaze.Maze.Factory;
+using ProceduralMaze.Maze.Helper;
namespace ProceduralMaze.Maze.Serialization
{
@@ -13,6 +14,17 @@ public class MazeStatsSerializer : IMazeStatsSerializer
{
public const string StatsFileExtension = ".stats.json";
+ private readonly ISystemClock _clock;
+
+ ///
+ /// Time source for the generatedAt field. Defaults to the real clock; pass a
+ /// fixed clock to make serialized stats byte-stable for golden comparisons.
+ ///
+ public MazeStatsSerializer(ISystemClock? clock = null)
+ {
+ _clock = clock ?? new SystemClock();
+ }
+
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
@@ -51,7 +63,7 @@ public MazeStatsData BuildStatsData(MazeGenerationResults results)
var data = new MazeStatsData
{
- GeneratedAt = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"),
+ GeneratedAt = _clock.Now.ToString("yyyy-MM-ddTHH:mm:ss"),
Dimensions = new DimensionsData
{
Width = model.Size.X,
diff --git a/tests/DeterminismTests.cs b/tests/DeterminismTests.cs
new file mode 100644
index 0000000..ecc11af
--- /dev/null
+++ b/tests/DeterminismTests.cs
@@ -0,0 +1,191 @@
+using NUnit.Framework;
+using ProceduralMaze.Autoload;
+using ProceduralMaze.Maze;
+using ProceduralMaze.Maze.Agents;
+using ProceduralMaze.Maze.Factory;
+using ProceduralMaze.Maze.Model;
+using ProceduralMaze.Maze.Solver;
+using ProceduralMaze.Maze.Solver.Heuristics;
+
+namespace ProceduralMaze.Tests;
+
+///
+/// Guards the property that makes golden-file regression testing possible: a seed plus
+/// settings fully determines the generated maze.
+///
+/// Before seeding existed, every algorithm produced a different maze on each run
+/// (8/8 unique outputs across 8 runs) and the shortest-path length for identical settings
+/// ranged from 1 to 151. Nothing downstream could assert on generated output.
+///
+[TestFixture]
+[Parallelizable(ParallelScope.All)]
+public class DeterminismTests
+{
+ private const int Seed = 20260725;
+
+ private static readonly Algorithm[] Algorithms =
+ [
+ Algorithm.GrowingTreeAlgorithm,
+ Algorithm.RecursiveBacktrackerAlgorithm,
+ Algorithm.BinaryTreeAlgorithm,
+ Algorithm.PrimsAlgorithm
+ ];
+
+ private static MazeGenerationSettings Settings(Algorithm algorithm, int? seed) => new()
+ {
+ Algorithm = algorithm,
+ Size = new MazeSize { X = 12, Y = 12, Z = 2 },
+ Option = MazeType.ArrayBidirectional,
+ DoorsAtEdge = true,
+ WallRemovalPercent = 0,
+ AgentType = AgentType.None,
+ SolverType = SolverType.Dijkstra,
+ HeuristicType = HeuristicType.Manhattan,
+ Seed = seed,
+ GrowingTreeSettings = new GrowingTreeSettings { NewestWeight = 50, OldestWeight = 25, RandomWeight = 25 }
+ };
+
+ /// Serialised maze structure — the thing a golden file would store.
+ private static string Fingerprint(ServiceContainer services, MazeGenerationResults result) =>
+ services.MazeSerializer.SerializeToString(result.MazeJumper.GetModel());
+
+ [Test]
+ public void SameSeed_SameSettings_ProducesIdenticalMaze([ValueSource(nameof(Algorithms))] Algorithm algorithm)
+ {
+ var fingerprints = new HashSet();
+ for (var run = 0; run < 5; run++)
+ {
+ var services = new ServiceContainer();
+ var result = services.MazeGenerationFactory.GenerateMaze(Settings(algorithm, Seed));
+ fingerprints.Add(Fingerprint(services, result));
+ }
+
+ Assert.That(fingerprints, Has.Count.EqualTo(1),
+ $"{algorithm} produced {fingerprints.Count} distinct mazes from the same seed — generation is not deterministic.");
+ }
+
+ [Test]
+ public void SameSeed_ProducesIdenticalStartAndEndPoints([ValueSource(nameof(Algorithms))] Algorithm algorithm)
+ {
+ var endpoints = new HashSet();
+ for (var run = 0; run < 5; run++)
+ {
+ var services = new ServiceContainer();
+ var r = services.MazeGenerationFactory.GenerateMaze(Settings(algorithm, Seed));
+ endpoints.Add($"{r.MazeJumper.StartPoint.X},{r.MazeJumper.StartPoint.Y},{r.MazeJumper.StartPoint.Z}" +
+ $"->{r.MazeJumper.EndPoint.X},{r.MazeJumper.EndPoint.Y},{r.MazeJumper.EndPoint.Z}");
+ }
+
+ Assert.That(endpoints, Has.Count.EqualTo(1), "Start/end placement is not seed-stable.");
+ }
+
+ [Test]
+ public void SameSeed_ProducesIdenticalHeuristics([ValueSource(nameof(Algorithms))] Algorithm algorithm)
+ {
+ var lengths = new HashSet();
+ for (var run = 0; run < 5; run++)
+ {
+ var services = new ServiceContainer();
+ var r = services.MazeGenerationFactory.GenerateMaze(Settings(algorithm, Seed));
+ lengths.Add(r.HeuristicsResults.ShortestPathResult.ShortestPath);
+ }
+
+ Assert.That(lengths, Has.Count.EqualTo(1),
+ $"Shortest-path length varied across identical seeds: [{string.Join(", ", lengths)}]");
+ }
+
+ [Test]
+ public void DifferentSeeds_ProduceDifferentMazes([ValueSource(nameof(Algorithms))] Algorithm algorithm)
+ {
+ // The counterpart to the tests above: seeding must not accidentally collapse
+ // every run onto one maze. Distinct seeds should still explore the space.
+ var fingerprints = new HashSet();
+ for (var seed = 1; seed <= 5; seed++)
+ {
+ var services = new ServiceContainer();
+ var result = services.MazeGenerationFactory.GenerateMaze(Settings(algorithm, seed));
+ fingerprints.Add(Fingerprint(services, result));
+ }
+
+ Assert.That(fingerprints, Has.Count.GreaterThan(1),
+ $"{algorithm} produced the same maze for 5 different seeds — the seed is being ignored.");
+ }
+
+ [Test]
+ public void UnseededRun_ReportsTheSeedItUsed_AndThatSeedReproducesTheMaze()
+ {
+ // The reproduce-after-the-fact path: a run with no seed must still report a
+ // concrete seed that regenerates the identical maze. This is what makes a
+ // randomly-discovered bug reportable.
+ var first = new ServiceContainer();
+ var original = first.MazeGenerationFactory.GenerateMaze(
+ Settings(Algorithm.RecursiveBacktrackerAlgorithm, seed: null));
+
+ Assert.That(original.Seed, Is.Not.Zero, "An unseeded run must still report the seed it used.");
+
+ var second = new ServiceContainer();
+ var reproduced = second.MazeGenerationFactory.GenerateMaze(
+ Settings(Algorithm.RecursiveBacktrackerAlgorithm, seed: original.Seed));
+
+ Assert.That(Fingerprint(second, reproduced), Is.EqualTo(Fingerprint(first, original)),
+ $"Replaying reported seed {original.Seed} did not reproduce the original maze.");
+ }
+
+ [Test]
+ public void SeededGeneration_IsStableAcrossWallRemovalAndAgents()
+ {
+ // Wall removal and agent walks are separate consumers of randomness; a seed has to
+ // pin those too, or a golden file covering a full pipeline run would still flake.
+ var settings = Settings(Algorithm.RecursiveBacktrackerAlgorithm, Seed);
+ settings.WallRemovalPercent = 10;
+ settings.AgentType = AgentType.Perfect;
+
+ var fingerprints = new HashSet();
+ var agentPathLengths = new HashSet();
+ for (var run = 0; run < 5; run++)
+ {
+ var services = new ServiceContainer();
+ var r = services.MazeGenerationFactory.GenerateMaze(settings);
+ fingerprints.Add(Fingerprint(services, r));
+ agentPathLengths.Add(r.AgentResults?.Movements.Count ?? -1);
+ }
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(fingerprints, Has.Count.EqualTo(1), "Wall removal is not seed-stable.");
+ Assert.That(agentPathLengths, Has.Count.EqualTo(1),
+ $"Agent walk is not seed-stable: [{string.Join(", ", agentPathLengths)}]");
+ });
+ }
+
+ [Test]
+ public void SeedIsReportedBack_EvenWhenSpecified()
+ {
+ // Results always carry the seed that produced them, so a golden file can record
+ // which seed it was generated from.
+ var services = new ServiceContainer();
+ var result = services.MazeGenerationFactory.GenerateMaze(
+ Settings(Algorithm.GrowingTreeAlgorithm, Seed));
+
+ Assert.That(result.Seed, Is.EqualTo(Seed));
+ }
+
+ [Test]
+ public void OneContainer_ManySeeds_StaysDeterministic()
+ {
+ // Golden-file suites reuse a container across cases. Reseeding happens per
+ // GenerateMaze call, so earlier runs must not bleed into later ones — generating
+ // A then B must give the same B as generating B alone.
+ var shared = new ServiceContainer();
+ shared.MazeGenerationFactory.GenerateMaze(Settings(Algorithm.PrimsAlgorithm, 111));
+ var bAfterA = Fingerprint(shared,
+ shared.MazeGenerationFactory.GenerateMaze(Settings(Algorithm.PrimsAlgorithm, 222)));
+
+ var fresh = new ServiceContainer();
+ var bAlone = Fingerprint(fresh,
+ fresh.MazeGenerationFactory.GenerateMaze(Settings(Algorithm.PrimsAlgorithm, 222)));
+
+ Assert.That(bAfterA, Is.EqualTo(bAlone),
+ "Generation order affected output — reseeding is leaking state between runs.");
+ }
+}
diff --git a/tests/RandomnessDisciplineTests.cs b/tests/RandomnessDisciplineTests.cs
new file mode 100644
index 0000000..615a80b
--- /dev/null
+++ b/tests/RandomnessDisciplineTests.cs
@@ -0,0 +1,107 @@
+using System.Text.RegularExpressions;
+using NUnit.Framework;
+
+namespace ProceduralMaze.Tests;
+
+///
+/// Architecture test: keeps maze logic free of ungoverned global randomness.
+///
+/// Determinism is a property of the whole pipeline — one Random.Shared call
+/// anywhere in generation silently breaks seed reproducibility, and the symptom shows up
+/// as a flaky golden-file test far from the cause. This scans the source instead of
+/// relying on reviewers to notice.
+///
+/// If this fails: inject IRandomValueGenerator and use its GetNext /
+/// Shuffle members rather than static randomness.
+///
+[TestFixture]
+public class RandomnessDisciplineTests
+{
+ ///
+ /// Patterns that bypass the injected generator or clock.
+ ///
+ private static readonly (string Pattern, string Why)[] Banned =
+ [
+ (@"Random\s*\.\s*Shared", "Random.Shared is process-global and cannot be seeded"),
+ (@"new\s+Random\s*\(", "a locally-constructed Random escapes the seeded sequence"),
+ (@"ArrayHelper\s*\.\s*Shuffle", "ArrayHelper.Shuffle used Random.Shared and has been removed; use IRandomValueGenerator.Shuffle"),
+ (@"Guid\s*\.\s*NewGuid", "Guid.NewGuid is nondeterministic"),
+ (@"DateTime\s*\.\s*(Now|UtcNow)", "wall-clock reads make output unreproducible"),
+ ];
+
+ ///
+ /// The designated sources of nondeterminism. Each is injected, so tests can pin it.
+ /// Adding to this list means adding a new global-state escape hatch — think twice.
+ ///
+ private static readonly string[] Exempt = ["RandomValueGenerator.cs", "SystemClock.cs"];
+
+ private static string MazeSourceRoot()
+ {
+ // Walk up from the test output directory to the repo root, then into scripts/maze.
+ var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory);
+ while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "scripts", "maze")))
+ {
+ dir = dir.Parent;
+ }
+
+ Assert.That(dir, Is.Not.Null, "Could not locate scripts/maze from the test directory.");
+ return Path.Combine(dir!.FullName, "scripts", "maze");
+ }
+
+ [Test]
+ public void MazeLogic_ContainsNoUngovernedRandomness()
+ {
+ var root = MazeSourceRoot();
+ var files = Directory.GetFiles(root, "*.cs", SearchOption.AllDirectories)
+ .Where(f => !Exempt.Contains(Path.GetFileName(f)))
+ .ToList();
+
+ Assert.That(files, Is.Not.Empty, $"No source files found under {root} — the scan would vacuously pass.");
+
+ var violations = new List();
+ foreach (var file in files)
+ {
+ var lines = File.ReadAllLines(file);
+ for (var i = 0; i < lines.Length; i++)
+ {
+ var line = lines[i];
+
+ // Skip comments and doc comments — this file and the interface docs
+ // legitimately name the banned APIs when explaining why they're banned.
+ var trimmed = line.TrimStart();
+ if (trimmed.StartsWith("//") || trimmed.StartsWith("///") || trimmed.StartsWith("*")) continue;
+
+ foreach (var (pattern, why) in Banned)
+ {
+ if (Regex.IsMatch(line, pattern))
+ {
+ violations.Add($"{Path.GetFileName(file)}:{i + 1}: {trimmed}\n -> {why}");
+ }
+ }
+ }
+ }
+
+ Assert.That(violations, Is.Empty,
+ $"Ungoverned randomness in maze logic ({violations.Count} site(s)):\n " +
+ string.Join("\n ", violations) +
+ "\n\nInject IRandomValueGenerator and use GetNext/Shuffle instead.");
+ }
+
+ [Test]
+ public void EveryRandomnessConsumer_ResolvesThroughTheContainer()
+ {
+ // Catches the other half: a class that takes IRandomValueGenerator but was wired
+ // up with a throwaway instance instead of the container's, which would sit outside
+ // the reseeded sequence.
+ var root = MazeSourceRoot();
+ var offenders = Directory.GetFiles(root, "*.cs", SearchOption.AllDirectories)
+ .Where(f => !Exempt.Contains(Path.GetFileName(f)))
+ .Where(f => Regex.IsMatch(File.ReadAllText(f), @"new\s+RandomValueGenerator\s*\("))
+ .Select(Path.GetFileName)
+ .ToList();
+
+ Assert.That(offenders, Is.Empty,
+ "These files construct their own RandomValueGenerator instead of taking the " +
+ "injected one, so they won't follow the seeded sequence: " + string.Join(", ", offenders));
+ }
+}
diff --git a/tests/SampleMazeTests.cs b/tests/SampleMazeTests.cs
index a6cb526..0fcdaac 100644
--- a/tests/SampleMazeTests.cs
+++ b/tests/SampleMazeTests.cs
@@ -28,6 +28,30 @@ public class SampleMazeTests
///
private const int MaxTestMazeCells = 5000;
+ ///
+ /// Maximum cells for a maze used in PerfectAgent tests.
+ ///
+ /// Much smaller than because PerfectAgent's search is
+ /// worst-case exponential: it tracks visited cells per-path
+ /// (previousPoints.Any(...), a linear scan) rather than with a shared visited
+ /// set, so the same cell is re-explored along different paths, and it copies the whole
+ /// path per branch. Whether it finishes quickly depends on the shuffled direction
+ /// order.
+ ///
+ /// Measured on the 1200- and 1600-cell samples (20x20x3, 20x20x4), 8 runs of these two
+ /// tests: 1.8s, 1.9s, 2.0s, 3.2s, 14.6s, 25.9s and two runs still unfinished at 120s.
+ /// The same runs with RandomAgent were flat at 1.6-1.8s. That heavy tail is what made
+ /// CI wall-time range from 29s to over 40 minutes on identical code.
+ ///
+ /// 200 cells keeps the 10x10x1 sample and drops the two large ones, which is enough to
+ /// cover the agent's behaviour. Larger mazes belong in benchmarks, where a long run is
+ /// measured rather than blocking a merge.
+ ///
+ /// This bounds the symptom, not the cause — PerfectAgent should use a shared visited
+ /// set. See docs/REGRESSION_TESTING.md -> "Open questions".
+ ///
+ private const int MaxPerfectAgentMazeCells = 200;
+
///
/// All available maze files for testing (excluding very large mazes).
///
@@ -38,6 +62,17 @@ private static IEnumerable AllMazeFiles()
.Where(f => GetCellCount(f) <= MaxTestMazeCells);
}
+ ///
+ /// Maze files small enough for PerfectAgent's exponential search.
+ /// See .
+ ///
+ private static IEnumerable PerfectAgentMazeFiles()
+ {
+ return Directory.GetFiles(SampleDataDirectory, "*.maze")
+ .Select(f => Path.GetFileName(f)!)
+ .Where(f => GetCellCount(f) <= MaxPerfectAgentMazeCells);
+ }
+
///
/// Calculate cell count from filename (e.g., "40x40x20.maze" = 32000).
///
@@ -383,7 +418,7 @@ public void DijkstraAnimator_VisitedNodesGrowMonotonically(string filename)
#region Agent Tests
- [Test, TestCaseSource(nameof(AllMazeFiles))]
+ [Test, TestCaseSource(nameof(PerfectAgentMazeFiles)), Timeout(60_000)]
public void PerfectAgent_SolvesMaze(string filename)
{
var services = CreateServices();
@@ -397,7 +432,7 @@ public void PerfectAgent_SolvesMaze(string filename)
Assert.That(result.Movements, Is.Not.Empty, "Agent should make movements");
}
- [Test, TestCaseSource(nameof(AllMazeFiles))]
+ [Test, TestCaseSource(nameof(PerfectAgentMazeFiles)), Timeout(60_000)]
public void PerfectAgent_PathReachesEnd(string filename)
{
var services = CreateServices();
diff --git a/tests/visual/canvas-stability.ts b/tests/visual/canvas-stability.ts
new file mode 100644
index 0000000..aa40aa5
--- /dev/null
+++ b/tests/visual/canvas-stability.ts
@@ -0,0 +1,99 @@
+import { Page, expect } from "@playwright/test";
+
+/**
+ * Shared helpers for screenshotting a live WebGL/WASM canvas.
+ *
+ * A canvas app has no "load complete" event that means "finished drawing". Screenshotting
+ * on DOM-ready captures a half-drawn frame and produces a flaky baseline, so these helpers
+ * wait for the pixels themselves to stop changing.
+ */
+
+/** Boot signal: cross-origin isolation granted and the engine has sized its canvas. */
+export async function waitForEngineBoot(page: Page, timeout = 150_000): Promise {
+ // The .NET WASM runtime needs SharedArrayBuffer, which the browser only grants under
+ // COOP/COEP. Assert it explicitly — without it the canvas appears but never starts, and
+ // the screenshot would silently capture a blank frame.
+ const env = await page.evaluate(() => ({
+ coi: self.crossOriginIsolated,
+ sab: typeof SharedArrayBuffer !== "undefined",
+ }));
+ expect(env.coi, "crossOriginIsolated is false — COOP/COEP headers missing").toBe(true);
+ expect(env.sab, "SharedArrayBuffer unavailable — cross-origin isolation not effective").toBe(true);
+
+ await page.waitForFunction(
+ () => {
+ const c = document.querySelector("canvas") as HTMLCanvasElement | null;
+ return !!c && c.width > 0 && c.height > 0;
+ },
+ undefined,
+ { timeout },
+ );
+}
+
+/**
+ * Waits until the canvas renders the same content on consecutive samples.
+ *
+ * Hashes a downscaled copy of the canvas rather than diffing full frames: cheap enough to
+ * poll, and insensitive to the sub-pixel noise that would stop a strict comparison from
+ * ever settling.
+ */
+export async function waitForStableFrame(
+ page: Page,
+ { samples = 3, intervalMs = 500, timeoutMs = 60_000 }: {
+ samples?: number;
+ intervalMs?: number;
+ timeoutMs?: number;
+ } = {},
+): Promise {
+ const deadline = Date.now() + timeoutMs;
+ let previous: string | null = null;
+ let stableRuns = 0;
+
+ while (Date.now() < deadline) {
+ const hash = await page.evaluate(() => {
+ const c = document.querySelector("canvas") as HTMLCanvasElement | null;
+ if (!c) return null;
+ // Downscale to 64x64 through a 2D context, then hash the bytes.
+ const scratch = document.createElement("canvas");
+ scratch.width = 64;
+ scratch.height = 64;
+ const ctx = scratch.getContext("2d");
+ if (!ctx) return null;
+ try {
+ ctx.drawImage(c, 0, 0, 64, 64);
+ } catch {
+ return null; // tainted or not yet drawable
+ }
+ const { data } = ctx.getImageData(0, 0, 64, 64);
+ let h1 = 0x811c9dc5;
+ for (let i = 0; i < data.length; i += 4) {
+ h1 ^= data[i] | (data[i + 1] << 8) | (data[i + 2] << 16);
+ h1 = Math.imul(h1, 0x01000193);
+ }
+ return (h1 >>> 0).toString(16);
+ });
+
+ if (hash !== null && hash === previous) {
+ if (++stableRuns >= samples - 1) return;
+ } else {
+ stableRuns = 0;
+ }
+ previous = hash;
+ await page.waitForTimeout(intervalMs);
+ }
+
+ throw new Error(
+ `Canvas never reached a stable frame within ${timeoutMs}ms — it is still animating, ` +
+ `or the seed did not pin the render. Screenshotting now would produce a flaky baseline.`,
+ );
+}
+
+/** Fails the test on fatal WASM/runtime errors, which otherwise yield a blank screenshot. */
+export function failOnRuntimeErrors(page: Page): string[] {
+ const fatal: string[] = [];
+ page.on("pageerror", (e) => {
+ const text = String(e);
+ if (/abort|Aborted|RuntimeError|unreachable|out of memory/i.test(text)) fatal.push(text);
+ });
+ return fatal;
+}
diff --git a/tests/visual/harness.spec.ts b/tests/visual/harness.spec.ts
new file mode 100644
index 0000000..82208da
--- /dev/null
+++ b/tests/visual/harness.spec.ts
@@ -0,0 +1,104 @@
+import { test, expect } from "@playwright/test";
+import { waitForStableFrame } from "./canvas-stability";
+
+/**
+ * Self-test for the visual-regression harness.
+ *
+ * The real suite (maze.spec.ts) can only run against a deployed build, which needs a
+ * patched Windows editor to produce. That makes it easy for the harness itself — the
+ * stability polling, the snapshot comparison, the diff thresholds — to sit unverified until
+ * the day someone needs it and finds it broken.
+ *
+ * This renders a deterministic canvas locally and asserts the same machinery works on it:
+ * a seeded draw is byte-stable, an animating canvas is correctly rejected as unstable, and
+ * a changed render is actually caught rather than passing under a loose threshold.
+ */
+
+/** Seeded canvas drawing, standing in for a seeded maze render. */
+function fixture(seed: number, animate = false): string {
+ return `
+
+
+ `;
+}
+
+test.describe("visual harness self-test", () => {
+ test("a seeded canvas render is byte-identical across fresh pages", async ({ browser }) => {
+ // Each load gets a FRESH page. That detail is load-bearing: measured on this harness,
+ // a seeded draw is byte-identical across fresh pages and even across separate browser
+ // launches, but calling setContent twice on one page yields ~3800 differing pixels
+ // (1.6%) for identical content. Reusing a page perturbs the raster; a fresh page does
+ // not. So screenshot each case on its own page — and byte-exact is a legitimate
+ // assertion here, no threshold needed.
+ const shoot = async () => {
+ const page = await browser.newPage({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 });
+ try {
+ await page.setContent(fixture(12345));
+ await waitForStableFrame(page, { samples: 2, intervalMs: 100, timeoutMs: 10_000 });
+ return await page.locator("canvas").screenshot();
+ } finally {
+ await page.close();
+ }
+ };
+
+ expect(Buffer.compare(await shoot(), await shoot()),
+ "Identical seed produced different pixels on fresh pages — the harness cannot trust any baseline.").toBe(0);
+ });
+
+ test("different seeds produce visibly different renders", async ({ page }) => {
+ // If this failed, the suite could pass while comparing two blank canvases.
+ await page.setContent(fixture(1));
+ await waitForStableFrame(page, { samples: 2, intervalMs: 100, timeoutMs: 10_000 });
+ const a = await page.locator("canvas").screenshot();
+
+ await page.setContent(fixture(2));
+ await waitForStableFrame(page, { samples: 2, intervalMs: 100, timeoutMs: 10_000 });
+ const b = await page.locator("canvas").screenshot();
+
+ expect(Buffer.compare(a, b), "Two different seeds rendered identically.").not.toBe(0);
+ });
+
+ test("an animating canvas is rejected rather than screenshotted mid-frame", async ({ page }) => {
+ // The failure mode this guards: screenshotting before the render settles, which yields
+ // a baseline that flakes forever. waitForStableFrame must throw, not return.
+ await page.setContent(fixture(999, /* animate */ true));
+ await expect(
+ waitForStableFrame(page, { samples: 3, intervalMs: 100, timeoutMs: 3_000 }),
+ ).rejects.toThrow(/never reached a stable frame/);
+ });
+
+ test("baseline comparison catches a changed render", async ({ page }) => {
+ // Establishes/compares a committed baseline, exercising the real snapshot path and the
+ // configured maxDiffPixelRatio.
+ await page.setContent(fixture(4242));
+ await waitForStableFrame(page, { samples: 2, intervalMs: 100, timeoutMs: 10_000 });
+ await expect(page.locator("canvas")).toHaveScreenshot("harness-baseline.png");
+ });
+});
diff --git a/tests/visual/harness.spec.ts-snapshots/harness-baseline-harness-selftest-linux.png b/tests/visual/harness.spec.ts-snapshots/harness-baseline-harness-selftest-linux.png
new file mode 100644
index 0000000..e0f5f26
Binary files /dev/null and b/tests/visual/harness.spec.ts-snapshots/harness-baseline-harness-selftest-linux.png differ
diff --git a/tests/visual/maze.spec.ts b/tests/visual/maze.spec.ts
new file mode 100644
index 0000000..7e5b87e
--- /dev/null
+++ b/tests/visual/maze.spec.ts
@@ -0,0 +1,79 @@
+import { test, expect } from "@playwright/test";
+import { waitForEngineBoot, waitForStableFrame, failOnRuntimeErrors } from "./canvas-stability";
+
+/**
+ * Visual regression against the deployed C#/WASM build.
+ *
+ * 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.
+ */
+
+const SEEDING_SUPPORTED = process.env.MAZE_SEEDING === "1";
+
+/** Fixed cases. Each must render a byte-stable maze given the seeding contract. */
+const CASES = [
+ { name: "backtracker-10x10", query: "seed=20260725&algorithm=backtracker&x=10&y=10&z=1" },
+ { name: "growingtree-10x10", query: "seed=20260725&algorithm=growingtree&x=10&y=10&z=1" },
+ { name: "prims-15x15", query: "seed=99&algorithm=prims&x=15&y=15&z=1" },
+ { name: "backtracker-3d-8x8x3", query: "seed=7&algorithm=backtracker&x=8&y=8&z=3" },
+];
+
+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.",
+ );
+
+ for (const testCase of CASES) {
+ test(testCase.name, async ({ page }) => {
+ const fatal = failOnRuntimeErrors(page);
+
+ const response = await page.goto(`/?${testCase.query}`, { waitUntil: "domcontentloaded" });
+ expect(response?.ok(), `HTTP ${response?.status()} loading the build`).toBeTruthy();
+
+ await waitForEngineBoot(page);
+ await waitForStableFrame(page);
+
+ expect(fatal, `fatal runtime error(s):\n${fatal.join("\n")}`).toHaveLength(0);
+
+ // Screenshot the canvas alone, not the page: surrounding chrome (loading bars,
+ // fullscreen buttons) is not what we're regression-testing.
+ const canvas = page.locator("canvas");
+ await expect(canvas).toHaveScreenshot(`${testCase.name}.png`);
+ });
+ }
+
+ test("same seed reproduces the same render across two independent loads", async ({ browser }) => {
+ // Guards the property the whole suite rests on: if the seed isn't actually pinning the
+ // render, every other baseline here is untrustworthy.
+ //
+ // Each load uses a FRESH page. harness.spec.ts measured why: reusing one page across
+ // two loads perturbs the raster (~1.6% of pixels differ for identical content), while
+ // fresh pages are byte-identical. Compared with the configured threshold rather than
+ // byte-exact, because unlike the 2D-canvas harness this is a WebGL/WASM render whose
+ // cross-run determinism has not been measured — tighten to byte-exact if it proves
+ // stable in practice.
+ const shoot = async () => {
+ const page = await browser.newPage({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 });
+ try {
+ await page.goto(`${process.env.MAZE_URL}/?${CASES[0].query}`, { waitUntil: "domcontentloaded" });
+ await waitForEngineBoot(page);
+ await waitForStableFrame(page);
+ await expect(page.locator("canvas")).toHaveScreenshot("seed-stability.png");
+ } finally {
+ await page.close();
+ }
+ };
+
+ await shoot();
+ await shoot();
+ });
+});
diff --git a/tests/visual/package-lock.json b/tests/visual/package-lock.json
new file mode 100644
index 0000000..6033531
--- /dev/null
+++ b/tests/visual/package-lock.json
@@ -0,0 +1,78 @@
+{
+ "name": "maze-visual-regression",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "maze-visual-regression",
+ "version": "1.0.0",
+ "devDependencies": {
+ "@playwright/test": "^1.55.0"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.62.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
+ "integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.62.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.62.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
+ "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.62.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.62.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
+ "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ }
+ }
+}
diff --git a/tests/visual/package.json b/tests/visual/package.json
new file mode 100644
index 0000000..2abd5e7
--- /dev/null
+++ b/tests/visual/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "maze-visual-regression",
+ "private": true,
+ "version": "1.0.0",
+ "description": "Playwright visual regression suite for the C#/WASM web build",
+ "scripts": {
+ "test": "playwright test",
+ "test:update": "playwright test --update-snapshots",
+ "report": "playwright show-report",
+ "selftest": "playwright test --project=harness-selftest"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.55.0"
+ }
+}
diff --git a/tests/visual/playwright.config.ts b/tests/visual/playwright.config.ts
new file mode 100644
index 0000000..43c3931
--- /dev/null
+++ b/tests/visual/playwright.config.ts
@@ -0,0 +1,77 @@
+import { defineConfig, devices } from "@playwright/test";
+
+/**
+ * Visual regression config for the maze web build.
+ *
+ * Two projects:
+ * - `maze` — screenshots the deployed build. Needs MAZE_URL.
+ * - `harness-selftest`— screenshots a local deterministic canvas fixture. Proves the
+ * comparison harness itself works without needing a Godot export,
+ * which matters because producing the web build requires a patched
+ * Windows editor (see docs/WEB_EXPORT.md).
+ *
+ * Snapshots are platform-keyed by Playwright. Baselines MUST be generated on the same
+ * platform CI uses (linux) or every run fails on a missing snapshot — see
+ * docs/VISUAL_REGRESSION.md.
+ */
+/**
+ * Optional escape hatch for environments that ship a pinned Chromium whose build number
+ * doesn't match this @playwright/test version (sandboxes, air-gapped runners). GitHub
+ * Actions runs `playwright install` and needs none of this — leave it unset there.
+ */
+const launchOptions = process.env.CHROMIUM_PATH
+ ? { executablePath: process.env.CHROMIUM_PATH }
+ : {};
+
+export default defineConfig({
+ testDir: ".",
+ // Canvas/WASM boot is slow: ~96 MB payload plus .NET runtime init.
+ timeout: 180_000,
+ expect: {
+ toHaveScreenshot: {
+ // Canvas rendering is not bit-identical across driver/GPU revisions even on the
+ // same image, so allow a small ratio rather than demanding zero diff. Tight enough
+ // that a changed maze (thousands of differing pixels) still fails.
+ maxDiffPixelRatio: 0.01,
+ // Ignore sub-perceptual per-pixel noise from antialiasing.
+ threshold: 0.2,
+ animations: "disabled",
+ },
+ },
+ // Visual baselines are order- and load-sensitive; keep it serial and retry-free so a
+ // failure means a real diff rather than a flake masked by a retry.
+ workers: 1,
+ retries: 0,
+ fullyParallel: false,
+ forbidOnly: !!process.env.CI,
+ reporter: process.env.CI
+ ? [["github"], ["html", { open: "never" }], ["list"]]
+ : [["html", { open: "never" }], ["list"]],
+ use: {
+ // Fixed viewport: a different window size is a different screenshot.
+ viewport: { width: 1280, height: 720 },
+ // Deterministic rendering across machines.
+ deviceScaleFactor: 1,
+ colorScheme: "light",
+ timezoneId: "UTC",
+ locale: "en-GB",
+ screenshot: "only-on-failure",
+ trace: "retain-on-failure",
+ },
+ projects: [
+ {
+ name: "maze",
+ testMatch: /maze\.spec\.ts/,
+ use: {
+ ...devices["Desktop Chrome"],
+ baseURL: process.env.MAZE_URL,
+ launchOptions,
+ },
+ },
+ {
+ name: "harness-selftest",
+ testMatch: /harness\.spec\.ts/,
+ use: { ...devices["Desktop Chrome"], launchOptions },
+ },
+ ],
+});