fix(bmgd): improve E2E testing infrastructure robustness

- Add WaitForValueApprox overloads for float/double comparisons
- Fix assembly definition to use precompiledReferences for test runners
- Fix CaptureOnFailure to yield before screenshot capture (main thread)
- Add error handling to test file cleanup with try/catch
- Fix ClickButton to use FindObjectsByType and check scene.isLoaded
- Add engine-specific output paths (Unity/Unreal/Godot) to workflow
- Fix knowledge_fragments paths to use correct relative paths
This commit is contained in:
Scott Jennings 2026-01-14 08:47:57 -06:00
parent 16190f7a04
commit 04cc4ce55c
4 changed files with 200 additions and 57 deletions

View File

@ -472,6 +472,8 @@ public static class AsyncAssert
/// <summary> /// <summary>
/// Wait for a specific value, with descriptive failure. /// Wait for a specific value, with descriptive failure.
/// Note: For floating-point comparisons, use WaitForValueApprox instead
/// to handle precision issues. This method uses exact equality.
/// </summary> /// </summary>
public static IEnumerator WaitForValue<T>( public static IEnumerator WaitForValue<T>(
Func<T> getter, Func<T> getter,
@ -484,7 +486,39 @@ public static class AsyncAssert
$"{description} to equal {expected} (current: {getter()})", $"{description} to equal {expected} (current: {getter()})",
timeout); timeout);
} }
/// <summary>
/// Wait for a float value within tolerance (handles floating-point precision).
/// </summary>
public static IEnumerator WaitForValueApprox(
Func<float> getter,
float expected,
string description,
float tolerance = 0.0001f,
float timeout = 5f)
{
yield return WaitUntil(
() => Mathf.Abs(expected - getter()) < tolerance,
$"{description} to equal ~{expected} ±{tolerance} (current: {getter()})",
timeout);
}
/// <summary>
/// Wait for a double value within tolerance (handles floating-point precision).
/// </summary>
public static IEnumerator WaitForValueApprox(
Func<double> getter,
double expected,
string description,
double tolerance = 0.0001,
float timeout = 5f)
{
yield return WaitUntil(
() => Math.Abs(expected - getter()) < tolerance,
$"{description} to equal ~{expected} ±{tolerance} (current: {getter()})",
timeout);
}
/// <summary> /// <summary>
/// Wait for an event to fire. /// Wait for an event to fire.
/// </summary> /// </summary>
@ -642,7 +676,22 @@ public IEnumerator SaveLoad_PreservesGameState()
"Movement points should be preserved"); "Movement points should be preserved");
// Cleanup // Cleanup
System.IO.File.Delete(GameState.GetSavePath(savePath)); var savedFilePath = GameState.GetSavePath(savePath);
if (System.IO.File.Exists(savedFilePath))
{
try
{
System.IO.File.Delete(savedFilePath);
}
catch (System.IO.IOException ex)
{
Debug.LogWarning($"[E2E] Failed to delete test save file '{savedFilePath}': {ex.Message}");
}
catch (UnauthorizedAccessException ex)
{
Debug.LogWarning($"[E2E] Access denied deleting test save file '{savedFilePath}': {ex.Message}");
}
}
} }
``` ```
@ -744,12 +793,17 @@ Tests/
{ {
"name": "E2E", "name": "E2E",
"references": [ "references": [
"GameAssembly", "GameAssembly"
"UnityEngine.TestRunner",
"UnityEditor.TestRunner"
], ],
"includePlatforms": [], "includePlatforms": [],
"excludePlatforms": [], "excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": true,
"precompiledReferences": [
"nunit.framework.dll",
"UnityEngine.TestRunner.dll",
"UnityEditor.TestRunner.dll"
],
"defineConstraints": [ "defineConstraints": [
"UNITY_INCLUDE_TESTS" "UNITY_INCLUDE_TESTS"
], ],
@ -796,16 +850,18 @@ Capture screenshots and logs on failure:
[UnityTearDown] [UnityTearDown]
public IEnumerator CaptureOnFailure() public IEnumerator CaptureOnFailure()
{ {
// Yield first to ensure we're on the main thread for screenshot capture
yield return null;
if (TestContext.CurrentContext.Result.Outcome.Status == TestStatus.Failed) if (TestContext.CurrentContext.Result.Outcome.Status == TestStatus.Failed)
{ {
var screenshot = ScreenCapture.CaptureScreenshotAsTexture(); var screenshot = ScreenCapture.CaptureScreenshotAsTexture();
var bytes = screenshot.EncodeToPNG(); var bytes = screenshot.EncodeToPNG();
var path = $"TestResults/Screenshots/{TestContext.CurrentContext.Test.Name}.png"; var screenshotPath = $"TestResults/Screenshots/{TestContext.CurrentContext.Test.Name}.png";
System.IO.File.WriteAllBytes(path, bytes); System.IO.File.WriteAllBytes(screenshotPath, bytes);
Debug.Log($"[E2E FAILURE] Screenshot saved: {path}"); Debug.Log($"[E2E FAILURE] Screenshot saved: {screenshotPath}");
} }
yield return null;
} }
``` ```

View File

@ -67,11 +67,12 @@
## Assembly Definition ## Assembly Definition
- [ ] References main game assembly - [ ] References main game assembly
- [ ] References UnityEngine.TestRunner
- [ ] References UnityEditor.TestRunner
- [ ] References Unity.InputSystem (if applicable) - [ ] References Unity.InputSystem (if applicable)
- [ ] `overrideReferences` set to true
- [ ] `precompiledReferences` includes nunit.framework.dll
- [ ] `precompiledReferences` includes UnityEngine.TestRunner.dll
- [ ] `precompiledReferences` includes UnityEditor.TestRunner.dll
- [ ] `UNITY_INCLUDE_TESTS` define constraint set - [ ] `UNITY_INCLUDE_TESTS` define constraint set
- [ ] nunit.framework.dll in precompiled references
## Verification ## Verification

View File

@ -122,8 +122,6 @@ Tests/PlayMode/E2E/
"rootNamespace": "{ProjectNamespace}.Tests.E2E", "rootNamespace": "{ProjectNamespace}.Tests.E2E",
"references": [ "references": [
"{GameAssemblyName}", "{GameAssemblyName}",
"UnityEngine.TestRunner",
"UnityEditor.TestRunner",
"Unity.InputSystem", "Unity.InputSystem",
"Unity.InputSystem.TestFramework" "Unity.InputSystem.TestFramework"
], ],
@ -132,7 +130,9 @@ Tests/PlayMode/E2E/
"allowUnsafeCode": false, "allowUnsafeCode": false,
"overrideReferences": true, "overrideReferences": true,
"precompiledReferences": [ "precompiledReferences": [
"nunit.framework.dll" "nunit.framework.dll",
"UnityEngine.TestRunner.dll",
"UnityEditor.TestRunner.dll"
], ],
"autoReferenced": false, "autoReferenced": false,
"defineConstraints": [ "defineConstraints": [
@ -474,24 +474,30 @@ namespace {Namespace}.Tests.E2E
{ {
var button = GameObject.Find(buttonName)? var button = GameObject.Find(buttonName)?
.GetComponent<UnityEngine.UI.Button>(); .GetComponent<UnityEngine.UI.Button>();
if (button == null) if (button == null)
{ {
// Try searching in inactive objects // Search in inactive objects within loaded scenes only
var buttons = Resources.FindObjectsOfTypeAll<UnityEngine.UI.Button>(); var buttons = Object.FindObjectsByType<UnityEngine.UI.Button>(
FindObjectsInactive.Include, FindObjectsSortMode.None);
foreach (var b in buttons) foreach (var b in buttons)
{ {
if (b.name == buttonName) if (b.name == buttonName && b.gameObject.scene.isLoaded)
{ {
button = b; button = b;
break; break;
} }
} }
} }
UnityEngine.Assertions.Assert.IsNotNull(button, UnityEngine.Assertions.Assert.IsNotNull(button,
$"Button '{buttonName}' not found"); $"Button '{buttonName}' not found in active scenes");
if (!button.interactable)
{
Debug.LogWarning($"[InputSimulator] Button '{buttonName}' is not interactable");
}
button.onClick.Invoke(); button.onClick.Invoke();
yield return null; yield return null;
} }
@ -697,6 +703,8 @@ namespace {Namespace}.Tests.E2E
/// <summary> /// <summary>
/// Wait for a value to equal expected. /// Wait for a value to equal expected.
/// Note: For floating-point comparisons, use WaitForValueApprox instead
/// to handle precision issues. This method uses exact equality.
/// </summary> /// </summary>
public static IEnumerator WaitForValue<T>( public static IEnumerator WaitForValue<T>(
Func<T> getter, Func<T> getter,
@ -709,7 +717,39 @@ namespace {Namespace}.Tests.E2E
$"{description} to equal '{expected}' (current: '{getter()}')", $"{description} to equal '{expected}' (current: '{getter()}')",
timeout); timeout);
} }
/// <summary>
/// Wait for a float value within tolerance (handles floating-point precision).
/// </summary>
public static IEnumerator WaitForValueApprox(
Func<float> getter,
float expected,
string description,
float tolerance = 0.0001f,
float timeout = 5f)
{
yield return WaitUntil(
() => Mathf.Abs(expected - getter()) < tolerance,
$"{description} to equal ~{expected} ±{tolerance} (current: {getter()})",
timeout);
}
/// <summary>
/// Wait for a double value within tolerance (handles floating-point precision).
/// </summary>
public static IEnumerator WaitForValueApprox(
Func<double> getter,
double expected,
string description,
double tolerance = 0.0001,
float timeout = 5f)
{
yield return WaitUntil(
() => Math.Abs(expected - getter()) < tolerance,
$"{description} to equal ~{expected} ±{tolerance} (current: {getter()})",
timeout);
}
/// <summary> /// <summary>
/// Wait for a value to not equal a specific value. /// Wait for a value to not equal a specific value.
/// </summary> /// </summary>
@ -835,6 +875,8 @@ namespace {Namespace}.Tests.E2E
Assert.IsNotNull(Scenario, "ScenarioBuilder should be available"); Assert.IsNotNull(Scenario, "ScenarioBuilder should be available");
// Verify game is actually ready // Verify game is actually ready
// NOTE: {IsReadyProperty} is a template placeholder. Replace it with your
// game's actual ready-state property (e.g., IsReady, IsInitialized, HasLoaded).
yield return AsyncAssert.WaitUntil( yield return AsyncAssert.WaitUntil(
() => GameState.{IsReadyProperty}, () => GameState.{IsReadyProperty},
"Game should be in ready state"); "Game should be in ready state");

View File

@ -24,23 +24,24 @@ workflow:
- "Game has identifiable state manager" - "Game has identifiable state manager"
- "Main gameplay scene exists" - "Main gameplay scene exists"
# Paths are relative to this workflow file's location
knowledge_fragments: knowledge_fragments:
- "knowledge/e2e-testing.md" - "../../../gametest/knowledge/e2e-testing.md"
- "knowledge/unity-testing.md" - "../../../gametest/knowledge/unity-testing.md"
- "knowledge/unreal-testing.md" - "../../../gametest/knowledge/unreal-testing.md"
- "knowledge/godot-testing.md" - "../../../gametest/knowledge/godot-testing.md"
inputs: inputs:
game_state_class: game_state_class:
description: "Primary game state manager class name" description: "Primary game state manager class name"
required: true required: true
example: "GameStateManager" example: "GameStateManager"
main_scene: main_scene:
description: "Scene name where core gameplay occurs" description: "Scene name where core gameplay occurs"
required: true required: true
example: "GameScene" example: "GameScene"
input_system: input_system:
description: "Input system in use" description: "Input system in use"
required: false required: false
@ -52,47 +53,90 @@ workflow:
- "godot-input" - "godot-input"
- "custom" - "custom"
# Output paths vary by engine. Generate files matching detected engine.
outputs: outputs:
infrastructure_files: unity:
description: "Generated E2E infrastructure classes" condition: "engine == 'unity'"
files: infrastructure_files:
- "Tests/PlayMode/E2E/Infrastructure/GameE2ETestFixture.cs" description: "Generated E2E infrastructure classes"
- "Tests/PlayMode/E2E/Infrastructure/ScenarioBuilder.cs" files:
- "Tests/PlayMode/E2E/Infrastructure/InputSimulator.cs" - "Tests/PlayMode/E2E/Infrastructure/GameE2ETestFixture.cs"
- "Tests/PlayMode/E2E/Infrastructure/AsyncAssert.cs" - "Tests/PlayMode/E2E/Infrastructure/ScenarioBuilder.cs"
- "Tests/PlayMode/E2E/Infrastructure/InputSimulator.cs"
assembly_definition: - "Tests/PlayMode/E2E/Infrastructure/AsyncAssert.cs"
description: "E2E test assembly configuration" assembly_definition:
files: description: "E2E test assembly configuration"
- "Tests/PlayMode/E2E/E2E.asmdef" files:
- "Tests/PlayMode/E2E/E2E.asmdef"
example_test: example_test:
description: "Working example E2E test" description: "Working example E2E test"
files: files:
- "Tests/PlayMode/E2E/ExampleE2ETest.cs" - "Tests/PlayMode/E2E/ExampleE2ETest.cs"
documentation:
documentation: description: "E2E testing README"
description: "E2E testing README" files:
files: - "Tests/PlayMode/E2E/README.md"
- "Tests/PlayMode/E2E/README.md"
unreal:
condition: "engine == 'unreal'"
infrastructure_files:
description: "Generated E2E infrastructure classes"
files:
- "Source/{ProjectName}/Tests/E2E/GameE2ETestBase.h"
- "Source/{ProjectName}/Tests/E2E/GameE2ETestBase.cpp"
- "Source/{ProjectName}/Tests/E2E/ScenarioBuilder.h"
- "Source/{ProjectName}/Tests/E2E/ScenarioBuilder.cpp"
- "Source/{ProjectName}/Tests/E2E/InputSimulator.h"
- "Source/{ProjectName}/Tests/E2E/InputSimulator.cpp"
- "Source/{ProjectName}/Tests/E2E/AsyncAssert.h"
build_configuration:
description: "E2E test build configuration"
files:
- "Source/{ProjectName}/Tests/E2E/{ProjectName}E2ETests.Build.cs"
example_test:
description: "Working example E2E test"
files:
- "Source/{ProjectName}/Tests/E2E/ExampleE2ETest.cpp"
documentation:
description: "E2E testing README"
files:
- "Source/{ProjectName}/Tests/E2E/README.md"
godot:
condition: "engine == 'godot'"
infrastructure_files:
description: "Generated E2E infrastructure classes"
files:
- "tests/e2e/infrastructure/game_e2e_test_fixture.gd"
- "tests/e2e/infrastructure/scenario_builder.gd"
- "tests/e2e/infrastructure/input_simulator.gd"
- "tests/e2e/infrastructure/async_assert.gd"
example_test:
description: "Working example E2E test"
files:
- "tests/e2e/scenarios/example_e2e_test.gd"
documentation:
description: "E2E testing README"
files:
- "tests/e2e/README.md"
steps: steps:
- id: analyze - id: analyze
name: "Analyze Game Architecture" name: "Analyze Game Architecture"
instruction_file: "instructions.md#step-1-analyze-game-architecture" instruction_file: "instructions.md#step-1-analyze-game-architecture"
- id: scaffold - id: scaffold
name: "Generate Infrastructure" name: "Generate Infrastructure"
instruction_file: "instructions.md#step-2-generate-infrastructure" instruction_file: "instructions.md#step-2-generate-infrastructure"
- id: example - id: example
name: "Generate Example Test" name: "Generate Example Test"
instruction_file: "instructions.md#step-3-generate-example-test" instruction_file: "instructions.md#step-3-generate-example-test"
- id: document - id: document
name: "Generate Documentation" name: "Generate Documentation"
instruction_file: "instructions.md#step-4-generate-documentation" instruction_file: "instructions.md#step-4-generate-documentation"
- id: complete - id: complete
name: "Output Summary" name: "Output Summary"
instruction_file: "instructions.md#step-5-output-summary" instruction_file: "instructions.md#step-5-output-summary"