neon-sprawl/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoa...

351 lines
14 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.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<string>() == 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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<AbilityDefinitionCatalog>();
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<Program>().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);
}
}
}