using FluentAssertions; using Game.Blackjack; using OECS; using R3; using Xunit; using Xunit.Abstractions; namespace Game.Blackjack.Tests; /// /// Baseline snapshot and log tests for Blackjack. /// Captures the world state as text and R3 change logs so they can be /// reviewed manually and compared across changes. /// public class SnapshotTests { private readonly ITestOutputHelper _output; public SnapshotTests(ITestOutputHelper output) { _output = output; } [Fact] public void Snapshot_InitialState() { var (world, _) = SetupGame(); var snapshot = SnapshotWorld(world); _output.WriteLine(snapshot); snapshot.Should().Contain("Phase: Betting"); snapshot.Should().Contain("Chips: 100"); snapshot.Should().Contain("Round: 1"); } [Fact] public void Snapshot_AfterDeal() { var (world, group) = SetupGame(seed: 42); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); group.RunLogical(); var snapshot = SnapshotWorld(world); _output.WriteLine(snapshot); snapshot.Should().Contain("Phase: PlayerTurn"); snapshot.Should().Contain("Chips: 90"); snapshot.Should().Contain("Bet: 10"); snapshot.Should().Contain("Player hand:"); snapshot.Should().Contain("Dealer hand:"); snapshot.Should().Contain("Cards in deck:"); } [Fact] public void Snapshot_AfterHit() { var (world, group) = SetupGame(seed: 42); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); group.RunLogical(); world.Commands.Enqueue(new HitCommand()); group.RunLogical(); var snapshot = 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); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); group.RunLogical(); world.Commands.Enqueue(new StandCommand()); group.RunLogical(); var snapshot = SnapshotWorld(world); _output.WriteLine(snapshot); snapshot.Should().Contain("Phase: RoundOver"); snapshot.Should().Contain("Result:"); } [Fact] public void Log_ReactivityDuringRound() { var (world, group) = SetupGame(seed: 42); var log = new List(); world.ObserveEntityChanges().Subscribe(change => { log.Add($"[entity] {change}"); }); world.ObserveComponentChanges().Subscribe(change => { log.Add($"[card] {change}"); }); world.ObserveComponentChanges().Subscribe(change => { log.Add($"[gamestate] {change}"); }); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); group.RunLogical(); world.Commands.Enqueue(new StandCommand()); group.RunLogical(); var logText = string.Join("\n", log); _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")); } [Fact] public void Serialization_RoundTrip_PreservesCoreState() { var (world, group) = SetupGame(seed: 42); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); group.RunLogical(); var before = SnapshotWorld(world); using var stream = new MemoryStream(); WorldSerializer.Save(world, stream); stream.Position = 0; var world2 = new World(); WorldSerializer.Load(world2, stream); var after = 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(); } }