From 94a9cc9519b47aaf02044310a6de11cdecca4d90 Mon Sep 17 00:00:00 2001 From: hypercross Date: Tue, 21 Jul 2026 18:09:47 +0800 Subject: [PATCH] refactor(test): migrate blackjack tests to use TestHelpers Refactor the blackjack test suite to utilize the centralized `TestHelpers` from `OECS.PlayTest` instead of local implementations. This includes: - Replacing local `SetupGame`, `FindEntity`, and `SnapshotWorld` with `TestHelpers` equivalents. - Moving `PlayLog` logic and round running into the `OECS.PlayTest` framework. - Updating project references to include `OECS.PlayTest`. --- .../Game.Blackjack.Tests.csproj | 1 + Game.Blackjack.Tests/GameFlowTests.cs | 124 ++------- Game.Blackjack.Tests/PlayTests.cs | 248 +++--------------- Game.Blackjack.Tests/SnapshotTests.cs | 149 ++--------- 4 files changed, 84 insertions(+), 438 deletions(-) diff --git a/Game.Blackjack.Tests/Game.Blackjack.Tests.csproj b/Game.Blackjack.Tests/Game.Blackjack.Tests.csproj index a898773..3755345 100644 --- a/Game.Blackjack.Tests/Game.Blackjack.Tests.csproj +++ b/Game.Blackjack.Tests/Game.Blackjack.Tests.csproj @@ -19,6 +19,7 @@ + diff --git a/Game.Blackjack.Tests/GameFlowTests.cs b/Game.Blackjack.Tests/GameFlowTests.cs index 96ca1ee..27d551d 100644 --- a/Game.Blackjack.Tests/GameFlowTests.cs +++ b/Game.Blackjack.Tests/GameFlowTests.cs @@ -10,7 +10,7 @@ public class GameFlowTests [Fact] public void NewGame_StartsInBettingPhase() { - var (world, _) = SetupGame(); + var (world, _) = TestHelpers.SetupGame(); var state = world.ReadSingleton(); state.Phase.Should().Be(GamePhase.Betting); @@ -21,13 +21,13 @@ public class GameFlowTests [Fact] public void PlaceBet_AdvancesToDealing() { - var (world, group) = SetupGame(); + var (world, group) = TestHelpers.SetupGame(); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); group.RunLogical(); var state = world.ReadSingleton(); - state.Phase.Should().Be(GamePhase.PlayerTurn); // Dealing → PlayerTurn happens automatically + state.Phase.Should().Be(GamePhase.PlayerTurn); state.CurrentBet.Should().Be(10); state.Chips.Should().Be(90); } @@ -35,20 +35,20 @@ public class GameFlowTests [Fact] public void PlaceBet_RejectsInsufficientChips() { - var (world, group) = SetupGame(); + var (world, group) = TestHelpers.SetupGame(); world.Commands.Enqueue(new PlaceBetCommand { Amount = 200 }); group.RunLogical(); var state = world.ReadSingleton(); - state.Phase.Should().Be(GamePhase.Betting); // Should not advance + state.Phase.Should().Be(GamePhase.Betting); state.Chips.Should().Be(100); } [Fact] public void PlaceBet_RejectsZeroOrNegative() { - var (world, group) = SetupGame(); + var (world, group) = TestHelpers.SetupGame(); world.Commands.Enqueue(new PlaceBetCommand { Amount = 0 }); group.RunLogical(); @@ -62,60 +62,53 @@ public class GameFlowTests [Fact] public void Dealing_CreatesDeckAndHands() { - var (world, group) = SetupGame(); + var (world, group) = TestHelpers.SetupGame(); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); group.RunLogical(); - // Should have a deck entity. - var deckEntity = FindEntity(world); - deckEntity.Should().NotBe(Entity.Null); - - // Should have player and dealer hand entities. - var playerHand = FindEntity(world); - playerHand.Should().NotBe(Entity.Null); - - var dealerHand = FindEntity(world); - dealerHand.Should().NotBe(Entity.Null); + TestHelpers.FindEntity(world).Should().NotBe(Entity.Null); + TestHelpers.FindEntity(world).Should().NotBe(Entity.Null); + TestHelpers.FindEntity(world).Should().NotBe(Entity.Null); } [Fact] public void Dealing_DealsTwoCardsToEach() { - var (world, group) = SetupGame(); + var (world, group) = TestHelpers.SetupGame(); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); group.RunLogical(); - var playerHand = FindEntity(world); - var dealerHand = FindEntity(world); + var playerHand = TestHelpers.FindEntity(world); + var dealerHand = TestHelpers.FindEntity(world); - CountCardsInHand(world, playerHand).Should().Be(2); - CountCardsInHand(world, dealerHand).Should().Be(2); + TestHelpers.CountCardsInHand(world, playerHand).Should().Be(2); + TestHelpers.CountCardsInHand(world, dealerHand).Should().Be(2); } [Fact] public void Hit_DrawsOneCard() { - var (world, group) = SetupGame(); + var (world, group) = TestHelpers.SetupGame(); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); group.RunLogical(); - var playerHand = FindEntity(world); - var before = CountCardsInHand(world, playerHand); + var playerHand = TestHelpers.FindEntity(world); + var before = TestHelpers.CountCardsInHand(world, playerHand); world.Commands.Enqueue(new HitCommand()); group.RunLogical(); - var after = CountCardsInHand(world, playerHand); + var after = TestHelpers.CountCardsInHand(world, playerHand); after.Should().Be(before + 1); } [Fact] public void Stand_AdvancesToDealerTurn() { - var (world, group) = SetupGame(); + var (world, group) = TestHelpers.SetupGame(); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); group.RunLogical(); @@ -130,15 +123,13 @@ public class GameFlowTests [Fact] public void NewRound_ResetsPhase() { - var (world, group) = SetupGame(); + var (world, group) = TestHelpers.SetupGame(); - // Play a round. world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); group.RunLogical(); world.Commands.Enqueue(new StandCommand()); group.RunLogical(); - // Start new round. world.Commands.Enqueue(new NewRoundCommand()); group.RunLogical(); @@ -150,15 +141,15 @@ public class GameFlowTests [Fact] public void DeterministicSeed_ProducesSameDeal() { - var (world1, group1) = SetupGame(seed: 42); + var (world1, group1) = TestHelpers.SetupGame(seed: 42); world1.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); group1.RunLogical(); - var cards1 = GetAllCards(world1); + var cards1 = TestHelpers.GetAllCards(world1); - var (world2, group2) = SetupGame(seed: 42); + var (world2, group2) = TestHelpers.SetupGame(seed: 42); world2.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); group2.RunLogical(); - var cards2 = GetAllCards(world2); + var cards2 = TestHelpers.GetAllCards(world2); cards1.Should().Equal(cards2); } @@ -166,20 +157,17 @@ public class GameFlowTests [Fact] public void PlayerBust_LosesBet() { - // Use a seed that produces a bust-prone hand, then hit repeatedly. - var (world, group) = SetupGame(seed: 12345); + var (world, group) = TestHelpers.SetupGame(seed: 12345); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); group.RunLogical(); - // Hit until bust or stand. while (world.ReadSingleton().Phase == GamePhase.PlayerTurn) { world.Commands.Enqueue(new HitCommand()); group.RunLogical(); } - // Game should have resolved. var state = world.ReadSingleton(); state.Phase.Should().Be(GamePhase.RoundOver); } @@ -187,7 +175,7 @@ public class GameFlowTests [Fact] public void Serialization_RoundTrips() { - var (world, group) = SetupGame(seed: 42); + var (world, group) = TestHelpers.SetupGame(seed: 42); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); group.RunLogical(); @@ -204,60 +192,4 @@ public class GameFlowTests state.CurrentBet.Should().Be(10); state.RoundNumber.Should().Be(1); } - - // ── Helpers ─────────────────────────────────────────────────────── - - private static (World, SystemGroup) SetupGame(uint seed = 42) - { - var world = new World(); - var group = new SystemGroup(world); - group.Add(new DeckSetupSystem()); - group.Add(new DealSystem()); - group.Add(new PlayerBustCheckSystem()); - group.Add(new DealerSystem()); - - world.SetSingleton(new GameState - { - Phase = GamePhase.Betting, - Result = RoundResult.None, - Chips = 100, - CurrentBet = 0, - RoundNumber = 1, - Seed = seed - }); - world.PostChanges(); - - return (world, group); - } - - private static Entity FindEntity(World world) where T : struct - { - return world.FindEntity(); - } - - private static int CountCardsInHand(World world, Entity handEntity) - { - // Holds relationship: the card entity (which Holds points to the hand entity). - // GetSources returns all card entities that have a Holds pointing to handEntity. - return world.GetSources(handEntity).Count; - } - - private static List<(Suit, Rank)> GetAllCards(World world) - { - var cards = new List<(Suit, Rank)>(); - using (var iter = world.Select()) - { - while (iter.MoveNext()) - { - var card = iter.Val1; - cards.Add((card.Suit, card.Rank)); - } - } - cards.Sort((a, b) => - { - int cmp = a.Item1.CompareTo(b.Item1); - return cmp != 0 ? cmp : a.Item2.CompareTo(b.Item2); - }); - return cards; - } -} +} \ No newline at end of file diff --git a/Game.Blackjack.Tests/PlayTests.cs b/Game.Blackjack.Tests/PlayTests.cs index bf175e6..11bad81 100644 --- a/Game.Blackjack.Tests/PlayTests.cs +++ b/Game.Blackjack.Tests/PlayTests.cs @@ -1,7 +1,7 @@ using FluentAssertions; using Game.Blackjack; using OECS; -using R3; +using OECS.PlayTest; using Xunit; namespace Game.Blackjack.Tests; @@ -14,167 +14,73 @@ public class PlayTests [Fact] public void Play_BasicStrategy() { - var log = new PlayLog(); - var (world, group) = SetupGame(seed: 42); - - world.ObserveComponentChanges().Subscribe(change => - { - var card = world.ReadComponent(change.Entity); - log.Reactivity.Add($"{change.Kind} Card = {card} on {change.Entity}"); - }); - world.ObserveComponentChanges().Subscribe(change => - { - var state = world.ReadComponent(change.Entity); - log.Reactivity.Add($"{change.Kind} GameState = {state}"); - }); - + var (world, group) = TestHelpers.SetupGame(seed: 42); var agent = new BasicStrategyAgent(); - log.Header = $"Blackjack: Basic Strategy (seed=42, agent={agent})"; - RunUntilDone(world, group, agent, log); + var log = RunUntilDone(world, group, agent, + $"Blackjack: Basic Strategy (seed=42, agent={agent})"); - log.FinalSnapshot = SnapshotWorld(world); - - var path = SavePlayLog("blackjack_basic_strategy.playlog", log); + var path = log.SaveTo("blackjack_basic_strategy.playlog"); ReadAndVerify(path); } [Fact] public void Play_RandomAgent() { - var log = new PlayLog(); - var (world, group) = SetupGame(seed: 123); - - world.ObserveComponentChanges().Subscribe(change => - { - var card = world.ReadComponent(change.Entity); - log.Reactivity.Add($"{change.Kind} Card = {card} on {change.Entity}"); - }); - world.ObserveComponentChanges().Subscribe(change => - { - var state = world.ReadComponent(change.Entity); - log.Reactivity.Add($"{change.Kind} GameState = {state}"); - }); - + var (world, group) = TestHelpers.SetupGame(seed: 123); var agent = new RandomBlackjackAgent(); - log.Header = $"Blackjack: Random Agent (seed=123, agent={agent})"; - RunUntilDone(world, group, agent, log); + var log = RunUntilDone(world, group, agent, + $"Blackjack: Random Agent (seed=123, agent={agent})"); - log.FinalSnapshot = SnapshotWorld(world); - - var path = SavePlayLog("blackjack_random_agent.playlog", log); + var path = log.SaveTo("blackjack_random_agent.playlog"); ReadAndVerify(path); } [Fact] public void Play_WeightedPool() { - var log = new PlayLog(); - var (world, group) = SetupGame(seed: 77); + var (world, group) = TestHelpers.SetupGame(seed: 77); - world.ObserveComponentChanges().Subscribe(change => - { - var card = world.ReadComponent(change.Entity); - log.Reactivity.Add($"{change.Kind} Card = {card} on {change.Entity}"); - }); - world.ObserveComponentChanges().Subscribe(change => - { - var state = world.ReadComponent(change.Entity); - log.Reactivity.Add($"{change.Kind} GameState = {state}"); - }); - - var pool = new WeightedAgentPool(); + var pool = new WeightedAgentPool(); pool.Add(new BasicStrategyAgent(), 7); pool.Add(new RandomBlackjackAgent(), 3); var agent = pool.Pick(); - log.Header = $"Blackjack: Weighted Pool (seed=77, agent={agent})"; + var log = RunUntilDone(world, group, agent, + $"Blackjack: Weighted Pool (seed=77, agent={agent})"); - RunUntilDone(world, group, agent, log); - - log.FinalSnapshot = SnapshotWorld(world); - - var path = SavePlayLog("blackjack_weighted_pool.playlog", log); + var path = log.SaveTo("blackjack_weighted_pool.playlog"); ReadAndVerify(path); } - // ── Play Log ────────────────────────────────────────────────────── - - private sealed class PlayLog - { - public string Header { get; set; } = ""; - public readonly List RoundSummaries = new(); - public readonly List Decisions = 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("--- Round Summaries ---"); - for (int i = 0; i < RoundSummaries.Count; i++) - sb.AppendLine($" Round {i + 1}: {RoundSummaries[i]}"); - sb.AppendLine(); - sb.AppendLine("--- Decisions ---"); - for (int i = 0; i < Decisions.Count; i++) - sb.AppendLine($" {i + 1}. {Decisions[i]}"); - sb.AppendLine(); - sb.AppendLine("--- Reactivity ---"); - foreach (var entry in Reactivity) - sb.AppendLine($" {entry}"); - sb.AppendLine(); - sb.AppendLine("--- Final State ---"); - sb.AppendLine(FinalSnapshot); - return sb.ToString(); - } - } - // ── Round Runner ────────────────────────────────────────────────── - private static (World, SystemGroup) SetupGame(uint seed) - { - var world = new World(); - var group = new SystemGroup(world); - group.Add(new DeckSetupSystem()); - group.Add(new DealSystem()); - group.Add(new PlayerBustCheckSystem()); - group.Add(new DealerSystem()); - - world.SetSingleton(new GameState - { - Phase = GamePhase.Betting, - Result = RoundResult.None, - Chips = StartingChips, - CurrentBet = 0, - RoundNumber = 1, - Seed = seed - }); - world.PostChanges(); - - return (world, group); - } - /// /// Runs rounds until the player runs out of chips (can't afford minimum bet) /// or doubles their starting chips. /// - private static void RunUntilDone(World world, SystemGroup group, IBlackjackAgent agent, PlayLog log) + private static PlayLog RunUntilDone(World world, SystemGroup group, + IAgent agent, string header) { + var log = new PlayLog { Header = header }; + + using var capture = new ObservableCapture(world); + capture.FormatWith(c => $"{c.Rank} of {c.Suit}"); + capture.FormatWith(s => $"{s.Phase} Chips={s.Chips} Bet={s.CurrentBet}"); + int totalRounds = 0; int wins = 0; int losses = 0; int pushes = 0; + var roundSummaries = new List(); + var decisions = new List(); while (true) { var state = world.ReadSingleton(); int chips = state.Chips; - // Stop conditions. if (chips < BetAmount) { log.Header += $" — BUSTED after {totalRounds} rounds"; @@ -185,19 +91,15 @@ public class PlayTests log.Header += $" — DOUBLED after {totalRounds} rounds"; break; } - - // Safety: max 200 rounds to prevent infinite loops. if (totalRounds >= 200) { log.Header += $" — MAX ROUNDS ({totalRounds})"; break; } - // Place bet and run dealing. world.Commands.Enqueue(new PlaceBetCommand { Amount = BetAmount }); group.RunLogical(); - // Player turn: agent decides hit/stand. int roundHits = 0; int playerTurnSafety = 0; while (world.ReadSingleton().Phase == GamePhase.PlayerTurn && playerTurnSafety < 52) @@ -205,7 +107,7 @@ public class PlayTests playerTurnSafety++; var total = HandUtil.CalculateHand(world, new PlayerHand()); var decision = agent.Decide(world); - log.Decisions.Add($"R{totalRounds + 1} Hand={total}, Decision={decision}"); + decisions.Add($"R{totalRounds + 1} Hand={total}, Decision={decision}"); if (decision == BlackjackDecision.Hit) { @@ -220,12 +122,10 @@ public class PlayTests group.RunLogical(); } - // Record round result. state = world.ReadSingleton(); int newChips = state.Chips; int delta = newChips - chips; - string summary = $"{state.Result} | Bet={BetAmount} | Chips: {chips}→{newChips} ({delta:+0;-#}) | Hits: {roundHits}"; - log.RoundSummaries.Add(summary); + roundSummaries.Add($"Round {totalRounds + 1}: {state.Result} | Bet={BetAmount} | Chips: {chips}→{newChips} ({delta:+0;-#}) | Hits: {roundHits}"); switch (state.Result) { @@ -244,71 +144,22 @@ public class PlayTests totalRounds++; - // Start next round. world.Commands.Enqueue(new NewRoundCommand()); group.RunLogical(); } log.Header += $" | W:{wins} L:{losses} P:{pushes}"; - } - // ── Snapshot ────────────────────────────────────────────────────── + log.AddSection("Round Summaries", roundSummaries); + log.AddSection("Decisions", decisions); + log.AddSection("Reactivity", capture.GetLogLines()); + log.FinalSnapshot = TestHelpers.SnapshotWorld(world); - private static string SnapshotWorld(World world) - { - var sb = new System.Text.StringBuilder(); - var state = world.ReadSingleton(); - sb.AppendLine($"Phase: {state.Phase}"); - sb.AppendLine($"Chips: {state.Chips}, Bet: {state.CurrentBet}"); - sb.AppendLine($"Round: {state.RoundNumber}"); - sb.AppendLine($"Result: {state.Result}"); - - var playerHand = FindEntity(world); - if (playerHand != Entity.Null) - { - sb.AppendLine($"Player ({HandUtil.CalculateHand(world, new PlayerHand())}):"); - foreach (var card in GetHandCards(world, playerHand)) - sb.AppendLine($" {card}"); - } - - var dealerHand = FindEntity(world); - if (dealerHand != Entity.Null) - { - sb.AppendLine($"Dealer ({HandUtil.CalculateHand(world, new DealerHand())}):"); - foreach (var card in GetHandCards(world, dealerHand)) - sb.AppendLine($" {card}"); - } - - return sb.ToString().TrimEnd(); - } - - private static Entity FindEntity(World world) where T : struct - { - return world.FindEntity(); - } - - private static List GetHandCards(World world, Entity hand) - { - var cards = new List(); - foreach (var cardEntity in world.GetSources(hand)) - { - var card = world.ReadComponent(cardEntity); - cards.Add($"{card.Rank} of {card.Suit}"); - } - return cards; + 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); @@ -324,12 +175,7 @@ public class PlayTests private enum BlackjackDecision { Hit, Stand } - private interface IBlackjackAgent - { - BlackjackDecision Decide(World world); - } - - private sealed class BasicStrategyAgent : IBlackjackAgent + private sealed class BasicStrategyAgent : IAgent { public BlackjackDecision Decide(World world) { @@ -339,7 +185,7 @@ public class PlayTests public override string ToString() => "BasicStrategy"; } - private sealed class RandomBlackjackAgent : IBlackjackAgent + private sealed class RandomBlackjackAgent : IAgent { private static readonly Random _rng = new(); public BlackjackDecision Decide(World world) @@ -350,30 +196,4 @@ public class PlayTests } public override string ToString() => "Random"; } - - private sealed class WeightedAgentPool - { - private readonly List<(IBlackjackAgent Agent, int Weight)> _agents = new(); - private int _totalWeight; - private static readonly Random _rng = new(); - - public void Add(IBlackjackAgent agent, int weight) - { - _agents.Add((agent, weight)); - _totalWeight += weight; - } - - public IBlackjackAgent Pick() - { - int roll = _rng.Next(_totalWeight); - int cumulative = 0; - foreach (var (agent, weight) in _agents) - { - cumulative += weight; - if (roll < cumulative) - return agent; - } - return _agents[^1].Agent; - } - } -} +} \ No newline at end of file diff --git a/Game.Blackjack.Tests/SnapshotTests.cs b/Game.Blackjack.Tests/SnapshotTests.cs index b53310e..e745816 100644 --- a/Game.Blackjack.Tests/SnapshotTests.cs +++ b/Game.Blackjack.Tests/SnapshotTests.cs @@ -1,7 +1,7 @@ using FluentAssertions; using Game.Blackjack; using OECS; -using R3; +using OECS.PlayTest; using Xunit; using Xunit.Abstractions; @@ -24,9 +24,9 @@ public class SnapshotTests [Fact] public void Snapshot_InitialState() { - var (world, _) = SetupGame(); + var (world, _) = TestHelpers.SetupGame(); - var snapshot = SnapshotWorld(world); + var snapshot = TestHelpers.SnapshotWorld(world); _output.WriteLine(snapshot); snapshot.Should().Contain("Phase: Betting"); @@ -37,12 +37,12 @@ public class SnapshotTests [Fact] public void Snapshot_AfterDeal() { - var (world, group) = SetupGame(seed: 42); + var (world, group) = TestHelpers.SetupGame(seed: 42); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); group.RunLogical(); - var snapshot = SnapshotWorld(world); + var snapshot = TestHelpers.SnapshotWorld(world); _output.WriteLine(snapshot); snapshot.Should().Contain("Phase: PlayerTurn"); @@ -56,7 +56,7 @@ public class SnapshotTests [Fact] public void Snapshot_AfterHit() { - var (world, group) = SetupGame(seed: 42); + var (world, group) = TestHelpers.SetupGame(seed: 42); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); group.RunLogical(); @@ -64,17 +64,16 @@ public class SnapshotTests world.Commands.Enqueue(new HitCommand()); group.RunLogical(); - var snapshot = SnapshotWorld(world); + var snapshot = TestHelpers.SnapshotWorld(world); _output.WriteLine(snapshot); - // With seed 42, one hit may bust. Just verify the game resolved. snapshot.Should().NotContain("Phase: Dealing"); } [Fact] public void Snapshot_AfterStand() { - var (world, group) = SetupGame(seed: 42); + var (world, group) = TestHelpers.SetupGame(seed: 42); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); group.RunLogical(); @@ -82,7 +81,7 @@ public class SnapshotTests world.Commands.Enqueue(new StandCommand()); group.RunLogical(); - var snapshot = SnapshotWorld(world); + var snapshot = TestHelpers.SnapshotWorld(world); _output.WriteLine(snapshot); snapshot.Should().Contain("Phase: RoundOver"); @@ -92,23 +91,11 @@ public class SnapshotTests [Fact] public void Log_ReactivityDuringRound() { - var (world, group) = SetupGame(seed: 42); - var log = new List(); + var (world, group) = TestHelpers.SetupGame(seed: 42); - world.ObserveEntityChanges().Subscribe(change => - { - log.Add($"[entity] {change}"); - }); - - world.ObserveComponentChanges().Subscribe(change => - { - log.Add($"[card] {change}"); - }); - - world.ObserveComponentChanges().Subscribe(change => - { - log.Add($"[gamestate] {change}"); - }); + using var capture = new ObservableCapture(world); + capture.FormatWith(c => $"{c.Rank} of {c.Suit}"); + capture.FormatWith(s => $"{s.Phase} Chips={s.Chips} Bet={s.CurrentBet} Round={s.RoundNumber}"); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); group.RunLogical(); @@ -116,24 +103,23 @@ public class SnapshotTests world.Commands.Enqueue(new StandCommand()); group.RunLogical(); - var logText = string.Join("\n", log); + var logText = string.Join("\n", capture.GetLogLines()); _output.WriteLine(logText); - // Should have entity creations (deck, hands, cards) and component changes. - log.Should().Contain(l => l.Contains("EntityAdded")); - log.Should().Contain(l => l.Contains("ComponentAdded") && l.Contains("Card")); - log.Should().Contain(l => l.Contains("ComponentModified") && l.Contains("GameState")); + capture.GetLogLines().Should().Contain(l => l.Contains("EntityAdded")); + capture.GetLogLines().Should().Contain(l => l.Contains("ComponentAdded") && l.Contains("Card")); + capture.GetLogLines().Should().Contain(l => l.Contains("ComponentModified") && l.Contains("GameState")); } [Fact] public void Serialization_RoundTrip_PreservesCoreState() { - var (world, group) = SetupGame(seed: 42); + var (world, group) = TestHelpers.SetupGame(seed: 42); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); group.RunLogical(); - var before = SnapshotWorld(world); + var before = TestHelpers.SnapshotWorld(world); using var stream = new MemoryStream(); WorldSerializer.Save(world, stream); @@ -142,112 +128,19 @@ 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); _output.WriteLine("=== After ==="); _output.WriteLine(after); - // Core game state must round-trip. var state = world2.ReadSingleton(); state.Phase.Should().Be(GamePhase.PlayerTurn); state.Chips.Should().Be(90); state.CurrentBet.Should().Be(10); state.RoundNumber.Should().Be(1); - // Card entities must be preserved. after.Should().Contain("Card entities: 52"); } - - // ── Helpers ─────────────────────────────────────────────────────── - - private static (World, SystemGroup) SetupGame(uint seed = 42) - { - var world = new World(); - var group = new SystemGroup(world); - group.Add(new DeckSetupSystem()); - group.Add(new DealSystem()); - group.Add(new PlayerBustCheckSystem()); - group.Add(new DealerSystem()); - - world.SetSingleton(new GameState - { - Phase = GamePhase.Betting, - Result = RoundResult.None, - Chips = 100, - CurrentBet = 0, - RoundNumber = 1, - Seed = seed - }); - world.PostChanges(); - - return (world, group); - } - - /// - /// Produces a human-readable textual snapshot of the world state. - /// - private static string SnapshotWorld(World world) - { - var sb = new System.Text.StringBuilder(); - - // GameState singleton. - var state = world.ReadSingleton(); - sb.AppendLine($"Phase: {state.Phase}"); - sb.AppendLine($"Chips: {state.Chips}"); - sb.AppendLine($"Bet: {state.CurrentBet}"); - sb.AppendLine($"Round: {state.RoundNumber}"); - sb.AppendLine($"Result: {state.Result}"); - sb.AppendLine($"Seed: {state.Seed}"); - - // Deck entity. - var deckEntity = FindEntity(world); - if (deckEntity != Entity.Null) - { - var cardsInDeck = world.GetSources(deckEntity).ToList(); - sb.AppendLine($"Cards in deck: {cardsInDeck.Count}"); - } - - // Player hand. - var playerHand = FindEntity(world); - if (playerHand != Entity.Null) - { - var cards = world.GetSources(playerHand).ToList(); - sb.AppendLine($"Player hand: {cards.Count} cards"); - foreach (var cardEntity in cards) - { - var card = world.ReadComponent(cardEntity); - sb.AppendLine($" {card.Rank} of {card.Suit}"); - } - } - - // Dealer hand. - var dealerHand = FindEntity(world); - if (dealerHand != Entity.Null) - { - var cards = world.GetSources(dealerHand).ToList(); - sb.AppendLine($"Dealer hand: {cards.Count} cards"); - foreach (var cardEntity in cards) - { - var card = world.ReadComponent(cardEntity); - sb.AppendLine($" {card.Rank} of {card.Suit}"); - } - } - - // All entities. - int entityCount = 0; - using (var iter = world.Select()) - { - while (iter.MoveNext()) entityCount++; - } - sb.AppendLine($"Card entities: {entityCount}"); - - return sb.ToString().TrimEnd(); - } - - private static Entity FindEntity(World world) where T : struct - { - return world.FindEntity(); - } -} +} \ No newline at end of file