using Theriapolis.Core.Data; using Theriapolis.Core.Rules.Character; using Xunit; namespace Theriapolis.Tests.Rules; /// /// Phase 6.5 M2 — SubclassResolver covers the pure look-up surface /// (subclass id → unlocked feature ids per level, feature def lookup). /// All subclass mechanics are JSON-driven; tests run against the live /// content set so authoring drift is caught here. /// public sealed class SubclassResolverTests { private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory)); [Fact] public void TryFindSubclass_ReturnsDefForKnownId() { var def = SubclassResolver.TryFindSubclass(_content.Subclasses, "pack_forged"); Assert.NotNull(def); Assert.Equal("fangsworn", def!.ClassId); } [Fact] public void TryFindSubclass_NullForEmptyId() { Assert.Null(SubclassResolver.TryFindSubclass(_content.Subclasses, "")); Assert.Null(SubclassResolver.TryFindSubclass(_content.Subclasses, null)); } [Fact] public void TryFindSubclass_NullForUnknownId() { Assert.Null(SubclassResolver.TryFindSubclass(_content.Subclasses, "definitely_not_a_subclass")); } [Fact] public void UnlockedFeaturesAt_Level3_ReturnsFirstFeature() { var features = SubclassResolver.UnlockedFeaturesAt(_content.Subclasses, "pack_forged", 3); Assert.NotEmpty(features); Assert.Contains("packmates_howl", features); } [Fact] public void UnlockedFeaturesAt_LevelWithoutEntry_ReturnsEmpty() { // Pack-Forged has entries at L3, L7, L10, L15, L18 — L4 is empty. var features = SubclassResolver.UnlockedFeaturesAt(_content.Subclasses, "pack_forged", 4); Assert.Empty(features); } [Fact] public void UnlockedFeaturesAt_NullSubclassId_ReturnsEmpty() { Assert.Empty(SubclassResolver.UnlockedFeaturesAt(_content.Subclasses, null, 3)); Assert.Empty(SubclassResolver.UnlockedFeaturesAt(_content.Subclasses, "", 3)); } [Fact] public void ResolveFeatureDef_FindsSubclassFeature() { var subclass = _content.Subclasses["pack_forged"]; var classDef = _content.Classes["fangsworn"]; var fdef = SubclassResolver.ResolveFeatureDef(classDef, subclass, "packmates_howl"); Assert.NotNull(fdef); Assert.Equal("Packmate's Howl", fdef!.Name); } [Fact] public void ResolveFeatureDef_FallsThroughToClassDefForSharedIds() { var subclass = _content.Subclasses["pack_forged"]; var classDef = _content.Classes["fangsworn"]; // 'asi' is in the class feature_definitions (shared across subclasses). var fdef = SubclassResolver.ResolveFeatureDef(classDef, subclass, "asi"); Assert.NotNull(fdef); } [Fact] public void ResolveFeatureDef_NullForUnknownId() { var subclass = _content.Subclasses["pack_forged"]; var classDef = _content.Classes["fangsworn"]; Assert.Null(SubclassResolver.ResolveFeatureDef(classDef, subclass, "completely_made_up_feature")); } [Fact] public void EveryClass_HasAtLeastOneSubclass() { // Smoke: every class declared in classes.json should resolve to at // least one entry in subclasses.json. foreach (var cls in _content.Classes.Values) { Assert.NotEmpty(cls.SubclassIds); foreach (var sid in cls.SubclassIds) { Assert.True(_content.Subclasses.ContainsKey(sid), $"class '{cls.Id}' references unknown subclass '{sid}'"); } } } [Fact] public void EverySubclass_HasLevel3Features() { // M2 ship-point: every authored subclass should have at least one // level-3 feature so the L3 unlock fires meaningfully. foreach (var sub in _content.Subclasses.Values) { var l3 = SubclassResolver.UnlockedFeaturesAt(_content.Subclasses, sub.Id, 3); Assert.NotEmpty(l3); } } }