neon-sprawl/server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeCatalogLoaderTe...

552 lines
22 KiB
C#

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.Gathering;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Gathering;
public class ResourceNodeCatalogLoaderTests
{
private static readonly HashSet<string> ValidPrototypeItemIds =
[
"scrap_metal_bulk",
"refined_plate_stock",
"field_stim_mk0",
"survey_drone_kit",
"contract_handoff_token",
"prototype_armor_shell",
];
private const string ValidPrototypeNodeCatalogJson =
"""
{
"schemaVersion": 1,
"nodes": [
{
"nodeDefId": "prototype_resource_node_alpha",
"displayName": "Prototype Salvage Heap",
"gatherLens": "consumer_salvage",
"maxGathers": 10
},
{
"nodeDefId": "prototype_subsurface_vein_beta",
"displayName": "Prototype Subsurface Vein",
"gatherLens": "subsurface",
"maxGathers": 10
},
{
"nodeDefId": "prototype_bio_mat_gamma",
"displayName": "Prototype Bio Mat",
"gatherLens": "bio",
"maxGathers": 10
},
{
"nodeDefId": "prototype_urban_bulk_delta",
"displayName": "Prototype Urban Bulk",
"gatherLens": "urban_bulk",
"maxGathers": 10
}
]
}
""";
private const string ValidPrototypeYieldCatalogJson =
"""
{
"schemaVersion": 1,
"yields": [
{
"nodeDefId": "prototype_resource_node_alpha",
"itemId": "scrap_metal_bulk",
"quantity": 1
},
{
"nodeDefId": "prototype_subsurface_vein_beta",
"itemId": "scrap_metal_bulk",
"quantity": 2
},
{
"nodeDefId": "prototype_bio_mat_gamma",
"itemId": "scrap_metal_bulk",
"quantity": 3
},
{
"nodeDefId": "prototype_urban_bulk_delta",
"itemId": "scrap_metal_bulk",
"quantity": 5
}
]
}
""";
private static (string Root, string ResourceNodesDir, string NodeSchemaPath, string YieldSchemaPath) CreateTempContentLayout()
{
var root = Directory.CreateTempSubdirectory("neon-sprawl-resnodecat-");
var resourceNodesDir = Path.Combine(root.FullName, "content", "resource-nodes");
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
Directory.CreateDirectory(resourceNodesDir);
Directory.CreateDirectory(schemaDir);
var nodeSchemaPath = Path.Combine(schemaDir, "resource-node-def.schema.json");
var yieldSchemaPath = Path.Combine(schemaDir, "resource-yield-row.schema.json");
File.Copy(ResourceNodeCatalogTestPaths.DiscoverRepoResourceNodeDefSchemaPath(), nodeSchemaPath, overwrite: true);
File.Copy(ResourceNodeCatalogTestPaths.DiscoverRepoResourceYieldRowSchemaPath(), yieldSchemaPath, overwrite: true);
return (root.FullName, resourceNodesDir, nodeSchemaPath, yieldSchemaPath);
}
private static void WriteNodeCatalog(string resourceNodesDir, string catalogJson) =>
File.WriteAllText(Path.Combine(resourceNodesDir, "prototype_resource_nodes.json"), catalogJson, Encoding.UTF8);
private static void WriteYieldCatalog(string resourceNodesDir, string catalogJson) =>
File.WriteAllText(Path.Combine(resourceNodesDir, "prototype_resource_yields.json"), catalogJson, Encoding.UTF8);
private static void WriteValidCatalogs(string resourceNodesDir)
{
WriteNodeCatalog(resourceNodesDir, ValidPrototypeNodeCatalogJson);
WriteYieldCatalog(resourceNodesDir, ValidPrototypeYieldCatalogJson);
}
private static JsonObject GetNodeRow(JsonObject catalogRoot, string nodeDefId)
{
var nodes = catalogRoot["nodes"] as JsonArray
?? throw new InvalidOperationException("expected nodes array");
foreach (var node in nodes)
{
if (node is JsonObject row && row["nodeDefId"]?.GetValue<string>() == nodeDefId)
return row;
}
throw new InvalidOperationException($"nodeDefId not found: {nodeDefId}");
}
[Fact]
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
{
// Arrange
var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout();
WriteValidCatalogs(resourceNodesDir);
// Act
var catalog = ResourceNodeCatalogLoader.Load(
resourceNodesDir,
nodeSchemaPath,
yieldSchemaPath,
ValidPrototypeItemIds,
NullLogger.Instance);
// Assert
Assert.Equal(4, catalog.DistinctNodeCount);
Assert.Equal(4, catalog.DistinctYieldCount);
Assert.Equal(1, catalog.NodeCatalogJsonFileCount);
Assert.Equal(1, catalog.YieldCatalogJsonFileCount);
Assert.True(catalog.TryGetNode("prototype_resource_node_alpha", out var node));
Assert.NotNull(node);
Assert.Equal("consumer_salvage", node!.GatherLens);
Assert.Equal(10, node.MaxGathers);
Assert.True(catalog.TryGetYield("prototype_resource_node_alpha", out var yield));
Assert.NotNull(yield);
Assert.Equal("scrap_metal_bulk", yield!.ItemId);
Assert.Equal(1, yield.Quantity);
Assert.True(catalog.TryGetYield("prototype_urban_bulk_delta", out var deltaYield));
Assert.Equal(5, deltaYield!.Quantity);
}
[Fact]
public void Load_ShouldThrow_WhenNodesIsNotArray()
{
// Arrange
var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout();
WriteNodeCatalog(resourceNodesDir, """{"schemaVersion": 1, "nodes": "nope"}""");
WriteYieldCatalog(resourceNodesDir, ValidPrototypeYieldCatalogJson);
// Act
var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load(
resourceNodesDir,
nodeSchemaPath,
yieldSchemaPath,
ValidPrototypeItemIds,
NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("expected top-level 'nodes' array", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenYieldsIsNotArray()
{
// Arrange
var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout();
WriteNodeCatalog(resourceNodesDir, ValidPrototypeNodeCatalogJson);
WriteYieldCatalog(resourceNodesDir, """{"schemaVersion": 1, "yields": "nope"}""");
// Act
var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load(
resourceNodesDir,
nodeSchemaPath,
yieldSchemaPath,
ValidPrototypeItemIds,
NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("expected top-level 'yields' array", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSchemaVersionIsNotOne()
{
// Arrange
var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout();
WriteNodeCatalog(resourceNodesDir, """{"schemaVersion": 2, "nodes": []}""");
WriteYieldCatalog(resourceNodesDir, ValidPrototypeYieldCatalogJson);
// Act
var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load(
resourceNodesDir,
nodeSchemaPath,
yieldSchemaPath,
ValidPrototypeItemIds,
NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenNodeRowViolatesSchema()
{
// Arrange
var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout();
const string bad = """
{
"schemaVersion": 1,
"nodes": [
{
"nodeDefId": "prototype_resource_node_alpha",
"displayName": "",
"gatherLens": "consumer_salvage",
"maxGathers": 10
}
]
}
""";
WriteNodeCatalog(resourceNodesDir, bad);
WriteYieldCatalog(resourceNodesDir, ValidPrototypeYieldCatalogJson);
// Act
var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load(
resourceNodesDir,
nodeSchemaPath,
yieldSchemaPath,
ValidPrototypeItemIds,
NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("Resource node catalog validation failed", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenDuplicateNodeDefIdAcrossFiles()
{
// Arrange
var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout();
const string singleNode = """
{
"schemaVersion": 1,
"nodes": [
{
"nodeDefId": "prototype_resource_node_alpha",
"displayName": "A",
"gatherLens": "consumer_salvage",
"maxGathers": 10
}
]
}
""";
File.WriteAllText(Path.Combine(resourceNodesDir, "a_resource_nodes.json"), singleNode, Encoding.UTF8);
File.WriteAllText(Path.Combine(resourceNodesDir, "b_resource_nodes.json"), singleNode, Encoding.UTF8);
WriteYieldCatalog(resourceNodesDir, ValidPrototypeYieldCatalogJson);
// Act
var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load(
resourceNodesDir,
nodeSchemaPath,
yieldSchemaPath,
ValidPrototypeItemIds,
NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("duplicate nodeDefId", ioe.Message, StringComparison.Ordinal);
Assert.Contains("prototype_resource_node_alpha", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSlice2NodeIdsIncomplete()
{
// Arrange
var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout();
const string oneOnly = """
{
"schemaVersion": 1,
"nodes": [
{
"nodeDefId": "prototype_resource_node_alpha",
"displayName": "A",
"gatherLens": "consumer_salvage",
"maxGathers": 10
}
]
}
""";
WriteNodeCatalog(resourceNodesDir, oneOnly);
WriteYieldCatalog(resourceNodesDir, ValidPrototypeYieldCatalogJson);
// Act
var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load(
resourceNodesDir,
nodeSchemaPath,
yieldSchemaPath,
ValidPrototypeItemIds,
NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("prototype Slice 2 expects exactly nodeDefIds", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSlice2GatherLensesDoNotMatch()
{
// Arrange
var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeNodeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object root");
GetNodeRow(root, "prototype_subsurface_vein_beta")["gatherLens"] = "consumer_salvage";
WriteNodeCatalog(resourceNodesDir, root.ToJsonString());
WriteYieldCatalog(resourceNodesDir, ValidPrototypeYieldCatalogJson);
// Act
var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load(
resourceNodesDir,
nodeSchemaPath,
yieldSchemaPath,
ValidPrototypeItemIds,
NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("requires exactly one row per gatherLens", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenYieldNodeDefIdNotInNodes()
{
// Arrange
var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout();
WriteValidCatalogs(resourceNodesDir);
var yieldRoot = JsonNode.Parse(ValidPrototypeYieldCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object root");
var yields = yieldRoot["yields"] as JsonArray ?? throw new InvalidOperationException("expected yields");
yields.Add(JsonNode.Parse("""{"nodeDefId": "unknown_node_xyz", "itemId": "scrap_metal_bulk", "quantity": 1}"""));
WriteYieldCatalog(resourceNodesDir, yieldRoot.ToJsonString());
// Act
var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load(
resourceNodesDir,
nodeSchemaPath,
yieldSchemaPath,
ValidPrototypeItemIds,
NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("unknown_node_xyz", ioe.Message, StringComparison.Ordinal);
Assert.Contains("not defined in resource node catalogs", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenYieldItemIdUnknown()
{
// Arrange
var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout();
WriteValidCatalogs(resourceNodesDir);
var yieldRoot = JsonNode.Parse(ValidPrototypeYieldCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object root");
var yields = yieldRoot["yields"] as JsonArray ?? throw new InvalidOperationException("expected yields");
yields[0]!["itemId"] = "not_a_real_item";
WriteYieldCatalog(resourceNodesDir, yieldRoot.ToJsonString());
// Act
var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load(
resourceNodesDir,
nodeSchemaPath,
yieldSchemaPath,
ValidPrototypeItemIds,
NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("not_a_real_item", ioe.Message, StringComparison.Ordinal);
Assert.Contains("not in item catalogs", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenDuplicateYieldNodeDefId()
{
// Arrange
var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout();
WriteValidCatalogs(resourceNodesDir);
const string dupYield = """
{
"schemaVersion": 1,
"yields": [
{
"nodeDefId": "prototype_resource_node_alpha",
"itemId": "scrap_metal_bulk",
"quantity": 1
}
]
}
""";
File.WriteAllText(Path.Combine(resourceNodesDir, "a_resource_yields.json"), dupYield, Encoding.UTF8);
File.WriteAllText(Path.Combine(resourceNodesDir, "b_resource_yields.json"), dupYield, Encoding.UTF8);
// Act
var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load(
resourceNodesDir,
nodeSchemaPath,
yieldSchemaPath,
ValidPrototypeItemIds,
NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("duplicate yield nodeDefId", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSlice2YieldRowsIncomplete()
{
// Arrange
var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout();
WriteValidCatalogs(resourceNodesDir);
const string oneYield = """
{
"schemaVersion": 1,
"yields": [
{
"nodeDefId": "prototype_resource_node_alpha",
"itemId": "scrap_metal_bulk",
"quantity": 1
}
]
}
""";
WriteYieldCatalog(resourceNodesDir, oneYield);
// Act
var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load(
resourceNodesDir,
nodeSchemaPath,
yieldSchemaPath,
ValidPrototypeItemIds,
NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("expects exactly one yield row per nodeDefId", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSlice2YieldItemIdNotAllowed()
{
// Arrange
var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout();
WriteValidCatalogs(resourceNodesDir);
var yieldRoot = JsonNode.Parse(ValidPrototypeYieldCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object root");
var yields = yieldRoot["yields"] as JsonArray ?? throw new InvalidOperationException("expected yields");
yields[0]!["itemId"] = "refined_plate_stock";
WriteYieldCatalog(resourceNodesDir, yieldRoot.ToJsonString());
// Act
var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load(
resourceNodesDir,
nodeSchemaPath,
yieldSchemaPath,
ValidPrototypeItemIds,
NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("expects yield itemIds", ioe.Message, StringComparison.Ordinal);
Assert.Contains("scrap_metal_bulk", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenResourceNodesDirectoryMissing()
{
// Arrange
var missingDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-no-resnodes-" + Guid.NewGuid().ToString("n"));
var nodeSchemaPath = ResourceNodeCatalogTestPaths.DiscoverRepoResourceNodeDefSchemaPath();
var yieldSchemaPath = ResourceNodeCatalogTestPaths.DiscoverRepoResourceYieldRowSchemaPath();
// Act
var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load(
missingDir,
nodeSchemaPath,
yieldSchemaPath,
ValidPrototypeItemIds,
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_WhenNodeSchemaFileMissing()
{
// Arrange
var (_, resourceNodesDir, _, yieldSchemaPath) = CreateTempContentLayout();
WriteValidCatalogs(resourceNodesDir);
var missingSchema = Path.Combine(resourceNodesDir, "missing-node.schema.json");
// Act
var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load(
resourceNodesDir,
missingSchema,
yieldSchemaPath,
ValidPrototypeItemIds,
NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("missing schema file", 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<ResourceNodeCatalog>();
Assert.Equal(4, catalog.DistinctNodeCount);
Assert.True(catalog.TryGetNode("prototype_bio_mat_gamma", out var node));
Assert.Equal("bio", node!.GatherLens);
Assert.True(catalog.TryGetYield("prototype_bio_mat_gamma", out var yield));
Assert.Equal(3, yield!.Quantity);
}
[Fact]
public void Host_ShouldFailStartup_WhenCatalogDirectoryInvalid()
{
// Arrange
var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-resnodes-" + Guid.NewGuid().ToString("n"));
Directory.CreateDirectory(badDir);
try
{
// Act
var ex = Record.Exception(() =>
{
using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
b.UseSetting("Content:ResourceNodesDirectory", badDir));
factory.CreateClient();
});
// Assert
Assert.NotNull(ex);
Assert.Contains("Resource node catalog validation failed", ex.ToString(), StringComparison.Ordinal);
}
finally
{
if (Directory.Exists(badDir))
Directory.Delete(badDir);
}
}
}