neon-sprawl/server/NeonSprawl.Server.Tests/Game/Crafting/RecipeDefinitionCatalogLoad...

465 lines
20 KiB
C#

using System.Collections.Frozen;
using System.Net;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Crafting;
public class RecipeDefinitionCatalogLoaderTests
{
private static readonly FrozenSet<string> KnownItemIds = PrototypeSlice1ItemCatalogRules.ExpectedItemIds;
private static readonly FrozenSet<string> 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<string>() == 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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<string>() == "make")
row["recipeKind"] = "process";
}
WriteCatalog(recipesDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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_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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<RecipeDefinitionCatalog>();
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<Program>().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);
}
}
}