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
parent
174c390aa4
commit
07fc566e2c
|
|
@ -8,7 +8,7 @@
|
|||
| **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) |
|
||||
| **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)
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ Each catalog file has top-level **`schemaVersion`**, a **`perks`** map (`perkId`
|
|||
|
||||
## 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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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 2–3 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.
|
||||
|
|
@ -5,6 +5,7 @@ Validates:
|
|||
- 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
|
||||
- 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
|
||||
|
|
@ -51,6 +52,26 @@ def _prototype_slice1_gate(seen_ids: dict[str, str], id_to_category: dict[str, s
|
|||
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(
|
||||
*,
|
||||
mastery_files: list[Path],
|
||||
|
|
@ -130,11 +151,16 @@ def _validate_mastery_catalogs(
|
|||
|
||||
prev_required_level = 0
|
||||
prev_tier_branch_ids: frozenset[str] | None = None
|
||||
tier_index_values: list[int] = []
|
||||
|
||||
for tier_i, tier in enumerate(tiers):
|
||||
if not isinstance(tier, dict):
|
||||
continue
|
||||
|
||||
tier_index = tier.get("tierIndex")
|
||||
if isinstance(tier_index, int):
|
||||
tier_index_values.append(tier_index)
|
||||
|
||||
required_level = tier.get("requiredLevel")
|
||||
if isinstance(required_level, int) and required_level <= prev_required_level:
|
||||
print(
|
||||
|
|
@ -203,6 +229,11 @@ def _validate_mastery_catalogs(
|
|||
if tier_branch_ids:
|
||||
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:
|
||||
if isinstance(perk_key, str) and perk_key not in referenced_perk_ids_in_file:
|
||||
print(
|
||||
|
|
|
|||
|
|
@ -2,10 +2,8 @@ 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;
|
||||
|
||||
|
|
@ -59,7 +57,20 @@ public class MasteryCatalogLoaderTests
|
|||
}
|
||||
""";
|
||||
|
||||
private static (string Root, string MasteryDir, string SchemaPath) CreateTempContentLayout()
|
||||
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");
|
||||
|
|
@ -68,48 +79,106 @@ public class MasteryCatalogLoaderTests
|
|||
Directory.CreateDirectory(schemaDir);
|
||||
var schemaPath = Path.Combine(schemaDir, "mastery-catalog.schema.json");
|
||||
File.Copy(MasteryCatalogTestPaths.DiscoverRepoMasteryCatalogSchemaPath(), schemaPath, overwrite: true);
|
||||
return (root.FullName, masteryDir, schemaPath);
|
||||
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
|
||||
var (_, masteryDir, schemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(Path.Combine(masteryDir, "prototype_salvage_mastery.json"), ValidSalvageMasteryJson, Encoding.UTF8);
|
||||
using var layout = TempContentLayout.Create();
|
||||
File.WriteAllText(
|
||||
Path.Combine(layout.MasteryDir, "prototype_salvage_mastery.json"),
|
||||
ValidSalvageMasteryJson,
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var catalog = MasteryCatalogLoader.Load(masteryDir, schemaPath, PrototypeSkillIds, NullLogger.Instance);
|
||||
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
|
||||
var (_, masteryDir, schemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(Path.Combine(masteryDir, "bad_mastery.json"), """{"schemaVersion":1,"perks":{},"tracks":"nope"}""", Encoding.UTF8);
|
||||
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(masteryDir, schemaPath, PrototypeSkillIds, NullLogger.Instance));
|
||||
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
|
||||
var (_, masteryDir, schemaPath) = CreateTempContentLayout();
|
||||
using var layout = TempContentLayout.Create();
|
||||
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
|
||||
var ex = Record.Exception(() =>
|
||||
MasteryCatalogLoader.Load(masteryDir, schemaPath, onlyRefineSkills, NullLogger.Instance));
|
||||
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);
|
||||
|
|
@ -120,41 +189,94 @@ public class MasteryCatalogLoaderTests
|
|||
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);
|
||||
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(masteryDir, schemaPath, PrototypeSkillIds, NullLogger.Instance));
|
||||
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
|
||||
var (_, masteryDir, schemaPath) = CreateTempContentLayout();
|
||||
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(masteryDir, "orphan_mastery.json"), orphanPerk, Encoding.UTF8);
|
||||
File.WriteAllText(Path.Combine(layout.MasteryDir, "orphan_mastery.json"), orphanPerk, Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() =>
|
||||
MasteryCatalogLoader.Load(masteryDir, schemaPath, PrototypeSkillIds, NullLogger.Instance));
|
||||
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
|
||||
var (_, masteryDir, schemaPath) = CreateTempContentLayout();
|
||||
using var layout = TempContentLayout.Create();
|
||||
const string twoTracksJson = """
|
||||
{
|
||||
"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
|
||||
var ex = Record.Exception(() =>
|
||||
MasteryCatalogLoader.Load(masteryDir, schemaPath, PrototypeSkillIds, NullLogger.Instance));
|
||||
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_WhenTierIndexHasGap()
|
||||
public void Load_ShouldThrow_WhenSlice4SoleTrackSkillIdWrong()
|
||||
{
|
||||
// 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);
|
||||
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(masteryDir, schemaPath, PrototypeSkillIds, NullLogger.Instance));
|
||||
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);
|
||||
Assert.Contains("prototype Slice 4 expects the sole track skillId", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("refine", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Host_ShouldResolveMasteryCatalogFromDi_WhenStartupSucceeds()
|
||||
public void Load_ShouldThrow_WhenTierIndexDuplicate()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
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 response = await client.GetAsync("/health");
|
||||
var ex = Record.Exception(() =>
|
||||
MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance));
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var catalog = factory.Services.GetRequiredService<MasteryCatalog>();
|
||||
Assert.Equal(1, catalog.TrackCount);
|
||||
Assert.True(catalog.TracksBySkillId.ContainsKey("salvage"));
|
||||
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]
|
||||
|
|
@ -233,6 +371,8 @@ public class MasteryCatalogLoaderTests
|
|||
{
|
||||
// Arrange
|
||||
var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-mastery-" + Guid.NewGuid().ToString("n"));
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(badDir);
|
||||
var skillsDir = SkillCatalogTestPaths.DiscoverRepoSkillsDirectory();
|
||||
// Act
|
||||
|
|
@ -249,4 +389,17 @@ public class MasteryCatalogLoaderTests
|
|||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Net;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Tests;
|
||||
|
|
@ -67,18 +68,68 @@ public class MasteryCatalogRegistryTests
|
|||
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]
|
||||
public async Task Host_ShouldResolveRegistryFromDi_WhenStartupSucceeds()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
_ = await client.GetAsync("/health");
|
||||
// Act
|
||||
var response = await client.GetAsync("/health");
|
||||
var registry = factory.Services.GetRequiredService<IMasteryCatalogRegistry>();
|
||||
var catalog = factory.Services.GetRequiredService<MasteryCatalog>();
|
||||
var foundTrack = registry.TryGetTrack("salvage", out var track);
|
||||
var foundPerk = registry.TryGetPerk("salvage_scrap_efficiency_1", out var perk);
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(1, catalog.TrackCount);
|
||||
Assert.True(catalog.TracksBySkillId.ContainsKey("salvage"));
|
||||
Assert.True(foundTrack);
|
||||
Assert.NotNull(track);
|
||||
Assert.Equal(2, track.Tiers.Count);
|
||||
|
|
|
|||
Loading…
Reference in New Issue