From 1d8df63ac573b9aa9b3cef0187b250cd386e7b60 Mon Sep 17 00:00:00 2001 From: sjennings Date: Wed, 14 Jan 2026 20:53:40 -0600 Subject: [PATCH] feat(bmgd): Add E2E testing methodology and scaffold workflow (#1322) * feat(bmgd): Add E2E testing methodology and scaffold workflow - Add comprehensive e2e-testing.md knowledge fragment - Add e2e-scaffold workflow for infrastructure generation - Update qa-index.csv with e2e-testing fragment reference - Update game-qa.agent.yaml with ES trigger - Update test-design and automate instructions with E2E guidance - Update unity-testing.md with E2E section reference * 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 * feat(bmgd): add E2E testing support for Godot and Unreal Godot: - Add C# testing with xUnit/NSubstitute alongside GDScript GUT - Add E2E infrastructure: GameE2ETestFixture, ScenarioBuilder, InputSimulator, AsyncAssert (all GDScript) - Add example E2E tests and quick checklist Unreal: - Add E2E infrastructure extending AFunctionalTest - Add GameE2ETestBase, ScenarioBuilder, InputSimulator classes - Add AsyncTestHelpers with latent commands and macros - Add example E2E tests for combat and turn cycle - Add CLI commands for running E2E tests --------- Co-authored-by: Scott Jennings Co-authored-by: Brian --- src/modules/bmgd/agents/game-qa.agent.yaml | 6 + .../bmgd/gametest/knowledge/e2e-testing.md | 1013 +++++++++++++++ .../bmgd/gametest/knowledge/godot-testing.md | 499 ++++++++ .../bmgd/gametest/knowledge/unity-testing.md | 14 + .../bmgd/gametest/knowledge/unreal-testing.md | 1126 ++++++++++++++++ src/modules/bmgd/gametest/qa-index.csv | 3 +- .../gametest/automate/instructions.md | 81 ++ .../gametest/e2e-scaffold/checklist.md | 95 ++ .../gametest/e2e-scaffold/instructions.md | 1137 +++++++++++++++++ .../gametest/e2e-scaffold/workflow.yaml | 145 +++ .../gametest/test-design/instructions.md | 57 +- 11 files changed, 4169 insertions(+), 7 deletions(-) create mode 100644 src/modules/bmgd/gametest/knowledge/e2e-testing.md create mode 100644 src/modules/bmgd/workflows/gametest/e2e-scaffold/checklist.md create mode 100644 src/modules/bmgd/workflows/gametest/e2e-scaffold/instructions.md create mode 100644 src/modules/bmgd/workflows/gametest/e2e-scaffold/workflow.yaml diff --git a/src/modules/bmgd/agents/game-qa.agent.yaml b/src/modules/bmgd/agents/game-qa.agent.yaml index a1eddbc6..973d521c 100644 --- a/src/modules/bmgd/agents/game-qa.agent.yaml +++ b/src/modules/bmgd/agents/game-qa.agent.yaml @@ -22,6 +22,8 @@ agent: critical_actions: - "Consult {project-root}/_bmad/bmgd/gametest/qa-index.csv to select knowledge fragments under knowledge/ and load only the files needed for the current task" + - "For E2E testing requests, always load knowledge/e2e-testing.md first" + - "When scaffolding tests, distinguish between unit, integration, and E2E test needs" - "Load the referenced fragment(s) from {project-root}/_bmad/bmgd/gametest/knowledge/ before giving recommendations" - "Cross-check recommendations with the current official Unity Test Framework, Unreal Automation, or Godot GUT documentation" - "Find if this exists, if it does, always treat it as the bible I plan and execute against: `**/project-context.md`" @@ -43,6 +45,10 @@ agent: workflow: "{project-root}/_bmad/bmgd/workflows/gametest/automate/workflow.yaml" description: "[TA] Generate automated game tests" + - trigger: ES or fuzzy match on e2e-scaffold + workflow: "{project-root}/_bmad/bmgd/workflows/gametest/e2e-scaffold/workflow.yaml" + description: "[ES] Scaffold E2E testing infrastructure" + - trigger: PP or fuzzy match on playtest-plan workflow: "{project-root}/_bmad/bmgd/workflows/gametest/playtest-plan/workflow.yaml" description: "[PP] Create structured playtesting plan" diff --git a/src/modules/bmgd/gametest/knowledge/e2e-testing.md b/src/modules/bmgd/gametest/knowledge/e2e-testing.md new file mode 100644 index 00000000..8f35bcd7 --- /dev/null +++ b/src/modules/bmgd/gametest/knowledge/e2e-testing.md @@ -0,0 +1,1013 @@ +# End-to-End Testing for Games + +## Overview + +E2E tests validate complete gameplay flows from the player's perspective — the full orchestra, not individual instruments. Unlike integration tests that verify system interactions, E2E tests verify *player journeys* work correctly from start to finish. + +This is the difference between "does the damage calculator work with the inventory system?" (integration) and "can a player actually complete a combat encounter from selection to resolution?" (E2E). + +## E2E vs Integration vs Unit + +| Aspect | Unit | Integration | E2E | +|--------|------|-------------|-----| +| Scope | Single class | System interaction | Complete flow | +| Speed | < 10ms | < 1s | 1-30s | +| Stability | Very stable | Stable | Requires care | +| Example | DamageCalc math | Combat + Inventory | Full combat encounter | +| Dependencies | None/mocked | Some real | All real | +| Catches | Logic bugs | Wiring bugs | Journey bugs | + +## The E2E Testing Pyramid Addition + +``` + /\ + / \ Manual Playtesting + /----\ (Fun, Feel, Experience) + / \ + /--------\ E2E Tests + / \ (Player Journeys) + /------------\ + / \ Integration Tests + /----------------\ (System Interactions) + / \ Unit Tests + /____________________\ (Pure Logic) +``` + +E2E tests sit between integration tests and manual playtesting. They automate what *can* be automated about player experience while acknowledging that "is this fun?" still requires human judgment. + +## E2E Infrastructure Requirements + +Before writing E2E tests, scaffold supporting infrastructure. Without this foundation, E2E tests become brittle, flaky nightmares that erode trust faster than they build confidence. + +### 1. Test Fixture Base Class + +Provides scene loading, cleanup, and common utilities. Every E2E test inherits from this. + +**Unity Example:** + +```csharp +using System.Collections; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; + +public abstract class GameE2ETestFixture +{ + protected virtual string SceneName => "GameScene"; + protected GameStateManager GameState { get; private set; } + protected InputSimulator Input { get; private set; } + protected ScenarioBuilder Scenario { get; private set; } + + [UnitySetUp] + public IEnumerator BaseSetUp() + { + // Load the game scene + yield return SceneManager.LoadSceneAsync(SceneName); + yield return null; // Wait one frame for scene initialization + + // Get core references + GameState = Object.FindFirstObjectByType(); + Assert.IsNotNull(GameState, $"GameStateManager not found in {SceneName}"); + + // Initialize test utilities + Input = new InputSimulator(); + Scenario = new ScenarioBuilder(GameState); + + // Wait for game to be ready + yield return WaitForGameReady(); + } + + [UnityTearDown] + public IEnumerator BaseTearDown() + { + // Clean up any test-spawned objects + yield return CleanupTestObjects(); + + // Reset input state + Input?.Reset(); + } + + protected IEnumerator WaitForGameReady(float timeout = 10f) + { + yield return AsyncAssert.WaitUntil( + () => GameState != null && GameState.IsReady, + "Game ready state", + timeout); + } + + protected virtual IEnumerator CleanupTestObjects() + { + // Override in derived classes for game-specific cleanup + yield return null; + } +} +``` + +**Unreal Example:** + +```cpp +// GameE2ETestBase.h +UCLASS() +class AGameE2ETestBase : public AFunctionalTest +{ + GENERATED_BODY() + +protected: + UPROPERTY() + UGameStateManager* GameState; + + UPROPERTY() + UInputSimulator* InputSim; + + UPROPERTY() + UScenarioBuilder* Scenario; + + virtual void PrepareTest() override; + virtual void StartTest() override; + virtual void CleanUp() override; + + void WaitForGameReady(float Timeout = 10.f); +}; +``` + +**Godot Example:** + +```gdscript +extends GutTest +class_name GameE2ETestFixture + +var game_state: GameStateManager +var input_sim: InputSimulator +var scenario: ScenarioBuilder +var _scene_instance: Node + +func before_each(): + # Load game scene + var scene = load("res://scenes/game.tscn") + _scene_instance = scene.instantiate() + add_child(_scene_instance) + + # Get references + game_state = _scene_instance.get_node("GameStateManager") + input_sim = InputSimulator.new() + scenario = ScenarioBuilder.new(game_state) + + # Wait for ready + await wait_for_game_ready() + +func after_each(): + if _scene_instance: + _scene_instance.queue_free() + input_sim = null + scenario = null + +func wait_for_game_ready(timeout: float = 10.0): + var elapsed = 0.0 + while not game_state.is_ready and elapsed < timeout: + await get_tree().process_frame + elapsed += get_process_delta_time() + assert_true(game_state.is_ready, "Game should be ready") +``` + +### 2. Scenario Builder (Fluent API) + +Configure game state for test scenarios without manual setup. This is the secret sauce — it lets you express test preconditions in domain language. + +**Unity Example:** + +```csharp +public class ScenarioBuilder +{ + private readonly GameStateManager _gameState; + private readonly List> _setupActions = new(); + + public ScenarioBuilder(GameStateManager gameState) + { + _gameState = gameState; + } + + // Domain-specific setup methods + public ScenarioBuilder WithUnit(Faction faction, Hex position, int movementPoints = 6) + { + _setupActions.Add(() => SpawnUnit(faction, position, movementPoints)); + return this; + } + + public ScenarioBuilder WithTerrain(Hex position, TerrainType terrain) + { + _setupActions.Add(() => SetTerrain(position, terrain)); + return this; + } + + public ScenarioBuilder OnTurn(int turnNumber) + { + _setupActions.Add(() => SetTurn(turnNumber)); + return this; + } + + public ScenarioBuilder OnPhase(TurnPhase phase) + { + _setupActions.Add(() => SetPhase(phase)); + return this; + } + + public ScenarioBuilder WithActiveFaction(Faction faction) + { + _setupActions.Add(() => SetActiveFaction(faction)); + return this; + } + + public ScenarioBuilder FromSaveFile(string saveFileName) + { + _setupActions.Add(() => LoadSaveFile(saveFileName)); + return this; + } + + // Execute all setup actions + public IEnumerator Build() + { + foreach (var action in _setupActions) + { + yield return action(); + yield return null; // Allow state to propagate + } + _setupActions.Clear(); + } + + // Private implementation methods + private IEnumerator SpawnUnit(Faction faction, Hex position, int mp) + { + var unit = _gameState.SpawnUnit(faction, position); + unit.MovementPoints = mp; + yield return null; + } + + private IEnumerator SetTerrain(Hex position, TerrainType terrain) + { + _gameState.Map.SetTerrain(position, terrain); + yield return null; + } + + private IEnumerator SetTurn(int turn) + { + _gameState.SetTurnNumber(turn); + yield return null; + } + + private IEnumerator SetPhase(TurnPhase phase) + { + _gameState.SetPhase(phase); + yield return null; + } + + private IEnumerator SetActiveFaction(Faction faction) + { + _gameState.SetActiveFaction(faction); + yield return null; + } + + private IEnumerator LoadSaveFile(string fileName) + { + var path = $"TestData/{fileName}"; + yield return _gameState.LoadGame(path); + } +} +``` + +**Usage:** + +```csharp +yield return Scenario + .WithUnit(Faction.Player, new Hex(3, 4), movementPoints: 6) + .WithUnit(Faction.Enemy, new Hex(5, 4)) + .WithTerrain(new Hex(4, 4), TerrainType.Forest) + .OnTurn(1) + .WithActiveFaction(Faction.Player) + .Build(); +``` + +### 3. Input Simulator + +Abstract player input for deterministic testing. Don't simulate raw mouse positions — simulate player *intent*. + +**Unity Example (New Input System):** + +```csharp +using UnityEngine; +using UnityEngine.InputSystem; + +public class InputSimulator +{ + private Mouse _mouse; + private Keyboard _keyboard; + private Camera _camera; + + public InputSimulator() + { + _mouse = Mouse.current ?? InputSystem.AddDevice(); + _keyboard = Keyboard.current ?? InputSystem.AddDevice(); + _camera = Camera.main; + } + + public IEnumerator ClickWorldPosition(Vector3 worldPos) + { + var screenPos = _camera.WorldToScreenPoint(worldPos); + yield return ClickScreenPosition(screenPos); + } + + public IEnumerator ClickHex(Hex hex) + { + var worldPos = HexUtils.HexToWorld(hex); + yield return ClickWorldPosition(worldPos); + } + + public IEnumerator ClickScreenPosition(Vector2 screenPos) + { + // Move mouse + InputSystem.QueueStateEvent(_mouse, new MouseState { position = screenPos }); + yield return null; + + // Press + InputSystem.QueueStateEvent(_mouse, new MouseState + { + position = screenPos, + buttons = 1 + }); + yield return null; + + // Release + InputSystem.QueueStateEvent(_mouse, new MouseState + { + position = screenPos, + buttons = 0 + }); + yield return null; + } + + public IEnumerator ClickButton(string buttonName) + { + var button = GameObject.Find(buttonName)?.GetComponent(); + Assert.IsNotNull(button, $"Button '{buttonName}' not found"); + + button.onClick.Invoke(); + yield return null; + } + + public IEnumerator DragFromTo(Vector3 from, Vector3 to, float duration = 0.5f) + { + var fromScreen = _camera.WorldToScreenPoint(from); + var toScreen = _camera.WorldToScreenPoint(to); + + // Start drag + InputSystem.QueueStateEvent(_mouse, new MouseState + { + position = fromScreen, + buttons = 1 + }); + yield return null; + + // Interpolate drag + var elapsed = 0f; + while (elapsed < duration) + { + var t = elapsed / duration; + var pos = Vector2.Lerp(fromScreen, toScreen, t); + InputSystem.QueueStateEvent(_mouse, new MouseState + { + position = pos, + buttons = 1 + }); + yield return null; + elapsed += Time.deltaTime; + } + + // End drag + InputSystem.QueueStateEvent(_mouse, new MouseState + { + position = toScreen, + buttons = 0 + }); + yield return null; + } + + public IEnumerator PressKey(Key key) + { + _keyboard.SetKeyDown(key); + yield return null; + _keyboard.SetKeyUp(key); + yield return null; + } + + public void Reset() + { + // Reset any held state + if (_mouse != null) + { + InputSystem.QueueStateEvent(_mouse, new MouseState()); + } + } +} +``` + +### 4. Async Assertions + +Wait-for-condition assertions with meaningful failure messages. The timeout and message are critical — when tests fail, you need to know *what* it was waiting for. + +**Unity Example:** + +```csharp +using System; +using System.Collections; +using NUnit.Framework; +using UnityEngine; + +public static class AsyncAssert +{ + /// + /// Wait until condition is true, or fail with message after timeout. + /// + public static IEnumerator WaitUntil( + Func condition, + string description, + float timeout = 5f) + { + var elapsed = 0f; + while (!condition() && elapsed < timeout) + { + yield return null; + elapsed += Time.deltaTime; + } + + Assert.IsTrue(condition(), + $"Timeout after {timeout}s waiting for: {description}"); + } + + /// + /// Wait until condition is true, with periodic logging. + /// + public static IEnumerator WaitUntilVerbose( + Func condition, + string description, + float timeout = 5f, + float logInterval = 1f) + { + var elapsed = 0f; + var lastLog = 0f; + + while (!condition() && elapsed < timeout) + { + if (elapsed - lastLog >= logInterval) + { + Debug.Log($"[E2E] Still waiting for: {description} ({elapsed:F1}s)"); + lastLog = elapsed; + } + yield return null; + elapsed += Time.deltaTime; + } + + Assert.IsTrue(condition(), + $"Timeout after {timeout}s waiting for: {description}"); + } + + /// + /// 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. + /// + public static IEnumerator WaitForValue( + Func getter, + T expected, + string description, + float timeout = 5f) where T : IEquatable + { + yield return WaitUntil( + () => expected.Equals(getter()), + $"{description} to equal {expected} (current: {getter()})", + timeout); + } + + /// + /// Wait for a float value within tolerance (handles floating-point precision). + /// + public static IEnumerator WaitForValueApprox( + Func 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); + } + + /// + /// Wait for a double value within tolerance (handles floating-point precision). + /// + public static IEnumerator WaitForValueApprox( + Func 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); + } + + /// + /// Wait for an event to fire. + /// + public static IEnumerator WaitForEvent( + Action> subscribe, + Action> unsubscribe, + string eventName, + float timeout = 5f) where T : class + { + T received = null; + Action handler = e => received = e; + + subscribe(handler); + + yield return WaitUntil( + () => received != null, + $"Event '{eventName}' to fire", + timeout); + + unsubscribe(handler); + } + + /// + /// Assert that something does NOT happen within a time window. + /// + public static IEnumerator WaitAndAssertNot( + Func condition, + string description, + float duration = 1f) + { + var elapsed = 0f; + while (elapsed < duration) + { + Assert.IsFalse(condition(), + $"Condition unexpectedly became true: {description}"); + yield return null; + elapsed += Time.deltaTime; + } + } +} +``` + +## E2E Test Patterns + +### Given-When-Then with Async + +The core pattern for E2E tests. Clear structure, readable intent. + +```csharp +[UnityTest] +public IEnumerator PlayerCanMoveUnitThroughZOC() +{ + // GIVEN: Soviet unit adjacent to German ZOC + yield return Scenario + .WithUnit(Faction.Soviet, new Hex(3, 4), movementPoints: 6) + .WithUnit(Faction.German, new Hex(4, 4)) // Creates ZOC at adjacent hexes + .WithActiveFaction(Faction.Soviet) + .Build(); + + // WHEN: Player selects unit and moves through ZOC + yield return Input.ClickHex(new Hex(3, 4)); // Select unit + yield return AsyncAssert.WaitUntil( + () => GameState.Selection.HasSelectedUnit, + "Unit should be selected"); + + yield return Input.ClickHex(new Hex(5, 4)); // Click destination (through ZOC) + + // THEN: Unit arrives with reduced movement points (ZOC cost) + yield return AsyncAssert.WaitUntil( + () => GetUnitAt(new Hex(5, 4)) != null, + "Unit should arrive at destination"); + + var unit = GetUnitAt(new Hex(5, 4)); + Assert.Less(unit.MovementPoints, 3, + "ZOC passage should cost extra movement points"); +} +``` + +### Full Turn Cycle + +Testing the complete turn lifecycle. + +```csharp +[UnityTest] +public IEnumerator FullTurnCycle_PlayerToAIAndBack() +{ + // GIVEN: Mid-game state with both factions having units + yield return Scenario + .FromSaveFile("mid_game_scenario.json") + .Build(); + + var startingTurn = GameState.TurnNumber; + + // WHEN: Player ends their turn + yield return Input.ClickButton("EndPhaseButton"); + yield return AsyncAssert.WaitUntil( + () => GameState.CurrentPhase == TurnPhase.EndPhaseConfirmation, + "End phase confirmation"); + + yield return Input.ClickButton("ConfirmButton"); + + // THEN: AI executes its turn + yield return AsyncAssert.WaitUntil( + () => GameState.CurrentFaction == Faction.AI, + "AI turn should begin"); + + // AND: Eventually returns to player + yield return AsyncAssert.WaitUntil( + () => GameState.CurrentFaction == Faction.Player, + "Player turn should return", + timeout: 30f); // AI might take a while + + Assert.AreEqual(startingTurn + 1, GameState.TurnNumber, + "Turn number should increment"); +} +``` + +### Save/Load Round-Trip + +Critical for any game with persistence. + +```csharp +[UnityTest] +public IEnumerator SaveLoad_PreservesGameState() +{ + // GIVEN: Game in specific state + yield return Scenario + .WithUnit(Faction.Player, new Hex(5, 5), movementPoints: 3) + .OnTurn(7) + .Build(); + + var unitPosition = new Hex(5, 5); + var originalMP = GetUnitAt(unitPosition).MovementPoints; + var originalTurn = GameState.TurnNumber; + + // WHEN: Save and reload + var savePath = "test_save_roundtrip"; + yield return GameState.SaveGame(savePath); + + // Trash the current state + yield return SceneManager.LoadSceneAsync(SceneName); + yield return WaitForGameReady(); + + // Load the save + yield return GameState.LoadGame(savePath); + yield return WaitForGameReady(); + + // THEN: State is preserved + Assert.AreEqual(originalTurn, GameState.TurnNumber, + "Turn number should be preserved"); + + var loadedUnit = GetUnitAt(unitPosition); + Assert.IsNotNull(loadedUnit, "Unit should exist at saved position"); + Assert.AreEqual(originalMP, loadedUnit.MovementPoints, + "Movement points should be preserved"); + + // Cleanup + 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}"); + } + } +} +``` + +### UI Flow Testing + +Testing complete UI journeys. + +```csharp +[UnityTest] +public IEnumerator MainMenu_NewGame_ReachesGameplay() +{ + // GIVEN: At main menu + yield return SceneManager.LoadSceneAsync("MainMenu"); + yield return null; + + // WHEN: Start new game flow + yield return Input.ClickButton("NewGameButton"); + yield return AsyncAssert.WaitUntil( + () => FindPanel("DifficultySelect") != null, + "Difficulty selection should appear"); + + yield return Input.ClickButton("NormalDifficultyButton"); + yield return Input.ClickButton("StartButton"); + + // THEN: Game scene loads and is playable + yield return AsyncAssert.WaitUntil( + () => SceneManager.GetActiveScene().name == "GameScene", + "Game scene should load", + timeout: 10f); + + yield return WaitForGameReady(); + + Assert.AreEqual(TurnPhase.PlayerMovement, GameState.CurrentPhase, + "Should start in player movement phase"); +} +``` + +## What to E2E Test + +### High Priority (Test These) + +| Category | Why | Examples | +|----------|-----|----------| +| Core gameplay loop | 90% of player time | Select → Move → Attack → End Turn | +| Turn/phase transitions | State machine boundaries | Phase changes, turn handoff | +| Save → Load → Resume | Data integrity | Full round-trip with verification | +| Win/lose conditions | Critical path endpoints | Victory triggers, game over | +| Critical UI flows | First impressions | Menu → Game → Pause → Resume | + +### Medium Priority (Test Key Paths) + +| Category | Why | Examples | +|----------|-----|----------| +| Undo/redo | Easy to break | Action reversal | +| Multiplayer sync | Complex state | Turn handoff in MP | +| Tutorial flow | First-time experience | Guided sequence | + +### Low Priority (Usually Skip for E2E) + +| Category | Why | Better Tested By | +|----------|-----|------------------| +| Edge cases | Combinatorial explosion | Unit tests | +| Visual correctness | Subjective, changes often | Manual testing | +| Performance | Needs dedicated tooling | Performance tests | +| Every permutation | Infinite combinations | Unit + integration | +| AI decision quality | Subjective | Playtesting | + +## E2E Test Organization + +``` +Tests/ +├── EditMode/ +│ └── ... (existing unit tests) +├── PlayMode/ +│ ├── Integration/ +│ │ └── ... (existing integration tests) +│ └── E2E/ +│ ├── E2E.asmdef +│ ├── Infrastructure/ +│ │ ├── GameE2ETestFixture.cs +│ │ ├── ScenarioBuilder.cs +│ │ ├── InputSimulator.cs +│ │ └── AsyncAssert.cs +│ ├── Scenarios/ +│ │ ├── TurnCycleE2ETests.cs +│ │ ├── MovementE2ETests.cs +│ │ ├── CombatE2ETests.cs +│ │ ├── SaveLoadE2ETests.cs +│ │ └── UIFlowE2ETests.cs +│ └── TestData/ +│ ├── mid_game_scenario.json +│ ├── endgame_scenario.json +│ └── edge_case_setup.json +``` + +### Assembly Definition for E2E + +```json +{ + "name": "E2E", + "references": [ + "GameAssembly" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll", + "UnityEngine.TestRunner.dll", + "UnityEditor.TestRunner.dll" + ], + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "autoReferenced": false +} +``` + +## CI Considerations + +E2E tests are slower and potentially flaky. Handle with care. + +### Separate CI Job + +```yaml +# GitHub Actions example +e2e-tests: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: game-ci/unity-test-runner@v4 + with: + testMode: PlayMode + projectPath: . + customParameters: -testCategory E2E +``` + +### Retry Strategy + +```yaml +# Retry flaky tests once before failing +- uses: nick-fields/retry@v2 + with: + timeout_minutes: 15 + max_attempts: 2 + command: | + unity-test-runner --category E2E +``` + +### Failure Artifacts + +Capture screenshots and logs on failure: + +```csharp +[UnityTearDown] +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) + { + var screenshot = ScreenCapture.CaptureScreenshotAsTexture(); + var bytes = screenshot.EncodeToPNG(); + var screenshotPath = $"TestResults/Screenshots/{TestContext.CurrentContext.Test.Name}.png"; + System.IO.File.WriteAllBytes(screenshotPath, bytes); + + Debug.Log($"[E2E FAILURE] Screenshot saved: {screenshotPath}"); + } +} +``` + +### Execution Frequency + +| Suite | When | Timeout | +|-------|------|---------| +| Unit tests | Every commit | 5 min | +| Integration | Every commit | 10 min | +| E2E (smoke) | Every commit | 15 min | +| E2E (full) | Nightly | 60 min | + +## Avoiding Flaky Tests + +E2E tests are notorious for flakiness. Fight it proactively. + +### DO + +- Use explicit waits with `AsyncAssert.WaitUntil` +- Wait for *game state*, not time +- Clean up thoroughly in TearDown +- Isolate tests completely +- Use deterministic scenarios +- Seed random number generators + +### DON'T + +- Use `yield return new WaitForSeconds(x)` as primary sync +- Depend on test execution order +- Share state between tests +- Rely on animation timing +- Assume frame-perfect timing +- Skip cleanup "because it's slow" + +### Debugging Flaky Tests + +```csharp +// Add verbose logging to track down race conditions +[UnityTest] +public IEnumerator FlakyTest_WithDebugging() +{ + Debug.Log($"[E2E] Test start: {Time.frameCount}"); + + yield return Scenario.Build(); + Debug.Log($"[E2E] Scenario built: {Time.frameCount}"); + + yield return Input.ClickHex(targetHex); + Debug.Log($"[E2E] Input sent: {Time.frameCount}, Selection: {GameState.Selection}"); + + yield return AsyncAssert.WaitUntilVerbose( + () => ExpectedCondition(), + "Expected condition", + timeout: 10f, + logInterval: 0.5f); +} +``` + +## Engine-Specific Notes + +### Unity + +- Use `[UnityTest]` attribute for coroutine-based tests +- Prefer `WaitUntil` over `WaitForSeconds` +- Use `Object.FindFirstObjectByType()` (not the deprecated `FindObjectOfType`) +- Consider `InputTestFixture` for Input System simulation +- Remember: `yield return null` waits one frame + +### Unreal + +- Use `FFunctionalTest` actors for level-based E2E +- `LatentIt` for async test steps in Automation Framework +- Gauntlet for extended E2E suites running in isolated processes +- `ADD_LATENT_AUTOMATION_COMMAND` for sequenced operations + +### Godot + +- Use `await` for async operations in GUT +- `await get_tree().create_timer(x).timeout` for timed waits +- Scene instancing provides natural test isolation +- Use `queue_free()` for cleanup, not `free()` (avoids errors) + +## Anti-Patterns + +### The "Test Everything" Trap + +Don't try to E2E test every edge case. That's what unit tests are for. + +```csharp +// BAD: Testing edge case via E2E +[UnityTest] +public IEnumerator Movement_WithExactlyZeroMP_CannotMove() // Unit test this +{ + // 30 seconds of setup for a condition unit tests cover +} + +// GOOD: E2E tests the journey, unit tests the edge cases +[UnityTest] +public IEnumerator Movement_TypicalPlayerJourney_WorksCorrectly() +{ + // Tests the common path players actually experience +} +``` + +### The "Magic Sleep" Pattern + +```csharp +// BAD: Hoping 2 seconds is enough +yield return new WaitForSeconds(2f); +Assert.IsTrue(condition); + +// GOOD: Wait for the actual condition +yield return AsyncAssert.WaitUntil(() => condition, "description"); +``` + +### The "Shared State" Trap + +```csharp +// BAD: Tests pollute each other +private static int testCounter = 0; // Shared between tests! + +// GOOD: Each test is isolated +[SetUp] +public void Setup() +{ + // Fresh state every test +} +``` + +## Measuring E2E Test Value + +### Coverage Metrics That Matter + +- **Journey coverage**: How many critical player paths are tested? +- **Failure detection rate**: How many real bugs do E2E tests catch? +- **False positive rate**: How often do E2E tests fail spuriously? + +### Warning Signs + +- E2E suite takes > 30 minutes +- Flaky test rate > 5% +- E2E tests duplicate unit test coverage +- Team skips E2E tests because they're "always broken" + +### Health Indicators + +- E2E tests catch integration bugs unit tests miss +- New features include E2E tests for happy path +- Flaky tests are fixed or removed within a sprint +- E2E suite runs on every PR (at least smoke subset) diff --git a/src/modules/bmgd/gametest/knowledge/godot-testing.md b/src/modules/bmgd/gametest/knowledge/godot-testing.md index e282be22..ab79e093 100644 --- a/src/modules/bmgd/gametest/knowledge/godot-testing.md +++ b/src/modules/bmgd/gametest/knowledge/godot-testing.md @@ -374,3 +374,502 @@ test: | Signal not detected | Signal not watched | Call `watch_signals()` before action | | Physics not working | Missing frames | Await `physics_frame` | | Flaky tests | Timing issues | Use proper await/signals | + +## C# Testing in Godot + +Godot 4 supports C# via .NET 6+. You can use standard .NET testing frameworks alongside GUT. + +### Project Setup for C# + +``` +project/ +├── addons/ +│ └── gut/ +├── src/ +│ ├── Player/ +│ │ └── PlayerController.cs +│ └── Combat/ +│ └── DamageCalculator.cs +├── tests/ +│ ├── gdscript/ +│ │ └── test_integration.gd +│ └── csharp/ +│ ├── Tests.csproj +│ └── DamageCalculatorTests.cs +└── project.csproj +``` + +### C# Test Project Setup + +Create a separate test project that references your game assembly: + +```xml + + + + net6.0 + true + false + + + + + + + + + + + + + +``` + +### Basic C# Unit Tests + +```csharp +// tests/csharp/DamageCalculatorTests.cs +using Xunit; +using YourGame.Combat; + +public class DamageCalculatorTests +{ + private readonly DamageCalculator _calculator; + + public DamageCalculatorTests() + { + _calculator = new DamageCalculator(); + } + + [Fact] + public void Calculate_BaseDamage_ReturnsCorrectValue() + { + var result = _calculator.Calculate(100f, 1f); + Assert.Equal(100f, result); + } + + [Fact] + public void Calculate_CriticalHit_DoublesDamage() + { + var result = _calculator.Calculate(100f, 2f); + Assert.Equal(200f, result); + } + + [Theory] + [InlineData(100f, 0.5f, 50f)] + [InlineData(100f, 1.5f, 150f)] + [InlineData(50f, 2f, 100f)] + public void Calculate_Parameterized_ReturnsExpected( + float baseDamage, float multiplier, float expected) + { + var result = _calculator.Calculate(baseDamage, multiplier); + Assert.Equal(expected, result); + } +} +``` + +### Testing Godot Nodes in C# + +For tests requiring Godot runtime, use a hybrid approach: + +```csharp +// tests/csharp/PlayerControllerTests.cs +using Godot; +using Xunit; +using YourGame.Player; + +public class PlayerControllerTests : IDisposable +{ + private readonly SceneTree _sceneTree; + private PlayerController _player; + + public PlayerControllerTests() + { + // These tests must run within Godot runtime + // Use GodotXUnit or similar adapter + } + + [GodotFact] // Custom attribute for Godot runtime tests + public async Task Player_Move_ChangesPosition() + { + var startPos = _player.GlobalPosition; + + _player.SetInput(new Vector2(1, 0)); + + await ToSignal(GetTree().CreateTimer(0.5f), "timeout"); + + Assert.True(_player.GlobalPosition.X > startPos.X); + } + + public void Dispose() + { + _player?.QueueFree(); + } +} +``` + +### C# Mocking with NSubstitute + +```csharp +using NSubstitute; +using Xunit; + +public class EnemyAITests +{ + [Fact] + public void Enemy_UsesPathfinding_WhenMoving() + { + var mockPathfinding = Substitute.For(); + mockPathfinding.FindPath(Arg.Any(), Arg.Any()) + .Returns(new[] { Vector2.Zero, new Vector2(10, 10) }); + + var enemy = new EnemyAI(mockPathfinding); + + enemy.MoveTo(new Vector2(10, 10)); + + mockPathfinding.Received().FindPath( + Arg.Any(), + Arg.Is(v => v == new Vector2(10, 10))); + } +} +``` + +### Running C# Tests + +```bash +# Run C# unit tests (no Godot runtime needed) +dotnet test tests/csharp/Tests.csproj + +# Run with coverage +dotnet test tests/csharp/Tests.csproj --collect:"XPlat Code Coverage" + +# Run specific test +dotnet test tests/csharp/Tests.csproj --filter "FullyQualifiedName~DamageCalculator" +``` + +### Hybrid Test Strategy + +| Test Type | Framework | When to Use | +| ------------- | ---------------- | ---------------------------------- | +| Pure logic | xUnit/NUnit (C#) | Classes without Godot dependencies | +| Node behavior | GUT (GDScript) | MonoBehaviour-like testing | +| Integration | GUT (GDScript) | Scene and signal testing | +| E2E | GUT (GDScript) | Full gameplay flows | + +## End-to-End Testing + +For comprehensive E2E testing patterns, infrastructure scaffolding, and +scenario builders, see **knowledge/e2e-testing.md**. + +### E2E Infrastructure for Godot + +#### GameE2ETestFixture (GDScript) + +```gdscript +# tests/e2e/infrastructure/game_e2e_test_fixture.gd +extends GutTest +class_name GameE2ETestFixture + +var game_state: GameStateManager +var input_sim: InputSimulator +var scenario: ScenarioBuilder +var _scene_instance: Node + +## Override to specify a different scene for specific test classes. +func get_scene_path() -> String: + return "res://scenes/game.tscn" + +func before_each(): + # Load game scene + var scene = load(get_scene_path()) + _scene_instance = scene.instantiate() + add_child(_scene_instance) + + # Get references + game_state = _scene_instance.get_node("GameStateManager") + assert_not_null(game_state, "GameStateManager not found in scene") + + input_sim = InputSimulator.new() + scenario = ScenarioBuilder.new(game_state) + + # Wait for ready + await wait_for_game_ready() + +func after_each(): + if _scene_instance: + _scene_instance.queue_free() + _scene_instance = null + input_sim = null + scenario = null + +func wait_for_game_ready(timeout: float = 10.0): + var elapsed = 0.0 + while not game_state.is_ready and elapsed < timeout: + await get_tree().process_frame + elapsed += get_process_delta_time() + assert_true(game_state.is_ready, "Game should be ready within timeout") +``` + +#### ScenarioBuilder (GDScript) + +```gdscript +# tests/e2e/infrastructure/scenario_builder.gd +extends RefCounted +class_name ScenarioBuilder + +var _game_state: GameStateManager +var _setup_actions: Array[Callable] = [] + +func _init(game_state: GameStateManager): + _game_state = game_state + +## Load a pre-configured scenario from a save file. +func from_save_file(file_name: String) -> ScenarioBuilder: + _setup_actions.append(func(): await _load_save_file(file_name)) + return self + +## Configure the current turn number. +func on_turn(turn_number: int) -> ScenarioBuilder: + _setup_actions.append(func(): _set_turn(turn_number)) + return self + +## Spawn a unit at position. +func with_unit(faction: int, position: Vector2, movement_points: int = 6) -> ScenarioBuilder: + _setup_actions.append(func(): await _spawn_unit(faction, position, movement_points)) + return self + +## Execute all configured setup actions. +func build() -> void: + for action in _setup_actions: + await action.call() + _setup_actions.clear() + +## Clear pending actions without executing. +func reset() -> void: + _setup_actions.clear() + +# Private implementation +func _load_save_file(file_name: String) -> void: + var path = "res://tests/e2e/test_data/%s" % file_name + await _game_state.load_game(path) + +func _set_turn(turn: int) -> void: + _game_state.set_turn_number(turn) + +func _spawn_unit(faction: int, pos: Vector2, mp: int) -> void: + var unit = _game_state.spawn_unit(faction, pos) + unit.movement_points = mp +``` + +#### InputSimulator (GDScript) + +```gdscript +# tests/e2e/infrastructure/input_simulator.gd +extends RefCounted +class_name InputSimulator + +## Click at a world position. +func click_world_position(world_pos: Vector2) -> void: + var viewport = Engine.get_main_loop().root.get_viewport() + var camera = viewport.get_camera_2d() + var screen_pos = camera.get_screen_center_position() + (world_pos - camera.global_position) + await click_screen_position(screen_pos) + +## Click at a screen position. +func click_screen_position(screen_pos: Vector2) -> void: + var press = InputEventMouseButton.new() + press.button_index = MOUSE_BUTTON_LEFT + press.pressed = true + press.position = screen_pos + + var release = InputEventMouseButton.new() + release.button_index = MOUSE_BUTTON_LEFT + release.pressed = false + release.position = screen_pos + + Input.parse_input_event(press) + await Engine.get_main_loop().process_frame + Input.parse_input_event(release) + await Engine.get_main_loop().process_frame + +## Click a UI button by name. +func click_button(button_name: String) -> void: + var root = Engine.get_main_loop().root + var button = _find_button_recursive(root, button_name) + assert(button != null, "Button '%s' not found in scene tree" % button_name) + + if not button.visible: + push_warning("[InputSimulator] Button '%s' is not visible" % button_name) + if button.disabled: + push_warning("[InputSimulator] Button '%s' is disabled" % button_name) + + button.pressed.emit() + await Engine.get_main_loop().process_frame + +func _find_button_recursive(node: Node, button_name: String) -> Button: + if node is Button and node.name == button_name: + return node + for child in node.get_children(): + var found = _find_button_recursive(child, button_name) + if found: + return found + return null + +## Press and release a key. +func press_key(keycode: Key) -> void: + var press = InputEventKey.new() + press.keycode = keycode + press.pressed = true + + var release = InputEventKey.new() + release.keycode = keycode + release.pressed = false + + Input.parse_input_event(press) + await Engine.get_main_loop().process_frame + Input.parse_input_event(release) + await Engine.get_main_loop().process_frame + +## Simulate an input action. +func action_press(action_name: String) -> void: + Input.action_press(action_name) + await Engine.get_main_loop().process_frame + +func action_release(action_name: String) -> void: + Input.action_release(action_name) + await Engine.get_main_loop().process_frame + +## Reset all input state. +func reset() -> void: + Input.flush_buffered_events() +``` + +#### AsyncAssert (GDScript) + +```gdscript +# tests/e2e/infrastructure/async_assert.gd +extends RefCounted +class_name AsyncAssert + +## Wait until condition is true, or fail after timeout. +static func wait_until( + condition: Callable, + description: String, + timeout: float = 5.0 +) -> void: + var elapsed := 0.0 + while not condition.call() and elapsed < timeout: + await Engine.get_main_loop().process_frame + elapsed += Engine.get_main_loop().root.get_process_delta_time() + + assert(condition.call(), + "Timeout after %.1fs waiting for: %s" % [timeout, description]) + +## Wait for a value to equal expected. +static func wait_for_value( + getter: Callable, + expected: Variant, + description: String, + timeout: float = 5.0 +) -> void: + await wait_until( + func(): return getter.call() == expected, + "%s to equal '%s' (current: '%s')" % [description, expected, getter.call()], + timeout) + +## Wait for a float value within tolerance. +static func wait_for_value_approx( + getter: Callable, + expected: float, + description: String, + tolerance: float = 0.0001, + timeout: float = 5.0 +) -> void: + await wait_until( + func(): return absf(expected - getter.call()) < tolerance, + "%s to equal ~%s ±%s (current: %s)" % [description, expected, tolerance, getter.call()], + timeout) + +## Assert that condition does NOT become true within duration. +static func assert_never_true( + condition: Callable, + description: String, + duration: float = 1.0 +) -> void: + var elapsed := 0.0 + while elapsed < duration: + assert(not condition.call(), + "Condition unexpectedly became true: %s" % description) + await Engine.get_main_loop().process_frame + elapsed += Engine.get_main_loop().root.get_process_delta_time() + +## Wait for specified number of frames. +static func wait_frames(count: int) -> void: + for i in range(count): + await Engine.get_main_loop().process_frame + +## Wait for physics to settle. +static func wait_for_physics(frames: int = 3) -> void: + for i in range(frames): + await Engine.get_main_loop().root.get_tree().physics_frame +``` + +### Example E2E Test (GDScript) + +```gdscript +# tests/e2e/scenarios/test_combat_flow.gd +extends GameE2ETestFixture + +func test_player_can_attack_enemy(): + # GIVEN: Player and enemy in combat range + await scenario \ + .with_unit(Faction.PLAYER, Vector2(100, 100)) \ + .with_unit(Faction.ENEMY, Vector2(150, 100)) \ + .build() + + var enemy = game_state.get_units(Faction.ENEMY)[0] + var initial_health = enemy.health + + # WHEN: Player attacks + await input_sim.click_world_position(Vector2(100, 100)) # Select player + await AsyncAssert.wait_until( + func(): return game_state.selected_unit != null, + "Unit should be selected") + + await input_sim.click_world_position(Vector2(150, 100)) # Attack enemy + + # THEN: Enemy takes damage + await AsyncAssert.wait_until( + func(): return enemy.health < initial_health, + "Enemy should take damage") + +func test_turn_cycle_completes(): + # GIVEN: Game in progress + await scenario.on_turn(1).build() + var starting_turn = game_state.turn_number + + # WHEN: Player ends turn + await input_sim.click_button("EndTurnButton") + await AsyncAssert.wait_until( + func(): return game_state.current_faction == Faction.ENEMY, + "Should switch to enemy turn") + + # AND: Enemy turn completes + await AsyncAssert.wait_until( + func(): return game_state.current_faction == Faction.PLAYER, + "Should return to player turn", + 30.0) # AI might take a while + + # THEN: Turn number incremented + assert_eq(game_state.turn_number, starting_turn + 1) +``` + +### Quick E2E Checklist for Godot + +- [ ] Create `GameE2ETestFixture` base class extending GutTest +- [ ] Implement `ScenarioBuilder` for your game's domain +- [ ] Create `InputSimulator` wrapping Godot Input +- [ ] Add `AsyncAssert` utilities with proper await +- [ ] Organize E2E tests under `tests/e2e/scenarios/` +- [ ] Configure GUT to include E2E test directory +- [ ] Set up CI with headless Godot execution diff --git a/src/modules/bmgd/gametest/knowledge/unity-testing.md b/src/modules/bmgd/gametest/knowledge/unity-testing.md index f1b872d9..f057933c 100644 --- a/src/modules/bmgd/gametest/knowledge/unity-testing.md +++ b/src/modules/bmgd/gametest/knowledge/unity-testing.md @@ -381,3 +381,17 @@ test: | NullReferenceException | Missing Setup | Ensure [SetUp] initializes all fields | | Tests hang | Infinite coroutine | Add timeout or max iterations | | Flaky physics tests | Timing dependent | Use WaitForFixedUpdate, increase tolerance | + +## End-to-End Testing + +For comprehensive E2E testing patterns, infrastructure scaffolding, and +scenario builders, see **knowledge/e2e-testing.md**. + +### Quick E2E Checklist for Unity + +- [ ] Create `GameE2ETestFixture` base class +- [ ] Implement `ScenarioBuilder` for your game's domain +- [ ] Create `InputSimulator` wrapping Input System +- [ ] Add `AsyncAssert` utilities +- [ ] Organize E2E tests under `Tests/PlayMode/E2E/` +- [ ] Configure separate CI job for E2E suite diff --git a/src/modules/bmgd/gametest/knowledge/unreal-testing.md b/src/modules/bmgd/gametest/knowledge/unreal-testing.md index 0863bd0c..3b8f668d 100644 --- a/src/modules/bmgd/gametest/knowledge/unreal-testing.md +++ b/src/modules/bmgd/gametest/knowledge/unreal-testing.md @@ -386,3 +386,1129 @@ test: | Crash in test | Missing world | Use proper test context | | Flaky results | Timing issues | Use latent commands | | Slow tests | Too many actors | Optimize test setup | + +## End-to-End Testing + +For comprehensive E2E testing patterns, infrastructure scaffolding, and +scenario builders, see **knowledge/e2e-testing.md**. + +### E2E Infrastructure for Unreal + +E2E tests in Unreal leverage Functional Tests with custom infrastructure for scenario setup, input simulation, and async assertions. + +#### Project Structure + +``` +Source/ +├── MyGame/ +│ └── ... (game code) +└── MyGameTests/ + ├── MyGameTests.Build.cs + ├── Public/ + │ ├── GameE2ETestBase.h + │ ├── ScenarioBuilder.h + │ ├── InputSimulator.h + │ └── AsyncTestHelpers.h + ├── Private/ + │ ├── GameE2ETestBase.cpp + │ ├── ScenarioBuilder.cpp + │ ├── InputSimulator.cpp + │ ├── AsyncTestHelpers.cpp + │ └── E2E/ + │ ├── CombatE2ETests.cpp + │ ├── TurnCycleE2ETests.cpp + │ └── SaveLoadE2ETests.cpp + └── TestMaps/ + ├── E2E_Combat.umap + └── E2E_TurnCycle.umap +``` + +#### Test Module Build File + +```cpp +// MyGameTests.Build.cs +using UnrealBuildTool; + +public class MyGameTests : ModuleRules +{ + public MyGameTests(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; + + PublicDependencyModuleNames.AddRange(new string[] { + "Core", + "CoreUObject", + "Engine", + "InputCore", + "EnhancedInput", + "MyGame" + }); + + PrivateDependencyModuleNames.AddRange(new string[] { + "FunctionalTesting", + "AutomationController" + }); + + // Only include in editor/test builds + if (Target.bBuildDeveloperTools || Target.Configuration == UnrealTargetConfiguration.Debug) + { + PrecompileForTargets = PrecompileTargetsType.Any; + } + } +} +``` + +#### GameE2ETestBase (Base Class) + +```cpp +// GameE2ETestBase.h +#pragma once + +#include "CoreMinimal.h" +#include "FunctionalTest.h" +#include "GameE2ETestBase.generated.h" + +class UScenarioBuilder; +class UInputSimulator; +class UGameStateManager; + +/** + * Base class for all E2E functional tests. + * Provides scenario setup, input simulation, and async assertion utilities. + */ +UCLASS(Abstract) +class MYGAMETESTS_API AGameE2ETestBase : public AFunctionalTest +{ + GENERATED_BODY() + +public: + AGameE2ETestBase(); + +protected: + /** Game state manager reference, found automatically on test start. */ + UPROPERTY(BlueprintReadOnly, Category = "E2E") + UGameStateManager* GameState; + + /** Input simulation utility. */ + UPROPERTY(BlueprintReadOnly, Category = "E2E") + UInputSimulator* InputSim; + + /** Scenario configuration builder. */ + UPROPERTY(BlueprintReadOnly, Category = "E2E") + UScenarioBuilder* Scenario; + + /** Timeout for waiting operations (seconds). */ + UPROPERTY(EditAnywhere, Category = "E2E") + float DefaultTimeout = 10.0f; + + // AFunctionalTest interface + virtual void PrepareTest() override; + virtual void StartTest() override; + virtual void CleanUp() override; + + /** Override to specify custom game state class. */ + virtual TSubclassOf GetGameStateClass() const; + + /** + * Wait until game state reports ready. + * Calls OnGameReady() when complete or fails test on timeout. + */ + UFUNCTION(BlueprintCallable, Category = "E2E") + void WaitForGameReady(); + + /** Called when game is ready. Override to begin test logic. */ + virtual void OnGameReady(); + + /** + * Wait until condition is true, then call callback. + * Fails test if timeout exceeded. + */ + void WaitUntil(TFunction Condition, const FString& Description, + TFunction OnComplete, float Timeout = -1.0f); + + /** + * Wait for a specific value, then call callback. + */ + template + void WaitForValue(TFunction Getter, T Expected, + const FString& Description, TFunction OnComplete, + float Timeout = -1.0f); + + /** + * Assert condition and fail test with message if false. + */ + void AssertTrue(bool Condition, const FString& Message); + + /** + * Assert values are equal within tolerance. + */ + void AssertNearlyEqual(float Actual, float Expected, + const FString& Message, float Tolerance = 0.0001f); + +private: + FTimerHandle WaitTimerHandle; + float WaitElapsed; + float WaitTimeout; + TFunction WaitCondition; + TFunction WaitCallback; + FString WaitDescription; + + void TickWaitCondition(); +}; +``` + +```cpp +// GameE2ETestBase.cpp +#include "GameE2ETestBase.h" +#include "ScenarioBuilder.h" +#include "InputSimulator.h" +#include "GameStateManager.h" +#include "Engine/World.h" +#include "TimerManager.h" +#include "Kismet/GameplayStatics.h" + +AGameE2ETestBase::AGameE2ETestBase() +{ + // Default test settings + TimeLimit = 120.0f; // 2 minute max for E2E tests + TimesUpMessage = TEXT("E2E test exceeded time limit"); +} + +void AGameE2ETestBase::PrepareTest() +{ + Super::PrepareTest(); + + // Create utilities + InputSim = NewObject(this); + Scenario = NewObject(this); +} + +void AGameE2ETestBase::StartTest() +{ + Super::StartTest(); + + // Find game state manager + TSubclassOf GameStateClass = GetGameStateClass(); + TArray FoundActors; + UGameplayStatics::GetAllActorsOfClass(GetWorld(), GameStateClass, FoundActors); + + if (FoundActors.Num() > 0) + { + GameState = Cast( + FoundActors[0]->GetComponentByClass(GameStateClass)); + } + + if (!GameState) + { + FinishTest(EFunctionalTestResult::Failed, + FString::Printf(TEXT("GameStateManager not found in test world"))); + return; + } + + // Initialize scenario builder with game state + Scenario->Initialize(GameState); + + // Wait for game to be ready + WaitForGameReady(); +} + +void AGameE2ETestBase::CleanUp() +{ + // Clear timer + if (WaitTimerHandle.IsValid()) + { + GetWorld()->GetTimerManager().ClearTimer(WaitTimerHandle); + } + + // Reset input state + if (InputSim) + { + InputSim->Reset(); + } + + Super::CleanUp(); +} + +TSubclassOf AGameE2ETestBase::GetGameStateClass() const +{ + return UGameStateManager::StaticClass(); +} + +void AGameE2ETestBase::WaitForGameReady() +{ + WaitUntil( + [this]() { return GameState && GameState->IsReady(); }, + TEXT("Game to reach ready state"), + [this]() { OnGameReady(); }, + DefaultTimeout + ); +} + +void AGameE2ETestBase::OnGameReady() +{ + // Override in derived classes to begin test logic +} + +void AGameE2ETestBase::WaitUntil( + TFunction Condition, + const FString& Description, + TFunction OnComplete, + float Timeout) +{ + WaitCondition = Condition; + WaitCallback = OnComplete; + WaitDescription = Description; + WaitElapsed = 0.0f; + WaitTimeout = (Timeout < 0.0f) ? DefaultTimeout : Timeout; + + // Check immediately + if (WaitCondition()) + { + WaitCallback(); + return; + } + + // Set up polling timer + GetWorld()->GetTimerManager().SetTimer( + WaitTimerHandle, + this, + &AGameE2ETestBase::TickWaitCondition, + 0.1f, // Check every 100ms + true + ); +} + +void AGameE2ETestBase::TickWaitCondition() +{ + WaitElapsed += 0.1f; + + if (WaitCondition()) + { + GetWorld()->GetTimerManager().ClearTimer(WaitTimerHandle); + WaitCallback(); + } + else if (WaitElapsed >= WaitTimeout) + { + GetWorld()->GetTimerManager().ClearTimer(WaitTimerHandle); + FinishTest(EFunctionalTestResult::Failed, + FString::Printf(TEXT("Timeout after %.1fs waiting for: %s"), + WaitTimeout, *WaitDescription)); + } +} + +void AGameE2ETestBase::AssertTrue(bool Condition, const FString& Message) +{ + if (!Condition) + { + FinishTest(EFunctionalTestResult::Failed, Message); + } +} + +void AGameE2ETestBase::AssertNearlyEqual( + float Actual, float Expected, + const FString& Message, float Tolerance) +{ + if (!FMath::IsNearlyEqual(Actual, Expected, Tolerance)) + { + FinishTest(EFunctionalTestResult::Failed, + FString::Printf(TEXT("%s: Expected ~%f, got %f"), + *Message, Expected, Actual)); + } +} +``` + +#### ScenarioBuilder + +```cpp +// ScenarioBuilder.h +#pragma once + +#include "CoreMinimal.h" +#include "UObject/NoExportTypes.h" +#include "ScenarioBuilder.generated.h" + +class UGameStateManager; + +/** + * Fluent API for configuring E2E test scenarios. + */ +UCLASS(BlueprintType) +class MYGAMETESTS_API UScenarioBuilder : public UObject +{ + GENERATED_BODY() + +public: + /** Initialize with game state reference. */ + void Initialize(UGameStateManager* InGameState); + + /** + * Load scenario from save file. + * @param FileName Save file name (without path) + */ + UFUNCTION(BlueprintCallable, Category = "Scenario") + UScenarioBuilder* FromSaveFile(const FString& FileName); + + /** + * Set the current turn number. + */ + UFUNCTION(BlueprintCallable, Category = "Scenario") + UScenarioBuilder* OnTurn(int32 TurnNumber); + + /** + * Set the active faction. + */ + UFUNCTION(BlueprintCallable, Category = "Scenario") + UScenarioBuilder* WithActiveFaction(EFaction Faction); + + /** + * Spawn a unit at position. + * @param Faction Unit's faction + * @param Position World position + * @param MovementPoints Starting movement points + */ + UFUNCTION(BlueprintCallable, Category = "Scenario") + UScenarioBuilder* WithUnit(EFaction Faction, FVector Position, + int32 MovementPoints = 6); + + /** + * Set terrain at position. + */ + UFUNCTION(BlueprintCallable, Category = "Scenario") + UScenarioBuilder* WithTerrain(FVector Position, ETerrainType Terrain); + + /** + * Execute all queued setup actions. + * @param OnComplete Called when all actions complete + */ + void Build(TFunction OnComplete); + + /** Clear pending actions without executing. */ + UFUNCTION(BlueprintCallable, Category = "Scenario") + void Reset(); + +private: + UPROPERTY() + UGameStateManager* GameState; + + TArray)>> SetupActions; + + void ExecuteNextAction(int32 Index, TFunction FinalCallback); +}; +``` + +```cpp +// ScenarioBuilder.cpp +#include "ScenarioBuilder.h" +#include "GameStateManager.h" + +void UScenarioBuilder::Initialize(UGameStateManager* InGameState) +{ + GameState = InGameState; + SetupActions.Empty(); +} + +UScenarioBuilder* UScenarioBuilder::FromSaveFile(const FString& FileName) +{ + SetupActions.Add([this, FileName](TFunction Done) { + FString Path = FString::Printf(TEXT("TestData/%s"), *FileName); + GameState->LoadGame(Path, FOnLoadComplete::CreateLambda([Done](bool bSuccess) { + Done(); + })); + }); + return this; +} + +UScenarioBuilder* UScenarioBuilder::OnTurn(int32 TurnNumber) +{ + SetupActions.Add([this, TurnNumber](TFunction Done) { + GameState->SetTurnNumber(TurnNumber); + Done(); + }); + return this; +} + +UScenarioBuilder* UScenarioBuilder::WithActiveFaction(EFaction Faction) +{ + SetupActions.Add([this, Faction](TFunction Done) { + GameState->SetActiveFaction(Faction); + Done(); + }); + return this; +} + +UScenarioBuilder* UScenarioBuilder::WithUnit( + EFaction Faction, FVector Position, int32 MovementPoints) +{ + SetupActions.Add([this, Faction, Position, MovementPoints](TFunction Done) { + AUnit* Unit = GameState->SpawnUnit(Faction, Position); + if (Unit) + { + Unit->SetMovementPoints(MovementPoints); + } + Done(); + }); + return this; +} + +UScenarioBuilder* UScenarioBuilder::WithTerrain( + FVector Position, ETerrainType Terrain) +{ + SetupActions.Add([this, Position, Terrain](TFunction Done) { + GameState->GetMap()->SetTerrain(Position, Terrain); + Done(); + }); + return this; +} + +void UScenarioBuilder::Build(TFunction OnComplete) +{ + if (SetupActions.Num() == 0) + { + OnComplete(); + return; + } + + ExecuteNextAction(0, OnComplete); +} + +void UScenarioBuilder::Reset() +{ + SetupActions.Empty(); +} + +void UScenarioBuilder::ExecuteNextAction( + int32 Index, TFunction FinalCallback) +{ + if (Index >= SetupActions.Num()) + { + SetupActions.Empty(); + FinalCallback(); + return; + } + + SetupActions[Index]([this, Index, FinalCallback]() { + ExecuteNextAction(Index + 1, FinalCallback); + }); +} +``` + +#### InputSimulator + +```cpp +// InputSimulator.h +#pragma once + +#include "CoreMinimal.h" +#include "UObject/NoExportTypes.h" +#include "InputCoreTypes.h" +#include "InputSimulator.generated.h" + +class APlayerController; + +/** + * Simulates player input for E2E tests. + */ +UCLASS(BlueprintType) +class MYGAMETESTS_API UInputSimulator : public UObject +{ + GENERATED_BODY() + +public: + /** + * Click at a world position. + * @param WorldPos Position in world space + * @param OnComplete Called when click completes + */ + void ClickWorldPosition(FVector WorldPos, TFunction OnComplete); + + /** + * Click at screen coordinates. + */ + void ClickScreenPosition(FVector2D ScreenPos, TFunction OnComplete); + + /** + * Click a UI button by name. + * @param ButtonName Name of the button widget + * @param OnComplete Called when click completes + */ + UFUNCTION(BlueprintCallable, Category = "Input") + void ClickButton(const FString& ButtonName, TFunction OnComplete); + + /** + * Press and release a key. + */ + void PressKey(FKey Key, TFunction OnComplete); + + /** + * Trigger an input action. + */ + void TriggerAction(FName ActionName, TFunction OnComplete); + + /** + * Drag from one position to another. + */ + void DragFromTo(FVector From, FVector To, float Duration, + TFunction OnComplete); + + /** Reset all input state. */ + UFUNCTION(BlueprintCallable, Category = "Input") + void Reset(); + +private: + APlayerController* GetPlayerController() const; + void SimulateMouseClick(FVector2D ScreenPos, TFunction OnComplete); +}; +``` + +```cpp +// InputSimulator.cpp +#include "InputSimulator.h" +#include "GameFramework/PlayerController.h" +#include "Blueprint/UserWidget.h" +#include "Components/Button.h" +#include "Blueprint/WidgetBlueprintLibrary.h" +#include "Kismet/GameplayStatics.h" +#include "Engine/World.h" +#include "TimerManager.h" +#include "Framework/Application/SlateApplication.h" + +void UInputSimulator::ClickWorldPosition( + FVector WorldPos, TFunction OnComplete) +{ + APlayerController* PC = GetPlayerController(); + if (!PC) + { + OnComplete(); + return; + } + + FVector2D ScreenPos; + if (PC->ProjectWorldLocationToScreen(WorldPos, ScreenPos, true)) + { + ClickScreenPosition(ScreenPos, OnComplete); + } + else + { + OnComplete(); + } +} + +void UInputSimulator::ClickScreenPosition( + FVector2D ScreenPos, TFunction OnComplete) +{ + SimulateMouseClick(ScreenPos, OnComplete); +} + +void UInputSimulator::ClickButton( + const FString& ButtonName, TFunction OnComplete) +{ + APlayerController* PC = GetPlayerController(); + if (!PC) + { + UE_LOG(LogTemp, Warning, + TEXT("[InputSimulator] No PlayerController found")); + OnComplete(); + return; + } + + // Find button in all widgets + TArray FoundWidgets; + UWidgetBlueprintLibrary::GetAllWidgetsOfClass( + PC->GetWorld(), FoundWidgets, UUserWidget::StaticClass(), false); + + UButton* TargetButton = nullptr; + for (UUserWidget* Widget : FoundWidgets) + { + if (UButton* Button = Cast( + Widget->GetWidgetFromName(FName(*ButtonName)))) + { + TargetButton = Button; + break; + } + } + + if (TargetButton) + { + if (!TargetButton->GetIsEnabled()) + { + UE_LOG(LogTemp, Warning, + TEXT("[InputSimulator] Button '%s' is not enabled"), *ButtonName); + } + + // Simulate click via delegate + TargetButton->OnClicked.Broadcast(); + + // Delay to allow UI to process + FTimerHandle TimerHandle; + PC->GetWorld()->GetTimerManager().SetTimer( + TimerHandle, + [OnComplete]() { OnComplete(); }, + 0.1f, + false + ); + } + else + { + UE_LOG(LogTemp, Warning, + TEXT("[InputSimulator] Button '%s' not found"), *ButtonName); + OnComplete(); + } +} + +void UInputSimulator::PressKey(FKey Key, TFunction OnComplete) +{ + APlayerController* PC = GetPlayerController(); + if (!PC) + { + OnComplete(); + return; + } + + // Simulate key press + FInputKeyEventArgs PressArgs(PC->GetLocalPlayer()->GetControllerId(), + Key, EInputEvent::IE_Pressed, 1.0f, false); + PC->InputKey(PressArgs); + + // Delay then release + FTimerHandle TimerHandle; + PC->GetWorld()->GetTimerManager().SetTimer( + TimerHandle, + [this, PC, Key, OnComplete]() { + FInputKeyEventArgs ReleaseArgs(PC->GetLocalPlayer()->GetControllerId(), + Key, EInputEvent::IE_Released, 0.0f, false); + PC->InputKey(ReleaseArgs); + OnComplete(); + }, + 0.1f, + false + ); +} + +void UInputSimulator::TriggerAction(FName ActionName, TFunction OnComplete) +{ + APlayerController* PC = GetPlayerController(); + if (!PC) + { + OnComplete(); + return; + } + + // For Enhanced Input System + if (UEnhancedInputComponent* EIC = Cast( + PC->InputComponent.Get())) + { + // Trigger the action through the input subsystem + // Implementation depends on your input action setup + } + + OnComplete(); +} + +void UInputSimulator::DragFromTo( + FVector From, FVector To, float Duration, TFunction OnComplete) +{ + APlayerController* PC = GetPlayerController(); + if (!PC) + { + OnComplete(); + return; + } + + FVector2D FromScreen, ToScreen; + PC->ProjectWorldLocationToScreen(From, FromScreen, true); + PC->ProjectWorldLocationToScreen(To, ToScreen, true); + + // Simulate drag start + FSlateApplication::Get().ProcessMouseButtonDownEvent( + nullptr, FPointerEvent( + 0, FromScreen, FromScreen, TSet(), + EKeys::LeftMouseButton, 0, FModifierKeysState() + ) + ); + + // Interpolate drag over duration + float Elapsed = 0.0f; + float Interval = 0.05f; + + FTimerHandle DragTimer; + PC->GetWorld()->GetTimerManager().SetTimer( + DragTimer, + [this, PC, FromScreen, ToScreen, Duration, &Elapsed, Interval, OnComplete, &DragTimer]() { + Elapsed += Interval; + float Alpha = FMath::Clamp(Elapsed / Duration, 0.0f, 1.0f); + FVector2D CurrentPos = FMath::Lerp(FromScreen, ToScreen, Alpha); + + FSlateApplication::Get().ProcessMouseMoveEvent( + FPointerEvent( + 0, CurrentPos, CurrentPos - FVector2D(1, 0), + TSet({EKeys::LeftMouseButton}), + FModifierKeysState() + ) + ); + + if (Alpha >= 1.0f) + { + PC->GetWorld()->GetTimerManager().ClearTimer(DragTimer); + + FSlateApplication::Get().ProcessMouseButtonUpEvent( + FPointerEvent( + 0, ToScreen, ToScreen, TSet(), + EKeys::LeftMouseButton, 0, FModifierKeysState() + ) + ); + + OnComplete(); + } + }, + Interval, + true + ); +} + +void UInputSimulator::Reset() +{ + // Release any held inputs + FSlateApplication::Get().ClearAllUserFocus(); +} + +APlayerController* UInputSimulator::GetPlayerController() const +{ + UWorld* World = GEngine->GetWorldContexts()[0].World(); + return World ? UGameplayStatics::GetPlayerController(World, 0) : nullptr; +} + +void UInputSimulator::SimulateMouseClick( + FVector2D ScreenPos, TFunction OnComplete) +{ + // Press + FSlateApplication::Get().ProcessMouseButtonDownEvent( + nullptr, FPointerEvent( + 0, ScreenPos, ScreenPos, TSet(), + EKeys::LeftMouseButton, 0, FModifierKeysState() + ) + ); + + // Delay then release + UWorld* World = GEngine->GetWorldContexts()[0].World(); + if (World) + { + FTimerHandle TimerHandle; + World->GetTimerManager().SetTimer( + TimerHandle, + [ScreenPos, OnComplete]() { + FSlateApplication::Get().ProcessMouseButtonUpEvent( + FPointerEvent( + 0, ScreenPos, ScreenPos, TSet(), + EKeys::LeftMouseButton, 0, FModifierKeysState() + ) + ); + OnComplete(); + }, + 0.1f, + false + ); + } + else + { + OnComplete(); + } +} +``` + +#### AsyncTestHelpers + +```cpp +// AsyncTestHelpers.h +#pragma once + +#include "CoreMinimal.h" +#include "Misc/AutomationTest.h" + +/** + * Latent command to wait for a condition. + */ +DEFINE_LATENT_AUTOMATION_COMMAND_THREE_PARAMETER( + FWaitUntilCondition, + TFunction, Condition, + FString, Description, + float, Timeout +); + +/** + * Latent command to wait for a value to equal expected. + */ +template +class FWaitForValue : public IAutomationLatentCommand +{ +public: + FWaitForValue(TFunction InGetter, T InExpected, + const FString& InDescription, float InTimeout) + : Getter(InGetter) + , Expected(InExpected) + , Description(InDescription) + , Timeout(InTimeout) + , Elapsed(0.0f) + {} + + virtual bool Update() override + { + Elapsed += FApp::GetDeltaTime(); + + if (Getter() == Expected) + { + return true; + } + + if (Elapsed >= Timeout) + { + UE_LOG(LogTemp, Error, + TEXT("Timeout after %.1fs waiting for: %s"), + Timeout, *Description); + return true; + } + + return false; + } + +private: + TFunction Getter; + T Expected; + FString Description; + float Timeout; + float Elapsed; +}; + +/** + * Latent command to wait for float value within tolerance. + */ +class FWaitForValueApprox : public IAutomationLatentCommand +{ +public: + FWaitForValueApprox(TFunction InGetter, float InExpected, + const FString& InDescription, + float InTolerance = 0.0001f, float InTimeout = 5.0f) + : Getter(InGetter) + , Expected(InExpected) + , Description(InDescription) + , Tolerance(InTolerance) + , Timeout(InTimeout) + , Elapsed(0.0f) + {} + + virtual bool Update() override + { + Elapsed += FApp::GetDeltaTime(); + + if (FMath::IsNearlyEqual(Getter(), Expected, Tolerance)) + { + return true; + } + + if (Elapsed >= Timeout) + { + UE_LOG(LogTemp, Error, + TEXT("Timeout after %.1fs waiting for: %s (expected ~%f, got %f)"), + Timeout, *Description, Expected, Getter()); + return true; + } + + return false; + } + +private: + TFunction Getter; + float Expected; + FString Description; + float Tolerance; + float Timeout; + float Elapsed; +}; + +/** + * Latent command to assert condition never becomes true. + */ +DEFINE_LATENT_AUTOMATION_COMMAND_THREE_PARAMETER( + FAssertNeverTrue, + TFunction, Condition, + FString, Description, + float, Duration +); + +/** Helper macros for E2E tests */ +#define E2E_WAIT_UNTIL(Cond, Desc, Timeout) \ + ADD_LATENT_AUTOMATION_COMMAND(FWaitUntilCondition(Cond, Desc, Timeout)) + +#define E2E_WAIT_FOR_VALUE(Getter, Expected, Desc, Timeout) \ + ADD_LATENT_AUTOMATION_COMMAND(FWaitForValue(Getter, Expected, Desc, Timeout)) + +#define E2E_WAIT_FOR_FLOAT(Getter, Expected, Desc, Tolerance, Timeout) \ + ADD_LATENT_AUTOMATION_COMMAND(FWaitForValueApprox(Getter, Expected, Desc, Tolerance, Timeout)) +``` + +### Example E2E Test + +```cpp +// CombatE2ETests.cpp +#include "GameE2ETestBase.h" +#include "ScenarioBuilder.h" +#include "InputSimulator.h" +#include "AsyncTestHelpers.h" + +/** + * E2E test: Player can attack enemy and deal damage. + */ +UCLASS() +class AE2E_PlayerAttacksEnemy : public AGameE2ETestBase +{ + GENERATED_BODY() + +protected: + virtual void OnGameReady() override + { + // GIVEN: Player and enemy units in combat range + Scenario + ->WithUnit(EFaction::Player, FVector(100, 100, 0), 6) + ->WithUnit(EFaction::Enemy, FVector(200, 100, 0), 6) + ->WithActiveFaction(EFaction::Player) + ->Build([this]() { OnScenarioReady(); }); + } + +private: + void OnScenarioReady() + { + // Store enemy reference and initial health + TArray Enemies = GameState->GetUnits(EFaction::Enemy); + if (Enemies.Num() == 0) + { + FinishTest(EFunctionalTestResult::Failed, TEXT("No enemy found")); + return; + } + + AUnit* Enemy = Enemies[0]; + float InitialHealth = Enemy->GetHealth(); + + // WHEN: Player selects unit and attacks + InputSim->ClickWorldPosition(FVector(100, 100, 0), [this]() { + WaitUntil( + [this]() { return GameState->GetSelectedUnit() != nullptr; }, + TEXT("Unit should be selected"), + [this, Enemy, InitialHealth]() { PerformAttack(Enemy, InitialHealth); } + ); + }); + } + + void PerformAttack(AUnit* Enemy, float InitialHealth) + { + // Click on enemy to attack + InputSim->ClickWorldPosition(Enemy->GetActorLocation(), [this, Enemy, InitialHealth]() { + // THEN: Enemy takes damage + WaitUntil( + [Enemy, InitialHealth]() { return Enemy->GetHealth() < InitialHealth; }, + TEXT("Enemy should take damage"), + [this]() { + FinishTest(EFunctionalTestResult::Succeeded, + TEXT("Player successfully attacked enemy")); + } + ); + }); + } +}; + +/** + * E2E test: Full turn cycle completes correctly. + */ +UCLASS() +class AE2E_TurnCycleCompletes : public AGameE2ETestBase +{ + GENERATED_BODY() + +protected: + int32 StartingTurn; + + virtual void OnGameReady() override + { + // GIVEN: Game in progress + Scenario + ->OnTurn(1) + ->WithActiveFaction(EFaction::Player) + ->Build([this]() { OnScenarioReady(); }); + } + +private: + void OnScenarioReady() + { + StartingTurn = GameState->GetTurnNumber(); + + // WHEN: Player ends turn + InputSim->ClickButton(TEXT("EndTurnButton"), [this]() { + WaitUntil( + [this]() { + return GameState->GetActiveFaction() == EFaction::Enemy; + }, + TEXT("Should switch to enemy turn"), + [this]() { WaitForPlayerTurnReturn(); } + ); + }); + } + + void WaitForPlayerTurnReturn() + { + // Wait for AI turn to complete + WaitUntil( + [this]() { + return GameState->GetActiveFaction() == EFaction::Player; + }, + TEXT("Should return to player turn"), + [this]() { VerifyTurnIncremented(); }, + 30.0f // AI might take a while + ); + } + + void VerifyTurnIncremented() + { + // THEN: Turn number incremented + int32 CurrentTurn = GameState->GetTurnNumber(); + if (CurrentTurn == StartingTurn + 1) + { + FinishTest(EFunctionalTestResult::Succeeded, + TEXT("Turn cycle completed successfully")); + } + else + { + FinishTest(EFunctionalTestResult::Failed, + FString::Printf(TEXT("Expected turn %d, got %d"), + StartingTurn + 1, CurrentTurn)); + } + } +}; +``` + +### Running E2E Tests + +```bash +# Run all E2E tests +UnrealEditor-Cmd.exe MyGame.uproject \ + -ExecCmds="Automation RunTests MyGame.E2E" \ + -unattended -nopause -nullrhi + +# Run specific E2E test +UnrealEditor-Cmd.exe MyGame.uproject \ + -ExecCmds="Automation RunTests MyGame.E2E.Combat.PlayerAttacksEnemy" \ + -unattended -nopause + +# Run with detailed logging +UnrealEditor-Cmd.exe MyGame.uproject \ + -ExecCmds="Automation RunTests MyGame.E2E" \ + -unattended -nopause -log=E2ETests.log +``` + +### Quick E2E Checklist for Unreal + +- [ ] Create `GameE2ETestBase` class extending `AFunctionalTest` +- [ ] Implement `ScenarioBuilder` for your game's domain +- [ ] Create `InputSimulator` wrapping Slate input system +- [ ] Add `AsyncTestHelpers` with latent commands +- [ ] Create dedicated E2E test maps with spawn points +- [ ] Organize E2E tests under `Source/MyGameTests/Private/E2E/` +- [ ] Configure separate CI job for E2E suite with extended timeout +- [ ] Use Gauntlet for extended E2E scenarios if needed diff --git a/src/modules/bmgd/gametest/qa-index.csv b/src/modules/bmgd/gametest/qa-index.csv index af026afd..05b3ba79 100644 --- a/src/modules/bmgd/gametest/qa-index.csv +++ b/src/modules/bmgd/gametest/qa-index.csv @@ -14,4 +14,5 @@ input-testing,Input Testing,"Controller, keyboard, and touch input validation"," localization-testing,Localization Testing,"Text, audio, and cultural validation for international releases","localization,i18n,text",knowledge/localization-testing.md certification-testing,Platform Certification,"Console TRC/XR requirements and certification testing","certification,console,trc,xr",knowledge/certification-testing.md smoke-testing,Smoke Testing,"Critical path validation for build verification","smoke-tests,bvt,ci",knowledge/smoke-testing.md -test-priorities,Test Priorities Matrix,"P0-P3 criteria, coverage targets, execution ordering for games","prioritization,risk,coverage",knowledge/test-priorities.md \ No newline at end of file +test-priorities,Test Priorities Matrix,"P0-P3 criteria, coverage targets, execution ordering for games","prioritization,risk,coverage",knowledge/test-priorities.md +e2e-testing,End-to-End Testing,"Complete player journey testing with infrastructure patterns and async utilities","e2e,integration,player-journeys,scenarios,infrastructure",knowledge/e2e-testing.md diff --git a/src/modules/bmgd/workflows/gametest/automate/instructions.md b/src/modules/bmgd/workflows/gametest/automate/instructions.md index 2af2e4fe..8fb1615c 100644 --- a/src/modules/bmgd/workflows/gametest/automate/instructions.md +++ b/src/modules/bmgd/workflows/gametest/automate/instructions.md @@ -209,6 +209,87 @@ func test_{feature}_integration(): # Cleanup scene.queue_free() ``` +### E2E Journey Tests + +**Knowledge Base Reference**: `knowledge/e2e-testing.md` +```csharp +public class {Feature}E2ETests : GameE2ETestFixture +{ + [UnityTest] + public IEnumerator {JourneyName}_Succeeds() + { + // GIVEN + yield return Scenario + .{SetupMethod1}() + .{SetupMethod2}() + .Build(); + + // WHEN + yield return Input.{Action1}(); + yield return AsyncAssert.WaitUntil( + () => {Condition1}, "{Description1}"); + yield return Input.{Action2}(); + + // THEN + yield return AsyncAssert.WaitUntil( + () => {FinalCondition}, "{FinalDescription}"); + Assert.{Assertion}({expected}, {actual}); + } +} +``` + + +## Step 3.5: Generate E2E Infrastructure + +Before generating E2E tests, scaffold the required infrastructure. + +### Infrastructure Checklist + +1. **Test Fixture Base Class** + - Scene loading/unloading + - Game ready state waiting + - Common service access + - Cleanup guarantees + +2. **Scenario Builder** + - Fluent API for game state configuration + - Domain-specific methods (e.g., `WithUnit`, `OnTurn`) + - Yields for state propagation + +3. **Input Simulator** + - Click/drag abstractions + - Button press simulation + - Keyboard input queuing + +4. **Async Assertions** + - `WaitUntil` with timeout and message + - `WaitForEvent` for event-driven flows + - `WaitForState` for state machine transitions + +### Generation Template +```csharp +// GameE2ETestFixture.cs +public abstract class GameE2ETestFixture +{ + protected {GameStateClass} GameState; + protected {InputSimulatorClass} Input; + protected {ScenarioBuilderClass} Scenario; + + [UnitySetUp] + public IEnumerator BaseSetUp() + { + yield return LoadScene("{main_scene}"); + GameState = Object.FindFirstObjectByType<{GameStateClass}>(); + Input = new {InputSimulatorClass}(); + Scenario = new {ScenarioBuilderClass}(GameState); + yield return WaitForReady(); + } + + // ... (fill from e2e-testing.md patterns) +} +``` + +**After scaffolding infrastructure, proceed to generate actual E2E tests.** --- diff --git a/src/modules/bmgd/workflows/gametest/e2e-scaffold/checklist.md b/src/modules/bmgd/workflows/gametest/e2e-scaffold/checklist.md new file mode 100644 index 00000000..58a510d2 --- /dev/null +++ b/src/modules/bmgd/workflows/gametest/e2e-scaffold/checklist.md @@ -0,0 +1,95 @@ +# E2E Infrastructure Scaffold Checklist + +## Preflight Validation + +- [ ] Test framework already initialized (`Tests/` directory exists with proper structure) +- [ ] Game state manager class identified +- [ ] Main gameplay scene identified and loads without errors +- [ ] No existing E2E infrastructure conflicts + +## Architecture Analysis + +- [ ] Game engine correctly detected +- [ ] Engine version identified +- [ ] Input system type determined (New Input System, Legacy, Custom) +- [ ] Game state manager class located +- [ ] Ready/initialized state property identified +- [ ] Key domain entities catalogued for ScenarioBuilder + +## Generated Files + +### Directory Structure +- [ ] `Tests/PlayMode/E2E/` directory created +- [ ] `Tests/PlayMode/E2E/Infrastructure/` directory created +- [ ] `Tests/PlayMode/E2E/Scenarios/` directory created +- [ ] `Tests/PlayMode/E2E/TestData/` directory created + +### Infrastructure Files +- [ ] `E2E.asmdef` created with correct assembly references +- [ ] `GameE2ETestFixture.cs` created with correct class references +- [ ] `ScenarioBuilder.cs` created with at least placeholder methods +- [ ] `InputSimulator.cs` created matching detected input system +- [ ] `AsyncAssert.cs` created with core assertion methods + +### Example and Documentation +- [ ] `ExampleE2ETest.cs` created with working infrastructure test +- [ ] `README.md` created with usage documentation + +## Code Quality + +### GameE2ETestFixture +- [ ] Correct namespace applied +- [ ] Correct `GameStateClass` reference +- [ ] Correct `SceneName` default +- [ ] `WaitForGameReady` uses correct ready property +- [ ] `UnitySetUp` and `UnityTearDown` properly structured +- [ ] Virtual methods for derived class customization + +### ScenarioBuilder +- [ ] Fluent API pattern correctly implemented +- [ ] `Build()` executes all queued actions +- [ ] At least one domain-specific method added (or clear TODOs) +- [ ] `FromSaveFile` method scaffolded + +### InputSimulator +- [ ] Matches detected input system (New vs Legacy) +- [ ] Mouse click simulation works +- [ ] Button click by name works +- [ ] Keyboard input scaffolded +- [ ] `Reset()` method cleans up state + +### AsyncAssert +- [ ] `WaitUntil` includes timeout and descriptive failure +- [ ] `WaitForValue` provides current vs expected in failure +- [ ] `AssertNeverTrue` for negative assertions +- [ ] Frame/physics wait utilities included + +## Assembly Definition + +- [ ] References main game assembly +- [ ] 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 + +## Verification + +- [ ] Project compiles without errors after scaffold +- [ ] `ExampleE2ETests.Infrastructure_GameLoadsAndReachesReadyState` passes +- [ ] Test appears in Test Runner under PlayMode → E2E category + +## Documentation Quality + +- [ ] README explains all infrastructure components +- [ ] Quick start example is copy-pasteable +- [ ] Extension instructions are clear +- [ ] Troubleshooting table addresses common issues + +## Handoff + +- [ ] Summary output provided with all configuration values +- [ ] Next steps clearly listed +- [ ] Customization requirements highlighted +- [ ] Knowledge fragments referenced diff --git a/src/modules/bmgd/workflows/gametest/e2e-scaffold/instructions.md b/src/modules/bmgd/workflows/gametest/e2e-scaffold/instructions.md new file mode 100644 index 00000000..42b99840 --- /dev/null +++ b/src/modules/bmgd/workflows/gametest/e2e-scaffold/instructions.md @@ -0,0 +1,1137 @@ + + +# E2E Test Infrastructure Scaffold + +**Workflow ID**: `_bmad/bmgd/gametest/e2e-scaffold` +**Version**: 1.0 (BMad v6) + +--- + +## Overview + +Scaffold complete E2E testing infrastructure for an existing game project. This workflow creates the foundation required for reliable, maintainable end-to-end tests: test fixtures, scenario builders, input simulators, and async assertion utilities — all tailored to the project's specific architecture. + +E2E tests validate complete player journeys. Without proper infrastructure, they become brittle nightmares. This workflow prevents that. + +--- + +## Preflight Requirements + +**Critical:** Verify these requirements before proceeding. If any fail, HALT and guide the user. + +- ✅ Test framework already initialized (run `test-framework` workflow first) +- ✅ Game has identifiable state manager class +- ✅ Main gameplay scene exists and is functional +- ✅ No existing E2E infrastructure (check for `Tests/PlayMode/E2E/`) + +**Knowledge Base:** Load `knowledge/e2e-testing.md` before proceeding. + +--- + +## Step 1: Analyze Game Architecture + +### 1.1 Detect Game Engine + +Identify engine type by checking for: + +- **Unity**: `Assets/`, `ProjectSettings/`, `*.unity` scenes +- **Unreal**: `*.uproject`, `Source/`, `Config/DefaultEngine.ini` +- **Godot**: `project.godot`, `*.tscn`, `*.gd` files + +Load the appropriate engine-specific knowledge fragment: +- Unity: `knowledge/unity-testing.md` +- Unreal: `knowledge/unreal-testing.md` +- Godot: `knowledge/godot-testing.md` + +### 1.2 Identify Core Systems + +Locate and document: + +1. **Game State Manager** + - Primary class that holds game state + - Look for: `GameManager`, `GameStateManager`, `GameController`, `GameMode` + - Note: initialization method, ready state property, save/load methods + +2. **Input Handling** + - Unity: New Input System (`InputSystem` package) vs Legacy (`Input.GetKey`) + - Unreal: Enhanced Input vs Legacy + - Godot: Built-in Input singleton + - Custom input abstraction layer + +3. **Event/Messaging System** + - Event bus pattern + - C# events/delegates + - UnityEvents + - Signals (Godot) + +4. **Scene Structure** + - Main gameplay scene name + - Scene loading approach (additive, single) + - Bootstrap/initialization flow + +### 1.3 Identify Domain Concepts + +For the ScenarioBuilder, identify: + +- **Primary Entities**: Units, players, items, enemies, etc. +- **State Machine States**: Turn phases, game modes, player states +- **Spatial System**: Grid/hex positions, world coordinates, regions +- **Resources**: Currency, health, mana, ammunition, etc. + +### 1.4 Check Existing Test Structure + +``` +Expected structure after test-framework workflow: +Tests/ +├── EditMode/ +│ └── ... (unit tests) +└── PlayMode/ + └── ... (integration tests) +``` + +If `Tests/PlayMode/E2E/` already exists, HALT and ask user how to proceed. + +--- + +## Step 2: Generate Infrastructure + +### 2.1 Create Directory Structure + +``` +Tests/PlayMode/E2E/ +├── E2E.asmdef +├── Infrastructure/ +│ ├── GameE2ETestFixture.cs +│ ├── ScenarioBuilder.cs +│ ├── InputSimulator.cs +│ └── AsyncAssert.cs +├── Scenarios/ +│ └── (empty - user will add tests here) +├── TestData/ +│ └── (empty - user will add fixtures here) +└── README.md +``` + +### 2.2 Generate Assembly Definition + +**Unity: `E2E.asmdef`** + +```json +{ + "name": "E2E", + "rootNamespace": "{ProjectNamespace}.Tests.E2E", + "references": [ + "{GameAssemblyName}", + "Unity.InputSystem", + "Unity.InputSystem.TestFramework" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll", + "UnityEngine.TestRunner.dll", + "UnityEditor.TestRunner.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false +} +``` + +**Notes:** +- Replace `{ProjectNamespace}` with detected project namespace +- Replace `{GameAssemblyName}` with main game assembly +- Include `Unity.InputSystem` references only if Input System package detected + +### 2.3 Generate GameE2ETestFixture + +This is the base class all E2E tests inherit from. + +**Unity Template:** + +```csharp +using System.Collections; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; + +namespace {Namespace}.Tests.E2E +{ + /// + /// Base fixture for all E2E tests. Handles scene loading, game initialization, + /// and provides access to core test utilities. + /// + public abstract class GameE2ETestFixture + { + /// + /// Override to specify a different scene for specific test classes. + /// + protected virtual string SceneName => "{MainSceneName}"; + + /// + /// Primary game state manager reference. + /// + protected {GameStateClass} GameState { get; private set; } + + /// + /// Input simulation utility. + /// + protected InputSimulator Input { get; private set; } + + /// + /// Scenario configuration builder. + /// + protected ScenarioBuilder Scenario { get; private set; } + + [UnitySetUp] + public IEnumerator BaseSetUp() + { + // Load the game scene + yield return SceneManager.LoadSceneAsync(SceneName); + yield return null; // Wait one frame for Awake/Start + + // Get core references + GameState = Object.FindFirstObjectByType<{GameStateClass}>(); + Assert.IsNotNull(GameState, + $"{nameof({GameStateClass})} not found in scene '{SceneName}'"); + + // Initialize test utilities + Input = new InputSimulator(); + Scenario = new ScenarioBuilder(GameState); + + // Wait for game to reach ready state + yield return WaitForGameReady(); + + // Call derived class setup + yield return SetUp(); + } + + [UnityTearDown] + public IEnumerator BaseTearDown() + { + // Call derived class teardown first + yield return TearDown(); + + // Reset input state + Input?.Reset(); + + // Clear references + GameState = null; + Input = null; + Scenario = null; + } + + /// + /// Override for test-class-specific setup. Called after scene loads and game is ready. + /// + protected virtual IEnumerator SetUp() + { + yield return null; + } + + /// + /// Override for test-class-specific teardown. Called before base cleanup. + /// + protected virtual IEnumerator TearDown() + { + yield return null; + } + + /// + /// Waits until the game reaches a playable state. + /// + protected virtual IEnumerator WaitForGameReady(float timeout = 10f) + { + yield return AsyncAssert.WaitUntil( + () => GameState != null && GameState.{IsReadyProperty}, + "Game to reach ready state", + timeout); + } + + /// + /// Captures screenshot on test failure for debugging. + /// + protected IEnumerator CaptureFailureScreenshot() + { + if (TestContext.CurrentContext.Result.Outcome.Status == + NUnit.Framework.Interfaces.TestStatus.Failed) + { + var texture = ScreenCapture.CaptureScreenshotAsTexture(); + var bytes = texture.EncodeToPNG(); + var testName = TestContext.CurrentContext.Test.Name; + var path = $"TestResults/E2E_Failure_{testName}_{System.DateTime.Now:yyyyMMdd_HHmmss}.png"; + + System.IO.Directory.CreateDirectory("TestResults"); + System.IO.File.WriteAllBytes(path, bytes); + Debug.Log($"[E2E] Failure screenshot saved: {path}"); + + Object.Destroy(texture); + } + yield return null; + } + } +} +``` + +**Customization Points:** +- `{Namespace}`: Project namespace (e.g., `AugustStorm`) +- `{MainSceneName}`: Detected main gameplay scene +- `{GameStateClass}`: Identified game state manager class +- `{IsReadyProperty}`: Property indicating game is initialized (e.g., `IsReady`, `IsInitialized`) + +### 2.4 Generate ScenarioBuilder + +Fluent API for configuring test scenarios. This must be customized to the game's domain. + +**Unity Template:** + +```csharp +using System; +using System.Collections; +using System.Collections.Generic; +using UnityEngine; + +namespace {Namespace}.Tests.E2E +{ + /// + /// Fluent builder for configuring E2E test scenarios. + /// Add domain-specific methods as needed for your game. + /// + public class ScenarioBuilder + { + private readonly {GameStateClass} _gameState; + private readonly List> _setupActions = new(); + + public ScenarioBuilder({GameStateClass} gameState) + { + _gameState = gameState; + } + + #region State Configuration + + /// + /// Load a pre-configured scenario from a save file. + /// + public ScenarioBuilder FromSaveFile(string fileName) + { + _setupActions.Add(() => LoadSaveFile(fileName)); + return this; + } + + // TODO: Add domain-specific configuration methods + // Examples for a turn-based strategy game: + // + // public ScenarioBuilder WithUnit(Faction faction, Hex position, int mp = 6) + // { + // _setupActions.Add(() => SpawnUnit(faction, position, mp)); + // return this; + // } + // + // public ScenarioBuilder OnTurn(int turnNumber) + // { + // _setupActions.Add(() => SetTurn(turnNumber)); + // return this; + // } + // + // public ScenarioBuilder WithActiveFaction(Faction faction) + // { + // _setupActions.Add(() => SetActiveFaction(faction)); + // return this; + // } + + #endregion + + #region Execution + + /// + /// Execute all configured setup actions. + /// + public IEnumerator Build() + { + foreach (var action in _setupActions) + { + yield return action(); + yield return null; // Allow state to propagate + } + _setupActions.Clear(); + } + + /// + /// Clear pending actions without executing. + /// + public void Reset() + { + _setupActions.Clear(); + } + + #endregion + + #region Private Implementation + + private IEnumerator LoadSaveFile(string fileName) + { + var path = $"TestData/{fileName}"; + // TODO: Implement save loading based on your save system + // yield return _gameState.LoadGame(path); + Debug.Log($"[ScenarioBuilder] Loading scenario from: {path}"); + yield return null; + } + + // TODO: Implement domain-specific setup methods + // private IEnumerator SpawnUnit(Faction faction, Hex position, int mp) + // { + // var unit = _gameState.SpawnUnit(faction, position); + // unit.MovementPoints = mp; + // yield return null; + // } + + #endregion + } +} +``` + +**Note to Agent:** After generating the template, analyze the game's domain model and add 3-5 concrete configuration methods based on identified entities (Step 1.3). + +### 2.5 Generate InputSimulator + +Abstract player input for deterministic testing. + +**Unity Template (New Input System):** + +```csharp +using System.Collections; +using UnityEngine; +using UnityEngine.InputSystem; +using UnityEngine.InputSystem.LowLevel; + +namespace {Namespace}.Tests.E2E +{ + /// + /// Simulates player input for E2E tests. + /// + public class InputSimulator + { + private Mouse _mouse; + private Keyboard _keyboard; + private Camera _camera; + + public InputSimulator() + { + _mouse = Mouse.current ?? InputSystem.AddDevice(); + _keyboard = Keyboard.current ?? InputSystem.AddDevice(); + _camera = Camera.main; + } + + #region Mouse Input + + /// + /// Click at a world position. + /// + public IEnumerator ClickWorldPosition(Vector3 worldPos) + { + var screenPos = _camera.WorldToScreenPoint(worldPos); + yield return ClickScreenPosition(new Vector2(screenPos.x, screenPos.y)); + } + + /// + /// Click at a screen position. + /// + public IEnumerator ClickScreenPosition(Vector2 screenPos) + { + // Move mouse to position + InputState.Change(_mouse.position, screenPos); + yield return null; + + // Press + using (StateEvent.From(_mouse, out var eventPtr)) + { + _mouse.CopyState(eventPtr); + _mouse.leftButton.WriteValueIntoEvent(1f, eventPtr); + InputSystem.QueueEvent(eventPtr); + } + yield return null; + + // Release + using (StateEvent.From(_mouse, out var eventPtr)) + { + _mouse.CopyState(eventPtr); + _mouse.leftButton.WriteValueIntoEvent(0f, eventPtr); + InputSystem.QueueEvent(eventPtr); + } + yield return null; + } + + /// + /// Click a UI button by name. + /// + public IEnumerator ClickButton(string buttonName) + { + var button = GameObject.Find(buttonName)? + .GetComponent(); + + if (button == null) + { + // Search in inactive objects within loaded scenes only + var buttons = Object.FindObjectsByType( + FindObjectsInactive.Include, FindObjectsSortMode.None); + foreach (var b in buttons) + { + if (b.name == buttonName && b.gameObject.scene.isLoaded) + { + button = b; + break; + } + } + } + + UnityEngine.Assertions.Assert.IsNotNull(button, + $"Button '{buttonName}' not found in active scenes"); + + if (!button.interactable) + { + Debug.LogWarning($"[InputSimulator] Button '{buttonName}' is not interactable"); + } + + button.onClick.Invoke(); + yield return null; + } + + /// + /// Drag from one world position to another. + /// + public IEnumerator DragFromTo(Vector3 from, Vector3 to, float duration = 0.3f) + { + var fromScreen = (Vector2)_camera.WorldToScreenPoint(from); + var toScreen = (Vector2)_camera.WorldToScreenPoint(to); + + // Move to start + InputState.Change(_mouse.position, fromScreen); + yield return null; + + // Press + using (StateEvent.From(_mouse, out var eventPtr)) + { + _mouse.CopyState(eventPtr); + _mouse.leftButton.WriteValueIntoEvent(1f, eventPtr); + InputSystem.QueueEvent(eventPtr); + } + yield return null; + + // Drag + var elapsed = 0f; + while (elapsed < duration) + { + var t = elapsed / duration; + var pos = Vector2.Lerp(fromScreen, toScreen, t); + InputState.Change(_mouse.position, pos); + yield return null; + elapsed += Time.deltaTime; + } + + // Release at destination + InputState.Change(_mouse.position, toScreen); + using (StateEvent.From(_mouse, out var eventPtr)) + { + _mouse.CopyState(eventPtr); + _mouse.leftButton.WriteValueIntoEvent(0f, eventPtr); + InputSystem.QueueEvent(eventPtr); + } + yield return null; + } + + #endregion + + #region Keyboard Input + + /// + /// Press and release a key. + /// + public IEnumerator PressKey(Key key) + { + var control = _keyboard[key]; + using (StateEvent.From(_keyboard, out var eventPtr)) + { + control.WriteValueIntoEvent(1f, eventPtr); + InputSystem.QueueEvent(eventPtr); + } + yield return null; + + using (StateEvent.From(_keyboard, out var eventPtr)) + { + control.WriteValueIntoEvent(0f, eventPtr); + InputSystem.QueueEvent(eventPtr); + } + yield return null; + } + + /// + /// Hold a key for a duration. + /// + public IEnumerator HoldKey(Key key, float duration) + { + var control = _keyboard[key]; + using (StateEvent.From(_keyboard, out var eventPtr)) + { + control.WriteValueIntoEvent(1f, eventPtr); + InputSystem.QueueEvent(eventPtr); + } + + yield return new WaitForSeconds(duration); + + using (StateEvent.From(_keyboard, out var eventPtr)) + { + control.WriteValueIntoEvent(0f, eventPtr); + InputSystem.QueueEvent(eventPtr); + } + yield return null; + } + + #endregion + + #region Utility + + /// + /// Reset all input state. + /// + public void Reset() + { + if (_mouse != null) + { + InputState.Change(_mouse, new MouseState()); + } + if (_keyboard != null) + { + InputState.Change(_keyboard, new KeyboardState()); + } + } + + /// + /// Update camera reference (call after scene load if needed). + /// + public void RefreshCamera() + { + _camera = Camera.main; + } + + #endregion + } +} +``` + +**Unity Template (Legacy Input):** + +If legacy input system detected, generate a simpler version using `Input.mousePosition` simulation or UI event triggering. + +### 2.6 Generate AsyncAssert + +Wait-for-condition assertions with meaningful failure messages. + +**Unity Template:** + +```csharp +using System; +using System.Collections; +using NUnit.Framework; +using UnityEngine; + +namespace {Namespace}.Tests.E2E +{ + /// + /// Async assertion utilities for E2E tests. + /// + public static class AsyncAssert + { + /// + /// Wait until condition is true, or fail with message after timeout. + /// + /// Condition to wait for + /// Human-readable description of what we're waiting for + /// Maximum seconds to wait + public static IEnumerator WaitUntil( + Func condition, + string description, + float timeout = 5f) + { + var elapsed = 0f; + while (!condition() && elapsed < timeout) + { + yield return null; + elapsed += Time.deltaTime; + } + + Assert.IsTrue(condition(), + $"Timeout after {timeout:F1}s waiting for: {description}"); + } + + /// + /// Wait until condition is true, with periodic debug logging. + /// + public static IEnumerator WaitUntilVerbose( + Func condition, + string description, + float timeout = 5f, + float logInterval = 1f) + { + var elapsed = 0f; + var lastLog = 0f; + + while (!condition() && elapsed < timeout) + { + if (elapsed - lastLog >= logInterval) + { + Debug.Log($"[E2E] Waiting for: {description} ({elapsed:F1}s elapsed)"); + lastLog = elapsed; + } + yield return null; + elapsed += Time.deltaTime; + } + + if (condition()) + { + Debug.Log($"[E2E] Condition met: {description} (after {elapsed:F1}s)"); + } + + Assert.IsTrue(condition(), + $"Timeout after {timeout:F1}s waiting for: {description}"); + } + + /// + /// Wait for a value to equal expected. + /// Note: For floating-point comparisons, use WaitForValueApprox instead + /// to handle precision issues. This method uses exact equality. + /// + public static IEnumerator WaitForValue( + Func getter, + T expected, + string description, + float timeout = 5f) where T : IEquatable + { + yield return WaitUntil( + () => expected.Equals(getter()), + $"{description} to equal '{expected}' (current: '{getter()}')", + timeout); + } + + /// + /// Wait for a float value within tolerance (handles floating-point precision). + /// + public static IEnumerator WaitForValueApprox( + Func 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); + } + + /// + /// Wait for a double value within tolerance (handles floating-point precision). + /// + public static IEnumerator WaitForValueApprox( + Func 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); + } + + /// + /// Wait for a value to not equal a specific value. + /// + public static IEnumerator WaitForValueNot( + Func getter, + T notExpected, + string description, + float timeout = 5f) where T : IEquatable + { + yield return WaitUntil( + () => !notExpected.Equals(getter()), + $"{description} to change from '{notExpected}'", + timeout); + } + + /// + /// Wait for a reference to become non-null. + /// + public static IEnumerator WaitForNotNull( + Func getter, + string description, + float timeout = 5f) where T : class + { + yield return WaitUntil( + () => getter() != null, + $"{description} to exist (not null)", + timeout); + } + + /// + /// Wait for a Unity Object to exist (handles Unity's fake null). + /// + public static IEnumerator WaitForUnityObject( + Func getter, + string description, + float timeout = 5f) where T : UnityEngine.Object + { + yield return WaitUntil( + () => getter() != null, // Unity overloads == for destroyed objects + $"{description} to exist", + timeout); + } + + /// + /// Assert that a condition does NOT become true within a time window. + /// Useful for testing that something doesn't happen. + /// + public static IEnumerator AssertNeverTrue( + Func condition, + string description, + float duration = 1f) + { + var elapsed = 0f; + while (elapsed < duration) + { + Assert.IsFalse(condition(), + $"Condition unexpectedly became true: {description}"); + yield return null; + elapsed += Time.deltaTime; + } + } + + /// + /// Wait for a specific number of frames. + /// Use sparingly - prefer WaitUntil with conditions. + /// + public static IEnumerator WaitFrames(int frameCount) + { + for (int i = 0; i < frameCount; i++) + { + yield return null; + } + } + + /// + /// Wait for physics to settle (multiple FixedUpdates). + /// + public static IEnumerator WaitForPhysics(int fixedUpdateCount = 3) + { + for (int i = 0; i < fixedUpdateCount; i++) + { + yield return new WaitForFixedUpdate(); + } + } + } +} +``` + +--- + +## Step 3: Generate Example Test + +Create a working E2E test that exercises the infrastructure and proves it works. + +**Unity Template:** + +```csharp +using System.Collections; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +namespace {Namespace}.Tests.E2E +{ + /// + /// Example E2E tests demonstrating infrastructure usage. + /// Delete or modify these once you've verified the setup works. + /// + [Category("E2E")] + public class ExampleE2ETests : GameE2ETestFixture + { + [UnityTest] + public IEnumerator Infrastructure_GameLoadsAndReachesReadyState() + { + // This test verifies the E2E infrastructure is working correctly. + // If this test passes, your infrastructure is properly configured. + + // The base fixture already loaded the scene and waited for ready, + // so if we get here, everything worked. + + Assert.IsNotNull(GameState, "GameState should be available"); + Assert.IsNotNull(Input, "InputSimulator should be available"); + Assert.IsNotNull(Scenario, "ScenarioBuilder should be available"); + + // 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( + () => GameState.{IsReadyProperty}, + "Game should be in ready state"); + + Debug.Log("[E2E] Infrastructure test passed - E2E framework is working!"); + } + + [UnityTest] + public IEnumerator Infrastructure_InputSimulatorCanClickButtons() + { + // Test that input simulation works + // Modify this to click an actual button in your game + + // Example: Click a button that should exist in your main scene + // yield return Input.ClickButton("SomeButtonName"); + // yield return AsyncAssert.WaitUntil( + // () => /* button click result */, + // "Button click should have effect"); + + Debug.Log("[E2E] Input simulation test - customize with your UI elements"); + yield return null; + } + + [UnityTest] + public IEnumerator Infrastructure_ScenarioBuilderCanConfigureState() + { + // Test that scenario builder works + // Modify this to use your domain-specific setup methods + + // Example: + // yield return Scenario + // .WithUnit(Faction.Player, new Hex(3, 3)) + // .OnTurn(1) + // .Build(); + // + // Assert.AreEqual(1, GameState.TurnNumber); + + Debug.Log("[E2E] Scenario builder test - customize with your domain methods"); + yield return Scenario.Build(); // Execute empty builder (no-op) + } + } +} +``` + +--- + +## Step 4: Generate Documentation + +Create a README explaining how to use the E2E infrastructure. + +**Template: `Tests/PlayMode/E2E/README.md`** + +```markdown +# E2E Testing Infrastructure + +End-to-end tests that validate complete player journeys through the game. + +## Quick Start + +1. Create a new test class inheriting from `GameE2ETestFixture` +2. Use `Scenario` to configure game state +3. Use `Input` to simulate player actions +4. Use `AsyncAssert` to wait for and verify outcomes + +## Example Test + +```csharp +[UnityTest] +public IEnumerator Player_CanCompleteBasicAction() +{ + // GIVEN: Configured scenario + yield return Scenario + .WithSomeSetup() + .Build(); + + // WHEN: Player takes action + yield return Input.ClickButton("ActionButton"); + + // THEN: Expected outcome occurs + yield return AsyncAssert.WaitUntil( + () => GameState.ActionCompleted, + "Action should complete"); +} +``` + +## Infrastructure Components + +### GameE2ETestFixture + +Base class for all E2E tests. Provides: +- Automatic scene loading and cleanup +- Access to `GameState`, `Input`, and `Scenario` +- Override `SetUp()` and `TearDown()` for test-specific setup + +### ScenarioBuilder + +Fluent API for configuring test scenarios. Extend with domain-specific methods: + +```csharp +// In ScenarioBuilder.cs, add methods like: +public ScenarioBuilder WithPlayer(Vector3 position) +{ + _setupActions.Add(() => SpawnPlayer(position)); + return this; +} +``` + +### InputSimulator + +Simulates player input: +- `ClickWorldPosition(Vector3)` - Click in 3D space +- `ClickScreenPosition(Vector2)` - Click at screen coordinates +- `ClickButton(string)` - Click UI button by name +- `DragFromTo(Vector3, Vector3)` - Drag gesture +- `PressKey(Key)` - Keyboard input + +### AsyncAssert + +Async assertions with timeouts: +- `WaitUntil(condition, description, timeout)` - Wait for condition +- `WaitForValue(getter, expected, description)` - Wait for specific value +- `AssertNeverTrue(condition, description, duration)` - Assert something doesn't happen + +## Directory Structure + +``` +E2E/ +├── Infrastructure/ # Base classes and utilities (don't modify often) +├── Scenarios/ # Your actual E2E tests go here +└── TestData/ # Save files and fixtures for scenarios +``` + +## Running Tests + +**In Unity Editor:** +- Window → General → Test Runner +- Select "PlayMode" tab +- Filter by "E2E" category + +**Command Line:** +```bash +unity -runTests -testPlatform PlayMode -testCategory E2E -batchmode +``` + +## Best Practices + +1. **Use Given-When-Then structure** for readable tests +2. **Wait for conditions, not time** - avoid `WaitForSeconds` as primary sync +3. **One journey per test** - keep tests focused +4. **Descriptive assertions** - include context in failure messages +5. **Clean up state** - don't let tests pollute each other + +## Extending the Framework + +### Adding Scenario Methods + +Edit `ScenarioBuilder.cs` to add domain-specific setup: + +```csharp +public ScenarioBuilder OnLevel(int level) +{ + _setupActions.Add(() => LoadLevel(level)); + return this; +} + +private IEnumerator LoadLevel(int level) +{ + _gameState.LoadLevel(level); + yield return null; +} +``` + +### Adding Input Methods + +Edit `InputSimulator.cs` for game-specific input: + +```csharp +public IEnumerator ClickHex(Hex hex) +{ + var worldPos = HexUtils.HexToWorld(hex); + yield return ClickWorldPosition(worldPos); +} +``` + +## Troubleshooting + +| Issue | Cause | Fix | +|-------|-------|-----| +| Tests timeout waiting for ready | Game init takes too long | Increase timeout in `WaitForGameReady` | +| Input simulation doesn't work | Wrong input system | Check `InputSimulator` matches your setup | +| Flaky tests | Race conditions | Use `AsyncAssert.WaitUntil` instead of `WaitForSeconds` | +| Can't find GameState | Wrong scene or class name | Check `SceneName` and class reference | +``` + +--- + +## Step 5: Output Summary + +After generating all files, provide this summary: + +```markdown +## E2E Infrastructure Scaffold Complete + +**Engine**: {Unity | Unreal | Godot} +**Version**: {detected_version} + +### Files Created + +``` +Tests/PlayMode/E2E/ +├── E2E.asmdef +├── Infrastructure/ +│ ├── GameE2ETestFixture.cs +│ ├── ScenarioBuilder.cs +│ ├── InputSimulator.cs +│ └── AsyncAssert.cs +├── Scenarios/ +│ └── (empty) +├── TestData/ +│ └── (empty) +├── ExampleE2ETest.cs +└── README.md +``` + +### Configuration + +| Setting | Value | +|---------|-------| +| Game State Class | `{GameStateClass}` | +| Main Scene | `{MainSceneName}` | +| Input System | `{InputSystemType}` | +| Ready Property | `{IsReadyProperty}` | + +### Customization Required + +1. **ScenarioBuilder**: Add domain-specific setup methods for your game entities +2. **InputSimulator**: Add game-specific input methods (e.g., hex clicking, gesture shortcuts) +3. **ExampleE2ETest**: Modify example tests to use your actual UI elements + +### Next Steps + +1. ✅ Run `ExampleE2ETests.Infrastructure_GameLoadsAndReachesReadyState` to verify setup +2. 📝 Extend `ScenarioBuilder` with your domain methods +3. 📝 Extend `InputSimulator` with game-specific input helpers +4. 🧪 Use `test-design` workflow to identify E2E scenarios +5. 🤖 Use `automate` workflow to generate E2E tests from scenarios + +### Knowledge Applied + +- `knowledge/e2e-testing.md` - Core E2E patterns and infrastructure +- `knowledge/{engine}-testing.md` - Engine-specific implementation details +``` + +--- + +## Validation + +Refer to `checklist.md` for comprehensive validation criteria. diff --git a/src/modules/bmgd/workflows/gametest/e2e-scaffold/workflow.yaml b/src/modules/bmgd/workflows/gametest/e2e-scaffold/workflow.yaml new file mode 100644 index 00000000..03d7c465 --- /dev/null +++ b/src/modules/bmgd/workflows/gametest/e2e-scaffold/workflow.yaml @@ -0,0 +1,145 @@ +# E2E Test Infrastructure Scaffold Workflow + +workflow: + id: e2e-scaffold + name: E2E Test Infrastructure Scaffold + version: 1.0 + module: bmgd + agent: game-qa + + description: | + Scaffold complete E2E testing infrastructure for an existing game project. + Creates test fixtures, scenario builders, input simulators, and async + assertion utilities tailored to the project's architecture. + + triggers: + - "ES" + - "e2e-scaffold" + - "scaffold e2e" + - "e2e infrastructure" + - "setup e2e" + + preflight: + - "Test framework initialized (run `test-framework` workflow first)" + - "Game has identifiable state manager" + - "Main gameplay scene exists" + + # Paths are relative to this workflow file's location + knowledge_fragments: + - "../../../gametest/knowledge/e2e-testing.md" + - "../../../gametest/knowledge/unity-testing.md" + - "../../../gametest/knowledge/unreal-testing.md" + - "../../../gametest/knowledge/godot-testing.md" + + inputs: + game_state_class: + description: "Primary game state manager class name" + required: true + example: "GameStateManager" + + main_scene: + description: "Scene name where core gameplay occurs" + required: true + example: "GameScene" + + input_system: + description: "Input system in use" + required: false + default: "auto-detect" + options: + - "unity-input-system" + - "unity-legacy" + - "unreal-enhanced" + - "godot-input" + - "custom" + + # Output paths vary by engine. Generate files matching detected engine. + outputs: + unity: + condition: "engine == 'unity'" + infrastructure_files: + description: "Generated E2E infrastructure classes" + files: + - "Tests/PlayMode/E2E/Infrastructure/GameE2ETestFixture.cs" + - "Tests/PlayMode/E2E/Infrastructure/ScenarioBuilder.cs" + - "Tests/PlayMode/E2E/Infrastructure/InputSimulator.cs" + - "Tests/PlayMode/E2E/Infrastructure/AsyncAssert.cs" + assembly_definition: + description: "E2E test assembly configuration" + files: + - "Tests/PlayMode/E2E/E2E.asmdef" + example_test: + description: "Working example E2E test" + files: + - "Tests/PlayMode/E2E/ExampleE2ETest.cs" + documentation: + description: "E2E testing README" + files: + - "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: + - id: analyze + name: "Analyze Game Architecture" + instruction_file: "instructions.md#step-1-analyze-game-architecture" + + - id: scaffold + name: "Generate Infrastructure" + instruction_file: "instructions.md#step-2-generate-infrastructure" + + - id: example + name: "Generate Example Test" + instruction_file: "instructions.md#step-3-generate-example-test" + + - id: document + name: "Generate Documentation" + instruction_file: "instructions.md#step-4-generate-documentation" + + - id: complete + name: "Output Summary" + instruction_file: "instructions.md#step-5-output-summary" + + validation: + checklist: "checklist.md" diff --git a/src/modules/bmgd/workflows/gametest/test-design/instructions.md b/src/modules/bmgd/workflows/gametest/test-design/instructions.md index b799dafe..96bf2869 100644 --- a/src/modules/bmgd/workflows/gametest/test-design/instructions.md +++ b/src/modules/bmgd/workflows/gametest/test-design/instructions.md @@ -91,6 +91,18 @@ Create comprehensive test scenarios for game projects, covering gameplay mechani | Performance | FPS, loading times | P1 | | Accessibility | Assist features | P1 | +### E2E Journey Testing + +**Knowledge Base Reference**: `knowledge/e2e-testing.md` + +| Category | Focus | Priority | +|----------|-------|----------| +| Core Loop | Complete gameplay cycle | P0 | +| Turn Lifecycle | Full turn from start to end | P0 | +| Save/Load Round-trip | Save → quit → load → resume | P0 | +| Scene Transitions | Menu → Game → Back | P1 | +| Win/Lose Paths | Victory and defeat conditions | P1 | + --- ## Step 3: Create Test Scenarios @@ -153,6 +165,39 @@ SCENARIO: Gameplay Under High Latency CATEGORY: multiplayer ``` +### E2E Scenario Format + +For player journey tests, use this extended format: +``` +E2E SCENARIO: [Player Journey Name] + GIVEN [Initial game state - use ScenarioBuilder terms] + WHEN [Sequence of player actions] + THEN [Observable outcomes] + TIMEOUT: [Expected max duration in seconds] + PRIORITY: P0/P1 + CATEGORY: e2e + INFRASTRUCTURE: [Required fixtures/builders] +``` + +### Example E2E Scenario +``` +E2E SCENARIO: Complete Combat Encounter + GIVEN game loaded with player unit adjacent to enemy + AND player unit has full health and actions + WHEN player selects unit + AND player clicks attack on enemy + AND player confirms attack + AND attack animation completes + AND enemy responds (if alive) + THEN enemy health is reduced OR enemy is defeated + AND turn state advances appropriately + AND UI reflects new state + TIMEOUT: 15 + PRIORITY: P0 + CATEGORY: e2e + INFRASTRUCTURE: ScenarioBuilder, InputSimulator, AsyncAssert +``` + --- ## Step 4: Prioritize Test Coverage @@ -161,12 +206,12 @@ SCENARIO: Gameplay Under High Latency **Knowledge Base Reference**: `knowledge/test-priorities.md` -| Priority | Criteria | Coverage Target | -| -------- | ---------------------------- | --------------- | -| P0 | Ship blockers, certification | 100% automated | -| P1 | Major features, common paths | 80% automated | -| P2 | Secondary features | 60% automated | -| P3 | Edge cases, polish | Manual only | +| Priority | Criteria | Unit | Integration | E2E | Manual | +|----------|----------|------|-------------|-----|--------| +| P0 | Ship blockers | 100% | 80% | Core flows | Smoke | +| P1 | Major features | 90% | 70% | Happy paths | Full | +| P2 | Secondary | 80% | 50% | - | Targeted | +| P3 | Edge cases | 60% | - | - | As needed | ### Risk-Based Ordering