using System.Collections.Frozen; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Mvc.Testing; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Items; namespace NeonSprawl.Server.Tests.Game.Crafting; public class RecipeDefinitionCatalogLoaderTests { private static readonly FrozenSet KnownItemIds = PrototypeSlice1ItemCatalogRules.ExpectedItemIds; private static readonly FrozenSet KnownSkillIds = FrozenSet.ToFrozenSet( ["salvage", "refine", "intrusion"], StringComparer.Ordinal); private const string ValidPrototypeCatalogJson = """ { "schemaVersion": 1, "recipes": [ { "id": "refine_scrap_standard", "displayName": "Refine Scrap (Standard)", "recipeKind": "process", "requiredSkillId": "refine", "inputs": [{ "itemId": "scrap_metal_bulk", "quantity": 5 }], "outputs": [{ "itemId": "refined_plate_stock", "quantity": 1 }] }, { "id": "refine_scrap_efficient", "displayName": "Refine Scrap (Efficient)", "recipeKind": "process", "requiredSkillId": "refine", "inputs": [{ "itemId": "scrap_metal_bulk", "quantity": 10 }], "outputs": [{ "itemId": "refined_plate_stock", "quantity": 2 }] }, { "id": "make_field_stim_mk0", "displayName": "Make Field Stim Mk0", "recipeKind": "make", "requiredSkillId": "refine", "inputs": [ { "itemId": "refined_plate_stock", "quantity": 2 }, { "itemId": "scrap_metal_bulk", "quantity": 1 } ], "outputs": [{ "itemId": "field_stim_mk0", "quantity": 1 }] }, { "id": "make_field_stim_batch", "displayName": "Make Field Stim (Batch)", "recipeKind": "make", "requiredSkillId": "refine", "inputs": [ { "itemId": "refined_plate_stock", "quantity": 5 }, { "itemId": "scrap_metal_bulk", "quantity": 3 } ], "outputs": [{ "itemId": "field_stim_mk0", "quantity": 3 }] }, { "id": "make_prototype_armor", "displayName": "Make Prototype Armor Shell", "recipeKind": "make", "requiredSkillId": "refine", "inputs": [ { "itemId": "refined_plate_stock", "quantity": 8 }, { "itemId": "scrap_metal_bulk", "quantity": 5 } ], "outputs": [{ "itemId": "prototype_armor_shell", "quantity": 1 }] }, { "id": "make_armor_quick", "displayName": "Make Armor (Quick)", "recipeKind": "make", "requiredSkillId": "refine", "inputs": [ { "itemId": "refined_plate_stock", "quantity": 4 }, { "itemId": "scrap_metal_bulk", "quantity": 3 } ], "outputs": [{ "itemId": "prototype_armor_shell", "quantity": 1 }] }, { "id": "make_survey_drone_kit", "displayName": "Make Survey Drone Kit", "recipeKind": "make", "requiredSkillId": "refine", "inputs": [ { "itemId": "refined_plate_stock", "quantity": 3 }, { "itemId": "scrap_metal_bulk", "quantity": 2 } ], "outputs": [{ "itemId": "survey_drone_kit", "quantity": 1 }] }, { "id": "make_contract_token", "displayName": "Make Contract Handoff Token", "recipeKind": "make", "requiredSkillId": "refine", "inputs": [ { "itemId": "refined_plate_stock", "quantity": 2 }, { "itemId": "scrap_metal_bulk", "quantity": 1 } ], "outputs": [{ "itemId": "contract_handoff_token", "quantity": 1 }] } ] } """; private static (string Root, string RecipesDir, string DefSchemaPath, string IoSchemaPath) CreateTempContentLayout() { var root = Directory.CreateTempSubdirectory("neon-sprawl-recipecat-"); var recipesDir = Path.Combine(root.FullName, "content", "recipes"); var schemaDir = Path.Combine(root.FullName, "content", "schemas"); Directory.CreateDirectory(recipesDir); Directory.CreateDirectory(schemaDir); var defSchemaPath = Path.Combine(schemaDir, "recipe-def.schema.json"); var ioSchemaPath = Path.Combine(schemaDir, "recipe-io-row.schema.json"); File.Copy(RecipeCatalogTestPaths.DiscoverRepoRecipeDefSchemaPath(), defSchemaPath, overwrite: true); File.Copy(RecipeCatalogTestPaths.DiscoverRepoRecipeIoRowSchemaPath(), ioSchemaPath, overwrite: true); return (root.FullName, recipesDir, defSchemaPath, ioSchemaPath); } private static void WriteCatalog(string recipesDir, string catalogJson) => File.WriteAllText(Path.Combine(recipesDir, "prototype_recipes.json"), catalogJson, Encoding.UTF8); private static JsonObject GetRecipeRow(JsonObject catalogRoot, string recipeId) { var recipes = catalogRoot["recipes"] as JsonArray ?? throw new InvalidOperationException("expected recipes array"); foreach (var node in recipes) { if (node is JsonObject row && row["id"]?.GetValue() == recipeId) return row; } throw new InvalidOperationException($"recipe id not found: {recipeId}"); } private static RecipeDefinitionCatalog LoadCatalog( string recipesDir, string defSchemaPath, string ioSchemaPath) => RecipeDefinitionCatalogLoader.Load( recipesDir, defSchemaPath, ioSchemaPath, KnownItemIds, KnownSkillIds, NullLogger.Instance); [Fact] public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract() { // Arrange var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout(); WriteCatalog(recipesDir, ValidPrototypeCatalogJson); // Act var catalog = LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath); // Assert Assert.Equal(8, catalog.DistinctRecipeCount); Assert.Equal(1, catalog.CatalogJsonFileCount); Assert.True(catalog.TryGetRecipe("refine_scrap_standard", out var row)); Assert.NotNull(row); Assert.Equal("process", row!.RecipeKind); Assert.Single(row.Inputs); Assert.Equal("scrap_metal_bulk", row.Inputs[0].ItemId); Assert.Equal(5, row.Inputs[0].Quantity); } [Fact] public void Load_ShouldThrow_WhenRecipesIsNotArray() { // Arrange var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout(); File.WriteAllText( Path.Combine(recipesDir, "bad_recipes.json"), """{"schemaVersion": 1, "recipes": "nope"}""", Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("bad_recipes.json", ioe.Message, StringComparison.Ordinal); Assert.Contains("expected top-level 'recipes' array", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenSchemaVersionIsNotOne() { // Arrange var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout(); File.WriteAllText( Path.Combine(recipesDir, "bad_recipes.json"), """{"schemaVersion": 2, "recipes": []}""", Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenRowViolatesSchema() { // Arrange var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout(); const string bad = """ { "schemaVersion": 1, "recipes": [ { "id": "refine_scrap_standard", "displayName": "", "recipeKind": "process", "requiredSkillId": "refine", "inputs": [{ "itemId": "scrap_metal_bulk", "quantity": 5 }], "outputs": [{ "itemId": "refined_plate_stock", "quantity": 1 }] } ] } """; File.WriteAllText(Path.Combine(recipesDir, "bad_recipes.json"), bad, Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("bad_recipes.json", ioe.Message, StringComparison.Ordinal); Assert.Contains("Recipe catalog validation failed", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenDuplicateIdAcrossFiles() { // Arrange var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout(); const string singleRecipe = """ { "schemaVersion": 1, "recipes": [ { "id": "refine_scrap_standard", "displayName": "Refine Scrap (Standard)", "recipeKind": "process", "requiredSkillId": "refine", "inputs": [{ "itemId": "scrap_metal_bulk", "quantity": 5 }], "outputs": [{ "itemId": "refined_plate_stock", "quantity": 1 }] } ] } """; File.WriteAllText(Path.Combine(recipesDir, "a_recipes.json"), singleRecipe, Encoding.UTF8); File.WriteAllText(Path.Combine(recipesDir, "b_recipes.json"), singleRecipe, Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("duplicate recipe id", ioe.Message, StringComparison.Ordinal); Assert.Contains("refine_scrap_standard", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenSlice3IdsIncomplete() { // Arrange var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout(); const string twoOnly = """ { "schemaVersion": 1, "recipes": [ { "id": "refine_scrap_standard", "displayName": "Refine Scrap (Standard)", "recipeKind": "process", "requiredSkillId": "refine", "inputs": [{ "itemId": "scrap_metal_bulk", "quantity": 5 }], "outputs": [{ "itemId": "refined_plate_stock", "quantity": 1 }] }, { "id": "refine_scrap_efficient", "displayName": "Refine Scrap (Efficient)", "recipeKind": "process", "requiredSkillId": "refine", "inputs": [{ "itemId": "scrap_metal_bulk", "quantity": 10 }], "outputs": [{ "itemId": "refined_plate_stock", "quantity": 2 }] } ] } """; File.WriteAllText(Path.Combine(recipesDir, "partial_recipes.json"), twoOnly, Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("prototype Slice 3 expects exactly recipe ids", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenMissingMakeRecipeKind() { // Arrange var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout(); var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject ?? throw new InvalidOperationException("expected object root"); var recipes = root["recipes"] as JsonArray ?? throw new InvalidOperationException("expected recipes array"); foreach (var node in recipes) { if (node is JsonObject row && row["recipeKind"]?.GetValue() == "make") row["recipeKind"] = "process"; } WriteCatalog(recipesDir, root.ToJsonString()); // Act var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("requires at least one process and one make recipe", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenRequiredSkillIdIsNotRefine() { // Arrange var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout(); var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject ?? throw new InvalidOperationException("expected object root"); GetRecipeRow(root, "make_field_stim_mk0")["requiredSkillId"] = "salvage"; WriteCatalog(recipesDir, root.ToJsonString()); // Act var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("make_field_stim_mk0", ioe.Message, StringComparison.Ordinal); Assert.Contains("requiredSkillId must be 'refine'", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenUnknownItemIdInInputs() { // Arrange var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout(); var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject ?? throw new InvalidOperationException("expected object root"); var row = GetRecipeRow(root, "refine_scrap_standard"); var inputs = row["inputs"] as JsonArray ?? throw new InvalidOperationException("expected inputs array"); inputs[0] = JsonNode.Parse("""{"itemId": "unknown_item_xyz", "quantity": 5}"""); WriteCatalog(recipesDir, root.ToJsonString()); // Act var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("unknown_item_xyz", ioe.Message, StringComparison.Ordinal); Assert.Contains("is not in item catalogs", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenUnknownItemIdInOutputs() { // Arrange var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout(); var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject ?? throw new InvalidOperationException("expected object root"); var row = GetRecipeRow(root, "refine_scrap_standard"); var outputs = row["outputs"] as JsonArray ?? throw new InvalidOperationException("expected outputs array"); outputs[0] = JsonNode.Parse("""{"itemId": "unknown_item_xyz", "quantity": 1}"""); WriteCatalog(recipesDir, root.ToJsonString()); // Act var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("unknown_item_xyz", ioe.Message, StringComparison.Ordinal); Assert.Contains("outputs[0]", ioe.Message, StringComparison.Ordinal); Assert.Contains("is not in item catalogs", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenNoRecipeCatalogFiles() { // Arrange var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout(); // Act var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("no *_recipes.json files", ioe.Message, StringComparison.Ordinal); Assert.Contains(recipesDir, ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenJsonIsInvalid() { // Arrange var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout(); File.WriteAllText(Path.Combine(recipesDir, "bad_recipes.json"), "{ not json", Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("bad_recipes.json", ioe.Message, StringComparison.Ordinal); Assert.Contains("invalid JSON", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenUnknownRequiredSkillId() { // Arrange var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout(); var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject ?? throw new InvalidOperationException("expected object root"); GetRecipeRow(root, "refine_scrap_standard")["requiredSkillId"] = "unknown_skill_xyz"; WriteCatalog(recipesDir, root.ToJsonString()); // Act var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("unknown_skill_xyz", ioe.Message, StringComparison.Ordinal); Assert.Contains("is not a known SkillDef id", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenRecipesDirectoryMissing() { // Arrange var missingDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-no-recipes-" + Guid.NewGuid().ToString("n")); var defSchemaPath = RecipeCatalogTestPaths.DiscoverRepoRecipeDefSchemaPath(); var ioSchemaPath = RecipeCatalogTestPaths.DiscoverRepoRecipeIoRowSchemaPath(); // Act var ex = Record.Exception(() => RecipeDefinitionCatalogLoader.Load( missingDir, defSchemaPath, ioSchemaPath, KnownItemIds, KnownSkillIds, NullLogger.Instance)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("missing directory", ioe.Message, StringComparison.Ordinal); Assert.Contains(missingDir, ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenSchemaFileMissing() { // Arrange var (_, recipesDir, _, _) = CreateTempContentLayout(); var missingSchema = Path.Combine(recipesDir, "missing-recipe-def.schema.json"); var ioSchemaPath = RecipeCatalogTestPaths.DiscoverRepoRecipeIoRowSchemaPath(); // Act var ex = Record.Exception(() => RecipeDefinitionCatalogLoader.Load( recipesDir, missingSchema, ioSchemaPath, KnownItemIds, KnownSkillIds, NullLogger.Instance)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal); Assert.Contains(missingSchema, ioe.Message, StringComparison.Ordinal); } [Fact] public async Task Host_ShouldResolveCatalogFromDi_WhenStartupSucceeds() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); using var client = factory.CreateClient(); // Act var response = await client.GetAsync("/health"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var catalog = factory.Services.GetRequiredService(); Assert.Equal(8, catalog.DistinctRecipeCount); Assert.True(catalog.TryGetRecipe("make_field_stim_mk0", out var stim)); Assert.Equal("make", stim!.RecipeKind); Assert.Equal(2, stim.Inputs.Count); } [Fact] public void Host_ShouldFailStartup_WhenCatalogDirectoryInvalid() { // Arrange var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-recipes-" + Guid.NewGuid().ToString("n")); Directory.CreateDirectory(badDir); try { // Act var ex = Record.Exception(() => { using var factory = new WebApplicationFactory().WithWebHostBuilder(b => b.UseSetting("Content:RecipesDirectory", badDir)); factory.CreateClient(); }); // Assert Assert.NotNull(ex); Assert.Contains("Recipe catalog validation failed", ex.ToString(), StringComparison.Ordinal); } finally { if (Directory.Exists(badDir)) Directory.Delete(badDir); } } }