using System.Collections.Frozen; using System.Net; using System.Text; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Logging.Abstractions; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Tests.Game.Skills; using Xunit; namespace NeonSprawl.Server.Tests.Game.Mastery; public class MasteryCatalogLoaderTests { private static readonly FrozenSet 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 sealed class TempContentLayout : IDisposable { public string MasteryDir { get; } public string SchemaPath { get; } private readonly string _root; private TempContentLayout(string root, string masteryDir, string schemaPath) { _root = root; MasteryDir = masteryDir; SchemaPath = schemaPath; } public static TempContentLayout Create() { 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 new TempContentLayout(root.FullName, masteryDir, schemaPath); } public void Dispose() { try { if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true); } catch (IOException) { // Best-effort cleanup for parallel test runs. } } } [Fact] public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract() { // Arrange using var layout = TempContentLayout.Create(); File.WriteAllText( Path.Combine(layout.MasteryDir, "prototype_salvage_mastery.json"), ValidSalvageMasteryJson, Encoding.UTF8); // Act var catalog = MasteryCatalogLoader.Load( layout.MasteryDir, layout.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_WhenJsonIsMalformed() { // Arrange using var layout = TempContentLayout.Create(); File.WriteAllText(Path.Combine(layout.MasteryDir, "bad_mastery.json"), "{ not json", Encoding.UTF8); // Act var ex = Record.Exception(() => MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("bad_mastery.json", ioe.Message, StringComparison.Ordinal); Assert.Contains("invalid JSON", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenTracksIsNotArray() { // Arrange using var layout = TempContentLayout.Create(); File.WriteAllText( Path.Combine(layout.MasteryDir, "bad_mastery.json"), """{"schemaVersion":1,"perks":{},"tracks":"nope"}""", Encoding.UTF8); // Act var ex = Record.Exception(() => MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance)); // Assert var ioe = Assert.IsType(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_WhenRowViolatesSchema() { // Arrange using var layout = TempContentLayout.Create(); var bad = ValidSalvageMasteryJson.Replace( "\"displayName\": \"Scrap Sifter\"", "\"displayName\": \"\"", StringComparison.Ordinal); File.WriteAllText(Path.Combine(layout.MasteryDir, "schema_bad_mastery.json"), bad, Encoding.UTF8); // Act var ex = Record.Exception(() => MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("schema_bad_mastery.json", ioe.Message, StringComparison.Ordinal); Assert.Contains("Mastery catalog validation failed", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenUnknownSkillId() { // Arrange using var layout = TempContentLayout.Create(); var onlyRefineSkills = FrozenSet.ToFrozenSet(["refine"], StringComparer.Ordinal); File.WriteAllText( Path.Combine(layout.MasteryDir, "prototype_salvage_mastery.json"), ValidSalvageMasteryJson, Encoding.UTF8); // Act var ex = Record.Exception(() => MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, onlyRefineSkills, NullLogger.Instance)); // Assert var ioe = Assert.IsType(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 using var layout = TempContentLayout.Create(); File.WriteAllText(Path.Combine(layout.MasteryDir, "a_mastery.json"), ValidSalvageMasteryJson, Encoding.UTF8); File.WriteAllText(Path.Combine(layout.MasteryDir, "b_mastery.json"), ValidSalvageMasteryJson, Encoding.UTF8); // Act var ex = Record.Exception(() => MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance)); // Assert var ioe = Assert.IsType(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_WhenDuplicatePerkIdReferenceAcrossBranches() { // Arrange using var layout = TempContentLayout.Create(); var duplicateRef = ValidSalvageMasteryJson.Replace( "\"perkIds\": [\"salvage_bulk_haul_1\"]", "\"perkIds\": [\"salvage_scrap_efficiency_1\"]", StringComparison.Ordinal); File.WriteAllText(Path.Combine(layout.MasteryDir, "dup_ref_mastery.json"), duplicateRef, Encoding.UTF8); // Act var ex = Record.Exception(() => MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("duplicate perk id reference", ioe.Message, StringComparison.Ordinal); Assert.Contains("salvage_scrap_efficiency_1", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenUnreferencedPerkInMap() { // Arrange using var layout = TempContentLayout.Create(); 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(layout.MasteryDir, "orphan_mastery.json"), orphanPerk, Encoding.UTF8); // Act var ex = Record.Exception(() => MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("not referenced by any branch perkIds", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenBranchIdSetDiffersBetweenTiers() { // Arrange using var layout = TempContentLayout.Create(); var mismatch = ValidSalvageMasteryJson.Replace( "\"branchId\": \"bulk_haul\", \"perkIds\": [\"salvage_bulk_haul_1\"]", "\"branchId\": \"other_branch\", \"perkIds\": [\"salvage_bulk_haul_1\"]", StringComparison.Ordinal); File.WriteAllText(Path.Combine(layout.MasteryDir, "branch_mismatch_mastery.json"), mismatch, Encoding.UTF8); // Act var ex = Record.Exception(() => MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("branchId set", ioe.Message, StringComparison.Ordinal); Assert.Contains("must match previous tier", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenRequiredLevelNotStrictlyIncreasing() { // Arrange using var layout = TempContentLayout.Create(); var flatLevel = ValidSalvageMasteryJson.Replace("\"requiredLevel\": 4", "\"requiredLevel\": 2", StringComparison.Ordinal); File.WriteAllText(Path.Combine(layout.MasteryDir, "flat_level_mastery.json"), flatLevel, Encoding.UTF8); // Act var ex = Record.Exception(() => MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("requiredLevel must be strictly greater", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenSlice4TrackCountWrong() { // Arrange using var layout = TempContentLayout.Create(); 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(layout.MasteryDir, "two_tracks_mastery.json"), twoTracksJson, Encoding.UTF8); // Act var ex = Record.Exception(() => MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("prototype Slice 4 expects exactly one MasteryTrack", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenSlice4SoleTrackSkillIdWrong() { // Arrange using var layout = TempContentLayout.Create(); var refineTrack = ValidSalvageMasteryJson.Replace("\"skillId\": \"salvage\"", "\"skillId\": \"refine\"", StringComparison.Ordinal); File.WriteAllText(Path.Combine(layout.MasteryDir, "refine_track_mastery.json"), refineTrack, Encoding.UTF8); // Act var ex = Record.Exception(() => MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("prototype Slice 4 expects the sole track skillId", ioe.Message, StringComparison.Ordinal); Assert.Contains("refine", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenTierIndexDuplicate() { // Arrange using var layout = TempContentLayout.Create(); var dupTier = ValidSalvageMasteryJson.Replace("\"tierIndex\": 2", "\"tierIndex\": 1", StringComparison.Ordinal); File.WriteAllText(Path.Combine(layout.MasteryDir, "dup_tier_mastery.json"), dupTier, Encoding.UTF8); // Act var ex = Record.Exception(() => MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("duplicate tierIndex", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenTierIndexHasGap() { // Arrange using var layout = TempContentLayout.Create(); var gapTier = ValidSalvageMasteryJson.Replace("\"tierIndex\": 2", "\"tierIndex\": 3", StringComparison.Ordinal); File.WriteAllText(Path.Combine(layout.MasteryDir, "gap_tier_mastery.json"), gapTier, Encoding.UTF8); // Act var ex = Record.Exception(() => MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("tierIndex values must be unique and sequential", ioe.Message, StringComparison.Ordinal); } [Fact] public void Host_ShouldFailStartup_WhenMasteryDirectoryInvalid() { // Arrange var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-mastery-" + Guid.NewGuid().ToString("n")); try { Directory.CreateDirectory(badDir); var skillsDir = SkillCatalogTestPaths.DiscoverRepoSkillsDirectory(); // Act var ex = Record.Exception(() => { using var factory = new WebApplicationFactory().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); } finally { try { if (Directory.Exists(badDir)) Directory.Delete(badDir, recursive: true); } catch (IOException) { // Best-effort cleanup. } } } }