465 lines
20 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|