neon-sprawl/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionCatalogL...

310 lines
12 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.Encounters;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Encounters;
public class EncounterDefinitionCatalogLoaderTests
{
private const string ValidPrototypeCatalogJson =
"""
{
"schemaVersion": 1,
"encounters": [
{
"id": "prototype_combat_pocket",
"displayName": "Prototype Combat Pocket",
"completionCriteria": { "kind": "defeat_all_targets" },
"requiredNpcInstanceIds": [
"prototype_npc_melee",
"prototype_npc_ranged",
"prototype_npc_elite"
],
"rewardTableId": "prototype_combat_pocket_clear"
}
]
}
""";
private static readonly HashSet<string> KnownRewardTableIds =
["prototype_combat_pocket_clear"];
private static (string Root, string EncountersDir, string SchemaPath) CreateTempContentLayout()
{
var root = Directory.CreateTempSubdirectory("neon-sprawl-encountercat-");
var encountersDir = Path.Combine(root.FullName, "content", "encounters");
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
Directory.CreateDirectory(encountersDir);
Directory.CreateDirectory(schemaDir);
var schemaPath = Path.Combine(schemaDir, "encounter-def.schema.json");
File.Copy(EncounterCatalogTestPaths.DiscoverRepoEncounterDefSchemaPath(), schemaPath, overwrite: true);
return (root.FullName, encountersDir, schemaPath);
}
private static void WriteCatalog(string encountersDir, string catalogJson) =>
File.WriteAllText(Path.Combine(encountersDir, "prototype_encounters.json"), catalogJson, Encoding.UTF8);
private static EncounterDefinitionCatalog LoadCatalog(
string encountersDir,
string schemaPath,
IReadOnlySet<string>? knownRewardTableIds = null) =>
EncounterDefinitionCatalogLoader.Load(
encountersDir,
schemaPath,
knownRewardTableIds ?? KnownRewardTableIds,
NullLogger.Instance);
[Fact]
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
{
// Arrange
var (_, encountersDir, schemaPath) = CreateTempContentLayout();
WriteCatalog(encountersDir, ValidPrototypeCatalogJson);
// Act
var catalog = LoadCatalog(encountersDir, schemaPath);
// Assert
Assert.Equal(1, catalog.DistinctEncounterCount);
Assert.Equal(1, catalog.CatalogJsonFileCount);
Assert.True(catalog.TryGetEncounter("prototype_combat_pocket", out var encounter));
Assert.NotNull(encounter);
Assert.Equal("Prototype Combat Pocket", encounter!.DisplayName);
Assert.Equal("defeat_all_targets", encounter.CompletionCriteriaKind);
Assert.Equal("prototype_combat_pocket_clear", encounter.RewardTableId);
Assert.Equal(3, encounter.RequiredNpcInstanceIds.Count);
}
[Fact]
public void Load_ShouldThrow_WhenEncountersIsNotArray()
{
// Arrange
var (_, encountersDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(
Path.Combine(encountersDir, "bad_encounters.json"),
"""{"schemaVersion": 1, "encounters": "nope"}""",
Encoding.UTF8);
// Act
var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("bad_encounters.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("expected top-level 'encounters' array", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSchemaVersionIsNotOne()
{
// Arrange
var (_, encountersDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(
Path.Combine(encountersDir, "bad_encounters.json"),
"""{"schemaVersion": 2, "encounters": []}""",
Encoding.UTF8);
// Act
var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenDuplicateEncounterIdAcrossFiles()
{
// Arrange
var (_, encountersDir, schemaPath) = CreateTempContentLayout();
WriteCatalog(encountersDir, ValidPrototypeCatalogJson);
File.WriteAllText(
Path.Combine(encountersDir, "extra_encounters.json"),
"""
{
"schemaVersion": 1,
"encounters": [
{
"id": "prototype_combat_pocket",
"displayName": "Duplicate Encounter",
"completionCriteria": { "kind": "defeat_all_targets" },
"requiredNpcInstanceIds": [
"prototype_npc_melee",
"prototype_npc_ranged",
"prototype_npc_elite"
],
"rewardTableId": "prototype_combat_pocket_clear"
}
]
}
""",
Encoding.UTF8);
// Act
var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("duplicate encounter id 'prototype_combat_pocket'", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenE5M3AllowlistMismatch()
{
// Arrange
var (_, encountersDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(
Path.Combine(encountersDir, "prototype_encounters.json"),
"""
{
"schemaVersion": 1,
"encounters": [
{
"id": "wrong_encounter_id",
"displayName": "Wrong Encounter",
"completionCriteria": { "kind": "defeat_all_targets" },
"requiredNpcInstanceIds": [
"prototype_npc_melee",
"prototype_npc_ranged",
"prototype_npc_elite"
],
"rewardTableId": "prototype_combat_pocket_clear"
}
]
}
""",
Encoding.UTF8);
// Act
var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("prototype E5M3 expects exactly encounter ids", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenRequiredNpcInstanceIdsMismatch()
{
// Arrange
var (_, encountersDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(
Path.Combine(encountersDir, "prototype_encounters.json"),
"""
{
"schemaVersion": 1,
"encounters": [
{
"id": "prototype_combat_pocket",
"displayName": "Prototype Combat Pocket",
"completionCriteria": { "kind": "defeat_all_targets" },
"requiredNpcInstanceIds": [
"prototype_npc_melee",
"prototype_npc_ranged",
"prototype_npc_unknown"
],
"rewardTableId": "prototype_combat_pocket_clear"
}
]
}
""",
Encoding.UTF8);
// Act
var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("requiredNpcInstanceIds must be exactly", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenRewardTableIdUnknown()
{
// Arrange
var (_, encountersDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(
Path.Combine(encountersDir, "prototype_encounters.json"),
"""
{
"schemaVersion": 1,
"encounters": [
{
"id": "prototype_combat_pocket",
"displayName": "Prototype Combat Pocket",
"completionCriteria": { "kind": "defeat_all_targets" },
"requiredNpcInstanceIds": [
"prototype_npc_melee",
"prototype_npc_ranged",
"prototype_npc_elite"
],
"rewardTableId": "missing_reward_table"
}
]
}
""",
Encoding.UTF8);
// Act
var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("rewardTableId 'missing_reward_table' is not in reward table catalogs", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSchemaFileMissing()
{
// Arrange
var (_, encountersDir, _) = CreateTempContentLayout();
WriteCatalog(encountersDir, ValidPrototypeCatalogJson);
var missingSchema = Path.Combine(encountersDir, "missing-encounter-def.schema.json");
// Act
var ex = Record.Exception(() => LoadCatalog(encountersDir, missingSchema));
// 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_ShouldResolveCatalogsFromDi_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 rewardCatalog = factory.Services.GetRequiredService<RewardTableDefinitionCatalog>();
Assert.Equal(1, rewardCatalog.DistinctRewardTableCount);
Assert.True(rewardCatalog.TryGetRewardTable("prototype_combat_pocket_clear", out var rewardTable));
Assert.NotNull(rewardTable);
Assert.Equal(10, rewardTable!.FixedGrants.First(g => g.ItemId == "scrap_metal_bulk").Quantity);
var encounterCatalog = factory.Services.GetRequiredService<EncounterDefinitionCatalog>();
Assert.Equal(1, encounterCatalog.DistinctEncounterCount);
Assert.True(encounterCatalog.TryGetEncounter("prototype_combat_pocket", out var encounter));
Assert.NotNull(encounter);
Assert.Equal("prototype_combat_pocket_clear", encounter!.RewardTableId);
}
[Fact]
public void Host_ShouldFailStartup_WhenEncountersDirectoryInvalid()
{
// Arrange
var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-encounters-" + Guid.NewGuid().ToString("n"));
Directory.CreateDirectory(badDir);
try
{
// Act
var ex = Record.Exception(() =>
{
using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
b.UseSetting("Content:EncountersDirectory", badDir));
factory.CreateClient();
});
// Assert
Assert.NotNull(ex);
Assert.Contains("Encounter catalog validation failed", ex.ToString(), StringComparison.Ordinal);
}
finally
{
if (Directory.Exists(badDir))
Directory.Delete(badDir);
}
}
}