neon-sprawl/server/NeonSprawl.Server.Tests/Game/Items/ItemDefinitionCatalogLoader...

272 lines
11 KiB
C#

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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<ItemDefinitionCatalog>();
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<Program>().WithWebHostBuilder(b =>
b.UseSetting("Content:ItemsDirectory", badDir));
factory.CreateClient();
});
// Assert
Assert.NotNull(ex);
Assert.Contains("Item catalog validation failed", ex.ToString(), StringComparison.Ordinal);
}
}