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.Combat; using NeonSprawl.Server.Tests; using Xunit; namespace NeonSprawl.Server.Tests.Game.Combat; public class AbilityDefinitionCatalogLoaderTests { private const string ValidPrototypeCatalogJson = """ { "schemaVersion": 1, "abilities": [ { "id": "prototype_pulse", "displayName": "Prototype Pulse", "baseDamage": 25, "cooldownSeconds": 3.0, "abilityKind": "attack" }, { "id": "prototype_guard", "displayName": "Prototype Guard", "baseDamage": 0, "cooldownSeconds": 6.0, "abilityKind": "utility" }, { "id": "prototype_dash", "displayName": "Prototype Dash", "baseDamage": 0, "cooldownSeconds": 4.0, "abilityKind": "movement" }, { "id": "prototype_burst", "displayName": "Prototype Burst", "baseDamage": 40, "cooldownSeconds": 5.0, "abilityKind": "attack" } ] } """; private static (string Root, string AbilitiesDir, string SchemaPath) CreateTempContentLayout() { var root = Directory.CreateTempSubdirectory("neon-sprawl-abilitycat-"); var abilitiesDir = Path.Combine(root.FullName, "content", "abilities"); var schemaDir = Path.Combine(root.FullName, "content", "schemas"); Directory.CreateDirectory(abilitiesDir); Directory.CreateDirectory(schemaDir); var schemaPath = Path.Combine(schemaDir, "ability-def.schema.json"); File.Copy(AbilityCatalogTestPaths.DiscoverRepoAbilityDefSchemaPath(), schemaPath, overwrite: true); return (root.FullName, abilitiesDir, schemaPath); } private static void WriteCatalog(string abilitiesDir, string catalogJson) => File.WriteAllText(Path.Combine(abilitiesDir, "prototype_abilities.json"), catalogJson, Encoding.UTF8); private static JsonObject GetAbilityRow(JsonObject catalogRoot, string abilityId) { var abilities = catalogRoot["abilities"] as JsonArray ?? throw new InvalidOperationException("expected abilities array"); foreach (var node in abilities) { if (node is JsonObject row && row["id"]?.GetValue() == abilityId) return row; } throw new InvalidOperationException($"ability id not found: {abilityId}"); } private static AbilityDefinitionCatalog LoadCatalog(string abilitiesDir, string schemaPath) => AbilityDefinitionCatalogLoader.Load(abilitiesDir, schemaPath, NullLogger.Instance); [Fact] public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract() { // Arrange var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); WriteCatalog(abilitiesDir, ValidPrototypeCatalogJson); // Act var catalog = LoadCatalog(abilitiesDir, schemaPath); // Assert Assert.Equal(4, catalog.DistinctAbilityCount); Assert.Equal(1, catalog.CatalogJsonFileCount); Assert.True(catalog.TryGetAbility("prototype_pulse", out var pulse)); Assert.NotNull(pulse); Assert.Equal(25, pulse!.BaseDamage); Assert.Equal(3.0, pulse.CooldownSeconds); Assert.Equal("attack", pulse.AbilityKind); } [Fact] public void Load_ShouldThrow_WhenAbilitiesIsNotArray() { // Arrange var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); File.WriteAllText( Path.Combine(abilitiesDir, "bad_abilities.json"), """{"schemaVersion": 1, "abilities": "nope"}""", Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("bad_abilities.json", ioe.Message, StringComparison.Ordinal); Assert.Contains("expected top-level 'abilities' array", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenSchemaVersionIsNotOne() { // Arrange var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); File.WriteAllText( Path.Combine(abilitiesDir, "bad_abilities.json"), """{"schemaVersion": 2, "abilities": []}""", Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenDuplicateAbilityIdAcrossFiles() { // Arrange var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); WriteCatalog(abilitiesDir, ValidPrototypeCatalogJson); File.WriteAllText( Path.Combine(abilitiesDir, "extra_abilities.json"), """ { "schemaVersion": 1, "abilities": [ { "id": "prototype_pulse", "displayName": "Duplicate Pulse", "baseDamage": 1, "cooldownSeconds": 1.0 } ] } """, Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("duplicate ability id 'prototype_pulse'", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenE5M1GateFailsWithMissingId() { // Arrange var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject ?? throw new InvalidOperationException("expected object root"); var abilities = root["abilities"] as JsonArray ?? throw new InvalidOperationException("expected abilities array"); abilities.RemoveAt(abilities.Count - 1); WriteCatalog(abilitiesDir, root.ToJsonString()); // Act var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("prototype E5M1 expects exactly ability ids", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenE5M1GateFailsWithExtraId() { // Arrange var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject ?? throw new InvalidOperationException("expected object root"); GetAbilityRow(root, "prototype_burst")["id"] = "prototype_extra"; WriteCatalog(abilitiesDir, root.ToJsonString()); // Act var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("prototype E5M1 expects exactly ability ids", ioe.Message, StringComparison.Ordinal); Assert.Contains("prototype_extra", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenBaseDamageIsNegative() { // Arrange var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject ?? throw new InvalidOperationException("expected object root"); GetAbilityRow(root, "prototype_pulse")["baseDamage"] = -1; WriteCatalog(abilitiesDir, root.ToJsonString()); // Act var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("Ability catalog validation failed", ioe.Message, StringComparison.Ordinal); Assert.Contains("baseDamage", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenCooldownSecondsIsZero() { // Arrange var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject ?? throw new InvalidOperationException("expected object root"); GetAbilityRow(root, "prototype_pulse")["cooldownSeconds"] = 0; WriteCatalog(abilitiesDir, root.ToJsonString()); // Act var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("Ability catalog validation failed", ioe.Message, StringComparison.Ordinal); Assert.Contains("cooldownSeconds", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenDisplayNameIsEmpty() { // Arrange var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject ?? throw new InvalidOperationException("expected object root"); GetAbilityRow(root, "prototype_pulse")["displayName"] = ""; WriteCatalog(abilitiesDir, root.ToJsonString()); // Act var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("Ability catalog validation failed", ioe.Message, StringComparison.Ordinal); Assert.Contains("displayName", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenNoAbilityCatalogFiles() { // Arrange var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); // Act var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("no *_abilities.json files", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenJsonIsInvalid() { // Arrange var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); File.WriteAllText( Path.Combine(abilitiesDir, "bad_abilities.json"), "{not json", Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("bad_abilities.json", ioe.Message, StringComparison.Ordinal); Assert.Contains("invalid JSON", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenAbilitiesDirectoryMissing() { // Arrange var missingDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-no-abilities-" + Guid.NewGuid().ToString("n")); var schemaPath = AbilityCatalogTestPaths.DiscoverRepoAbilityDefSchemaPath(); // Act var ex = Record.Exception(() => AbilityDefinitionCatalogLoader.Load(missingDir, schemaPath, 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_WhenSchemaFileMissing() { // Arrange var (_, abilitiesDir, _) = CreateTempContentLayout(); var missingSchema = Path.Combine(abilitiesDir, "missing-ability-def.schema.json"); // Act var ex = Record.Exception(() => AbilityDefinitionCatalogLoader.Load(abilitiesDir, missingSchema, NullLogger.Instance)); // Assert var ioe = Assert.IsType(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(); Assert.Equal(4, catalog.DistinctAbilityCount); Assert.True(catalog.TryGetAbility("prototype_burst", out var burst)); Assert.Equal(40, burst!.BaseDamage); Assert.Equal(5.0, burst.CooldownSeconds); } [Fact] public void Host_ShouldFailStartup_WhenCatalogDirectoryInvalid() { // Arrange var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-abilities-" + Guid.NewGuid().ToString("n")); Directory.CreateDirectory(badDir); try { // Act var ex = Record.Exception(() => { using var factory = new WebApplicationFactory().WithWebHostBuilder(b => b.UseSetting("Content:AbilitiesDirectory", badDir)); factory.CreateClient(); }); // Assert Assert.NotNull(ex); Assert.Contains("Ability catalog validation failed", ex.ToString(), StringComparison.Ordinal); } finally { if (Directory.Exists(badDir)) Directory.Delete(badDir); } } }