From 9ef387f010077e76ebb93ef0edd92578bea2f28f Mon Sep 17 00:00:00 2001 From: hypercross Date: Tue, 21 Jul 2026 15:47:33 +0800 Subject: [PATCH] refactor(tests): extract test helpers and clean up play tests Refactor the TicTacToe test suite by moving shared logic, such as game setup, snapshotting, and win checking, into a new `TestHelpers` class. This simplifies `PlayTests.cs` and `GameFlowTests.cs` by removing redundant private methods and local agent implementations, replacing them with standardized utility calls from `OECS.PlayTest`. --- .../Game.TicTacToe.Tests.csproj | 1 + Game.TicTacToe.Tests/GameFlowTests.cs | 83 ++---- Game.TicTacToe.Tests/PlayTests.cs | 246 ++++-------------- Game.TicTacToe.Tests/SnapshotTests.cs | 150 ++--------- Game.TicTacToe.Tests/TestHelpers.cs | 126 +++++++++ 5 files changed, 223 insertions(+), 383 deletions(-) create mode 100644 Game.TicTacToe.Tests/TestHelpers.cs diff --git a/Game.TicTacToe.Tests/Game.TicTacToe.Tests.csproj b/Game.TicTacToe.Tests/Game.TicTacToe.Tests.csproj index 61b1908..afd9a9e 100644 --- a/Game.TicTacToe.Tests/Game.TicTacToe.Tests.csproj +++ b/Game.TicTacToe.Tests/Game.TicTacToe.Tests.csproj @@ -19,6 +19,7 @@ + diff --git a/Game.TicTacToe.Tests/GameFlowTests.cs b/Game.TicTacToe.Tests/GameFlowTests.cs index 7ef208b..ed15434 100644 --- a/Game.TicTacToe.Tests/GameFlowTests.cs +++ b/Game.TicTacToe.Tests/GameFlowTests.cs @@ -10,7 +10,7 @@ public class GameFlowTests [Fact] public void NewGame_HasEmptyBoard() { - var (world, _) = SetupGame(); + var (world, _) = TestHelpers.SetupGame(); var emptyCount = 0; foreach (var _ in world.Select(new Query().Without())) @@ -22,7 +22,7 @@ public class GameFlowTests [Fact] public void PlaceMark_ClaimsCell() { - var (world, group) = SetupGame(); + var (world, group) = TestHelpers.SetupGame(); world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 }); group.RunLogical(); @@ -42,7 +42,7 @@ public class GameFlowTests [Fact] public void PlaceMark_TogglesPlayer() { - var (world, group) = SetupGame(); + var (world, group) = TestHelpers.SetupGame(); world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 }); // X group.RunLogical(); @@ -56,14 +56,13 @@ public class GameFlowTests [Fact] public void PlaceMark_CannotOverwriteClaimedCell() { - var (world, group) = SetupGame(); + var (world, group) = TestHelpers.SetupGame(); world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 }); // X group.RunLogical(); world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 }); // O tries same cell group.RunLogical(); - // Only one mark should exist at (0,0), and it should still be X. var marks = new List<(int Row, int Col, Player Player)>(); foreach (var it in world.Select()) marks.Add((it.Val1.Row, it.Val1.Col, it.Val2.Player)); @@ -75,10 +74,9 @@ public class GameFlowTests [Fact] public void XWins_Row() { - var (world, group) = SetupGame(); + var (world, group) = TestHelpers.SetupGame(); - // X: (0,0), O: (1,0), X: (0,1), O: (1,1), X: (0,2) → X wins row 0 - PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2)); + TestHelpers.PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2)); world.ReadSingleton().Status.Should().Be(GameStatus.XWon); } @@ -86,10 +84,9 @@ public class GameFlowTests [Fact] public void OWins_Column() { - var (world, group) = SetupGame(); + var (world, group) = TestHelpers.SetupGame(); - // X: (0,0), O: (1,0), X: (0,1), O: (1,1), X: (2,2), O: (1,2) → O wins col 0 - PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (2, 2), (1, 2)); + TestHelpers.PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (2, 2), (1, 2)); world.ReadSingleton().Status.Should().Be(GameStatus.OWon); } @@ -97,10 +94,9 @@ public class GameFlowTests [Fact] public void XWins_Diagonal() { - var (world, group) = SetupGame(); + var (world, group) = TestHelpers.SetupGame(); - // X: (0,0), O: (1,0), X: (1,1), O: (1,2), X: (2,2) → X wins diagonal - PlayMoves(world, group, (0, 0), (1, 0), (1, 1), (1, 2), (2, 2)); + TestHelpers.PlayMoves(world, group, (0, 0), (1, 0), (1, 1), (1, 2), (2, 2)); world.ReadSingleton().Status.Should().Be(GameStatus.XWon); } @@ -108,12 +104,9 @@ public class GameFlowTests [Fact] public void Draw_AllCellsFilled() { - var (world, group) = SetupGame(); + var (world, group) = TestHelpers.SetupGame(); - // Fill all 9 cells without a winner. - // X: (0,0) (0,2) (1,0) (2,1) (2,2) - // O: (0,1) (1,1) (1,2) (2,0) - PlayMoves(world, group, + TestHelpers.PlayMoves(world, group, (0, 0), (0, 1), (0, 2), (1, 1), (1, 0), (1, 2), (2, 1), (2, 0), (2, 2)); @@ -124,14 +117,12 @@ public class GameFlowTests [Fact] public void MovesAfterGameOver_AreIgnored() { - var (world, group) = SetupGame(); + var (world, group) = TestHelpers.SetupGame(); - // X wins. - PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2)); + TestHelpers.PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2)); world.ReadSingleton().Status.Should().Be(GameStatus.XWon); - // Try to place another mark — should be ignored. var moveCountBefore = world.ReadSingleton().MoveCount; world.Commands.Enqueue(new PlaceMarkCommand { Row = 2, Col = 2 }); group.RunLogical(); @@ -142,24 +133,17 @@ public class GameFlowTests [Fact] public void SaveLoad_RoundTrips() { - var (world, group) = SetupGame(); + var (world, group) = TestHelpers.SetupGame(); - // Play a few moves. - world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 }); - group.RunLogical(); - world.Commands.Enqueue(new PlaceMarkCommand { Row = 1, Col = 0 }); - group.RunLogical(); + TestHelpers.PlayMoves(world, group, (0, 0), (1, 0)); - // Save. using var stream = new MemoryStream(); WorldSerializer.Save(world, stream); stream.Position = 0; - // Load into a new world. var world2 = new World(); WorldSerializer.Load(world2, stream); - // Verify state. var state = world2.ReadSingleton(); state.CurrentPlayer.Should().Be(Player.X); state.MoveCount.Should().Be(2); @@ -170,37 +154,4 @@ public class GameFlowTests marks.Should().BeEquivalentTo([(0, 0, Player.X), (1, 0, Player.O)]); } - - // ── Helpers ─────────────────────────────────────────────────────── - - private static (World, SystemGroup) SetupGame() - { - var world = new World(); - var group = new SystemGroup(world); - group.Add(new WinCheckSystem()); - - // Create the 9 cells. - for (int r = 0; r < 3; r++) - for (int c = 0; c < 3; c++) - world.AddComponent(world.CreateEntity(), new Cell { Row = r, Col = c }); - - world.SetSingleton(new GameState - { - CurrentPlayer = Player.X, - Status = GameStatus.Playing, - MoveCount = 0 - }); - world.PostChanges(); - - return (world, group); - } - - private static void PlayMoves(World world, SystemGroup group, params (int Row, int Col)[] moves) - { - foreach (var (row, col) in moves) - { - world.Commands.Enqueue(new PlaceMarkCommand { Row = row, Col = col }); - group.RunLogical(); - } - } -} +} \ No newline at end of file diff --git a/Game.TicTacToe.Tests/PlayTests.cs b/Game.TicTacToe.Tests/PlayTests.cs index b3792bd..0dbfc81 100644 --- a/Game.TicTacToe.Tests/PlayTests.cs +++ b/Game.TicTacToe.Tests/PlayTests.cs @@ -1,7 +1,7 @@ using FluentAssertions; using Game.TicTacToe; using OECS; -using R3; +using OECS.PlayTest; using Xunit; namespace Game.TicTacToe.Tests; @@ -11,133 +11,53 @@ public class PlayTests [Fact] public void Play_GreedyVsRandom() { - var log = new PlayLog(); + var (world, group) = TestHelpers.SetupGame(); + var agentX = new GreedyTicTacToeAgent(GreedyTicTacToeAgent.Strategy.WinOrBlock); + var agentO = new RandomTicTacToeAgent(); - var (world, group) = SetupGame(); - var agentX = new GreedyAgent(GreedyAgent.Strategy.WinOrBlock); - var agentO = new RandomAgent(); + var log = RunGame(world, group, agentX, agentO, "TicTacToe: Greedy X vs Random O"); - // Subscribe reactivity. - world.ObserveComponentChanges().Subscribe(change => - { - var mark = world.ReadComponent(change.Entity); - log.Reactivity.Add($"{change.Kind} Mark = {mark} on {change.Entity}"); - }); - - log.Header = "TicTacToe: Greedy X vs Random O"; - - RunGame(world, group, agentX, agentO, log); - - log.FinalSnapshot = SnapshotWorld(world); - - var path = SavePlayLog("tic_tac_toe_greedy_vs_random.playlog", log); + var path = log.SaveTo("tic_tac_toe_greedy_vs_random.playlog"); ReadAndVerify(path); } [Fact] public void Play_RandomVsRandom() { - var log = new PlayLog(); + var (world, group) = TestHelpers.SetupGame(); + var agentX = new RandomTicTacToeAgent(); + var agentO = new RandomTicTacToeAgent(); - var (world, group) = SetupGame(); - var agentX = new RandomAgent(); - var agentO = new RandomAgent(); + var log = RunGame(world, group, agentX, agentO, "TicTacToe: Random X vs Random O"); - world.ObserveComponentChanges().Subscribe(change => - { - var mark = world.ReadComponent(change.Entity); - log.Reactivity.Add($"{change.Kind} Mark = {mark} on {change.Entity}"); - }); - - log.Header = "TicTacToe: Random X vs Random O"; - - RunGame(world, group, agentX, agentO, log); - - log.FinalSnapshot = SnapshotWorld(world); - - var path = SavePlayLog("tic_tac_toe_random_vs_random.playlog", log); + var path = log.SaveTo("tic_tac_toe_random_vs_random.playlog"); ReadAndVerify(path); } [Fact] public void Play_GreedyVsGreedy() { - var log = new PlayLog(); + var (world, group) = TestHelpers.SetupGame(); + var agentX = new GreedyTicTacToeAgent(GreedyTicTacToeAgent.Strategy.WinOrBlock); + var agentO = new GreedyTicTacToeAgent(GreedyTicTacToeAgent.Strategy.WinOrBlock); - var (world, group) = SetupGame(); - var agentX = new GreedyAgent(GreedyAgent.Strategy.WinOrBlock); - var agentO = new GreedyAgent(GreedyAgent.Strategy.WinOrBlock); + var log = RunGame(world, group, agentX, agentO, "TicTacToe: Greedy X vs Greedy O"); - world.ObserveComponentChanges().Subscribe(change => - { - var mark = world.ReadComponent(change.Entity); - log.Reactivity.Add($"{change.Kind} Mark = {mark} on {change.Entity}"); - }); - - log.Header = "TicTacToe: Greedy X vs Greedy O"; - - RunGame(world, group, agentX, agentO, log); - - log.FinalSnapshot = SnapshotWorld(world); - - var path = SavePlayLog("tic_tac_toe_greedy_vs_greedy.playlog", log); + var path = log.SaveTo("tic_tac_toe_greedy_vs_greedy.playlog"); ReadAndVerify(path); } - // ── Play Log ────────────────────────────────────────────────────── - - private sealed class PlayLog - { - public string Header { get; set; } = ""; - public readonly List Moves = new(); - public readonly List Reactivity = new(); - public string FinalSnapshot { get; set; } = ""; - - public string Build() - { - var sb = new System.Text.StringBuilder(); - sb.AppendLine(Header); - sb.AppendLine(new string('=', Header.Length)); - sb.AppendLine(); - sb.AppendLine("--- Moves ---"); - for (int i = 0; i < Moves.Count; i++) - sb.AppendLine($" {i + 1}. {Moves[i]}"); - sb.AppendLine(); - sb.AppendLine("--- Reactivity ---"); - foreach (var entry in Reactivity) - sb.AppendLine($" {entry}"); - sb.AppendLine(); - sb.AppendLine("--- Final Board ---"); - sb.AppendLine(FinalSnapshot); - return sb.ToString(); - } - } - // ── Game Runner ─────────────────────────────────────────────────── - private static (World, SystemGroup) SetupGame() + private static PlayLog RunGame(World world, SystemGroup group, + IAgent agentX, IAgent agentO, string header) { - var world = new World(); - var group = new SystemGroup(world); - group.Add(new WinCheckSystem()); + var log = new PlayLog { Header = header }; - for (int r = 0; r < 3; r++) - for (int c = 0; c < 3; c++) - world.AddComponent(world.CreateEntity(), new Cell { Row = r, Col = c }); + using var capture = new ObservableCapture(world); + capture.FormatWith(m => m.Player.ToString()); - world.SetSingleton(new GameState - { - CurrentPlayer = Player.X, - Status = GameStatus.Playing, - MoveCount = 0 - }); - world.PostChanges(); - - return (world, group); - } - - private static void RunGame(World world, SystemGroup group, IAgent agentX, IAgent agentO, PlayLog log) - { + var moves = new List(); while (world.ReadSingleton().Status == GameStatus.Playing) { var state = world.ReadSingleton(); @@ -147,132 +67,76 @@ public class PlayTests world.Commands.Enqueue(cmd); group.RunLogical(); - log.Moves.Add($"{state.CurrentPlayer} → ({cmd.Row},{cmd.Col})"); + moves.Add($"{state.CurrentPlayer} → ({cmd.Row},{cmd.Col})"); } var final = world.ReadSingleton(); log.Header += $" — Result: {final.Status}"; - } - // ── Snapshot ────────────────────────────────────────────────────── + log.AddSection("Moves", moves.Select((m, i) => $"{i + 1}. {m}")); + log.AddSection("Reactivity", capture.GetLogLines()); + log.FinalSnapshot = TestHelpers.SnapshotWorld(world); - private static string SnapshotWorld(World world) - { - var sb = new System.Text.StringBuilder(); - var grid = new char?[3, 3]; - foreach (var it in world.Select()) - grid[it.Val1.Row, it.Val1.Col] = - it.Val2.Player == Player.X ? 'X' : 'O'; - - for (int r = 0; r < 3; r++) - { - sb.Append(" "); - for (int c = 0; c < 3; c++) - sb.Append(grid[r, c]?.ToString() ?? "."); - sb.AppendLine(); - } - return sb.ToString().TrimEnd(); + return log; } // ── File I/O ────────────────────────────────────────────────────── - private static string SavePlayLog(string filename, PlayLog log) - { - var dir = Path.Combine(AppContext.BaseDirectory, "playlogs"); - Directory.CreateDirectory(dir); - var path = Path.Combine(dir, filename); - File.WriteAllText(path, log.Build()); - return path; - } - private static void ReadAndVerify(string path) { var content = File.ReadAllText(path); - // Verify the play log is well-formed. content.Should().Contain("--- Moves ---"); content.Should().Contain("--- Reactivity ---"); - content.Should().Contain("--- Final Board ---"); - - // The game must have finished. + content.Should().Contain("--- Final State ---"); content.Should().Contain("Result:"); content.Should().NotContain("Result: Playing"); } // ── Agents ──────────────────────────────────────────────────────── - private interface IAgent + private sealed class RandomTicTacToeAgent : GreedyAgent { - PlaceMarkCommand Decide(World world); - } - - private sealed class RandomAgent : IAgent - { - private static readonly Random _rng = new(); - public PlaceMarkCommand Decide(World world) + protected override List GetLegalActions(World world) { - var empty = GetEmptyCells(world); - var (row, col) = empty[_rng.Next(empty.Count)]; - return new PlaceMarkCommand { Row = row, Col = col }; + return TestHelpers.GetEmptyCells(world) + .Select(c => new PlaceMarkCommand { Row = c.Row, Col = c.Col }) + .ToList(); } } - private sealed class GreedyAgent : IAgent + private sealed class GreedyTicTacToeAgent : GreedyAgent { public enum Strategy { WinOrBlock } private readonly Strategy _strategy; private static readonly Random _rng = new(); - public GreedyAgent(Strategy strategy) => _strategy = strategy; - public PlaceMarkCommand Decide(World world) + public GreedyTicTacToeAgent(Strategy strategy) => _strategy = strategy; + + protected override List GetLegalActions(World world) { + return TestHelpers.GetEmptyCells(world) + .Select(c => new PlaceMarkCommand { Row = c.Row, Col = c.Col }) + .ToList(); + } + + protected override float ScoreAction(World world, PlaceMarkCommand action) + { + if (_strategy != Strategy.WinOrBlock) + return 0f; + var state = world.ReadSingleton(); var me = state.CurrentPlayer; - var empty = GetEmptyCells(world); - - foreach (var (r, c) in empty) - if (WouldWin(world, r, c, me)) - return new PlaceMarkCommand { Row = r, Col = c }; - var opponent = me == Player.X ? Player.O : Player.X; - foreach (var (r, c) in empty) - if (WouldWin(world, r, c, opponent)) - return new PlaceMarkCommand { Row = r, Col = c }; - if (empty.Any(c => c.Row == 1 && c.Col == 1)) - return new PlaceMarkCommand { Row = 1, Col = 1 }; + if (TestHelpers.WouldWin(world, action.Row, action.Col, me)) + return 100f; + if (TestHelpers.WouldWin(world, action.Row, action.Col, opponent)) + return 50f; + if (action.Row == 1 && action.Col == 1) + return 10f; - var (rr, cc) = empty[_rng.Next(empty.Count)]; - return new PlaceMarkCommand { Row = rr, Col = cc }; + return 0f; } } - - private static List<(int Row, int Col)> GetEmptyCells(World world) - { - var empty = new List<(int, int)>(); - foreach (var it in world.Select(new Query().Without())) - empty.Add((it.Val1.Row, it.Val1.Col)); - return empty; - } - - private static bool WouldWin(World world, int row, int col, Player player) - { - var grid = new Player[3, 3]; - foreach (var it in world.Select()) - grid[it.Val1.Row, it.Val1.Col] = it.Val2.Player; - grid[row, col] = player; - - for (int r = 0; r < 3; r++) - if (grid[r, 0] == player && grid[r, 1] == player && grid[r, 2] == player) - return true; - for (int c = 0; c < 3; c++) - if (grid[0, c] == player && grid[1, c] == player && grid[2, c] == player) - return true; - if (grid[0, 0] == player && grid[1, 1] == player && grid[2, 2] == player) - return true; - if (grid[0, 2] == player && grid[1, 1] == player && grid[2, 0] == player) - return true; - - return false; - } -} +} \ No newline at end of file diff --git a/Game.TicTacToe.Tests/SnapshotTests.cs b/Game.TicTacToe.Tests/SnapshotTests.cs index 39acedb..847184e 100644 --- a/Game.TicTacToe.Tests/SnapshotTests.cs +++ b/Game.TicTacToe.Tests/SnapshotTests.cs @@ -1,7 +1,7 @@ using FluentAssertions; using Game.TicTacToe; using OECS; -using R3; +using OECS.PlayTest; using Xunit; using Xunit.Abstractions; @@ -36,10 +36,9 @@ public class SnapshotTests MoveCount = 0 }); - var snapshot = SnapshotWorld(world); + var snapshot = TestHelpers.SnapshotWorld(world); _output.WriteLine(snapshot); - // Verify baseline properties. snapshot.Should().Contain("GameState: X's turn, 0 moves"); snapshot.Should().Contain("Cells: 9 total, 0 marked"); } @@ -47,12 +46,11 @@ public class SnapshotTests [Fact] public void Snapshot_AfterThreeMoves() { - var (world, group) = SetupGame(); + var (world, group) = TestHelpers.SetupGame(); - // X: (0,0), O: (1,1), X: (2,2) - PlayMoves(world, group, (0, 0), (1, 1), (2, 2)); + TestHelpers.PlayMoves(world, group, (0, 0), (1, 1), (2, 2)); - var snapshot = SnapshotWorld(world); + var snapshot = TestHelpers.SnapshotWorld(world); _output.WriteLine(snapshot); snapshot.Should().Contain("GameState: O's turn, 3 moves"); @@ -65,12 +63,11 @@ public class SnapshotTests [Fact] public void Snapshot_XWins() { - var (world, group) = SetupGame(); + var (world, group) = TestHelpers.SetupGame(); - // X: (0,0), O: (1,0), X: (0,1), O: (1,1), X: (0,2) → X wins row 0 - PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2)); + TestHelpers.PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2)); - var snapshot = SnapshotWorld(world); + var snapshot = TestHelpers.SnapshotWorld(world); _output.WriteLine(snapshot); snapshot.Should().Contain("GameState: XWon"); @@ -80,14 +77,14 @@ public class SnapshotTests [Fact] public void Snapshot_Draw() { - var (world, group) = SetupGame(); + var (world, group) = TestHelpers.SetupGame(); - PlayMoves(world, group, + TestHelpers.PlayMoves(world, group, (0, 0), (0, 1), (0, 2), (1, 1), (1, 0), (1, 2), (2, 1), (2, 0), (2, 2)); - var snapshot = SnapshotWorld(world); + var snapshot = TestHelpers.SnapshotWorld(world); _output.WriteLine(snapshot); snapshot.Should().Contain("GameState: Draw"); @@ -97,46 +94,29 @@ public class SnapshotTests [Fact] public void Log_ReactivityDuringGame() { - var (world, group) = SetupGame(); - var log = new List(); + var (world, group) = TestHelpers.SetupGame(); - // Subscribe to all entity-level changes. - world.ObserveEntityChanges().Subscribe(change => - { - log.Add($"[entity] {change}"); - }); + using var capture = new ObservableCapture(world); + capture.FormatWith(m => m.Player.ToString()); + capture.FormatWith(s => $"{s.Status} ({s.CurrentPlayer}, {s.MoveCount} moves)"); - // Subscribe to component-specific changes. - world.ObserveComponentChanges().Subscribe(change => - { - log.Add($"[mark] {change}"); - }); + TestHelpers.PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2)); - world.ObserveComponentChanges().Subscribe(change => - { - log.Add($"[gamestate] {change}"); - }); - - // Play a full game: X wins on row 0. - PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2)); - - var logText = string.Join("\n", log); + var logText = string.Join("\n", capture.GetLogLines()); _output.WriteLine(logText); - // Verify key events appear in the log. - log.Should().Contain(l => l.Contains("ComponentAdded") && l.Contains("Mark")); - log.Should().Contain(l => l.Contains("ComponentModified") && l.Contains("GameState")); + capture.GetLogLines().Should().Contain(l => l.Contains("ComponentAdded") && l.Contains("Mark")); + capture.GetLogLines().Should().Contain(l => l.Contains("ComponentModified") && l.Contains("GameState")); } [Fact] public void Serialization_RoundTrip_PreservesSnapshot() { - var (world, group) = SetupGame(); + var (world, group) = TestHelpers.SetupGame(); - // Play a few moves. - PlayMoves(world, group, (0, 0), (1, 1), (2, 2)); + TestHelpers.PlayMoves(world, group, (0, 0), (1, 1), (2, 2)); - var before = SnapshotWorld(world); + var before = TestHelpers.SnapshotWorld(world); using var stream = new MemoryStream(); WorldSerializer.Save(world, stream); @@ -145,7 +125,7 @@ public class SnapshotTests var world2 = new World(); WorldSerializer.Load(world2, stream); - var after = SnapshotWorld(world2); + var after = TestHelpers.SnapshotWorld(world2); _output.WriteLine("=== Before ==="); _output.WriteLine(before); @@ -154,86 +134,4 @@ public class SnapshotTests after.Should().Be(before); } - - // ── Helpers ─────────────────────────────────────────────────────── - - private static (World, SystemGroup) SetupGame() - { - var world = new World(); - var group = new SystemGroup(world); - group.Add(new WinCheckSystem()); - - for (int r = 0; r < 3; r++) - for (int c = 0; c < 3; c++) - world.AddComponent(world.CreateEntity(), new Cell { Row = r, Col = c }); - - world.SetSingleton(new GameState - { - CurrentPlayer = Player.X, - Status = GameStatus.Playing, - MoveCount = 0 - }); - world.PostChanges(); - - return (world, group); - } - - private static void PlayMoves(World world, SystemGroup group, params (int Row, int Col)[] moves) - { - foreach (var (row, col) in moves) - { - world.Commands.Enqueue(new PlaceMarkCommand { Row = row, Col = col }); - group.RunLogical(); - } - } - - /// - /// Produces a human-readable textual snapshot of the world state. - /// - private static string SnapshotWorld(World world) - { - var sb = new System.Text.StringBuilder(); - - // Singleton: GameState. - var state = world.ReadSingleton(); - var statusText = state.Status switch - { - GameStatus.Playing => $"{(state.CurrentPlayer == Player.X ? 'X' : 'O')}'s turn, {state.MoveCount} moves", - GameStatus.XWon => "XWon", - GameStatus.OWon => "OWon", - GameStatus.Draw => "Draw", - _ => "Unknown" - }; - sb.AppendLine($"GameState: {statusText}"); - - // Board: build a 3x3 grid. - var grid = new char?[3, 3]; - foreach (var it in world.Select()) - grid[it.Val1.Row, it.Val1.Col] = - it.Val2.Player == Player.X ? 'X' : 'O'; - - int totalCells = 0; - int markedCells = 0; - for (int r = 0; r < 3; r++) - { - for (int c = 0; c < 3; c++) - { - totalCells++; - var mark = grid[r, c]; - if (mark.HasValue) - { - markedCells++; - sb.AppendLine($" ({r},{c}): {mark.Value}"); - } - else - { - sb.AppendLine($" ({r},{c}): ."); - } - } - } - - sb.AppendLine($"Cells: {totalCells} total, {markedCells} marked"); - - return sb.ToString().TrimEnd(); - } -} +} \ No newline at end of file diff --git a/Game.TicTacToe.Tests/TestHelpers.cs b/Game.TicTacToe.Tests/TestHelpers.cs new file mode 100644 index 0000000..78288ff --- /dev/null +++ b/Game.TicTacToe.Tests/TestHelpers.cs @@ -0,0 +1,126 @@ +using Game.TicTacToe; +using OECS; +using System.Text; + +namespace Game.TicTacToe.Tests; + +internal static class TestHelpers +{ + /// + /// Creates a fresh TicTacToe world with 9 empty cells, GameState singleton, and WinCheckSystem. + /// + public static (World World, SystemGroup Group) SetupGame() + { + var world = new World(); + var group = new SystemGroup(world); + group.Add(new WinCheckSystem()); + + for (int r = 0; r < 3; r++) + for (int c = 0; c < 3; c++) + world.AddComponent(world.CreateEntity(), new Cell { Row = r, Col = c }); + + world.SetSingleton(new GameState + { + CurrentPlayer = Player.X, + Status = GameStatus.Playing, + MoveCount = 0 + }); + world.PostChanges(); + + return (world, group); + } + + /// + /// Enqueues and runs a sequence of moves. + /// + public static void PlayMoves(World world, SystemGroup group, params (int Row, int Col)[] moves) + { + foreach (var (row, col) in moves) + { + world.Commands.Enqueue(new PlaceMarkCommand { Row = row, Col = col }); + group.RunLogical(); + } + } + + /// + /// Returns a human-readable textual snapshot of the world state. + /// + public static string SnapshotWorld(World world) + { + var sb = new StringBuilder(); + var state = world.ReadSingleton(); + var statusText = state.Status switch + { + GameStatus.Playing => $"{(state.CurrentPlayer == Player.X ? 'X' : 'O')}'s turn, {state.MoveCount} moves", + GameStatus.XWon => "XWon", + GameStatus.OWon => "OWon", + GameStatus.Draw => "Draw", + _ => "Unknown" + }; + sb.AppendLine($"GameState: {statusText}"); + + var grid = new char?[3, 3]; + foreach (var it in world.Select()) + grid[it.Val1.Row, it.Val1.Col] = + it.Val2.Player == Player.X ? 'X' : 'O'; + + int totalCells = 0; + int markedCells = 0; + for (int r = 0; r < 3; r++) + { + for (int c = 0; c < 3; c++) + { + totalCells++; + var mark = grid[r, c]; + if (mark.HasValue) + { + markedCells++; + sb.AppendLine($" ({r},{c}): {mark.Value}"); + } + else + { + sb.AppendLine($" ({r},{c}): ."); + } + } + } + + sb.AppendLine($"Cells: {totalCells} total, {markedCells} marked"); + + return sb.ToString().TrimEnd(); + } + + /// + /// Returns the (Row, Col) of all empty cells. + /// + public static List<(int Row, int Col)> GetEmptyCells(World world) + { + var empty = new List<(int, int)>(); + foreach (var it in world.Select(new Query().Without())) + empty.Add((it.Val1.Row, it.Val1.Col)); + return empty; + } + + /// + /// Checks whether placing a mark at (row, col) for the given player would win the game. + /// + public static bool WouldWin(World world, int row, int col, Player player) + { + var grid = new Player[3, 3]; + foreach (var it in world.Select()) + grid[it.Val1.Row, it.Val1.Col] = it.Val2.Player; + grid[row, col] = player; + + for (int r = 0; r < 3; r++) + if (grid[r, 0] == player && grid[r, 1] == player && grid[r, 2] == player) + return true; + for (int c = 0; c < 3; c++) + if (grid[0, c] == player && grid[1, c] == player && grid[2, c] == player) + return true; + if (grid[0, 0] == player && grid[1, 1] == player && grid[2, 2] == player) + return true; + if (grid[0, 2] == player && grid[1, 1] == player && grid[2, 0] == player) + return true; + + return false; + } +} \ No newline at end of file