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 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() == 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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(); 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().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); } } }