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`.
This commit is contained in:
hypercross 2026-07-21 15:47:33 +08:00
parent 4871f68fb8
commit 9ef387f010
5 changed files with 223 additions and 383 deletions

View File

@ -19,6 +19,7 @@
<ItemGroup>
<ProjectReference Include="..\Game.TicTacToe\TicTacToe.csproj" />
<ProjectReference Include="..\OECS.PlayTest\OECS.PlayTest.csproj" />
</ItemGroup>
</Project>

View File

@ -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<Cell>().Without<Mark>()))
@ -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<Cell, Mark>())
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<GameState>().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<GameState>().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<GameState>().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<GameState>().Status.Should().Be(GameStatus.XWon);
// Try to place another mark — should be ignored.
var moveCountBefore = world.ReadSingleton<GameState>().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<GameState>();
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();
}
}
}

View File

@ -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<Mark>().Subscribe(change =>
{
var mark = world.ReadComponent<Mark>(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<Mark>().Subscribe(change =>
{
var mark = world.ReadComponent<Mark>(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<Mark>().Subscribe(change =>
{
var mark = world.ReadComponent<Mark>(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<string> Moves = new();
public readonly List<string> 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<PlaceMarkCommand> agentX, IAgent<PlaceMarkCommand> 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<Mark>(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<string>();
while (world.ReadSingleton<GameState>().Status == GameStatus.Playing)
{
var state = world.ReadSingleton<GameState>();
@ -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<GameState>();
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<Cell, Mark>())
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>
{
PlaceMarkCommand Decide(World world);
}
private sealed class RandomAgent : IAgent
protected override List<PlaceMarkCommand> GetLegalActions(World world)
{
private static readonly Random _rng = new();
public PlaceMarkCommand Decide(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<PlaceMarkCommand>
{
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<PlaceMarkCommand> 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<GameState>();
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<Cell>().Without<Mark>()))
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<Cell, Mark>())
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;
}
}

View File

@ -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<string>();
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<Mark>(m => m.Player.ToString());
capture.FormatWith<GameState>(s => $"{s.Status} ({s.CurrentPlayer}, {s.MoveCount} moves)");
// Subscribe to component-specific changes.
world.ObserveComponentChanges<Mark>().Subscribe(change =>
{
log.Add($"[mark] {change}");
});
TestHelpers.PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
world.ObserveComponentChanges<GameState>().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();
}
}
/// <summary>
/// Produces a human-readable textual snapshot of the world state.
/// </summary>
private static string SnapshotWorld(World world)
{
var sb = new System.Text.StringBuilder();
// Singleton: GameState.
var state = world.ReadSingleton<GameState>();
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<Cell, Mark>())
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();
}
}

View File

@ -0,0 +1,126 @@
using Game.TicTacToe;
using OECS;
using System.Text;
namespace Game.TicTacToe.Tests;
internal static class TestHelpers
{
/// <summary>
/// Creates a fresh TicTacToe world with 9 empty cells, GameState singleton, and WinCheckSystem.
/// </summary>
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);
}
/// <summary>
/// Enqueues and runs a sequence of moves.
/// </summary>
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();
}
}
/// <summary>
/// Returns a human-readable textual snapshot of the world state.
/// </summary>
public static string SnapshotWorld(World world)
{
var sb = new StringBuilder();
var state = world.ReadSingleton<GameState>();
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<Cell, Mark>())
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();
}
/// <summary>
/// Returns the (Row, Col) of all empty cells.
/// </summary>
public static List<(int Row, int Col)> GetEmptyCells(World world)
{
var empty = new List<(int, int)>();
foreach (var it in world.Select(new Query<Cell>().Without<Mark>()))
empty.Add((it.Val1.Row, it.Val1.Col));
return empty;
}
/// <summary>
/// Checks whether placing a mark at (row, col) for the given player would win the game.
/// </summary>
public static bool WouldWin(World world, int row, int col, Player player)
{
var grid = new Player[3, 3];
foreach (var it in world.Select<Cell, Mark>())
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;
}
}