Engine-Side Test Harness Implementation Plan
Engine-Side Test Harness Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Stand up a sim-parity test lane that runs the same scenarios under plain dotnet test and under godot --headless with GdUnit4Net, proving the existing pure-C# sim behaves identically when hosted by Godot 4.6.2 mono.
Architecture: A new shared Muscleman.Sim.TestFixtures library defines scenarios as data (Seed delegate + Inputs list + ExpectedEvents builder). Two test lanes — xUnit in Muscleman.Sim.Tests and GdUnit4 in a new engine/ Godot project — both consume Scenarios.All and assert equivalence against the same expected output.
Tech Stack: C# / .NET 9 · xUnit + FluentAssertions (existing) · Godot 4.6.2-stable mono · GdUnit4 (GDScript addon for headless runner) · gdUnit4.api NuGet (C# test API).
Spec reference
This plan implements docs/superpowers/specs/2026-05-13-engine-test-harness-design.md. Read §2 (architecture), §4 (components), §8 (definition of done) before starting.
Locking in spec open questions
The spec defers three details (§9). This plan locks them in:
Scenario.Seedshape: Returns a richerScenarioContext(not justint) soExpectedEventscan reference bothPlayerIdand named auxiliary entities like"box".ExpectedEventsbecomesFunc<ScenarioContext, IReadOnlyList<SimEvent>>(lazy, evaluated afterSeedruns so entity ids are known).- GdUnit4Net
[TestCase]parameterisation: Not relied upon. One[TestCase]method per scenario in the engine project, each method invoking a sharedRunScenarioByName(string)helper. Simpler and avoids type-mapping headaches. - Headless CLI flag set: Pinned in Task 9 against the version chosen in Task 7, with command shown in full.
File structure
muscleman-godot4/
├── Muscleman.sln MODIFIED — adds 2 new projects
├── Makefile NEW
├── .gitignore MODIFIED — adds Godot transients
├── src/
│ ├── Muscleman.Sim.TestFixtures/ NEW project
│ │ ├── Muscleman.Sim.TestFixtures.csproj
│ │ ├── Scenario.cs (Scenario record + ScenarioContext)
│ │ ├── ScenarioRunner.cs (runs a Scenario, returns SimEvent list)
│ │ └── Scenarios.cs (static catalog: All + 3 starter scenarios)
│ └── Muscleman.Sim.Tests/
│ └── ParityTests.cs NEW — xUnit theory over Scenarios.All
└── engine/ NEW Godot project root
├── project.godot (manifest, Godot 4.6 mono / .NET 9)
├── Muscleman.Engine.Tests.csproj (Godot-generated C# project)
├── icon.svg (Godot stub, may be auto-created)
├── addons/gdUnit4/ (committed addon for headless runner)
├── tests/
│ └── ParityTests.cs (GdUnit4 test suite — one method per scenario)
└── .gitignore (.godot/, .mono/, .import/, *.uid, etc.)
Decomposition rationale: Scenario, ScenarioRunner, and Scenarios are split because they have distinct responsibilities (type, executor, data). They live in one project because they always change together when a scenario is added or its expectations are revised. Tests live in their respective harness projects.
Task 1: Bootstrap Muscleman.Sim.TestFixtures project
Files:
- Create:
muscleman-godot4/src/Muscleman.Sim.TestFixtures/Muscleman.Sim.TestFixtures.csproj -
Modify:
muscleman-godot4/Muscleman.sln(add new project entry) - Step 1: Create the csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Muscleman.Core\Muscleman.Core.csproj" />
<ProjectReference Include="..\Muscleman.Sim\Muscleman.Sim.csproj" />
</ItemGroup>
</Project>
- Step 2: Add the project to the solution
Run from muscleman-godot4/:
dotnet sln Muscleman.sln add src/Muscleman.Sim.TestFixtures/Muscleman.Sim.TestFixtures.csproj
Expected output: Project ... added to the solution.
- Step 3: Verify it builds
Run:
dotnet build muscleman-godot4/Muscleman.sln
Expected: build succeeds with 0 errors. The new project compiles (empty but valid).
- Step 4: Commit
git add muscleman-godot4/src/Muscleman.Sim.TestFixtures/ muscleman-godot4/Muscleman.sln
git commit -m "test-fixtures: bootstrap Muscleman.Sim.TestFixtures project"
Task 2: Define Scenario and ScenarioContext types (TDD)
Files:
- Create:
muscleman-godot4/src/Muscleman.Sim.TestFixtures/Scenario.cs - Create:
muscleman-godot4/src/Muscleman.Sim.Tests/ScenarioTypeTests.cs(temporary, removed after Task 5) -
Modify:
muscleman-godot4/src/Muscleman.Sim.Tests/Muscleman.Sim.Tests.csproj(add ProjectReference to TestFixtures) - Step 1: Add a ProjectReference from Tests to TestFixtures
In muscleman-godot4/src/Muscleman.Sim.Tests/Muscleman.Sim.Tests.csproj, add inside the existing ItemGroup containing ProjectReference elements:
<ProjectReference Include="..\Muscleman.Sim.TestFixtures\Muscleman.Sim.TestFixtures.csproj" />
- Step 2: Write the failing test for the Scenario type shape
Create muscleman-godot4/src/Muscleman.Sim.Tests/ScenarioTypeTests.cs:
using Muscleman.Core.Events;
using Muscleman.Core.Grid;
using Muscleman.Core.Input;
using Muscleman.Sim;
using Muscleman.Sim.TestFixtures;
using FluentAssertions;
using Xunit;
namespace Muscleman.Sim.Tests;
public class ScenarioTypeTests
{
[Fact]
public void Scenario_holds_name_seed_inputs_and_expected_events_builder()
{
var s = new Scenario(
Name: "trivial",
Seed: w =>
{
var pid = w.SpawnEmpty(new GridPos(0, 0));
return new ScenarioContext(w, pid, new Dictionary<string, int>());
},
Inputs: new List<Input> { new Input.Wait() },
ExpectedEvents: _ => new List<SimEvent>());
s.Name.Should().Be("trivial");
s.Inputs.Should().HaveCount(1);
}
}
- Step 3: Run the test to confirm it fails
Run from muscleman-godot4/:
dotnet test --filter "FullyQualifiedName~ScenarioTypeTests" --no-restore || true
Expected: compile failure — The type or namespace 'Scenario' could not be found.
- Step 4: Implement
ScenarioandScenarioContext
Create muscleman-godot4/src/Muscleman.Sim.TestFixtures/Scenario.cs:
using Muscleman.Core.Events;
using Muscleman.Core.Input;
namespace Muscleman.Sim.TestFixtures;
public sealed record ScenarioContext(
World World,
int PlayerId,
IReadOnlyDictionary<string, int> Named);
public sealed record Scenario(
string Name,
Func<World, ScenarioContext> Seed,
IReadOnlyList<Input> Inputs,
Func<ScenarioContext, IReadOnlyList<SimEvent>> ExpectedEvents);
- Step 5: Run the test to confirm it passes
dotnet test --filter "FullyQualifiedName~ScenarioTypeTests"
Expected: 1 passed.
- Step 6: Commit
git add muscleman-godot4/src/Muscleman.Sim.TestFixtures/Scenario.cs \
muscleman-godot4/src/Muscleman.Sim.Tests/Muscleman.Sim.Tests.csproj \
muscleman-godot4/src/Muscleman.Sim.Tests/ScenarioTypeTests.cs
git commit -m "test-fixtures: add Scenario + ScenarioContext records"
Task 3: Implement ScenarioRunner (TDD)
Files:
- Create:
muscleman-godot4/src/Muscleman.Sim.TestFixtures/ScenarioRunner.cs -
Modify:
muscleman-godot4/src/Muscleman.Sim.Tests/ScenarioTypeTests.cs(add runner test) - Step 1: Write the failing test
Append to muscleman-godot4/src/Muscleman.Sim.Tests/ScenarioTypeTests.cs:
[Fact]
public void ScenarioRunner_drives_inputs_and_flattens_event_batches()
{
// Player at (0,0), wait once — produces no events.
var s = new Scenario(
Name: "wait-once",
Seed: w =>
{
var pid = Muscleman.Sim.Recipes.RecipeFactory.Spawn(
w, Muscleman.Core.Enums.EntityRecipe.Player, new GridPos(0, 0));
return new ScenarioContext(w, pid, new Dictionary<string, int>());
},
Inputs: new List<Input> { new Input.Wait() },
ExpectedEvents: _ => new List<SimEvent>());
var (events, _) = ScenarioRunner.Run(s);
events.Should().BeEmpty();
}
- Step 2: Run to confirm it fails
dotnet test --filter "FullyQualifiedName~ScenarioRunner_drives_inputs_and_flattens_event_batches" --no-restore || true
Expected: compile failure — ScenarioRunner not found.
- Step 3: Implement
ScenarioRunner
Create muscleman-godot4/src/Muscleman.Sim.TestFixtures/ScenarioRunner.cs:
using Muscleman.Core.Events;
namespace Muscleman.Sim.TestFixtures;
public static class ScenarioRunner
{
public static (IReadOnlyList<SimEvent> Events, ScenarioContext Context) Run(Scenario scenario)
{
var world = new World();
var ctx = scenario.Seed(world);
var all = new List<SimEvent>();
foreach (var input in scenario.Inputs)
{
var batch = StepRunner.Step(ctx.World, ctx.PlayerId, input);
all.AddRange(batch.Events);
}
return (all, ctx);
}
}
- Step 4: Run to confirm it passes
dotnet test --filter "FullyQualifiedName~ScenarioRunner_drives_inputs_and_flattens_event_batches"
Expected: 1 passed.
- Step 5: Commit
git add muscleman-godot4/src/Muscleman.Sim.TestFixtures/ScenarioRunner.cs \
muscleman-godot4/src/Muscleman.Sim.Tests/ScenarioTypeTests.cs
git commit -m "test-fixtures: ScenarioRunner — drive inputs, flatten event batches"
Task 4: Author the three starter scenarios (TDD)
Files:
- Create:
muscleman-godot4/src/Muscleman.Sim.TestFixtures/Scenarios.cs -
Modify:
muscleman-godot4/src/Muscleman.Sim.Tests/ScenarioTypeTests.cs(add catalog test) - Step 1: Write the failing test for the catalog
Append to muscleman-godot4/src/Muscleman.Sim.Tests/ScenarioTypeTests.cs:
[Fact]
public void Scenarios_All_exposes_the_three_starters_by_name()
{
Scenarios.All.Select(s => s.Name).Should().BeEquivalentTo(new[]
{
"WalkOneStepNorth",
"PushOneBoxEast",
"GrabThenDrop",
});
}
- Step 2: Run to confirm it fails
dotnet test --filter "FullyQualifiedName~Scenarios_All_exposes_the_three_starters_by_name" --no-restore || true
Expected: compile failure — Scenarios not found.
- Step 3: Implement the catalog
Create muscleman-godot4/src/Muscleman.Sim.TestFixtures/Scenarios.cs:
using Muscleman.Core.Enums;
using Muscleman.Core.Events;
using Muscleman.Core.Grid;
using Muscleman.Core.Input;
using Muscleman.Sim.Recipes;
namespace Muscleman.Sim.TestFixtures;
public static class Scenarios
{
public static readonly Scenario WalkOneStepNorth = new(
Name: "WalkOneStepNorth",
Seed: w =>
{
var player = RecipeFactory.Spawn(w, EntityRecipe.Player, new GridPos(5, 5));
return new ScenarioContext(w, player, new Dictionary<string, int>());
},
Inputs: new List<Input> { new Input.Move(GridDir.Up) },
ExpectedEvents: ctx => new List<SimEvent>
{
new Moved(ctx.PlayerId, new GridPos(5, 5), new GridPos(5, 4), MoveCause.Player),
});
public static readonly Scenario PushOneBoxEast = new(
Name: "PushOneBoxEast",
Seed: w =>
{
var player = RecipeFactory.Spawn(w, EntityRecipe.Player, new GridPos(5, 5));
var box = RecipeFactory.Spawn(w, EntityRecipe.Box, new GridPos(6, 5));
return new ScenarioContext(w, player, new Dictionary<string, int> { ["box"] = box });
},
Inputs: new List<Input> { new Input.Move(GridDir.Right) },
ExpectedEvents: ctx => new List<SimEvent>
{
new Moved(ctx.Named["box"], new GridPos(6, 5), new GridPos(7, 5), MoveCause.Push),
new Moved(ctx.PlayerId, new GridPos(5, 5), new GridPos(6, 5), MoveCause.Player),
});
public static readonly Scenario GrabThenDrop = new(
Name: "GrabThenDrop",
Seed: w =>
{
// Player default facing is GridDir.Down. Place box one cell south.
var player = RecipeFactory.Spawn(w, EntityRecipe.Player, new GridPos(5, 5));
var box = RecipeFactory.Spawn(w, EntityRecipe.Box, new GridPos(5, 6));
return new ScenarioContext(w, player, new Dictionary<string, int> { ["box"] = box });
},
Inputs: new List<Input> { new Input.Grab(), new Input.Drop() },
ExpectedEvents: ctx => new List<SimEvent>
{
new GrabAttached(ctx.PlayerId, ctx.Named["box"]),
new GrabReleased(ctx.PlayerId, ctx.Named["box"]),
});
public static readonly IReadOnlyList<Scenario> All = new List<Scenario>
{
WalkOneStepNorth,
PushOneBoxEast,
GrabThenDrop,
};
}
- Step 4: Run to confirm the catalog test passes
dotnet test --filter "FullyQualifiedName~Scenarios_All_exposes_the_three_starters_by_name"
Expected: 1 passed.
- Step 5: Commit
git add muscleman-godot4/src/Muscleman.Sim.TestFixtures/Scenarios.cs \
muscleman-godot4/src/Muscleman.Sim.Tests/ScenarioTypeTests.cs
git commit -m "test-fixtures: starter Scenarios.All (walk, push, grab+drop)"
Task 5: xUnit ParityTests over Scenarios.All
Files:
- Create:
muscleman-godot4/src/Muscleman.Sim.Tests/ParityTests.cs -
Delete:
muscleman-godot4/src/Muscleman.Sim.Tests/ScenarioTypeTests.cs(scaffolding, replaced) - Step 1: Write the parity tests
Create muscleman-godot4/src/Muscleman.Sim.Tests/ParityTests.cs:
using Muscleman.Sim.TestFixtures;
using FluentAssertions;
using Xunit;
namespace Muscleman.Sim.Tests;
public class ParityTests
{
public static IEnumerable<object[]> AllScenarios()
=> Scenarios.All.Select(s => new object[] { s });
[Theory]
[MemberData(nameof(AllScenarios))]
public void Scenario_produces_expected_events(Scenario scenario)
{
var (actual, ctx) = ScenarioRunner.Run(scenario);
var expected = scenario.ExpectedEvents(ctx);
actual.Should().BeEquivalentTo(expected, opts => opts.WithStrictOrdering(),
$"scenario '{scenario.Name}' must produce the expected event sequence");
}
}
- Step 2: Delete the scaffolding test file
rm muscleman-godot4/src/Muscleman.Sim.Tests/ScenarioTypeTests.cs
- Step 3: Run the parity tests
dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName~ParityTests"
Expected: 3 tests passed, 0 failed. If any scenario’s ExpectedEvents is wrong, fix the expectation (not the assertion), then re-run.
- Step 4: Run the full xUnit suite to confirm no regressions
dotnet test muscleman-godot4/Muscleman.sln
Expected: previous test count + 3 (the new parity tests). All green.
- Step 5: Commit
git add muscleman-godot4/src/Muscleman.Sim.Tests/ParityTests.cs
git rm muscleman-godot4/src/Muscleman.Sim.Tests/ScenarioTypeTests.cs
git commit -m "tests: xUnit ParityTests theory over Scenarios.All"
Task 6: Create the Godot project at engine/
Files:
- Create:
muscleman-godot4/engine/project.godot - Create:
muscleman-godot4/engine/.gitignore -
Create:
muscleman-godot4/engine/icon.svg(placeholder, Godot expects one) - Step 1: Create the project manifest
Create muscleman-godot4/engine/project.godot:
; Engine-side test harness for the Muscleman sim.
; This project exists solely to host GdUnit4 parity tests under godot --headless.
; No scenes, no presenter; the sim runs inside Godot's mono runtime.
config_version=5
[application]
config/name="MusclemanEngineTests"
config/features=PackedStringArray("4.6", "C#", "Forward Plus")
config/icon="res://icon.svg"
[dotnet]
project/assembly_name="Muscleman.Engine.Tests"
- Step 2: Create a minimal
icon.svgplaceholder
Create muscleman-godot4/engine/icon.svg:
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="128" height="128" fill="#478cbf"/></svg>
- Step 3: Create the engine
.gitignore
Create muscleman-godot4/engine/.gitignore:
# Godot transient state — do not commit
.godot/
.import/
# Mono / .NET build outputs inside the Godot project
.mono/
bin/
obj/
# Godot 4.x per-resource UID sidecar files (regenerated on import)
*.uid
- Step 4: Prime the project — first headless import
/home/henri/godot/godot --headless --path muscleman-godot4/engine --import --quit-after 2 || true
Expected: Godot prints “Scanning…”, imports the icon, then exits. A .godot/ directory appears under engine/. Exit code may be non-zero on first run — that’s fine for priming. || true swallows that.
Verify the cache directory exists:
ls muscleman-godot4/engine/.godot/ 2>/dev/null | head
Expected: non-empty listing.
- Step 5: Confirm
.gitignorekeeps the cache out of staging
git status --porcelain muscleman-godot4/engine/ | grep -E "^(M|A|\?\?)" | grep ".godot/" || echo "ok — .godot/ ignored"
Expected: ok — .godot/ ignored.
- Step 6: Commit
git add muscleman-godot4/engine/project.godot \
muscleman-godot4/engine/icon.svg \
muscleman-godot4/engine/.gitignore
git commit -m "engine: scaffold Godot 4.6 mono project for sim parity tests"
Task 7: Wire C# + GdUnit4Net into the engine project
Files:
- Create:
muscleman-godot4/engine/Muscleman.Engine.Tests.csproj -
Modify:
muscleman-godot4/Muscleman.sln(add the new project) - Step 1: Determine the GdUnit4Net version compatible with Godot 4.6.2
Run:
dotnet package search gdUnit4.api --take 10
From the printed list, pick the highest stable version that supports Godot 4.6.x (check release notes on https://github.com/MikeSchulze/gdUnit4Net/releases for the chosen version). Note that version — referred to below as <GDUNIT_VER>. If unsure, start with the latest stable; if integration fails in Task 9, downgrade one minor version and retry.
- Step 2: Hand-write the engine test csproj
Create muscleman-godot4/engine/Muscleman.Engine.Tests.csproj, substituting your chosen <GDUNIT_VER>:
<Project Sdk="Godot.NET.Sdk/4.6.0">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<EnableDynamicLoading>true</EnableDynamicLoading>
<Nullable>enable</Nullable>
<RootNamespace>Muscleman.Engine.Tests</RootNamespace>
<AssemblyName>Muscleman.Engine.Tests</AssemblyName>
</PropertyGroup>
<ItemGroup>
<!-- Pin GdUnit4Net to the version chosen in Step 1. -->
<PackageReference Include="gdUnit4.api" Version="<GDUNIT_VER>" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\src\Muscleman.Core\Muscleman.Core.csproj" />
<ProjectReference Include="..\src\Muscleman.Sim\Muscleman.Sim.csproj" />
<ProjectReference Include="..\src\Muscleman.Sim.TestFixtures\Muscleman.Sim.TestFixtures.csproj" />
</ItemGroup>
</Project>
- Step 3: Add it to the solution
dotnet sln muscleman-godot4/Muscleman.sln add muscleman-godot4/engine/Muscleman.Engine.Tests.csproj
Expected: Project ... added to the solution.
- Step 4: Restore and build the engine project
dotnet build muscleman-godot4/engine/Muscleman.Engine.Tests.csproj
Expected: build succeeds with 0 errors. If you see error NU1101: Unable to find package 'gdUnit4.api', the version you picked in Step 1 isn’t actually published — re-run the search and pick another.
If you see Godot.NET.Sdk/4.6.0 resolution errors, try Godot.NET.Sdk/4.6.2 (the exact version matches your installed Godot). The SDK is delivered as a NuGet package; whichever installed-Godot-aligned version restores cleanly is correct.
- Step 5: Commit
git add muscleman-godot4/engine/Muscleman.Engine.Tests.csproj muscleman-godot4/Muscleman.sln
git commit -m "engine: wire C# csproj with GdUnit4Net + sim project refs"
Task 8: Install the GdUnit4 GDScript addon for the headless runner
Files:
- Create:
muscleman-godot4/engine/addons/gdUnit4/(tree of vendored addon files)
The GdUnit4Net NuGet package provides the C# test API, but the headless test runner is a GDScript tool that lives under res://addons/gdUnit4/. It must be committed to the repo so CI / fresh clones can run tests.
- Step 1: Identify the matching GdUnit4 addon release
The GdUnit4 GDScript addon version must match (or be compatible with) the gdUnit4.api NuGet version chosen in Task 7. Check https://github.com/MikeSchulze/gdUnit4/releases for a release with the same major version as your chosen <GDUNIT_VER>. Note the release tag (e.g. v4.4.0).
- Step 2: Download and extract the addon
cd /tmp
GDUNIT_TAG="<release-tag-from-step-1>"
curl -L "https://github.com/MikeSchulze/gdUnit4/releases/download/${GDUNIT_TAG}/gdUnit4-${GDUNIT_TAG}.zip" -o gdUnit4.zip
unzip -q gdUnit4.zip -d gdUnit4-extracted
ls gdUnit4-extracted/addons/
Expected: gdUnit4 directory listed.
- Step 3: Copy the addon into the engine project
mkdir -p /home/henri/muscleman/muscleman-godot4/engine/addons
cp -r /tmp/gdUnit4-extracted/addons/gdUnit4 /home/henri/muscleman/muscleman-godot4/engine/addons/
ls /home/henri/muscleman/muscleman-godot4/engine/addons/gdUnit4/ | head
Expected: a tree including bin/, src/, plugin.cfg, etc.
- Step 4: Re-prime Godot to pick up the addon
/home/henri/godot/godot --headless --path muscleman-godot4/engine --import --quit-after 2 || true
Expected: imports succeed; no errors about missing addons.
- Step 5: Locate the headless runner script and confirm
--helpworks
The runner script path depends on the GdUnit4 version. For 4.x it’s typically at addons/gdUnit4/bin/GdUnitCmdTool.gd. Verify:
ls muscleman-godot4/engine/addons/gdUnit4/bin/*.gd
Expected: at least one .gd file, likely GdUnitCmdTool.gd or runtest.gd. Note the actual filename — referred to below as <RUNNER>.
Probe its CLI:
/home/henri/godot/godot --headless --path muscleman-godot4/engine \
-s "addons/gdUnit4/bin/<RUNNER>" --help 2>&1 | head -40
Expected: a help screen listing flags like -a (add suite path), -c (continue on failure), etc. Note the exact flag syntax — needed in Task 10.
- Step 6: Commit the addon
git add muscleman-godot4/engine/addons/gdUnit4
git commit -m "engine: vendor GdUnit4 addon for headless test runner"
This commit is large (hundreds of files). That’s expected — the addon must be in-repo for headless runs.
Task 9: Write engine-side ParityTests (TDD against godot --headless)
Files:
-
Create:
muscleman-godot4/engine/tests/ParityTests.cs -
Step 1: Write all three parity tests up front
Create muscleman-godot4/engine/tests/ParityTests.cs:
using GdUnit4;
using Muscleman.Sim.TestFixtures;
using static GdUnit4.Assertions;
namespace Muscleman.Engine.Tests;
[TestSuite]
public class ParityTests
{
[TestCase]
public void WalkOneStepNorth() => RunScenarioByName("WalkOneStepNorth");
[TestCase]
public void PushOneBoxEast() => RunScenarioByName("PushOneBoxEast");
[TestCase]
public void GrabThenDrop() => RunScenarioByName("GrabThenDrop");
private static void RunScenarioByName(string name)
{
var scenario = Scenarios.All.FirstOrDefault(s => s.Name == name)
?? throw new InvalidOperationException(
$"Scenario '{name}' not found in Scenarios.All — engine test out of sync with fixtures.");
var (actual, ctx) = ScenarioRunner.Run(scenario);
var expected = scenario.ExpectedEvents(ctx);
AssertArray(actual.ToArray()).IsEqual(expected.ToArray());
}
}
If GdUnit4.Assertions.AssertArray(...).IsEqual(...) doesn’t compile against the chosen GdUnit4Net version, substitute the equivalent assertion from your version’s Assertions static class (likely AssertObject(actual).IsEqual(expected) or AssertThat(actual).IsEqual(expected)).
- Step 2: Build the engine project
dotnet build muscleman-godot4/engine/Muscleman.Engine.Tests.csproj
Expected: 0 errors. The [TestSuite] and [TestCase] attributes come from gdUnit4.api.
- Step 3: Run the headless test suite
Using the runner filename and flag set discovered in Task 8 Step 5. The typical 4.x invocation is:
/home/henri/godot/godot --headless --path muscleman-godot4/engine \
-s "addons/gdUnit4/bin/GdUnitCmdTool.gd" \
-a tests \
-c
Adjust to your discovered runner name and flag set. Expected output ends with:
Test run summary: 3 passed, 0 failed
Exit code 0.
- Step 4: Force a failure to confirm the assertion path actually checks
Temporarily edit Scenarios.cs — change the expected (5, 4) in WalkOneStepNorth to (5, 3). Re-run the headless command. Expected: 1 failed, with a diff showing the actual (5, 4) vs expected (5, 3). Revert the change.
- Step 5: Re-run to confirm green again
Re-run the headless command from Step 3. Expected: 3 passed, 0 failed.
- Step 6: Commit
git add muscleman-godot4/engine/tests/ParityTests.cs
git commit -m "engine-tests: GdUnit4 ParityTests over Scenarios.All"
Task 10: Makefile orchestration
Files:
-
Create:
muscleman-godot4/Makefile -
Step 1: Write the Makefile
Create muscleman-godot4/Makefile, substituting the runner filename and flag set discovered in Task 8 Step 5 (and used in Task 9 Step 3):
# Muscleman test orchestration.
# Run `make test` to exercise both lanes.
GODOT ?= /home/henri/godot/godot
ENGINE_DIR := engine
GDUNIT_RUNNER := addons/gdUnit4/bin/GdUnitCmdTool.gd
.PHONY: test test-sim test-engine engine-prime clean
test: test-sim test-engine
test-sim:
dotnet test Muscleman.sln
# Run engine parity tests under godot --headless.
test-engine: engine-prime
$(GODOT) --headless --path $(ENGINE_DIR) -s $(GDUNIT_RUNNER) -a tests -c
# One-off priming: imports Godot resources on a fresh checkout so the
# subsequent test run doesn't hit "import in progress" warnings.
# Safe to re-run; idempotent once .godot/ exists.
engine-prime:
@if [ ! -d $(ENGINE_DIR)/.godot ]; then \
echo "Priming Godot import cache..."; \
$(GODOT) --headless --path $(ENGINE_DIR) --import --quit-after 2 || true; \
fi
clean:
rm -rf $(ENGINE_DIR)/.godot $(ENGINE_DIR)/.import $(ENGINE_DIR)/.mono
find $(ENGINE_DIR) src -type d \( -name bin -o -name obj \) -exec rm -rf {} +
If your GDUNIT_RUNNER filename or flag set differs (per Task 8 Step 5), edit the constants and the test-engine recipe accordingly.
- Step 2: Verify each target runs
make -C muscleman-godot4 test-sim
Expected: full xUnit suite green.
make -C muscleman-godot4 test-engine
Expected: 3 parity tests green.
make -C muscleman-godot4 test
Expected: both lanes run sequentially and both go green.
- Step 3: Commit
git add muscleman-godot4/Makefile
git commit -m "build: Makefile — test (both lanes), test-sim, test-engine, clean"
Task 11: Repo .gitignore hygiene check
Files:
-
Modify:
/home/henri/muscleman/.gitignore(root, if Godot transients leak aboveengine/) -
Step 1: Inspect what
make testleft behind
git status --porcelain | head -30
Expected: clean (no untracked files). If anything Godot-related appears outside engine/.gitignore’s coverage, add it to the engine .gitignore (if scoped) or the root .gitignore (if global).
- Step 2: If any leak was found, append to the appropriate .gitignore
For each leaked path, identify whether it’s engine-local (scope it to muscleman-godot4/engine/.gitignore) or repo-wide (scope it to the root .gitignore). Append precise patterns, not wildcards that risk over-ignoring.
- Step 3: Verify
git statusis now clean
git status --porcelain
Expected: empty output.
- Step 4: Commit (only if Step 2 produced changes)
git add muscleman-godot4/engine/.gitignore .gitignore
git commit -m "engine: ignore additional Godot transients leaking from headless runs"
If nothing changed, skip the commit.
Task 12: Final verification against Definition of Done
This task runs no new code — it confirms the spec’s DoD (§8) is met.
- DoD 1:
make testfrommuscleman-godot4/runs both lanes; both green
make -C muscleman-godot4 test
Expected: both lanes green.
- DoD 2: xUnit lane has ≥ 3 new ParityTests, total suite size grows by exactly 3
Compare counts:
git stash
dotnet test muscleman-godot4/Muscleman.sln 2>&1 | grep -E "Passed:|Failed:" # baseline
git stash pop
dotnet test muscleman-godot4/Muscleman.sln 2>&1 | grep -E "Passed:|Failed:" # after
Expected: after-count = baseline-count + 3.
- DoD 3: GdUnit4 lane has ≥ 3 parity tests; each maps 1:1 to
Scenarios.All
make -C muscleman-godot4 test-engine 2>&1 | tail -10
Expected output shows 3 tests passed, names match WalkOneStepNorth, PushOneBoxEast, GrabThenDrop.
- DoD 4:
GODOTenv var override works
GODOT=/nonexistent/godot make -C muscleman-godot4 test-engine 2>&1 | head -5
Expected: command fails loudly with a “no such file” / “command not found” message. (Confirms the variable is actually consulted.) Re-run with the real path to restore green.
- DoD 5: No new flakes in existing sim suite
for i in 1 2 3; do
dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName!~ParityTests" 2>&1 | grep -E "Passed:|Failed:"
done
Expected: identical pass/fail counts on all three runs.
- DoD 6:
git statusclean after a freshmake test
make -C muscleman-godot4 clean
make -C muscleman-godot4 test
git status --porcelain
Expected: empty output.
- Step 7: Done — no commit; this task just verifies
Self-review log
The plan against the spec:
- §2 architecture (two-lane sharing
TestFixtures) → Tasks 1–5 (lane 1), Tasks 6–9 (lane 2). ✓ - §3 directory layout → Task 1 (TestFixtures), Task 6 (engine), Task 10 (Makefile). ✓
- §4.1
Scenario/ScenarioRunner/Scenarios.All→ Tasks 2–4. ✓ - §4.2 xUnit
ParityTests→ Task 5. ✓ - §4.3 GdUnit4
ParityTests→ Task 9. ✓ - §4.4 invocation (
dotnet test,make test-engine,make test,GODOToverride) → Task 10 + DoD 4. ✓ - §6 test discipline → embodied in scenario authoring (Task 4: expected events hand-authored, source-controlled, in
All). - §7 gotchas:
- First-run import → Task 6 Step 4 + Task 10
engine-primetarget. ✓ - GdUnit4Net version pin → Task 7 Step 1, propagated to Step 2. ✓
- C# csproj generation → Task 7 (hand-written, bypassing Godot’s editor-based generation since this is headless-only). ✓
.godot/etc. ignored,addons/gdUnit4/committed → Task 6 Step 3 + Task 8 Step 6. ✓
- First-run import → Task 6 Step 4 + Task 10
- §8 DoD items 1–6 → Task 12 explicit checks. ✓
- §9 deferred questions:
- Q1 (
Seedshape) → “Locking in spec open questions” preamble + Task 2 Step 4 (ScenarioContext). ✓ - Q2 (
[TestCase]parameterisation) → preamble + Task 9 (one method per scenario). ✓ - Q3 (headless flag set) → Task 8 Step 5 (probe) + Task 9 Step 3 (use) + Task 10 (Makefile). ✓
- Q1 (
No placeholders found in code blocks; only <GDUNIT_VER> and <RUNNER> are deliberately marked for the engineer to fill in from version research in Task 7 Step 1 and Task 8 Step 5 (both have explicit dotnet package search / ls commands so the engineer doesn’t have to guess).
Type-consistency check: Scenario.ExpectedEvents is Func<ScenarioContext, IReadOnlyList<SimEvent>> everywhere it’s referenced (Task 2 definition, Task 4 usage, Task 5 invocation, Task 9 invocation). ScenarioRunner.Run returns (IReadOnlyList<SimEvent>, ScenarioContext) consistently. ✓