406 lines
17 KiB
C#
406 lines
17 KiB
C#
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<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 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<InvalidOperationException>(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<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_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<InvalidOperationException>(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<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
|
|
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<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_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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<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);
|
|
}
|
|
finally
|
|
{
|
|
try
|
|
{
|
|
if (Directory.Exists(badDir))
|
|
Directory.Delete(badDir, recursive: true);
|
|
}
|
|
catch (IOException)
|
|
{
|
|
// Best-effort cleanup.
|
|
}
|
|
}
|
|
}
|
|
}
|