Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
87 changes: 87 additions & 0 deletions .github/workflows/web-export.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: >
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ test-results/
playwright-report/
blob-report/
playwright/.cache/
.last-run.json

# Local scratch / planning notes
todo.md
Expand Down
33 changes: 33 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
19 changes: 12 additions & 7 deletions benchmarks/HelperBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ namespace ProceduralMaze.Benchmarks;

/// <summary>
/// Benchmarks for helper utility functions.
/// Tests ArrayHelper.Shuffle and DirectionsFlagParser performance.
/// Tests IRandomValueGenerator.Shuffle and DirectionsFlagParser performance.
/// </summary>
[MemoryDiagnoser]
[ShortRunJob]
Expand All @@ -20,9 +20,14 @@ public class HelperBenchmarks
private List<int> _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();
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading