using System.Net; using System.Text; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Tests; using Xunit; namespace NeonSprawl.Server.Tests.Game.Items; public class ItemDefinitionCatalogLoaderTests { private const string ValidPrototypeCatalogJson = """ { "schemaVersion": 1, "items": [ { "id": "scrap_metal_bulk", "displayName": "Scrap Metal (Bulk)", "prototypeRole": "material", "stackMax": 999, "inventorySlotKind": "bag" }, { "id": "refined_plate_stock", "displayName": "Refined Plate Stock", "prototypeRole": "intermediate", "stackMax": 999, "inventorySlotKind": "bag" }, { "id": "field_stim_mk0", "displayName": "Field Stim Mk0", "prototypeRole": "consumable", "stackMax": 20, "inventorySlotKind": "bag" }, { "id": "survey_drone_kit", "displayName": "Survey Drone Kit", "prototypeRole": "utility", "stackMax": 1, "inventorySlotKind": "bag" }, { "id": "contract_handoff_token", "displayName": "Contract Handoff Token", "prototypeRole": "quest_token", "stackMax": 1, "inventorySlotKind": "bag" }, { "id": "prototype_armor_shell", "displayName": "Prototype Armor Shell", "prototypeRole": "equip_stub", "stackMax": 1, "inventorySlotKind": "equipment" } ] } """; private static (string Root, string ItemsDir, string SchemaPath) CreateTempContentLayout() { var root = Directory.CreateTempSubdirectory("neon-sprawl-itemcat-"); var itemsDir = Path.Combine(root.FullName, "content", "items"); var schemaDir = Path.Combine(root.FullName, "content", "schemas"); Directory.CreateDirectory(itemsDir); Directory.CreateDirectory(schemaDir); var schemaPath = Path.Combine(schemaDir, "item-def.schema.json"); File.Copy(ItemCatalogTestPaths.DiscoverRepoItemDefSchemaPath(), schemaPath, overwrite: true); return (root.FullName, itemsDir, schemaPath); } [Fact] public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract() { // Arrange var (_, itemsDir, schemaPath) = CreateTempContentLayout(); File.WriteAllText(Path.Combine(itemsDir, "prototype_items.json"), ValidPrototypeCatalogJson, Encoding.UTF8); // Act var catalog = ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance); // Assert Assert.Equal(6, catalog.DistinctItemCount); Assert.Equal(1, catalog.CatalogJsonFileCount); Assert.True(catalog.TryGetItem("scrap_metal_bulk", out var row)); Assert.NotNull(row); Assert.Equal("material", row!.PrototypeRole); Assert.Equal(999, row.StackMax); } [Fact] public void Load_ShouldThrow_WhenItemsIsNotArray() { // Arrange var (_, itemsDir, schemaPath) = CreateTempContentLayout(); File.WriteAllText( Path.Combine(itemsDir, "bad_items.json"), """{"schemaVersion": 1, "items": "nope"}""", Encoding.UTF8); // Act var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("bad_items.json", ioe.Message, StringComparison.Ordinal); Assert.Contains("expected top-level 'items' array", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenSchemaVersionIsNotOne() { // Arrange var (_, itemsDir, schemaPath) = CreateTempContentLayout(); File.WriteAllText( Path.Combine(itemsDir, "bad_items.json"), """{"schemaVersion": 2, "items": []}""", Encoding.UTF8); // Act var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenRowViolatesSchema() { // Arrange var (_, itemsDir, schemaPath) = CreateTempContentLayout(); var bad = """ { "schemaVersion": 1, "items": [ { "id": "scrap_metal_bulk", "displayName": "", "prototypeRole": "material", "stackMax": 999, "inventorySlotKind": "bag" } ] } """; File.WriteAllText(Path.Combine(itemsDir, "bad_items.json"), bad, Encoding.UTF8); // Act var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("bad_items.json", ioe.Message, StringComparison.Ordinal); Assert.Contains("Item catalog validation failed", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenDuplicateIdAcrossFiles() { // Arrange var (_, itemsDir, schemaPath) = CreateTempContentLayout(); var singleItem = """ { "schemaVersion": 1, "items": [ { "id": "scrap_metal_bulk", "displayName": "Scrap Metal (Bulk)", "prototypeRole": "material", "stackMax": 999, "inventorySlotKind": "bag" } ] } """; File.WriteAllText(Path.Combine(itemsDir, "a_items.json"), singleItem, Encoding.UTF8); File.WriteAllText(Path.Combine(itemsDir, "b_items.json"), singleItem, Encoding.UTF8); // Act var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("duplicate item id", ioe.Message, StringComparison.Ordinal); Assert.Contains("scrap_metal_bulk", ioe.Message, StringComparison.Ordinal); Assert.Contains("a_items.json", ioe.Message, StringComparison.Ordinal); Assert.Contains("b_items.json", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenSlice1IdsIncomplete() { // Arrange var (_, itemsDir, schemaPath) = CreateTempContentLayout(); var twoOnly = """ { "schemaVersion": 1, "items": [ { "id": "scrap_metal_bulk", "displayName": "Scrap Metal (Bulk)", "prototypeRole": "material", "stackMax": 999, "inventorySlotKind": "bag" }, { "id": "refined_plate_stock", "displayName": "Refined Plate Stock", "prototypeRole": "intermediate", "stackMax": 999, "inventorySlotKind": "bag" } ] } """; File.WriteAllText(Path.Combine(itemsDir, "partial_items.json"), twoOnly, Encoding.UTF8); // Act var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("prototype Slice 1 expects exactly item ids", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenEquipStubUsesBagSlot() { // Arrange var (_, itemsDir, schemaPath) = CreateTempContentLayout(); var badEquip = ValidPrototypeCatalogJson.Replace( "\"inventorySlotKind\": \"equipment\"", "\"inventorySlotKind\": \"bag\"", StringComparison.Ordinal); File.WriteAllText(Path.Combine(itemsDir, "prototype_items.json"), badEquip, Encoding.UTF8); // Act var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("equip_stub", ioe.Message, StringComparison.Ordinal); Assert.Contains("inventorySlotKind 'equipment'", 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(6, catalog.DistinctItemCount); Assert.True(catalog.TryGetItem("prototype_armor_shell", out var shell)); Assert.Equal("equipment", shell!.InventorySlotKind); } [Fact] public void Host_ShouldFailStartup_WhenCatalogDirectoryInvalid() { // Arrange var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-items-" + Guid.NewGuid().ToString("n")); Directory.CreateDirectory(badDir); // Act var ex = Record.Exception(() => { using var factory = new WebApplicationFactory().WithWebHostBuilder(b => b.UseSetting("Content:ItemsDirectory", badDir)); factory.CreateClient(); }); // Assert Assert.NotNull(ex); Assert.Contains("Item catalog validation failed", ex.ToString(), StringComparison.Ordinal); } }