feat(vtank): slice 1c step 3 — content sanity refuses misplaced af files

A route (.af) file placed in metas/ used to silently "succeed" as an empty
MetaProfile: TryLoadMeta's STATE:/NAV: loop never adds a Rule for a NAV:-only
file, so the mistake was invisible. TryLoadMeta now tracks whether it saw
any STATE: block; a file with at least one NAV: block and zero STATE:
blocks throws (same FormatException path every other malformed-content
error already uses), naming the navs/ folder the file actually belongs in.
The opposite direction was already structurally caught by TryLoadNav's
existing "no NAV: block found" throw when a file has zero NAV: blocks (a
Meta profile with no embedded route, placed in navs/) — only the message
text is improved to name the metas/ folder. Both notices flow unchanged
through the existing MossTankProfileRecovery.Preserve/RecoveryNotice path
in MossTankMetaProfileStore.LoadCurrent/MossTankRouteProfileStore.LoadCurrent,
so no store-side code changes were needed for the wiring itself.

Mutation demonstrated: `git stash push -- src/AcDream.Plugins.MossTank/MetafSerializer.cs`
(reverting only the production fix, keeping every new test) reproduced 8
failures — the 5-fixture EveryNavOnlyAfFixtureIsRefusedByTryLoadMeta theory
(every real nav_*.af fixture parsed as a "successful" empty MetaProfile),
MetaOnlyContentIsRefusedByTryLoadNavWithMetasFolderNotice (message lacked
"metas/"), and the two store-level tests
MetaStoreRefusesToLoadANavOnlyFileWithNoticeNamingNavsFolder/
RouteStoreRefusesToLoadAMetaOnlyFileWithNoticeNamingMetasFolder — confirmed
by running the suite with the stash applied, then `git stash pop` to
restore the fix. All 640 tests (632 + 8 new) pass after.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 06:43:34 +02:00
parent 303c8a8687
commit 907afd4d5b
4 changed files with 120 additions and 1 deletions

View file

@ -270,6 +270,8 @@ internal static class MetafSerializer
var embeddedNavs = new Dictionary<string, (string NavType, List<RouteWaypoint> Nodes,
uint FollowTargetId, string FollowTargetName)>(StringComparer.Ordinal);
var parsed = new MetaProfile();
bool sawState = false;
bool sawNav = false;
cursor.SkipBlank();
while (cursor.L < cursor.Lines.Length)
{
@ -278,13 +280,30 @@ internal static class MetafSerializer
throw cursor.Error("expected 'STATE:' or 'NAV:'.");
if (lead.Groups["type"].Value == "NAV:")
{
sawNav = true;
ReadNavBlock(cursor, embeddedNavs, spells);
continue;
}
if (lead.Groups["type"].Value != "STATE:")
throw cursor.Error("expected 'STATE:' or 'NAV:'.");
sawState = true;
ReadState(cursor, parsed);
}
// Content sanity (Campaign VT slice 1c step 3): a file made ONLY
// of NAV: block(s) with zero STATE: blocks is a stand-alone route
// .af that ended up in the metas/ folder, not a Meta profile —
// without this check the loop above would happily "succeed" with
// an empty MetaProfile (a NAV:-only pass never adds a Rule),
// silently misloading it as a blank Meta profile instead of
// surfacing the mistake. A file mixing NAV: (embedded routes)
// with at least one real STATE: block is unaffected.
if (sawNav && !sawState)
{
throw cursor.Error(
"contains only NAV: block(s) and no STATE: block — this looks like "
+ "a stand-alone route profile that belongs in the navs/ folder, "
+ "not a Meta profile.");
}
// Second pass: EmbedNav actions reference navs that may be defined
// anywhere in the file (metaf allows forward references — a NAV:
// block can appear before or after the STATE that embeds it).
@ -758,7 +777,9 @@ internal static class MetafSerializer
SkipState(cursor);
}
if (navs.Count == 0)
throw cursor.Error("no NAV: block found.");
throw cursor.Error(
"no NAV: block found — this looks like a Meta profile that belongs "
+ "in the metas/ folder, not a route.");
var (navType, nodes, followId, followName) = navs.First().Value;
ApplyNavBody(target, navType, nodes, followId, followName);
error = string.Empty;

View file

@ -66,6 +66,42 @@ public sealed class MetafSerializerTests
$"{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]

View file

@ -417,6 +417,37 @@ public sealed class MossTankPanelTests
&& message.Contains("metas/Shared.af", StringComparison.Ordinal));
}
// ------------------------------------------------------------------
// Slice 1c step 3: content sanity on load.
// ------------------------------------------------------------------
/// <summary>
/// Owner decision 2026-09-07: a real nav-only <c>.af</c> file (a route
/// profile) that ends up at <c>metas/</c> — e.g. dropped there by hand,
/// or a bug elsewhere in the pipeline — must NOT silently load as an
/// empty Meta profile. <c>LoadCurrent</c> must refuse it and leave a
/// notice naming the <c>navs/</c> folder it actually belongs in.
/// </summary>
[Fact]
public void MetaStoreRefusesToLoadANavOnlyFileWithNoticeNamingNavsFolder()
{
string navOnlyContent = File.ReadAllText(
Path.Combine(AppContext.BaseDirectory, "Fixtures", "vtank", "af", "nav_ab.af"));
var storage = new MemoryStorage();
storage.Text["metas/Misplaced.af"] = navOnlyContent;
var host = new FakeHost(new FakeAutomation { Name = "Barris" }, storage);
var store = new MossTankMetaProfileStore(host);
store.BindCharacter("Barris");
Assert.True(store.Select("Misplaced"));
MetaProfile loaded = store.LoadCurrent();
Assert.Empty(loaded.Rules);
Assert.NotNull(store.RecoveryNotice);
Assert.Contains("navs/", store.RecoveryNotice, StringComparison.Ordinal);
}
/// <summary>
/// Reproduces MossTankMetaProfileStore's pre-cutover named-profile JSON
/// hash key (its own <c>LegacyNamedKey</c>/<c>Hash(string)</c> are

View file

@ -750,6 +750,37 @@ public sealed class NavigationTests
Assert.False(storage.Text.ContainsKey("navs/SharedMeta.af"));
}
// ------------------------------------------------------------------
// Slice 1c step 3: content sanity on load.
// ------------------------------------------------------------------
/// <summary>
/// Owner decision 2026-09-07: a real STATE:-only <c>.af</c> file (a
/// Meta profile with no embedded route) that ends up at <c>navs/</c>
/// must NOT silently misload. <c>LoadCurrent</c> must refuse it and
/// leave a notice naming the <c>metas/</c> folder it actually belongs
/// in.
/// </summary>
[Fact]
public void RouteStoreRefusesToLoadAMetaOnlyFileWithNoticeNamingMetasFolder()
{
string metaOnlyContent = File.ReadAllText(
Path.Combine(AppContext.BaseDirectory, "Fixtures", "vtank", "af", "bella.af"));
var storage = new MemoryStorage();
storage.Text["navs/Misplaced.af"] = metaOnlyContent;
var store = new MossTankRouteProfileStore(new FakeHost(new FakeAutomation(), storage));
Assert.True(store.BindCharacter("Barris"));
Assert.True(store.Select("Misplaced"));
var target = new NavigationSettings();
bool loaded = store.LoadCurrent(target, MetafSerializer.NoOpSpells.Instance);
Assert.False(loaded);
Assert.NotNull(store.RecoveryNotice);
Assert.Contains("metas/", store.RecoveryNotice, StringComparison.Ordinal);
}
[Fact]
public void FollowModeRouteRoundTripsTheFollowTargetThroughAf()
{