using System.Text.Json; using OpenTheBox.Core; using OpenTheBox.Core.Boxes; using OpenTheBox.Core.Enums; using OpenTheBox.Core.Interactions; using OpenTheBox.Core.Items; using OpenTheBox.Data; using OpenTheBox.Simulation; using OpenTheBox.Simulation.Actions; using OpenTheBox.Simulation.Events; namespace OpenTheBox.Tests; /// /// Validates that all JSON content files deserialize correctly and are internally consistent. /// These tests catch data issues (typos, missing references, schema mismatches) before runtime. /// public class ContentValidationTests { private static readonly string ContentRoot = "content"; private static readonly string ItemsPath = Path.Combine(ContentRoot, "data", "items.json"); private static readonly string BoxesPath = Path.Combine(ContentRoot, "data", "boxes.json"); private static readonly string InteractionsPath = Path.Combine(ContentRoot, "data", "interactions.json"); private static readonly string RecipesPath = Path.Combine(ContentRoot, "data", "recipes.json"); private static readonly string EnStringsPath = Path.Combine(ContentRoot, "strings", "en.json"); private static readonly string FrStringsPath = Path.Combine(ContentRoot, "strings", "fr.json"); private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } }; // ── Items ──────────────────────────────────────────────────────────── [Fact] public void ItemsJson_Deserializes() { var json = File.ReadAllText(ItemsPath); var items = JsonSerializer.Deserialize>(json, JsonOptions); Assert.NotNull(items); Assert.NotEmpty(items); } [Fact] public void ItemsJson_AllIdsAreUnique() { var items = LoadItems(); var duplicates = items.GroupBy(i => i.Id).Where(g => g.Count() > 1).Select(g => g.Key).ToList(); Assert.Empty(duplicates); } [Fact] public void ItemsJson_AllHaveNameKeys() { var items = LoadItems(); var missing = items.Where(i => string.IsNullOrWhiteSpace(i.NameKey)).Select(i => i.Id).ToList(); Assert.Empty(missing); } [Fact] public void ItemsJson_AllHaveValidCategory() { var items = LoadItems(); // If deserialization succeeded with JsonStringEnumConverter, all categories are valid Assert.All(items, item => Assert.True(Enum.IsDefined(item.Category), $"Item '{item.Id}' has invalid category")); } [Fact] public void ItemsJson_AllHaveValidRarity() { var items = LoadItems(); Assert.All(items, item => Assert.True(Enum.IsDefined(item.Rarity), $"Item '{item.Id}' has invalid rarity")); } // ── Boxes ──────────────────────────────────────────────────────────── [Fact] public void BoxesJson_Deserializes() { var json = File.ReadAllText(BoxesPath); var boxes = JsonSerializer.Deserialize>(json, JsonOptions); Assert.NotNull(boxes); Assert.NotEmpty(boxes); } [Fact] public void BoxesJson_AllIdsAreUnique() { var boxes = LoadBoxes(); var duplicates = boxes.GroupBy(b => b.Id).Where(g => g.Count() > 1).Select(g => g.Key).ToList(); Assert.Empty(duplicates); } [Fact] public void BoxesJson_GuaranteedRollsReferenceValidItems() { var items = LoadItems().Select(i => i.Id).ToHashSet(); var boxes = LoadBoxes(); var boxIds = boxes.Select(b => b.Id).ToHashSet(); var invalid = new List(); foreach (var box in boxes) { foreach (var guaranteedId in box.LootTable.GuaranteedRolls) { if (!items.Contains(guaranteedId) && !boxIds.Contains(guaranteedId)) invalid.Add($"{box.Id} -> {guaranteedId}"); } } Assert.Empty(invalid); } [Fact] public void BoxesJson_LootEntryItemsExist() { var items = LoadItems().Select(i => i.Id).ToHashSet(); var boxes = LoadBoxes(); var boxIds = boxes.Select(b => b.Id).ToHashSet(); var invalid = new List(); foreach (var box in boxes) { foreach (var entry in box.LootTable.Entries) { if (!items.Contains(entry.ItemDefinitionId) && !boxIds.Contains(entry.ItemDefinitionId)) invalid.Add($"{box.Id} -> {entry.ItemDefinitionId}"); } } Assert.Empty(invalid); } [Fact] public void BoxesJson_AllEntriesHavePositiveWeight() { var boxes = LoadBoxes(); var invalid = new List(); foreach (var box in boxes) { foreach (var entry in box.LootTable.Entries) { if (entry.Weight <= 0) invalid.Add($"{box.Id} -> {entry.ItemDefinitionId} (weight={entry.Weight})"); } } Assert.Empty(invalid); } [Fact] public void BoxesJson_AllHaveEitherGuaranteedOrRollEntries() { var boxes = LoadBoxes(); var empty = boxes .Where(b => b.LootTable.GuaranteedRolls.Count == 0 && b.LootTable.Entries.Count == 0) .Select(b => b.Id) .ToList(); Assert.Empty(empty); } [Fact] public void BoxesJson_LootConditionsHaveValidTypes() { var boxes = LoadBoxes(); // If deserialization with JsonStringEnumConverter worked, all condition types are valid. // But let's also verify targetId makes sense for specific conditions. var issues = new List(); foreach (var box in boxes) { foreach (var entry in box.LootTable.Entries) { if (entry.Condition is not null) { Assert.True(Enum.IsDefined(entry.Condition.Type), $"Box '{box.Id}' entry '{entry.ItemDefinitionId}' has invalid condition type"); if (entry.Condition.Type == LootConditionType.BoxesOpenedAbove && !entry.Condition.Value.HasValue) { issues.Add($"{box.Id}/{entry.ItemDefinitionId}: BoxesOpenedAbove needs a value"); } } } } Assert.Empty(issues); } // ── Interactions ───────────────────────────────────────────────────── [Fact] public void InteractionsJson_Deserializes() { var json = File.ReadAllText(InteractionsPath); var rules = JsonSerializer.Deserialize>(json, JsonOptions); Assert.NotNull(rules); Assert.NotEmpty(rules); } [Fact] public void InteractionsJson_AllIdsAreUnique() { var rules = LoadInteractions(); var duplicates = rules.GroupBy(r => r.Id).Where(g => g.Count() > 1).Select(g => g.Key).ToList(); Assert.Empty(duplicates); } // ── Recipes ────────────────────────────────────────────────────────── [Fact] public void RecipesJson_Deserializes() { var json = File.ReadAllText(RecipesPath); var doc = JsonDocument.Parse(json); Assert.NotNull(doc); Assert.True(doc.RootElement.GetArrayLength() > 0, "recipes.json is empty"); } [Fact] public void RecipesJson_AllIngredientsExist() { var items = LoadItems().Select(i => i.Id).ToHashSet(); var boxes = LoadBoxes().Select(b => b.Id).ToHashSet(); var json = File.ReadAllText(RecipesPath); var doc = JsonDocument.Parse(json); var invalid = new List(); foreach (var recipe in doc.RootElement.EnumerateArray()) { var recipeId = recipe.GetProperty("id").GetString()!; foreach (var ingredient in recipe.GetProperty("ingredients").EnumerateArray()) { var itemId = ingredient.GetProperty("itemDefinitionId").GetString()!; if (!items.Contains(itemId) && !boxes.Contains(itemId)) invalid.Add($"{recipeId} -> ingredient '{itemId}'"); } var resultId = recipe.GetProperty("result").GetProperty("itemDefinitionId").GetString()!; if (!items.Contains(resultId) && !boxes.Contains(resultId)) invalid.Add($"{recipeId} -> result '{resultId}'"); } Assert.Empty(invalid); } [Fact] public void RecipesJson_AllWorkstationsAreValid() { var json = File.ReadAllText(RecipesPath); var doc = JsonDocument.Parse(json); var invalid = new List(); foreach (var recipe in doc.RootElement.EnumerateArray()) { var recipeId = recipe.GetProperty("id").GetString()!; var workstation = recipe.GetProperty("workstation").GetString()!; if (!Enum.TryParse(workstation, out _)) invalid.Add($"{recipeId} -> workstation '{workstation}'"); } Assert.Empty(invalid); } // ── Localization ───────────────────────────────────────────────────── [Fact] public void EnStrings_IsValidJson() { var json = File.ReadAllText(EnStringsPath); var dict = JsonSerializer.Deserialize>(json); Assert.NotNull(dict); Assert.NotEmpty(dict); } [Fact] public void FrStrings_IsValidJson() { var json = File.ReadAllText(FrStringsPath); var dict = JsonSerializer.Deserialize>(json); Assert.NotNull(dict); Assert.NotEmpty(dict); } [Fact] public void FrStrings_HasAllKeysFromEn() { var enJson = File.ReadAllText(EnStringsPath); var en = JsonSerializer.Deserialize>(enJson)!; var frJson = File.ReadAllText(FrStringsPath); var fr = JsonSerializer.Deserialize>(frJson)!; var missing = en.Keys.Where(k => !fr.ContainsKey(k)).ToList(); Assert.Empty(missing); } [Fact] public void ItemNameKeys_ExistInLocalization() { var items = LoadItems(); var en = LoadEnStrings(); var missing = items .Where(i => !en.ContainsKey(i.NameKey)) .Select(i => $"{i.Id} -> nameKey '{i.NameKey}'") .ToList(); Assert.Empty(missing); } [Fact] public void BoxNameKeys_ExistInLocalization() { var boxes = LoadBoxes(); var en = LoadEnStrings(); var missing = boxes .Where(b => !en.ContainsKey(b.NameKey)) .Select(b => $"{b.Id} -> nameKey '{b.NameKey}'") .ToList(); Assert.Empty(missing); } // ── ContentRegistry integration ────────────────────────────────────── [Fact] public void ContentRegistry_LoadsSuccessfully() { var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath); Assert.True(registry.Items.Count > 0, "No items loaded"); Assert.True(registry.Boxes.Count > 0, "No boxes loaded"); Assert.True(registry.InteractionRules.Count > 0, "No interaction rules loaded"); } [Fact] public void ContentRegistry_StarterBoxExists() { var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath); var starter = registry.GetBox("box_starter"); Assert.NotNull(starter); Assert.NotEmpty(starter.LootTable.GuaranteedRolls); } // ── Simulation smoke test ──────────────────────────────────────────── [Fact] public void Simulation_OpenStarterBox_ProducesEvents() { var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath); var simulation = new GameSimulation(registry, new Random(42)); var state = GameState.Create("TestPlayer", Locale.EN); // Give the player a starter box var starterBox = ItemInstance.Create("box_starter"); state.AddItem(starterBox); var action = new OpenBoxAction(starterBox.Id) { BoxDefinitionId = "box_starter" }; var events = simulation.ProcessAction(action, state); Assert.NotEmpty(events); Assert.Contains(events, e => e is BoxOpenedEvent); Assert.Contains(events, e => e is ItemReceivedEvent); } // ── Black Box invariant: simulation owns all state mutations ───────── [Fact] public void Simulation_ProcessAction_HandlesAllStateMutations() { // The simulation (ProcessAction) must be the SOLE mutator of GameState. // After calling ProcessAction, the inventory should already reflect all // items received and consumed. External code (game loop, renderer) must // NOT call AddItem/RemoveItem — that would cause duplicates. var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath); var simulation = new GameSimulation(registry, new Random(42)); var state = GameState.Create("TestPlayer", Locale.EN); var starterBox = ItemInstance.Create("box_starter"); state.AddItem(starterBox); int inventoryBefore = state.Inventory.Count; // 1 (the starter box) var action = new OpenBoxAction(starterBox.Id) { BoxDefinitionId = "box_starter" }; var events = simulation.ProcessAction(action, state); // Count events int received = events.OfType().Count(); int consumed = events.OfType().Count(); // The simulation already mutated state. Verify the inventory matches // exactly: initial - consumed + received int expectedCount = inventoryBefore - consumed + received; Assert.Equal(expectedCount, state.Inventory.Count); } [Fact] public void Simulation_NoDuplicateItemInstances_InInventory() { // Each ItemInstance has a unique Guid. After opening a box, // no two inventory entries should share the same Id. var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath); var simulation = new GameSimulation(registry, new Random(42)); var state = GameState.Create("TestPlayer", Locale.EN); var starterBox = ItemInstance.Create("box_starter"); state.AddItem(starterBox); var action = new OpenBoxAction(starterBox.Id) { BoxDefinitionId = "box_starter" }; simulation.ProcessAction(action, state); var duplicateIds = state.Inventory .GroupBy(i => i.Id) .Where(g => g.Count() > 1) .Select(g => g.Key) .ToList(); Assert.Empty(duplicateIds); } // ── Adventures ─────────────────────────────────────────────────────── [Theory] [InlineData("space")] [InlineData("medieval")] [InlineData("pirate")] [InlineData("contemporary")] [InlineData("sentimental")] [InlineData("prehistoric")] [InlineData("cosmic")] [InlineData("microscopic")] [InlineData("darkfantasy")] public void Adventure_ScriptFileExists(string theme) { var path = Path.Combine(ContentRoot, "adventures", theme, "intro.lor"); Assert.True(File.Exists(path), $"Missing adventure script: {path}"); } [Theory] [InlineData("space")] [InlineData("medieval")] [InlineData("pirate")] [InlineData("contemporary")] [InlineData("sentimental")] [InlineData("prehistoric")] [InlineData("cosmic")] [InlineData("microscopic")] [InlineData("darkfantasy")] public void Adventure_FrenchTranslationExists(string theme) { var path = Path.Combine(ContentRoot, "adventures", theme, "intro.fr.lor"); Assert.True(File.Exists(path), $"Missing French translation: {path}"); } // ── Full run integration tests ───────────────────────────────────── [Fact] public void FullRun_AllReachableContentIsObtained() { // Simulates an entire game playthrough by repeatedly opening boxes // until all content reachable via box openings is unlocked. // Uses only the simulation (zero I/O) to prove game completability. var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath); var simulation = new GameSimulation(registry, new Random(42)); var state = GameState.Create("CompletionTest", Locale.EN); // ── Compute the "reachable set" dynamically from item definitions ── var allItems = registry.Items.Values.ToList(); // All MetaUnlock values that exist on items → expected UI features var expectedUIFeatures = allItems .Where(i => i.MetaUnlock.HasValue) .Select(i => i.MetaUnlock!.Value) .ToHashSet(); // All cosmetic definition IDs → expected cosmetics var expectedCosmetics = allItems .Where(i => i.CosmeticSlot.HasValue) .Select(i => i.Id) .ToHashSet(); // All AdventureTheme values that exist on items → expected adventures var expectedAdventures = allItems .Where(i => i.AdventureTheme.HasValue) .Select(i => i.AdventureTheme!.Value) .ToHashSet(); // All ResourceType values that exist on items → expected visible resources var expectedResources = allItems .Where(i => i.ResourceType.HasValue) .Select(i => i.ResourceType!.Value) .ToHashSet(); // All lore fragment definition IDs var expectedLore = allItems .Where(i => i.Category == ItemCategory.LoreFragment) .Select(i => i.Id) .ToHashSet(); // All StatType values that exist on items → expected visible stats var expectedStats = allItems .Where(i => i.StatType.HasValue) .Select(i => i.StatType!.Value) .ToHashSet(); // All FontStyle values that exist on items → expected fonts var expectedFonts = allItems .Where(i => i.FontStyle.HasValue) .Select(i => i.FontStyle!.Value) .ToHashSet(); // Track all unique item definition IDs ever received var seenDefinitionIds = new HashSet(); // ── Give starter box and run the game loop ── var starterBox = ItemInstance.Create("box_starter"); state.AddItem(starterBox); const int maxBoxOpenings = 10_000; int totalBoxesOpened = 0; for (int i = 0; i < maxBoxOpenings; i++) { // Pick one box from inventory var box = state.Inventory .FirstOrDefault(item => registry.IsBox(item.DefinitionId)); if (box is null) break; // Game loop broke — will be caught by asserts var action = new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId }; var events = simulation.ProcessAction(action, state); // Track all received items foreach (var evt in events.OfType()) { seenDefinitionIds.Add(evt.Item.DefinitionId); } totalBoxesOpened++; // Check if we've covered everything bool allUIFeatures = expectedUIFeatures.IsSubsetOf(state.UnlockedUIFeatures); bool allCosmetics = expectedCosmetics.IsSubsetOf(state.UnlockedCosmetics); bool allAdventures = expectedAdventures.IsSubsetOf(state.UnlockedAdventures); bool allResources = expectedResources.IsSubsetOf(state.VisibleResources); bool allLore = expectedLore.IsSubsetOf(seenDefinitionIds); bool allStats = expectedStats.IsSubsetOf(state.VisibleStats); bool allFonts = expectedFonts.IsSubsetOf(state.AvailableFonts); if (allUIFeatures && allCosmetics && allAdventures && allResources && allLore && allStats && allFonts) break; // 100% completion reached } // ── Assertions with detailed failure messages ── var missingUIFeatures = expectedUIFeatures.Except(state.UnlockedUIFeatures).ToList(); Assert.True(missingUIFeatures.Count == 0, $"Missing UI features after {totalBoxesOpened} boxes: {string.Join(", ", missingUIFeatures)}"); var missingCosmetics = expectedCosmetics.Except(state.UnlockedCosmetics).ToList(); Assert.True(missingCosmetics.Count == 0, $"Missing cosmetics after {totalBoxesOpened} boxes: {string.Join(", ", missingCosmetics)}"); var missingAdventures = expectedAdventures.Except(state.UnlockedAdventures).ToList(); Assert.True(missingAdventures.Count == 0, $"Missing adventures after {totalBoxesOpened} boxes: {string.Join(", ", missingAdventures)}"); var missingResources = expectedResources.Except(state.VisibleResources).ToList(); Assert.True(missingResources.Count == 0, $"Missing visible resources after {totalBoxesOpened} boxes: {string.Join(", ", missingResources)}"); var missingLore = expectedLore.Except(seenDefinitionIds).ToList(); Assert.True(missingLore.Count == 0, $"Missing lore fragments after {totalBoxesOpened} boxes: {string.Join(", ", missingLore)}"); var missingStats = expectedStats.Except(state.VisibleStats).ToList(); Assert.True(missingStats.Count == 0, $"Missing visible stats after {totalBoxesOpened} boxes: {string.Join(", ", missingStats)}"); var missingFonts = expectedFonts.Except(state.AvailableFonts).ToList(); Assert.True(missingFonts.Count == 0, $"Missing fonts after {totalBoxesOpened} boxes: {string.Join(", ", missingFonts)}"); } [Fact] public void FullRun_GameLoopNeverBreaks() { // After 500 box openings, the player must still have at least 1 box // in inventory. This validates the box_of_boxes guaranteed roll // sustains the game loop indefinitely. var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath); var simulation = new GameSimulation(registry, new Random(123)); var state = GameState.Create("LoopTest", Locale.EN); var starterBox = ItemInstance.Create("box_starter"); state.AddItem(starterBox); for (int i = 0; i < 500; i++) { var box = state.Inventory .FirstOrDefault(item => registry.IsBox(item.DefinitionId)); Assert.True(box is not null, $"No boxes left in inventory after opening {i} boxes. Game loop is broken."); var action = new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId }; simulation.ProcessAction(action, state); } // After 500 openings, player should still have boxes var remainingBoxes = state.Inventory.Count(i => registry.IsBox(i.DefinitionId)); Assert.True(remainingBoxes > 0, "No boxes remaining after 500 openings. Game loop is unsustainable."); } [Fact] public void FullRun_PacingReport() { // Diagnostic test: outputs a pacing report showing when each piece // of content is first unlocked. Not a pass/fail test — it always // passes but prints progression milestones to the test output. var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath); var simulation = new GameSimulation(registry, new Random(42)); var state = GameState.Create("PacingTest", Locale.EN); var allItems = registry.Items.Values.ToList(); var expectedUIFeatures = allItems .Where(i => i.MetaUnlock.HasValue) .Select(i => i.MetaUnlock!.Value) .ToHashSet(); var expectedCosmetics = allItems .Where(i => i.CosmeticSlot.HasValue) .Select(i => i.Id) .ToHashSet(); var expectedAdventures = allItems .Where(i => i.AdventureTheme.HasValue) .Select(i => i.AdventureTheme!.Value) .ToHashSet(); var expectedResources = allItems .Where(i => i.ResourceType.HasValue) .Select(i => i.ResourceType!.Value) .ToHashSet(); var expectedLore = allItems .Where(i => i.Category == ItemCategory.LoreFragment) .Select(i => i.Id) .ToHashSet(); var seenDefinitionIds = new HashSet(); // Track unlock milestones: (box#, description) var milestones = new List<(int boxNum, string description)>(); // Track previous counts to detect new unlocks int prevUI = 0, prevCos = 0, prevAdv = 0, prevRes = 0, prevLore = 0; var starterBox = ItemInstance.Create("box_starter"); state.AddItem(starterBox); const int maxBoxOpenings = 10_000; int totalBoxesOpened = 0; bool complete = false; for (int i = 0; i < maxBoxOpenings && !complete; i++) { var box = state.Inventory .FirstOrDefault(item => registry.IsBox(item.DefinitionId)); if (box is null) break; var action = new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId }; var events = simulation.ProcessAction(action, state); foreach (var evt in events.OfType()) seenDefinitionIds.Add(evt.Item.DefinitionId); totalBoxesOpened++; // Detect new unlocks int curUI = state.UnlockedUIFeatures.Count(f => expectedUIFeatures.Contains(f)); int curCos = state.UnlockedCosmetics.Count(c => expectedCosmetics.Contains(c)); int curAdv = state.UnlockedAdventures.Count(a => expectedAdventures.Contains(a)); int curRes = state.VisibleResources.Count(r => expectedResources.Contains(r)); int curLore = seenDefinitionIds.Count(id => expectedLore.Contains(id)); if (curUI > prevUI) milestones.Add((totalBoxesOpened, $"UI Feature {curUI}/{expectedUIFeatures.Count}: +{string.Join(", ", state.UnlockedUIFeatures.Where(f => expectedUIFeatures.Contains(f)).Except(milestones.Where(m => m.description.StartsWith("UI")).SelectMany(_ => Array.Empty())))}")); if (curCos > prevCos) milestones.Add((totalBoxesOpened, $"Cosmetics: {curCos}/{expectedCosmetics.Count}")); if (curAdv > prevAdv) milestones.Add((totalBoxesOpened, $"Adventures: {curAdv}/{expectedAdventures.Count}")); if (curRes > prevRes) milestones.Add((totalBoxesOpened, $"Resources: {curRes}/{expectedResources.Count}")); if (curLore > prevLore) milestones.Add((totalBoxesOpened, $"Lore: {curLore}/{expectedLore.Count}")); prevUI = curUI; prevCos = curCos; prevAdv = curAdv; prevRes = curRes; prevLore = curLore; complete = curUI == expectedUIFeatures.Count && curCos == expectedCosmetics.Count && curAdv == expectedAdventures.Count && curRes == expectedResources.Count && curLore == expectedLore.Count; } // Build the pacing report var report = new System.Text.StringBuilder(); report.AppendLine(); report.AppendLine("╔══════════════════════════════════════════════════════╗"); report.AppendLine("║ PACING REPORT (seed=42) ║"); report.AppendLine("╠══════════════════════════════════════════════════════╣"); report.AppendLine($"║ Total boxes opened: {totalBoxesOpened,-32}║"); report.AppendLine($"║ Game completed: {(complete ? "YES" : "NO"),-36}║"); report.AppendLine("╠══════════════════════════════════════════════════════╣"); report.AppendLine("║ Box# │ Milestone ║"); report.AppendLine("╟────────┼────────────────────────────────────────────╢"); foreach (var (boxNum, desc) in milestones) { report.AppendLine($"║ {boxNum,5} │ {desc,-42} ║"); } report.AppendLine("╠══════════════════════════════════════════════════════╣"); // Summary by category with completion box# int uiDoneAt = milestones.LastOrDefault(m => m.description.Contains($"UI Feature {expectedUIFeatures.Count}/")).boxNum; int cosDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Cosmetics: {expectedCosmetics.Count}/")).boxNum; int advDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Adventures: {expectedAdventures.Count}/")).boxNum; int resDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Resources: {expectedResources.Count}/")).boxNum; int loreDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Lore: {expectedLore.Count}/")).boxNum; report.AppendLine($"║ UI Features ({expectedUIFeatures.Count,2}) complete at box #{uiDoneAt,-17}║"); report.AppendLine($"║ Cosmetics ({expectedCosmetics.Count,2}) complete at box #{cosDoneAt,-17}║"); report.AppendLine($"║ Adventures ({expectedAdventures.Count,2}) complete at box #{advDoneAt,-17}║"); report.AppendLine($"║ Resources ({expectedResources.Count,2}) complete at box #{resDoneAt,-17}║"); report.AppendLine($"║ Lore ({expectedLore.Count,2}) complete at box #{loreDoneAt,-17}║"); report.AppendLine("╚══════════════════════════════════════════════════════╝"); // Output via ITestOutputHelper would be ideal, but Assert message works too Assert.True(true, report.ToString()); // Also write to console for visibility in test runners Console.WriteLine(report.ToString()); } // ── Helpers ────────────────────────────────────────────────────────── private static List LoadItems() { var json = File.ReadAllText(ItemsPath); return JsonSerializer.Deserialize>(json, JsonOptions)!; } private static List LoadBoxes() { var json = File.ReadAllText(BoxesPath); return JsonSerializer.Deserialize>(json, JsonOptions)!; } private static List LoadInteractions() { var json = File.ReadAllText(InteractionsPath); return JsonSerializer.Deserialize>(json, JsonOptions)!; } private static Dictionary LoadEnStrings() { var json = File.ReadAllText(EnStringsPath); return JsonSerializer.Deserialize>(json)!; } }