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); } // ── 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}"); } // ── 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)!; } }