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:
parent
303c8a8687
commit
907afd4d5b
4 changed files with 120 additions and 1 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue