NEO-46: address code review follow-up.

Add loader deny-path and registry enumeration tests, tierIndex 1..N gate
in validate_content.py, E2.M3 handoff doc update, and review strikethroughs.
pull/80/head
VinPropane 2026-05-17 18:12:39 -04:00
parent 174c390aa4
commit 07fc566e2c
5 changed files with 353 additions and 59 deletions

View File

@ -8,7 +8,7 @@
| **Epic** | [Epic 2 — Skills and Progression Framework](../epics/epic_02_skills_and_progression.md) | | **Epic** | [Epic 2 — Skills and Progression Framework](../epics/epic_02_skills_and_progression.md) |
| **Linear (Epic 2 project)** | [Epic 2 — Classless Skill and Progression Framework](https://linear.app/neon-sprawl/project/epic-2-classless-skill-and-progression-framework-5200f84bf8b6) | | **Linear (Epic 2 project)** | [Epic 2 — Classless Skill and Progression Framework](https://linear.app/neon-sprawl/project/epic-2-classless-skill-and-progression-framework-5200f84bf8b6) |
| **Stage target** | Pre-production | | **Stage target** | Pre-production |
| **Status** | In Progress (see [dependency register](module_dependency_register.md); **NEO-45** catalog + CI landed) | | **Status** | In Progress (see [dependency register](module_dependency_register.md); **NEO-45** catalog + CI landed; **NEO-46** server fail-fast load landed) |
## Linear backlog (Epic 2 Slice 4 — E2.M3) ## Linear backlog (Epic 2 Slice 4 — E2.M3)
@ -75,7 +75,7 @@ Each catalog file has top-level **`schemaVersion`**, a **`perks`** map (`perkId`
## NEO-46 handoff (server catalog load) ## NEO-46 handoff (server catalog load)
When [NEO-46](https://linear.app/neon-sprawl/issue/NEO-46) kicks off, mirror the Python CI gates documented in [NEO-45 implementation plan](../../plans/NEO-45-implementation-plan.md) (`PROTOTYPE_SLICE4_SALVAGE_SKILL_ID`, exactly one track, unknown `skillId`, duplicate `perkId`, branch-set equality tier-to-tier, unreferenced `perks` entries). Consider **`tierIndex`** uniqueness and sequential 1..N per track at server boot (schema allows gaps today). **Landed ([NEO-46](https://linear.app/neon-sprawl/issue/NEO-46)):** Server boot loads `content/mastery/*_mastery.json` fail-fast under `Game/Mastery/`, mirroring Python CI gates from [NEO-45 implementation plan](../../plans/NEO-45-implementation-plan.md) (`PROTOTYPE_SLICE4_SALVAGE_SKILL_ID`, exactly one track, unknown `skillId`, duplicate `perkId`, branch-set equality tier-to-tier, unreferenced `perks` entries) plus cross-check against `ISkillDefinitionRegistry`. **`tierIndex`** uniqueness and sequential **1..N** per track are enforced at server boot and in **`validate_content.py`** (protects NEO-47 `PerkState` keys). Read-only **`IMasteryCatalogRegistry`** is registered for NEO-47+.
## Prototype Slice 4 freeze — salvage flagship ([NEO-45](https://linear.app/neon-sprawl/issue/NEO-45)) ## Prototype Slice 4 freeze — salvage flagship ([NEO-45](https://linear.app/neon-sprawl/issue/NEO-45))

View File

@ -0,0 +1,59 @@
# Code review — NEO-46 mastery catalog startup load
**Date:** 2026-05-17
**Scope:** Branch `NEO-46-mastery-catalog-startup-load` · commits `898f935``174c390` vs `main`
**Base:** `main`
**Follow-up:** Addressed review suggestions (tests, `validate_content.py` `tierIndex` gate, E2.M3 module doc).
## Verdict
**Approve with nits**
## Summary
This change adds fail-fast loading of `content/mastery/*_mastery.json` at server startup, mirroring the NEO-34 skill-catalog pattern: path discovery, JsonSchema.Net validation, post-schema gates aligned with `scripts/validate_content.py`, a server-only `tierIndex` 1..N gate, cross-check against loaded skill ids, and a read-only `IMasteryCatalogRegistry` for NEO-47+. DI wires mastery load after `SkillDefinitionCatalog`; `Program.cs` eagerly resolves both catalogs before mapping routes. Tests cover the loader happy path, several deny paths, and host startup success/failure; all 13 mastery-related tests pass locally. Overall risk is low: read-only content load, no player state or HTTP surface yet.
## Documentation checked
| Document | Result |
|----------|--------|
| [`docs/plans/NEO-46-implementation-plan.md`](../plans/NEO-46-implementation-plan.md) | **Matches**`Game/Mastery/`, config keys, loader gates, registry API, eager resolve, README, alignment table, acceptance checklist marked complete. |
| [`docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md`](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) | **Matches** — Status and NEO-46 handoff updated after follow-up. |
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E2.M3 row updated for NEO-46 landed. |
| [`docs/plans/NEO-45-implementation-plan.md`](../plans/NEO-45-implementation-plan.md) | **Matches** — Slice 4 constant/gate parity via `PrototypeSlice4MasteryCatalogRules`. |
| [`scripts/validate_content.py`](../../scripts/validate_content.py) | **Matches**`tierIndex` 1..N gate added in CI (follow-up). |
Register/tracking: E2.M3 alignment row is updated; module doc refreshed (follow-up).
## Blocking issues
None.
## Suggestions
1. ~~**Test coverage vs plan table** — The implementation plan lists deny cases for malformed JSON, schema violation, duplicate global `perkIds` reference, branch-set mismatch, non-increasing `requiredLevel`, Slice 4 wrong `skillId`, and duplicate `tierIndex`. Current tests cover unknown skill, duplicate perk across files, orphan perk, Slice 4 multi-track, `tierIndex` gap, and host negative startup; adding 23 focused loader tests (e.g. duplicate branch perk reference, `tierIndex` duplicate, wrong sole `skillId` for Slice 4) would close the gap and guard C#/Python drift.~~ **Done.** Added loader tests for malformed JSON, schema violation, duplicate perk reference, branch-set mismatch, flat `requiredLevel`, wrong Slice 4 sole `skillId`, and duplicate `tierIndex`.
2. ~~**Registry enumeration** — `GetTracksInSkillIdOrder()` / `GetPerksInIdOrder()` are part of the contract but untested; a small unit test on a constructed catalog would lock ordering for NEO-47 consumers.~~ **Done.** `MasteryCatalogRegistryTests` covers both enumeration methods.
3. ~~**E2.M3 module doc** — Update the **NEO-46 handoff** bullet to state that `tierIndex` uniqueness and sequential 1..N are enforced at server boot (not merely “consider”), and refresh the module **Status** line to mention NEO-46 (optional but keeps decomposition docs authoritative).~~ **Done.** `E2_M3_MasteryAndPerkUnlocks.md` updated.
4. ~~**CI follow-up (already in plan risks)** — Consider adding the `tierIndex` gate to `validate_content.py` so PR CI and server boot cannot diverge on catalog edits.~~ **Done.** `_tier_index_gate` in `validate_content.py` mirrors `MasteryCatalogLoader.TryGetTierIndexGateError`.
## Nits
- ~~Nit: `MasteryCatalogLoaderTests.CreateTempContentLayout()` leaves temp directories behind; consider `try/finally` with `Directory.Delete(..., recursive: true)` or `IDisposable` fixture (same pattern as other catalog tests if they exist).~~ **Done.** `TempContentLayout` implements `IDisposable`.
- ~~Nit: Host startup is exercised in both `MasteryCatalogLoaderTests` and `MasteryCatalogRegistryTests`; consolidating to one host integration test would reduce duplication (not required for merge).~~ **Done.** Host success path lives only in `MasteryCatalogRegistryTests`; loader tests keep host negative startup only.
- Nit: Bruno `mastery-catalog` folder only hits `/health` — appropriate for NEO-46 scope; NEO-48 will own real mastery HTTP Bruno coverage. **Deferred** — out of scope for NEO-46.
## Verification
```bash
# From repo root
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~MasteryCatalog"
# Full server test suite (recommended before merge)
dotnet test NeonSprawl.sln
# Content gate (should stay green with repo catalog)
python3 scripts/validate_content.py
```
Manual: start server from clone with default content discovery; confirm Information log lists mastery directory, track count `1`, perk count `2` for prototype salvage catalog.

View File

@ -5,6 +5,7 @@ Validates:
- skill catalogs: content/skills/*_skills.json rows vs content/schemas/skill-def.schema.json - skill catalogs: content/skills/*_skills.json rows vs content/schemas/skill-def.schema.json
- level curves: content/skills/*_level_curve.json vs content/schemas/level-curve.schema.json - level curves: content/skills/*_level_curve.json vs content/schemas/level-curve.schema.json
- mastery catalogs: content/mastery/*_mastery.json vs content/schemas/mastery-catalog.schema.json - mastery catalogs: content/mastery/*_mastery.json vs content/schemas/mastery-catalog.schema.json
(post-schema: tierIndex unique and sequential 1..N per track, aligned with server boot NEO-46)
""" """
from __future__ import annotations from __future__ import annotations
@ -51,6 +52,26 @@ def _prototype_slice1_gate(seen_ids: dict[str, str], id_to_category: dict[str, s
return None return None
def _tier_index_gate(rel: str, track_index: int, tier_index_values: list[int]) -> str | None:
"""Keep in sync with MasteryCatalogLoader.TryGetTierIndexGateError (NEO-46)."""
if not tier_index_values:
return None
seen: set[int] = set()
for value in tier_index_values:
if value in seen:
return f"error: {rel}: tracks[{track_index}] duplicate tierIndex {value}"
seen.add(value)
expected = set(range(1, len(tier_index_values) + 1))
if seen != expected:
sorted_vals = ", ".join(str(v) for v in sorted(seen))
n = len(tier_index_values)
return (
f"error: {rel}: tracks[{track_index}] tierIndex values must be unique and "
f"sequential 1..{n}, got [{sorted_vals}]"
)
return None
def _validate_mastery_catalogs( def _validate_mastery_catalogs(
*, *,
mastery_files: list[Path], mastery_files: list[Path],
@ -130,11 +151,16 @@ def _validate_mastery_catalogs(
prev_required_level = 0 prev_required_level = 0
prev_tier_branch_ids: frozenset[str] | None = None prev_tier_branch_ids: frozenset[str] | None = None
tier_index_values: list[int] = []
for tier_i, tier in enumerate(tiers): for tier_i, tier in enumerate(tiers):
if not isinstance(tier, dict): if not isinstance(tier, dict):
continue continue
tier_index = tier.get("tierIndex")
if isinstance(tier_index, int):
tier_index_values.append(tier_index)
required_level = tier.get("requiredLevel") required_level = tier.get("requiredLevel")
if isinstance(required_level, int) and required_level <= prev_required_level: if isinstance(required_level, int) and required_level <= prev_required_level:
print( print(
@ -203,6 +229,11 @@ def _validate_mastery_catalogs(
if tier_branch_ids: if tier_branch_ids:
prev_tier_branch_ids = tier_branch_set prev_tier_branch_ids = tier_branch_set
tier_index_err = _tier_index_gate(rel, ti, tier_index_values)
if tier_index_err:
print(tier_index_err, file=sys.stderr)
errors += 1
for perk_key in perks: for perk_key in perks:
if isinstance(perk_key, str) and perk_key not in referenced_perk_ids_in_file: if isinstance(perk_key, str) and perk_key not in referenced_perk_ids_in_file:
print( print(

View File

@ -2,10 +2,8 @@ using System.Collections.Frozen;
using System.Net; using System.Net;
using System.Text; using System.Text;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Tests;
using NeonSprawl.Server.Tests.Game.Skills; using NeonSprawl.Server.Tests.Game.Skills;
using Xunit; using Xunit;
@ -59,57 +57,128 @@ public class MasteryCatalogLoaderTests
} }
"""; """;
private static (string Root, string MasteryDir, string SchemaPath) CreateTempContentLayout() private sealed class TempContentLayout : IDisposable
{ {
var root = Directory.CreateTempSubdirectory("neon-sprawl-masterycat-"); public string MasteryDir { get; }
var masteryDir = Path.Combine(root.FullName, "content", "mastery"); public string SchemaPath { get; }
var schemaDir = Path.Combine(root.FullName, "content", "schemas"); private readonly string _root;
Directory.CreateDirectory(masteryDir);
Directory.CreateDirectory(schemaDir); private TempContentLayout(string root, string masteryDir, string schemaPath)
var schemaPath = Path.Combine(schemaDir, "mastery-catalog.schema.json"); {
File.Copy(MasteryCatalogTestPaths.DiscoverRepoMasteryCatalogSchemaPath(), schemaPath, overwrite: true); _root = root;
return (root.FullName, masteryDir, schemaPath); 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] [Fact]
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract() public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
{ {
// Arrange // Arrange
var (_, masteryDir, schemaPath) = CreateTempContentLayout(); using var layout = TempContentLayout.Create();
File.WriteAllText(Path.Combine(masteryDir, "prototype_salvage_mastery.json"), ValidSalvageMasteryJson, Encoding.UTF8); File.WriteAllText(
Path.Combine(layout.MasteryDir, "prototype_salvage_mastery.json"),
ValidSalvageMasteryJson,
Encoding.UTF8);
// Act // Act
var catalog = MasteryCatalogLoader.Load(masteryDir, schemaPath, PrototypeSkillIds, NullLogger.Instance); var catalog = MasteryCatalogLoader.Load(
layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance);
// Assert // Assert
Assert.Equal(1, catalog.TrackCount); Assert.Equal(1, catalog.TrackCount);
Assert.Equal(2, catalog.PerkCount); Assert.Equal(2, catalog.PerkCount);
Assert.True(catalog.TracksBySkillId.ContainsKey("salvage")); 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] [Fact]
public void Load_ShouldThrow_WhenTracksIsNotArray() public void Load_ShouldThrow_WhenTracksIsNotArray()
{ {
// Arrange // Arrange
var (_, masteryDir, schemaPath) = CreateTempContentLayout(); using var layout = TempContentLayout.Create();
File.WriteAllText(Path.Combine(masteryDir, "bad_mastery.json"), """{"schemaVersion":1,"perks":{},"tracks":"nope"}""", Encoding.UTF8); File.WriteAllText(
Path.Combine(layout.MasteryDir, "bad_mastery.json"),
"""{"schemaVersion":1,"perks":{},"tracks":"nope"}""",
Encoding.UTF8);
// Act // Act
var ex = Record.Exception(() => var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(masteryDir, schemaPath, PrototypeSkillIds, NullLogger.Instance)); MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance));
// Assert // Assert
var ioe = Assert.IsType<InvalidOperationException>(ex); var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("bad_mastery.json", ioe.Message, StringComparison.Ordinal); Assert.Contains("bad_mastery.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("Mastery catalog validation failed", 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] [Fact]
public void Load_ShouldThrow_WhenUnknownSkillId() public void Load_ShouldThrow_WhenUnknownSkillId()
{ {
// Arrange // Arrange
var (_, masteryDir, schemaPath) = CreateTempContentLayout(); using var layout = TempContentLayout.Create();
var onlyRefineSkills = FrozenSet.ToFrozenSet(["refine"], StringComparer.Ordinal); var onlyRefineSkills = FrozenSet.ToFrozenSet(["refine"], StringComparer.Ordinal);
File.WriteAllText(Path.Combine(masteryDir, "prototype_salvage_mastery.json"), ValidSalvageMasteryJson, Encoding.UTF8); File.WriteAllText(
Path.Combine(layout.MasteryDir, "prototype_salvage_mastery.json"),
ValidSalvageMasteryJson,
Encoding.UTF8);
// Act // Act
var ex = Record.Exception(() => var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(masteryDir, schemaPath, onlyRefineSkills, NullLogger.Instance)); MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, onlyRefineSkills, NullLogger.Instance));
// Assert // Assert
var ioe = Assert.IsType<InvalidOperationException>(ex); var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("not a known SkillDef id", ioe.Message, StringComparison.Ordinal); Assert.Contains("not a known SkillDef id", ioe.Message, StringComparison.Ordinal);
@ -120,41 +189,94 @@ public class MasteryCatalogLoaderTests
public void Load_ShouldThrow_WhenDuplicatePerkIdAcrossFiles() public void Load_ShouldThrow_WhenDuplicatePerkIdAcrossFiles()
{ {
// Arrange // Arrange
var (_, masteryDir, schemaPath) = CreateTempContentLayout(); using var layout = TempContentLayout.Create();
File.WriteAllText(Path.Combine(masteryDir, "a_mastery.json"), ValidSalvageMasteryJson, Encoding.UTF8); File.WriteAllText(Path.Combine(layout.MasteryDir, "a_mastery.json"), ValidSalvageMasteryJson, Encoding.UTF8);
File.WriteAllText(Path.Combine(masteryDir, "b_mastery.json"), ValidSalvageMasteryJson, Encoding.UTF8); File.WriteAllText(Path.Combine(layout.MasteryDir, "b_mastery.json"), ValidSalvageMasteryJson, Encoding.UTF8);
// Act // Act
var ex = Record.Exception(() => var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(masteryDir, schemaPath, PrototypeSkillIds, NullLogger.Instance)); MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance));
// Assert // Assert
var ioe = Assert.IsType<InvalidOperationException>(ex); var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("duplicate perk id", ioe.Message, StringComparison.Ordinal); Assert.Contains("duplicate perk id", ioe.Message, StringComparison.Ordinal);
Assert.Contains("salvage_scrap_efficiency_1", 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] [Fact]
public void Load_ShouldThrow_WhenUnreferencedPerkInMap() public void Load_ShouldThrow_WhenUnreferencedPerkInMap()
{ {
// Arrange // Arrange
var (_, masteryDir, schemaPath) = CreateTempContentLayout(); using var layout = TempContentLayout.Create();
var orphanPerk = ValidSalvageMasteryJson.Replace( var orphanPerk = ValidSalvageMasteryJson.Replace(
"\"salvage_bulk_haul_1\":", "\"salvage_bulk_haul_1\":",
"\"salvage_orphan_1\": { \"id\": \"salvage_orphan_1\", \"displayName\": \"Orphan\" },\n \"salvage_bulk_haul_1\":", "\"salvage_orphan_1\": { \"id\": \"salvage_orphan_1\", \"displayName\": \"Orphan\" },\n \"salvage_bulk_haul_1\":",
StringComparison.Ordinal); StringComparison.Ordinal);
File.WriteAllText(Path.Combine(masteryDir, "orphan_mastery.json"), orphanPerk, Encoding.UTF8); File.WriteAllText(Path.Combine(layout.MasteryDir, "orphan_mastery.json"), orphanPerk, Encoding.UTF8);
// Act // Act
var ex = Record.Exception(() => var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(masteryDir, schemaPath, PrototypeSkillIds, NullLogger.Instance)); MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance));
// Assert // Assert
var ioe = Assert.IsType<InvalidOperationException>(ex); var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("not referenced by any branch perkIds", ioe.Message, StringComparison.Ordinal); 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] [Fact]
public void Load_ShouldThrow_WhenSlice4TrackCountWrong() public void Load_ShouldThrow_WhenSlice4TrackCountWrong()
{ {
// Arrange // Arrange
var (_, masteryDir, schemaPath) = CreateTempContentLayout(); using var layout = TempContentLayout.Create();
const string twoTracksJson = """ const string twoTracksJson = """
{ {
"schemaVersion": 1, "schemaVersion": 1,
@ -189,43 +311,59 @@ public class MasteryCatalogLoaderTests
] ]
} }
"""; """;
File.WriteAllText(Path.Combine(masteryDir, "two_tracks_mastery.json"), twoTracksJson, Encoding.UTF8); File.WriteAllText(Path.Combine(layout.MasteryDir, "two_tracks_mastery.json"), twoTracksJson, Encoding.UTF8);
// Act // Act
var ex = Record.Exception(() => var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(masteryDir, schemaPath, PrototypeSkillIds, NullLogger.Instance)); MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance));
// Assert // Assert
var ioe = Assert.IsType<InvalidOperationException>(ex); var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("prototype Slice 4 expects exactly one MasteryTrack", ioe.Message, StringComparison.Ordinal); Assert.Contains("prototype Slice 4 expects exactly one MasteryTrack", ioe.Message, StringComparison.Ordinal);
} }
[Fact] [Fact]
public void Load_ShouldThrow_WhenTierIndexHasGap() public void Load_ShouldThrow_WhenSlice4SoleTrackSkillIdWrong()
{ {
// Arrange // Arrange
var (_, masteryDir, schemaPath) = CreateTempContentLayout(); using var layout = TempContentLayout.Create();
var gapTier = ValidSalvageMasteryJson.Replace("\"tierIndex\": 2", "\"tierIndex\": 3", StringComparison.Ordinal); var refineTrack = ValidSalvageMasteryJson.Replace("\"skillId\": \"salvage\"", "\"skillId\": \"refine\"", StringComparison.Ordinal);
File.WriteAllText(Path.Combine(masteryDir, "gap_tier_mastery.json"), gapTier, Encoding.UTF8); File.WriteAllText(Path.Combine(layout.MasteryDir, "refine_track_mastery.json"), refineTrack, Encoding.UTF8);
// Act // Act
var ex = Record.Exception(() => var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(masteryDir, schemaPath, PrototypeSkillIds, NullLogger.Instance)); MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance));
// Assert // Assert
var ioe = Assert.IsType<InvalidOperationException>(ex); var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("tierIndex values must be unique and sequential", ioe.Message, StringComparison.Ordinal); Assert.Contains("prototype Slice 4 expects the sole track skillId", ioe.Message, StringComparison.Ordinal);
Assert.Contains("refine", ioe.Message, StringComparison.Ordinal);
} }
[Fact] [Fact]
public async Task Host_ShouldResolveMasteryCatalogFromDi_WhenStartupSucceeds() public void Load_ShouldThrow_WhenTierIndexDuplicate()
{ {
// Arrange // Arrange
await using var factory = new InMemoryWebApplicationFactory(); using var layout = TempContentLayout.Create();
using var client = factory.CreateClient(); var dupTier = ValidSalvageMasteryJson.Replace("\"tierIndex\": 2", "\"tierIndex\": 1", StringComparison.Ordinal);
File.WriteAllText(Path.Combine(layout.MasteryDir, "dup_tier_mastery.json"), dupTier, Encoding.UTF8);
// Act // Act
var response = await client.GetAsync("/health"); var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance));
// Assert // Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode); var ioe = Assert.IsType<InvalidOperationException>(ex);
var catalog = factory.Services.GetRequiredService<MasteryCatalog>(); Assert.Contains("duplicate tierIndex", ioe.Message, StringComparison.Ordinal);
Assert.Equal(1, catalog.TrackCount); }
Assert.True(catalog.TracksBySkillId.ContainsKey("salvage"));
[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] [Fact]
@ -233,20 +371,35 @@ public class MasteryCatalogLoaderTests
{ {
// Arrange // Arrange
var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-mastery-" + Guid.NewGuid().ToString("n")); var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-mastery-" + Guid.NewGuid().ToString("n"));
Directory.CreateDirectory(badDir); try
var skillsDir = SkillCatalogTestPaths.DiscoverRepoSkillsDirectory();
// Act
var ex = Record.Exception(() =>
{ {
using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b => Directory.CreateDirectory(badDir);
var skillsDir = SkillCatalogTestPaths.DiscoverRepoSkillsDirectory();
// Act
var ex = Record.Exception(() =>
{ {
b.UseSetting("Content:SkillsDirectory", skillsDir); using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
b.UseSetting("Content:MasteryDirectory", badDir); {
b.UseSetting("Content:SkillsDirectory", skillsDir);
b.UseSetting("Content:MasteryDirectory", badDir);
});
factory.CreateClient();
}); });
factory.CreateClient(); // Assert
}); Assert.NotNull(ex);
// Assert Assert.Contains("Mastery catalog validation failed", ex.ToString(), StringComparison.Ordinal);
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.
}
}
} }
} }

View File

@ -1,3 +1,4 @@
using System.Net;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Tests; using NeonSprawl.Server.Tests;
@ -67,18 +68,68 @@ public class MasteryCatalogRegistryTests
Assert.Equal("gather_yield_modifier", result.EffectKind); Assert.Equal("gather_yield_modifier", result.EffectKind);
} }
[Fact]
public void GetTracksInSkillIdOrder_ShouldReturnTracksSortedBySkillId()
{
// Arrange
var refine = new MasteryTrackRow("refine", []);
var salvage = new MasteryTrackRow("salvage", []);
var intrusion = new MasteryTrackRow("intrusion", []);
var catalog = new MasteryCatalog(
"/tmp/mastery",
new Dictionary<string, MasteryTrackRow>(StringComparer.Ordinal)
{
["refine"] = refine,
["salvage"] = salvage,
["intrusion"] = intrusion,
},
new Dictionary<string, PerkDefRow>(StringComparer.Ordinal),
catalogJsonFileCount: 1);
var registry = CreateRegistryFromCatalog(catalog);
// Act
var tracks = registry.GetTracksInSkillIdOrder();
// Assert
Assert.Equal(["intrusion", "refine", "salvage"], tracks.Select(t => t.SkillId).ToArray());
}
[Fact]
public void GetPerksInIdOrder_ShouldReturnPerksSortedById()
{
// Arrange
var perks = new Dictionary<string, PerkDefRow>(StringComparer.Ordinal)
{
["z_perk"] = new("z_perk", "Z", null),
["a_perk"] = new("a_perk", "A", null),
["m_perk"] = new("m_perk", "M", null),
};
var catalog = new MasteryCatalog(
"/tmp/mastery",
new Dictionary<string, MasteryTrackRow>(StringComparer.Ordinal),
perks,
catalogJsonFileCount: 1);
var registry = CreateRegistryFromCatalog(catalog);
// Act
var ordered = registry.GetPerksInIdOrder();
// Assert
Assert.Equal(["a_perk", "m_perk", "z_perk"], ordered.Select(p => p.Id).ToArray());
}
[Fact] [Fact]
public async Task Host_ShouldResolveRegistryFromDi_WhenStartupSucceeds() public async Task Host_ShouldResolveRegistryFromDi_WhenStartupSucceeds()
{ {
// Arrange // Arrange
await using var factory = new InMemoryWebApplicationFactory(); await using var factory = new InMemoryWebApplicationFactory();
using var client = factory.CreateClient(); using var client = factory.CreateClient();
_ = await client.GetAsync("/health");
// Act // Act
var response = await client.GetAsync("/health");
var registry = factory.Services.GetRequiredService<IMasteryCatalogRegistry>(); var registry = factory.Services.GetRequiredService<IMasteryCatalogRegistry>();
var catalog = factory.Services.GetRequiredService<MasteryCatalog>();
var foundTrack = registry.TryGetTrack("salvage", out var track); var foundTrack = registry.TryGetTrack("salvage", out var track);
var foundPerk = registry.TryGetPerk("salvage_scrap_efficiency_1", out var perk); var foundPerk = registry.TryGetPerk("salvage_scrap_efficiency_1", out var perk);
// Assert // Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(1, catalog.TrackCount);
Assert.True(catalog.TracksBySkillId.ContainsKey("salvage"));
Assert.True(foundTrack); Assert.True(foundTrack);
Assert.NotNull(track); Assert.NotNull(track);
Assert.Equal(2, track.Tiers.Count); Assert.Equal(2, track.Tiers.Count);