using AcDream.Plugin.Abstractions; namespace AcDream.Plugins.MossTank.Tests; public sealed class MetafSerializerTests { private static readonly string FixturesRoot = Path.Combine( AppContext.BaseDirectory, "Fixtures", "vtank"); private static string[] AllAfFixtures() => Directory.GetFiles( Path.Combine(FixturesRoot, "af"), "*.af"); private static string[] NavOnlyAfFixtures() => Directory.GetFiles( Path.Combine(FixturesRoot, "af"), "nav_*.af"); private static string[] MetaAfFixtures() => Directory.GetFiles( Path.Combine(FixturesRoot, "af"), "*.af") .Where(static path => !Path.GetFileName(path).StartsWith( "nav_", StringComparison.Ordinal)) .ToArray(); public static TheoryData AllAfFixtureData() { var data = new TheoryData(); foreach (string path in AllAfFixtures()) data.Add(path); return data; } public static TheoryData MetaAfFixtureData() { var data = new TheoryData(); foreach (string path in MetaAfFixtures()) data.Add(path); return data; } public static TheoryData NavOnlyAfFixtureData() { var data = new TheoryData(); foreach (string path in NavOnlyAfFixtures()) data.Add(path); return data; } // Proof (1): every .af under the fixture set parses without error. [Theory] [MemberData(nameof(MetaAfFixtureData))] public void EveryMetaAfFixtureParses(string path) { string text = File.ReadAllText(path); Assert.True( MetafSerializer.TryLoadMeta(text, NoOpSpellCatalog.Instance, out MetaProfile profile, out string error), $"{Path.GetFileName(path)}: {error}"); Assert.NotEmpty(profile.Rules); } [Theory] [MemberData(nameof(NavOnlyAfFixtureData))] public void EveryNavOnlyAfFixtureParses(string path) { string text = File.ReadAllText(path); var target = new NavigationSettings(); Assert.True( MetafSerializer.TryLoadNav(text, target, NoOpSpellCatalog.Instance, out string error), $"{Path.GetFileName(path)}: {error}"); } // Campaign VT slice 1c step 3 (content sanity): a real nav-only .af // fixture — zero STATE: blocks, one or more NAV: blocks — must be // REFUSED by TryLoadMeta rather than silently "succeeding" with an // empty MetaProfile (a NAV:-only pass through the STATE:/NAV: loop // never adds a Rule). Proves the metas/ folder's content-sanity check // against every real nav-only fixture, not just a hand-built one. [Theory] [MemberData(nameof(NavOnlyAfFixtureData))] public void EveryNavOnlyAfFixtureIsRefusedByTryLoadMeta(string path) { string text = File.ReadAllText(path); bool loaded = MetafSerializer.TryLoadMeta( text, NoOpSpellCatalog.Instance, out MetaProfile profile, out string error); Assert.False(loaded, $"{Path.GetFileName(path)} should not parse as a Meta profile."); Assert.Empty(profile.Rules); Assert.Contains("navs/", error, StringComparison.Ordinal); } // The other direction: a STATE:-only .af with no embedded NAV: block at // all (bella.af — confirmed zero "NAV:" lines) must be REFUSED by // TryLoadNav (already the case: "no NAV: block found") with a notice // naming the metas/ folder it belongs in. [Fact] public void MetaOnlyContentIsRefusedByTryLoadNavWithMetasFolderNotice() { string text = File.ReadAllText(Path.Combine(FixturesRoot, "af", "bella.af")); var target = new NavigationSettings(); bool loaded = MetafSerializer.TryLoadNav( text, target, NoOpSpellCatalog.Instance, out string error); Assert.False(loaded); Assert.Contains("metas/", error, StringComparison.Ordinal); } // Proof (2): parse -> write -> parse is identical (model equality; the // writer's own byte-for-byte shape is proof (4), below). [Theory] [MemberData(nameof(MetaAfFixtureData))] public void MetaParseWriteParseIsIdentical(string path) { string text = File.ReadAllText(path); Assert.True(MetafSerializer.TryLoadMeta( text, NoOpSpellCatalog.Instance, out MetaProfile first, out string error1), error1); string rewritten = MetafSerializer.SaveMeta(first); Assert.True(MetafSerializer.TryLoadMeta( rewritten, NoOpSpellCatalog.Instance, out MetaProfile second, out string error2), error2); AssertProfilesEqual(first, second); } [Theory] [MemberData(nameof(NavOnlyAfFixtureData))] public void NavParseWriteParseIsIdentical(string path) { string text = File.ReadAllText(path); var first = new NavigationSettings(); Assert.True(MetafSerializer.TryLoadNav( text, first, NoOpSpellCatalog.Instance, out string error1), error1); string rewritten = MetafSerializer.SaveNav(first); var second = new NavigationSettings(); Assert.True(MetafSerializer.TryLoadNav( rewritten, second, NoOpSpellCatalog.Instance, out string error2), error2); AssertNavigationEqual(first, second); } // Proof (3): for the met/ and nav/ samples that have a matching af/ // conversion, our model loaded from the binary import path equals our // model loaded from metaf's own .af conversion. public static TheoryData MetMatchingAfPairs() { var data = new TheoryData(); string metDir = Path.Combine(FixturesRoot, "met"); string afDir = Path.Combine(FixturesRoot, "af"); foreach (string metPath in Directory.GetFiles(metDir, "*.met")) { string name = Path.GetFileNameWithoutExtension(metPath); string afPath = Path.Combine(afDir, name + ".af"); if (File.Exists(afPath)) data.Add(metPath, afPath); } return data; } [Theory] [MemberData(nameof(MetMatchingAfPairs))] public void BinaryImportMatchesMetafAfConversion(string metPath, string afPath) { string metText = File.ReadAllText(metPath); Assert.True( VtankMetaProfileSerializer.TryLoad(metText, out MetaProfile fromMet, out string metError), $"{Path.GetFileName(metPath)}: {metError}"); string afText = File.ReadAllText(afPath); Assert.True( MetafSerializer.TryLoadMeta(afText, NoOpSpellCatalog.Instance, out MetaProfile fromAf, out string afError), $"{Path.GetFileName(afPath)}: {afError}"); AssertProfilesEqual(fromMet, fromAf); } public static TheoryData NavMatchingAfPairs() { var data = new TheoryData(); string navDir = Path.Combine(FixturesRoot, "nav"); string afDir = Path.Combine(FixturesRoot, "af"); foreach (string navPath in Directory.GetFiles(navDir, "*.nav")) { string name = Path.GetFileNameWithoutExtension(navPath); string afPath = Path.Combine(afDir, name + ".af"); if (File.Exists(afPath)) data.Add(navPath, afPath); } return data; } [Theory] [MemberData(nameof(NavMatchingAfPairs))] public void BinaryNavImportMatchesMetafAfConversion(string navPath, string afPath) { string navText = File.ReadAllText(navPath); var fromNav = new NavigationSettings(); Assert.True( VtankNavRouteSerializer.TryLoad(navText, fromNav, NoOpSpellCatalog.Instance, out string navError), $"{Path.GetFileName(navPath)}: {navError}"); string afText = File.ReadAllText(afPath); var fromAf = new NavigationSettings(); Assert.True( MetafSerializer.TryLoadNav(afText, fromAf, NoOpSpellCatalog.Instance, out string afError), $"{Path.GetFileName(afPath)}: {afError}"); AssertNavigationEqual(fromNav, fromAf); } // Proof (4): the writer's output is REAL byte-identical to metaf's own // canonical emission (item E, slice-1 fix round: this used to strip // "~~" comments before comparing, which hid two facts — metaf's own // ExportToMetAF DOES mechanically emit the auto-completion banner // (OutputText.metaHeader, metaf_monolithic.py:365-406) plus a "~~ {"/ // "~~ }" editor-fold pair around every STATE:/NAV: block // (State.ExportToMetAF py:12463-12467, Nav.ExportToMetAF py:12050-12054), // which this writer now reproduces; and several committed fixtures do // NOT carry metaf's fresh canonical header at all, for reasons that // have nothing to do with this writer's correctness — see below). public static TheoryData ByteIdenticalFixtureData() { var data = new TheoryData(); // Excluded, all for reasons independent of this writer (confirmed // by inspecting each file's own first bytes, not asserted): // // bore_enhanced.af / bore_quest.af: hand-edited after generation — // some IF:/DO: lines use a space instead of metaf's own tab // separator (bore_quest.af line 9: "\tIF: Death", not // "\tIF:\tDeath"; metaf's Rule.ExportToMetAF always joins with a // tab, py:12371/12373). // // aphus.af, neftet.af, hunting.af, follower.af: the header's // internal line breaks are metaf's own bare "\n" for a freshly // generated file (confirmed against augments.af/bella.af/ // gauntlet_leader.af/empyrean_facility.af/example_sort_meta.af, // whose first 8 bytes are "~~ {\n~~ "), but these four instead open // with "~~ {\r\n~~ " (aphus/neftet/follower) or, for hunting.af, a // wholly custom banner ("~~ Hunting Meta - Solo lifestone // grinding..." instead of "~~ FOR AUTO-COMPLETION ASSISTANCE..."). // Both are evidence the file was re-saved by something other than // a fresh metaf conversion (a text editor normalizing every line // ending to CRLF loses metaf's internal bare-LF header formatting; // a custom banner is an intentional hand edit) — not something // this writer could or should reproduce. // // lockandkey.af: opens with a fully custom multi-line description // ("~~\n~~\tlockandkey.af - Portal Bot with Stipend..."), never // metaf's own banner at all. // // All five still exercise every other proof (parse, // parse-write-parse, and — for aphus/augments/lockandkey/neftet — // the ptl/tlk two-triple fidelity direct assertions in // PtlNodeKeepsBothCoordinateTriplesDistinct) normally. foreach (string name in new[] { "bella", "gauntlet_leader", "empyrean_facility", "augments", "example_sort_meta", }) { data.Add(Path.Combine(FixturesRoot, "af", name + ".af")); } return data; } [Theory] [MemberData(nameof(ByteIdenticalFixtureData))] public void WriterOutputMatchesMetafCanonicalEmission(string path) { string original = File.ReadAllText(path); Assert.True( MetafSerializer.TryLoadMeta(original, NoOpSpellCatalog.Instance, out MetaProfile profile, out string error), error); string rewritten = MetafSerializer.SaveMeta(profile); Assert.Equal(original, rewritten); } // Proof (4), nav-only case: SaveNav's own header (navHeader) and // single-Nav fold-marker wrap are byte-identical to a real nav-only // .af fixture. Every listed fixture carries metaf's own fresh banner // (first bytes "~~ {\n~~ "); round 3 item 8 extended this from a // single fixture (nav_ab.af) to the three checkpoint/recall/portal2 // fixtures copied from the metas repo (nav_briennecarlus.af carries // chk+jmp, nav_empyrean.af carries rcl+ptl, nav_lockandkeyjaw.af // carries rcl+chk) so the theory actually exercises every node kind // metaf's own grammar supports, not just plain point waypoints. public static TheoryData NavByteIdenticalFixtureData() { var data = new TheoryData(); foreach (string name in new[] { "nav_ab", "nav_briennecarlus", "nav_empyrean", "nav_lockandkeyjaw", }) { data.Add(Path.Combine(FixturesRoot, "af", name + ".af")); } return data; } [Theory] [MemberData(nameof(NavByteIdenticalFixtureData))] public void WriterOutputMatchesMetafCanonicalEmissionNavOnly(string path) { string original = File.ReadAllText(path); var settings = new NavigationSettings(); Assert.True( MetafSerializer.TryLoadNav(original, settings, NoOpSpellCatalog.Instance, out string error), error); string rewritten = MetafSerializer.SaveNav(settings); Assert.Equal(original, rewritten); } [Fact] public void ExampleSortMetaBinaryImportMatchesAf() { string metText = File.ReadAllText(Path.Combine(FixturesRoot, "met", "example_sort_meta.met")); Assert.True(VtankMetaProfileSerializer.TryLoad(metText, out MetaProfile fromMet, out string metError), metError); string afText = File.ReadAllText(Path.Combine(FixturesRoot, "af", "example_sort_meta.af")); Assert.True( MetafSerializer.TryLoadMeta(afText, NoOpSpellCatalog.Instance, out MetaProfile fromAf, out string afError), afError); AssertProfilesEqual(fromMet, fromAf); } // aphus.af's embedded "nav0__stipend_nav" route carries a real "ptl" // node whose two coordinate triples genuinely differ (line 1026: // "-101.597905190786 -96.6216093699137 2.08333134651184E-05" myxyz, // "59.3936458587647 -28.7256083488464 0.0508250035345554" tgtxyz). A // prior port collapsed both onto RouteWaypoint.Position, which this // test would have caught immediately: before the fix, Position held // 59.39/-28.72/0.0508 (the second triple) and there was nowhere to // read -101.59/-96.62/2.08e-05 back from at all. [Fact] public void PtlNodeKeepsBothCoordinateTriplesDistinct() { string original = File.ReadAllText(Path.Combine(FixturesRoot, "af", "aphus.af")); Assert.True( MetafSerializer.TryLoadMeta(original, NoOpSpellCatalog.Instance, out MetaProfile profile, out string error), error); RouteWaypoint waypoint = FindNavWaypoint(profile, "Portal to Town Network"); Assert.Equal(-101.597905190786d, waypoint.Position.EastWest, 6); Assert.Equal(-96.6216093699137d, waypoint.Position.NorthSouth, 6); Assert.Equal(2.08333134651184E-05d, waypoint.Position.Elevation, 10); Assert.Equal(59.3936458587647d, waypoint.ReferencePosition.EastWest, 6); Assert.Equal(-28.7256083488464d, waypoint.ReferencePosition.NorthSouth, 6); Assert.Equal(0.0508250035345554d, waypoint.ReferencePosition.Elevation, 6); string rewritten = MetafSerializer.SaveMeta(profile); Assert.Contains( "ptl -101.597905190786 -96.6216093699137 2.08333134651184E-05 " + "59.3936458587647 -28.7256083488464 0.0508250035345554 14 " + "{Portal to Town Network}", rewritten, StringComparison.Ordinal); } /// /// Walks every /// action's typed looking for a /// waypoint by object name. /// private static RouteWaypoint FindNavWaypoint(MetaProfile profile, string objectName) { foreach (MetaRule rule in profile.Rules) { RouteWaypoint? found = FindNavWaypoint(rule.Action, objectName); if (found is not null) return found; } throw new InvalidOperationException($"no nav waypoint named '{objectName}' found."); } private static RouteWaypoint? FindNavWaypoint(MetaAction action, string objectName) { if (action.Kind == MetaActionKind.LoadEmbeddedNavigationRoute && action.EmbeddedRoute is { } nav) { foreach (RouteWaypoint waypoint in nav.Waypoints) { if (waypoint.ObjectName == objectName) return waypoint; } } foreach (MetaAction child in action.Children) { RouteWaypoint? found = FindNavWaypoint(child, objectName); if (found is not null) return found; } return null; } // Item H (slice-1 fix round): real VTank/metaf has no marker for a // disabled rule at all, so SaveMeta refuses by default rather than // silently dropping it — see docs/research/vtank-kb/ // 07-meta-and-expressions.md section 5, gap 6. [Fact] public void SaveMetaRefusesToDropADisabledRuleByDefault() { var profile = new MetaProfile { Rules = [ new MetaRule { Enabled = false, Condition = MetaCondition.Always(), Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say must not silently vanish", }, }, ], }; InvalidOperationException thrown = Assert.Throws( () => MetafSerializer.SaveMeta(profile)); Assert.Contains("1 disabled rule", thrown.Message, StringComparison.Ordinal); } [Fact] public void SaveMetaDropsDisabledRulesOnlyWhenExplicitlyToldTo() { var profile = new MetaProfile { Rules = [ new MetaRule { Enabled = false, Condition = MetaCondition.Always(), Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say must not run", }, }, ], }; string source = MetafSerializer.SaveMeta(profile, dropDisabledRules: true); Assert.DoesNotContain("must not run", source, StringComparison.Ordinal); Assert.True(MetafSerializer.TryLoadMeta( source, NoOpSpellCatalog.Instance, out MetaProfile loaded, out string error), error); Assert.Empty(loaded.Rules); } [Fact] public void NestedAllAnyNotParsesCorrectly() { string af = string.Join("\r\n", [ "STATE: {Default}", "\tIF:\tAll", "\t\t\tAlways", "\t\t\tNot Death", "\t\t\tAny", "\t\t\t\tVendorOpen", "\t\t\t\tVendorClosed", "\t\tDO:\tDoAll", "\t\t\t\tChat {hello}", "\t\t\t\tSetState {Next}", ]) + "\r\n"; Assert.True( MetafSerializer.TryLoadMeta(af, NoOpSpellCatalog.Instance, out MetaProfile profile, out string error), error); MetaRule rule = Assert.Single(profile.Rules); Assert.Equal(MetaConditionKind.All, rule.Condition.Kind); Assert.Equal(3, rule.Condition.Children.Count); Assert.Equal(MetaConditionKind.Always, rule.Condition.Children[0].Kind); Assert.Equal(MetaConditionKind.Not, rule.Condition.Children[1].Kind); Assert.Equal(MetaConditionKind.CharacterDeath, rule.Condition.Children[1].Children[0].Kind); Assert.Equal(MetaConditionKind.Any, rule.Condition.Children[2].Kind); Assert.Equal(2, rule.Condition.Children[2].Children.Count); Assert.Equal(MetaActionKind.All, rule.Action.Kind); Assert.Equal(2, rule.Action.Children.Count); Assert.Equal("hello", rule.Action.Children[0].Text); Assert.Equal("Next", rule.Action.Children[1].Text); } // Item J (slice-1 fix round): MobsInDist_Priority is never exercised by // any committed real fixture (only its name appears, in the // auto-completion header banner text) — this synthesized round trip // pins the argument order (count, distance, priority — see the // regex-table comment in MetafSerializer.cs) with three DISTINCT // numeric values so an accidental swap of any two fields fails loudly // instead of silently agreeing with itself. [Fact] public void MobsInDistPriorityRoundTripsAllThreeNumbersDistinctly() { string af = string.Join("\r\n", [ "STATE: {Default}", "\tIF:\tMobsInDist_Priority 7 12.5 3", "\t\tDO:\tChat {seen}", ]) + "\r\n"; Assert.True( MetafSerializer.TryLoadMeta(af, NoOpSpellCatalog.Instance, out MetaProfile profile, out string error), error); MetaRule rule = Assert.Single(profile.Rules); Assert.Equal(MetaConditionKind.MonsterPriorityCountWithinDistance, rule.Condition.Kind); Assert.Equal(7, rule.Condition.Number); Assert.Equal(12.5, rule.Condition.SecondaryNumber); Assert.Equal(3, rule.Condition.TertiaryNumber); string rewritten = MetafSerializer.SaveMeta(profile); Assert.True( MetafSerializer.TryLoadMeta(rewritten, NoOpSpellCatalog.Instance, out MetaProfile reloaded, out string error2), error2); MetaRule reloadedRule = Assert.Single(reloaded.Rules); Assert.Equal(7, reloadedRule.Condition.Number); Assert.Equal(12.5, reloadedRule.Condition.SecondaryNumber); Assert.Equal(3, reloadedRule.Condition.TertiaryNumber); } [Fact] public void SynthesizedGetOptFollowAndJumpFixtureRoundTrips() { // GetOpt, the "flw" nav node, and "jmp" never appear in any real // fixture in metas/af (grep confirmed at slice-1 authoring time), so // this small hand-authored fixture exercises them — validated as // real metaf grammar via `py metaf_monolithic.py` during authoring // (not re-run automatically here, since python is not guaranteed on // the Ubuntu portable CI lane). string af = string.Join("\r\n", [ "STATE: {Default}", "\tIF:\tAlways", "\t\tDO:\tDoAll", "\t\t\t\tGetOpt {AttackDistance} {myvar}", "\t\t\t\tSetOpt {AttackDistance} {myvar}", "NAV: myfollow follow", "\tflw 00001234 {Some Monster}", ]) + "\r\n"; Assert.True( MetafSerializer.TryLoadMeta(af, NoOpSpellCatalog.Instance, out MetaProfile profile, out string error), error); MetaRule rule = Assert.Single(profile.Rules); MetaAction getOpt = rule.Action.Children[0]; MetaAction setOpt = rule.Action.Children[1]; Assert.Equal(MetaActionKind.GetVtankOption, getOpt.Kind); Assert.Equal("AttackDistance", getOpt.Text); Assert.Equal("myvar", getOpt.SecondaryText); Assert.Equal(MetaActionKind.SetVtankOption, setOpt.Kind); var nav = new NavigationSettings(); Assert.True( MetafSerializer.TryLoadNav(af, nav, NoOpSpellCatalog.Instance, out string navError), navError); Assert.Equal(RouteMode.Target, nav.Mode); Assert.Equal(0x00001234u, nav.FollowTargetObjectId); Assert.Equal("Some Monster", nav.FollowTargetName); } /// /// Round 3 item 5: the retail 2000 ms jump-charge ceiling /// (refs/vtank/decompiled/bi.cs:502-505) is EXECUTION-time behavior /// (NavigationController.TickJump), not a storage-format limit — metaf /// and VTank both round-trip the authored value verbatim /// (metaf_monolithic.py:11708-11820, NJump — no clamp at all). An /// authored 5000 ms waypoint must therefore survive an .af load /// unchanged; renamed from JumpNodeClampsChargeMillisecondsTo2000, /// which pinned the pre-fix (wrong) load-time clamp. /// [Fact] public void JumpNodeLoadPreservesAuthoredChargeMillisecondsAboveRetailCeiling() { const string af = """ NAV: j once jmp 1 2 3 90 {True} 5000 """; var target = new NavigationSettings(); Assert.True(MetafSerializer.TryLoadNav(af, target, NoOpSpellCatalog.Instance, out string error), error); RouteWaypoint waypoint = Assert.Single(target.Waypoints); Assert.Equal(RouteWaypointType.Jump, waypoint.Type); Assert.Equal(5000, waypoint.JumpChargeMilliseconds); Assert.True(waypoint.JumpRun); Assert.Equal(90f, waypoint.JumpHeadingDegrees); } [Fact] public void JumpNodeBelowClampPassesThroughUnchanged() { const string af = """ NAV: j once jmp 1 2 3 90 {False} 500 """; var target = new NavigationSettings(); Assert.True(MetafSerializer.TryLoadNav(af, target, NoOpSpellCatalog.Instance, out string error), error); RouteWaypoint waypoint = Assert.Single(target.Waypoints); Assert.Equal(500, waypoint.JumpChargeMilliseconds); Assert.False(waypoint.JumpRun); } /// /// Round 3 item 5 companion: SaveNav must round-trip the same /// above-ceiling value back out unchanged (the save side never had a /// clamp; this pins that save+load together preserve it). /// [Fact] public void JumpNodeSaveThenLoadRoundTripsChargeMillisecondsAboveRetailCeiling() { var source = new NavigationSettings { Mode = RouteMode.Once }; source.Waypoints.Add(new RouteWaypoint { Type = RouteWaypointType.Jump, JumpHeadingDegrees = 90f, JumpRun = true, JumpChargeMilliseconds = 5000, }); string af = MetafSerializer.SaveNav(source); var target = new NavigationSettings(); Assert.True(MetafSerializer.TryLoadNav(af, target, NoOpSpellCatalog.Instance, out string error), error); RouteWaypoint waypoint = Assert.Single(target.Waypoints); Assert.Equal(5000, waypoint.JumpChargeMilliseconds); } private static void AssertProfilesEqual(MetaProfile expected, MetaProfile actual) { Assert.Equal(expected.Rules.Count, actual.Rules.Count); for (int i = 0; i < expected.Rules.Count; i++) { MetaRule a = expected.Rules[i]; MetaRule b = actual.Rules[i]; Assert.Equal(a.State, b.State); AssertConditionsEqual(a.Condition, b.Condition); AssertActionsEqual(a.Action, b.Action); } } private static void AssertConditionsEqual(MetaCondition expected, MetaCondition actual) { Assert.Equal(expected.Kind, actual.Kind); Assert.Equal(expected.Text, actual.Text); Assert.Equal(expected.SecondaryText, actual.SecondaryText); Assert.Equal(expected.Number, actual.Number, 6); Assert.Equal(expected.SecondaryNumber, actual.SecondaryNumber, 6); Assert.Equal(expected.TertiaryNumber, actual.TertiaryNumber, 6); Assert.Equal(expected.Children.Count, actual.Children.Count); for (int i = 0; i < expected.Children.Count; i++) AssertConditionsEqual(expected.Children[i], actual.Children[i]); } private static void AssertActionsEqual(MetaAction expected, MetaAction actual) { Assert.Equal(expected.Kind, actual.Kind); if (expected.Kind == MetaActionKind.LoadEmbeddedNavigationRoute) { // Item B: MetaAction carries a typed EmbeddedRoute, not a // re-parse-me blob in Text (which is now always empty for this // action kind) — compare the routes waypoint-by-waypoint // instead of skipping the comparison entirely. Assert.Equal(expected.Text, actual.Text); Assert.NotNull(expected.EmbeddedRoute); Assert.NotNull(actual.EmbeddedRoute); AssertNavigationEqual(expected.EmbeddedRoute!, actual.EmbeddedRoute!); } else { Assert.Equal(expected.Text, actual.Text); } Assert.Equal(expected.SecondaryText, actual.SecondaryText); Assert.Equal(expected.Number, actual.Number, 6); Assert.Equal(expected.SecondaryNumber, actual.SecondaryNumber, 6); Assert.Equal(expected.Children.Count, actual.Children.Count); for (int i = 0; i < expected.Children.Count; i++) AssertActionsEqual(expected.Children[i], actual.Children[i]); } private static void AssertNavigationEqual(NavigationSettings expected, NavigationSettings actual) { Assert.Equal(expected.Mode, actual.Mode); if (expected.Mode == RouteMode.Target) { Assert.Equal(expected.FollowTargetObjectId, actual.FollowTargetObjectId); Assert.Equal(expected.FollowTargetName, actual.FollowTargetName); return; } Assert.Equal(expected.Waypoints.Count, actual.Waypoints.Count); for (int i = 0; i < expected.Waypoints.Count; i++) { RouteWaypoint a = expected.Waypoints[i]; RouteWaypoint b = actual.Waypoints[i]; Assert.Equal(a.Type, b.Type); Assert.Equal(a.Position.EastWest, b.Position.EastWest, 3); Assert.Equal(a.Position.NorthSouth, b.Position.NorthSouth, 3); Assert.Equal(a.Position.Elevation, b.Position.Elevation, 3); Assert.Equal(a.ReferencePosition.EastWest, b.ReferencePosition.EastWest, 3); Assert.Equal(a.ReferencePosition.NorthSouth, b.ReferencePosition.NorthSouth, 3); Assert.Equal(a.ReferencePosition.Elevation, b.ReferencePosition.Elevation, 3); Assert.Equal(a.ObjectId, b.ObjectId); Assert.Equal(a.ObjectName, b.ObjectName); Assert.Equal(a.Text, b.Text); Assert.Equal(a.DurationMilliseconds, b.DurationMilliseconds); // Recall waypoints are the one field-set that is genuinely // asymmetric between the two formats with a no-op spell // catalog: the binary .nav stores a spell ID and derives the // name only via a successful catalog lookup (which NoOpSpellCatalog // never provides), while .af stores the name literally and // derives the ID the same (catalog-dependent) way in reverse. // Comparing RecallSpellName/Id here would fail for reasons that // have nothing to do with the .af port's correctness. Assert.Equal(a.JumpHeadingDegrees, b.JumpHeadingDegrees, 3); Assert.Equal(a.JumpRun, b.JumpRun); Assert.Equal(a.JumpChargeMilliseconds, b.JumpChargeMilliseconds); // JumpDirection is likewise NOT compared here: metaf's .af // format has no field for it at all (docs/research/vtank-kb/ // 06-navigation-and-nav.md section 6, gap 9), so a binary .nav // fixture using StrafeLeft/StrafeRight always comes back // Forward from the matching .af conversion — a real, expected // asymmetry, not a bug in either reader. } } private sealed class NoOpSpellCatalog : ISpellCatalog { public static NoOpSpellCatalog Instance { get; } = new(); public IReadOnlyList KnownSelfBuffs => []; public bool TryGet(uint spellId, out PluginSpellInfo info) { info = default; return false; } } }