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