neon-sprawl/server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogLoaderTests.cs

253 lines
11 KiB
C#

using System.Collections.Frozen;
using System.Net;
using System.Text;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Tests;
using NeonSprawl.Server.Tests.Game.Skills;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Mastery;
public class MasteryCatalogLoaderTests
{
private static readonly FrozenSet<string> PrototypeSkillIds = FrozenSet.ToFrozenSet(
["salvage", "refine", "intrusion"],
StringComparer.Ordinal);
private const string ValidSalvageMasteryJson =
"""
{
"schemaVersion": 1,
"perks": {
"salvage_scrap_efficiency_1": {
"id": "salvage_scrap_efficiency_1",
"displayName": "Scrap Sifter",
"effectKind": "gather_yield_modifier"
},
"salvage_bulk_haul_1": {
"id": "salvage_bulk_haul_1",
"displayName": "Haul Frame",
"effectKind": "gather_carry_modifier"
}
},
"tracks": [
{
"skillId": "salvage",
"tiers": [
{
"tierIndex": 1,
"requiredLevel": 2,
"branches": [
{ "branchId": "scrap_efficiency", "displayName": "Scrap Efficiency", "perkIds": [] },
{ "branchId": "bulk_haul", "displayName": "Bulk Haul", "perkIds": [] }
]
},
{
"tierIndex": 2,
"requiredLevel": 4,
"branches": [
{ "branchId": "scrap_efficiency", "perkIds": ["salvage_scrap_efficiency_1"] },
{ "branchId": "bulk_haul", "perkIds": ["salvage_bulk_haul_1"] }
]
}
]
}
]
}
""";
private static (string Root, string MasteryDir, string SchemaPath) CreateTempContentLayout()
{
var root = Directory.CreateTempSubdirectory("neon-sprawl-masterycat-");
var masteryDir = Path.Combine(root.FullName, "content", "mastery");
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
Directory.CreateDirectory(masteryDir);
Directory.CreateDirectory(schemaDir);
var schemaPath = Path.Combine(schemaDir, "mastery-catalog.schema.json");
File.Copy(MasteryCatalogTestPaths.DiscoverRepoMasteryCatalogSchemaPath(), schemaPath, overwrite: true);
return (root.FullName, masteryDir, schemaPath);
}
[Fact]
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
{
// Arrange
var (_, masteryDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(Path.Combine(masteryDir, "prototype_salvage_mastery.json"), ValidSalvageMasteryJson, Encoding.UTF8);
// Act
var catalog = MasteryCatalogLoader.Load(masteryDir, schemaPath, PrototypeSkillIds, NullLogger.Instance);
// Assert
Assert.Equal(1, catalog.TrackCount);
Assert.Equal(2, catalog.PerkCount);
Assert.True(catalog.TracksBySkillId.ContainsKey("salvage"));
}
[Fact]
public void Load_ShouldThrow_WhenTracksIsNotArray()
{
// Arrange
var (_, masteryDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(Path.Combine(masteryDir, "bad_mastery.json"), """{"schemaVersion":1,"perks":{},"tracks":"nope"}""", Encoding.UTF8);
// Act
var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(masteryDir, schemaPath, PrototypeSkillIds, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("bad_mastery.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("Mastery catalog validation failed", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenUnknownSkillId()
{
// Arrange
var (_, masteryDir, schemaPath) = CreateTempContentLayout();
var onlyRefineSkills = FrozenSet.ToFrozenSet(["refine"], StringComparer.Ordinal);
File.WriteAllText(Path.Combine(masteryDir, "prototype_salvage_mastery.json"), ValidSalvageMasteryJson, Encoding.UTF8);
// Act
var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(masteryDir, schemaPath, onlyRefineSkills, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("not a known SkillDef id", ioe.Message, StringComparison.Ordinal);
Assert.Contains("salvage", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenDuplicatePerkIdAcrossFiles()
{
// Arrange
var (_, masteryDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(Path.Combine(masteryDir, "a_mastery.json"), ValidSalvageMasteryJson, Encoding.UTF8);
File.WriteAllText(Path.Combine(masteryDir, "b_mastery.json"), ValidSalvageMasteryJson, Encoding.UTF8);
// Act
var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(masteryDir, schemaPath, PrototypeSkillIds, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("duplicate perk id", ioe.Message, StringComparison.Ordinal);
Assert.Contains("salvage_scrap_efficiency_1", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenUnreferencedPerkInMap()
{
// Arrange
var (_, masteryDir, schemaPath) = CreateTempContentLayout();
var orphanPerk = ValidSalvageMasteryJson.Replace(
"\"salvage_bulk_haul_1\":",
"\"salvage_orphan_1\": { \"id\": \"salvage_orphan_1\", \"displayName\": \"Orphan\" },\n \"salvage_bulk_haul_1\":",
StringComparison.Ordinal);
File.WriteAllText(Path.Combine(masteryDir, "orphan_mastery.json"), orphanPerk, Encoding.UTF8);
// Act
var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(masteryDir, schemaPath, PrototypeSkillIds, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("not referenced by any branch perkIds", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSlice4TrackCountWrong()
{
// Arrange
var (_, masteryDir, schemaPath) = CreateTempContentLayout();
const string twoTracksJson = """
{
"schemaVersion": 1,
"perks": {},
"tracks": [
{
"skillId": "salvage",
"tiers": [
{
"tierIndex": 1,
"requiredLevel": 2,
"branches": [
{ "branchId": "a", "perkIds": [] },
{ "branchId": "b", "perkIds": [] }
]
}
]
},
{
"skillId": "refine",
"tiers": [
{
"tierIndex": 1,
"requiredLevel": 2,
"branches": [
{ "branchId": "a", "perkIds": [] },
{ "branchId": "b", "perkIds": [] }
]
}
]
}
]
}
""";
File.WriteAllText(Path.Combine(masteryDir, "two_tracks_mastery.json"), twoTracksJson, Encoding.UTF8);
// Act
var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(masteryDir, schemaPath, PrototypeSkillIds, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("prototype Slice 4 expects exactly one MasteryTrack", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenTierIndexHasGap()
{
// Arrange
var (_, masteryDir, schemaPath) = CreateTempContentLayout();
var gapTier = ValidSalvageMasteryJson.Replace("\"tierIndex\": 2", "\"tierIndex\": 3", StringComparison.Ordinal);
File.WriteAllText(Path.Combine(masteryDir, "gap_tier_mastery.json"), gapTier, Encoding.UTF8);
// Act
var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(masteryDir, schemaPath, PrototypeSkillIds, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("tierIndex values must be unique and sequential", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public async Task Host_ShouldResolveMasteryCatalogFromDi_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<MasteryCatalog>();
Assert.Equal(1, catalog.TrackCount);
Assert.True(catalog.TracksBySkillId.ContainsKey("salvage"));
}
[Fact]
public void Host_ShouldFailStartup_WhenMasteryDirectoryInvalid()
{
// Arrange
var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-mastery-" + Guid.NewGuid().ToString("n"));
Directory.CreateDirectory(badDir);
var skillsDir = SkillCatalogTestPaths.DiscoverRepoSkillsDirectory();
// Act
var ex = Record.Exception(() =>
{
using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
{
b.UseSetting("Content:SkillsDirectory", skillsDir);
b.UseSetting("Content:MasteryDirectory", badDir);
});
factory.CreateClient();
});
// Assert
Assert.NotNull(ex);
Assert.Contains("Mastery catalog validation failed", ex.ToString(), StringComparison.Ordinal);
}
}