fix(vt): round 3 item 2 — sweep the whole legacy profile roster, not just the selected one

The pre-cutover roster documents (profiles/{index,meta/index,route/index}.json)
carried EVERY named profile a character had, but each store's cutover only
ever converted the ONE currently-selected profile (MigrateLegacyIfNeeded) —
every other named profile was silently orphaned: never converted to the new
.usd/.af format, never listed again. Worse, MossTankProfileStore actively
reused "profiles/index.json" for a MineOnly-only shape, so the very next
SetMineOnly() save would have permanently discarded the roster.

Added a one-time SweepLegacyRosterIfNeeded() to all three stores
(settings/meta/route), guarded by a per-instance flag so repeat LoadCurrent
calls are a no-op:
- Settings: entries owned by the currently-bound character convert to their
  real per-(character,server) sub-profile .usd + side-car; entries owned by
  a different character are written back to the roster for that character's
  own future session. MineOnly now lives at a NEW dedicated key
  (profiles/macro/preferences.json) so the old roster key is never again
  flattened/overwritten.
- Meta/route: named profiles were never owner-scoped (one shared, globally-
  hashed key per name), so every roster entry converts unconditionally.

Mutation: reverted all three store .cs files to HEAD (keeping only the new
tests) and ran SettingsRosterSweepConvertsEveryNamedLegacyProfileOnce,
MetaRosterSweepConvertsEveryNamedLegacyProfileOnce, and
RouteRosterSweepConvertsEveryNamedLegacyProfileOnce — all three failed
(missing converted .usd/.af files) — confirming the tests exercise the bug
before the fix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 01:15:09 +02:00
parent b6d642c9b7
commit 145bd62503
5 changed files with 534 additions and 6 deletions

View file

@ -20,10 +20,20 @@ internal sealed class MossTankMetaProfileStore
{
public const string ByCharacter = "By char";
// The pre-cutover roster (round 3 item 2): before the .af cutover this
// store kept a flat Names list here (unlike the settings roster, named
// Meta profiles were never owner-scoped — one shared, globally-hashed
// key per name). The cutover stopped reading this key at all, orphaning
// every named profile except whichever one a character had selected
// (that one alone still converts, via MigrateLegacyIfNeeded). Read-only:
// consulted ONLY by the one-time SweepLegacyRosterIfNeeded.
private const string LegacyRosterKey = "profiles/meta/index.json";
private readonly IPluginHost _host;
private string _character = string.Empty;
private string _selected = ByCharacter;
private string? _pendingLegacyBareName;
private bool _rosterSwept;
public MossTankMetaProfileStore(IPluginHost host)
{
@ -86,6 +96,7 @@ internal sealed class MossTankMetaProfileStore
public MetaProfile LoadCurrent()
{
SweepLegacyRosterIfNeeded();
MigrateLegacyIfNeeded();
string fileName = CurrentFileName();
string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null;
@ -261,6 +272,94 @@ internal sealed class MossTankMetaProfileStore
// Legacy JSON -> .af migration.
// ------------------------------------------------------------------
/// <summary>
/// Round 3 item 2: a ONE-TIME sweep (guarded by <see cref="_rosterSwept"/>)
/// over the pre-cutover roster at <see cref="LegacyRosterKey"/>. Unlike
/// <see cref="MigrateLegacyIfNeeded"/> — which only ever converts the
/// ONE currently-selected profile — this converts every OTHER named
/// Meta profile the old roster still lists. Named Meta profiles were
/// never owner-scoped (one shared, globally-hashed key per name), so —
/// unlike the settings roster — every entry converts regardless of
/// which character is currently bound.
/// </summary>
private void SweepLegacyRosterIfNeeded()
{
if (_rosterSwept)
return;
_rosterSwept = true;
if (!_host.Storage.IsAvailable)
return;
List<string>? names = ReadRosterNames();
if (names is not { Count: > 0 })
return;
var remaining = new List<string>();
int migrated = 0;
foreach (string rawName in names)
{
string name = (rawName ?? string.Empty).Trim();
if (name.Length == 0 || name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
continue; // stale/invalid row; drop it rather than loop on it forever.
string legacyKey = LegacyNamedKey(name);
MetaProfile? legacy = ReadLegacyJson(legacyKey);
if (legacy is null)
continue; // already converted (or never existed); drop the row.
string fileName = name.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
? name
: name + ".af";
if (VtankStorage.IsAvailable && VtankStorage.ReadText(fileName) is null)
{
if (!SaveTo(fileName, legacy, out string notice))
{
// Representational loss (a disabled rule) — same gate as
// MigrateLegacyIfNeeded: keep the row so this can be
// retried once the user resolves it.
_host.Log.Warn($"MossTank could not sweep legacy Meta profile '{name}': {notice}");
remaining.Add(name);
continue;
}
}
_host.Storage.Delete(legacyKey);
migrated++;
}
if (migrated == 0)
return;
if (remaining.Count > 0)
{
_host.Storage.WriteText(
LegacyRosterKey,
JsonSerializer.Serialize(new LegacyRosterDocument { Names = remaining }, JsonOptions));
}
else
{
_host.Storage.Delete(LegacyRosterKey);
}
_host.Log.Warn($"Migrated {migrated} legacy MossTank named Meta profile(s) from the old roster.");
}
private List<string>? ReadRosterNames()
{
string? json = null;
try
{
json = _host.Storage.ReadText(LegacyRosterKey);
return string.IsNullOrWhiteSpace(json)
? null
: JsonSerializer.Deserialize<LegacyRosterDocument>(json, JsonOptions)?.Names;
}
catch (Exception error)
{
RecoveryNotice = MossTankProfileRecovery.Preserve(
_host, "meta", LegacyRosterKey, json, error);
_host.Log.Warn(RecoveryNotice);
return null;
}
}
private void MigrateLegacyIfNeeded()
{
string fileName = CurrentFileName();
@ -395,4 +494,11 @@ internal sealed class MossTankMetaProfileStore
WriteIndented = true,
PropertyNameCaseInsensitive = true,
};
// Read-only: the pre-cutover roster shape at LegacyRosterKey, kept
// solely so SweepLegacyRosterIfNeeded can recover it once.
private sealed class LegacyRosterDocument
{
public List<string> Names { get; set; } = [];
}
}

View file

@ -23,11 +23,18 @@ internal sealed class MossTankProfileStore
// Ancient pre-hash single-profile fallback (the very first shape this
// store ever wrote). Read-only: migration consults it, nothing else does.
private const string OldSingleProfileKey = "profile.json";
// Reuses the pre-cutover index key deliberately: this store never reads
// its old "Profiles"/"SelectedByCharacter" shape back (both concepts are
// now resolved from real files + the .cdf binding), so the collision is
// harmless and existing corrupt-recovery behavior keeps its original key.
private const string PreferencesKey = "profiles/index.json";
// The pre-cutover full index document (round 3 item 2): its OLD shape
// carried the entire named-profile roster (Profiles: [{Name, Owner}])
// plus MineOnly/SelectedByCharacter. The settings cutover (round 2 step
// 1) started reusing this exact key for a MineOnly-only shape, which
// silently discarded any roster a pre-cutover install still had on disk
// the next time SetMineOnly() saved — read-only here now, and consulted
// ONLY for the one-time roster sweep (SweepLegacyRosterIfNeeded);
// MineOnly itself lives at the NEW PreferencesKey below from now on so
// this key is never again written in the flattened shape that caused
// the loss.
private const string LegacyRosterKey = "profiles/index.json";
private const string PreferencesKey = "profiles/macro/preferences.json";
private const string SideCarDirectory = "profiles/macro/sidecar/";
private static readonly JsonSerializerOptions Options = new()
@ -43,11 +50,14 @@ internal sealed class MossTankProfileStore
private string? _pendingLegacyBareName;
private VtankDatabase? _currentDatabase;
private string? _currentDatabaseFileName;
private bool _rosterSwept;
public MossTankProfileStore(IPluginHost host)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_preferences = ReadJson<PreferencesDocument>(PreferencesKey) ?? new PreferencesDocument();
_preferences = ReadJson<PreferencesDocument>(PreferencesKey)
?? ReadJson<PreferencesDocument>(LegacyRosterKey)
?? new PreferencesDocument();
}
public string Selected => _selected;
@ -248,6 +258,7 @@ internal sealed class MossTankProfileStore
VtankSettingsProfileSerializer.AllSettings settings,
ISet<string> noBuffItemNames)
{
SweepLegacyRosterIfNeeded();
MigrateLegacyIfNeeded(settings, noBuffItemNames);
string fileName = CurrentFileName();
string? text = ReadUsdText(fileName);
@ -405,6 +416,96 @@ internal sealed class MossTankProfileStore
// Legacy JSON -> .usd/.json side-car migration.
// ------------------------------------------------------------------
/// <summary>
/// Round 3 item 2: a ONE-TIME sweep (guarded by <see cref="_rosterSwept"/>,
/// so repeat calls from <see cref="LoadCurrent"/> are a no-op) over the
/// pre-cutover roster document at <see cref="LegacyRosterKey"/>. Unlike
/// <see cref="MigrateLegacyIfNeeded"/> — which only ever converts the
/// ONE currently-selected profile — this converts every OTHER named
/// profile the old roster still lists, so a character's whole macro
/// library survives the cutover rather than only whichever profile
/// happened to be selected at the time.
///
/// A roster entry's real legacy document has no server dimension at all
/// (pre-cutover named profiles were one flat, globally-hashed key), but
/// its NEW <c>.usd</c> home is a per-(character,server) sub-profile
/// (<see cref="VtankProfileDirectory.SubProfilePrefix"/>) — the same
/// target <see cref="MigrateLegacyIfNeeded"/> already uses for the
/// selected profile. That requires a server, which is only known for
/// the CURRENTLY bound character, so an entry owned by a different
/// character is left in the roster (rewritten back minus whatever this
/// pass did convert) for that character's own future session to finish.
/// </summary>
private void SweepLegacyRosterIfNeeded()
{
if (_rosterSwept)
return;
_rosterSwept = true;
LegacyRosterDocument? roster = ReadJson<LegacyRosterDocument>(LegacyRosterKey);
if (roster?.Profiles is not { Count: > 0 } profiles)
return;
var remaining = new List<LegacyRosterEntry>();
int migrated = 0;
foreach (LegacyRosterEntry entry in profiles)
{
string name = (entry.Name ?? string.Empty).Trim();
if (name.Length == 0 || name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
continue; // stale/invalid row; drop it rather than loop on it forever.
bool ownedByCurrentCharacter = string.IsNullOrWhiteSpace(entry.Owner)
|| entry.Owner.Trim().Equals(_characterName, StringComparison.OrdinalIgnoreCase);
if (!ownedByCurrentCharacter)
{
remaining.Add(entry);
continue;
}
string legacyKey = LegacyProfileKey(name, byCharacter: false);
LegacyProfileDocument? legacy = ReadJson<LegacyProfileDocument>(legacyKey);
if (legacy is null)
continue; // already converted (or never existed); drop the row.
string fileName = VtankProfileDirectory.SubProfilePrefix(_characterName, Server)
+ name + ".usd";
if (ReadUsdText(fileName) is null)
{
var settings = new VtankSettingsProfileSerializer.AllSettings
{
Combat = new CombatSettings(),
Buffs = new BuffSettings(),
Vitals = new VitalSettings(),
Inventory = new InventorySettings(),
Navigation = new NavigationSettings(),
};
var noBuffItemNames = new HashSet<string>(StringComparer.Ordinal);
legacy.Apply(
settings.Combat, settings.Buffs, settings.Vitals, settings.Inventory, noBuffItemNames);
VtankDatabase database = VtankSettingsProfileSerializer.CreateNew(settings);
WriteUsdText(fileName, database.Render());
WriteJson(SideCarKey(fileName), SideCarDocument.Capture(settings, noBuffItemNames));
}
if (_host.Storage.IsAvailable)
_host.Storage.Delete(legacyKey);
migrated++;
}
if (migrated == 0)
return;
if (remaining.Count > 0)
WriteJson(LegacyRosterKey, new LegacyRosterDocument { Profiles = remaining });
else if (_host.Storage.IsAvailable)
_host.Storage.Delete(LegacyRosterKey);
// MineOnly's own migration to the new PreferencesKey is otherwise
// lazy (only SetMineOnly() writes it) — make it durable the moment
// this sweep runs so a user who never toggles the checkbox still
// stops depending on the old, now-legacy-only key.
WriteJson(PreferencesKey, _preferences);
_host.Log.Warn(
$"Migrated {migrated} legacy MossTank named settings profile(s) from the old roster.");
}
/// <summary>
/// Runs once per store (per selection) when the resolved <c>.usd</c>
/// file does not exist yet but the equivalent old JSON profile document
@ -594,6 +695,24 @@ internal sealed class MossTankProfileStore
public bool MineOnly { get; set; } = true;
}
// ------------------------------------------------------------------
// Read-only: the pre-cutover full roster shape at LegacyRosterKey, kept
// solely so SweepLegacyRosterIfNeeded can recover it once. Nothing here
// writes the OLD "Version"/"SelectedByCharacter" fields back — only a
// residual Profiles list (entries this session couldn't yet convert).
// ------------------------------------------------------------------
private sealed class LegacyRosterDocument
{
public List<LegacyRosterEntry> Profiles { get; set; } = [];
}
private sealed class LegacyRosterEntry
{
public string Name { get; set; } = string.Empty;
public string Owner { get; set; } = string.Empty;
}
// ------------------------------------------------------------------
// MossTank-only residual state: everything with no VTank setting name
// (item/consumable name lists, the monster-rule table, and a handful of

View file

@ -32,11 +32,20 @@ internal sealed class MossTankRouteProfileStore
{
public const string ByCharacter = "By char";
private const string NavPrefix = "nav_";
// The pre-cutover roster (round 3 item 2): before the .af cutover this
// store kept a flat Names list here (named routes were never
// owner-scoped — one shared, globally-hashed key per name). The
// cutover stopped reading this key at all, orphaning every named route
// except whichever one a character had selected (that one alone still
// converts, via MigrateLegacyIfNeeded). Read-only: consulted ONLY by
// the one-time SweepLegacyRosterIfNeeded.
private const string LegacyRosterKey = "profiles/route/index.json";
private readonly IPluginHost _host;
private string _characterName = string.Empty;
private string _selected = ByCharacter;
private string? _pendingLegacyBareName;
private bool _rosterSwept;
public MossTankRouteProfileStore(IPluginHost host)
{
@ -161,6 +170,7 @@ internal sealed class MossTankRouteProfileStore
{
ArgumentNullException.ThrowIfNull(target);
ArgumentNullException.ThrowIfNull(spells);
SweepLegacyRosterIfNeeded();
MigrateLegacyIfNeeded(target, spells);
string fileName = CurrentFileName();
string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null;
@ -236,6 +246,86 @@ internal sealed class MossTankRouteProfileStore
// Legacy JSON -> .af migration.
// ------------------------------------------------------------------
/// <summary>
/// Round 3 item 2: a ONE-TIME sweep (guarded by <see cref="_rosterSwept"/>)
/// over the pre-cutover roster at <see cref="LegacyRosterKey"/>. Unlike
/// <see cref="MigrateLegacyIfNeeded"/> — which only ever converts the
/// ONE currently-selected route — this converts every OTHER named route
/// the old roster still lists, using a fresh <see cref="NavigationSettings"/>
/// per entry so the live/selected route is never touched. Named routes
/// were never owner-scoped (one shared, globally-hashed key per name),
/// so every entry converts regardless of which character is currently
/// bound.
/// </summary>
private void SweepLegacyRosterIfNeeded()
{
if (_rosterSwept)
return;
_rosterSwept = true;
if (!_host.Storage.IsAvailable)
return;
List<string>? names = ReadRosterNames();
if (names is not { Count: > 0 })
return;
var remaining = new List<string>();
int migrated = 0;
foreach (string rawName in names)
{
string name = (rawName ?? string.Empty).Trim();
if (name.Length == 0 || name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
continue; // stale/invalid row; drop it rather than loop on it forever.
string legacyKey = LegacyProfileKey(name, byCharacter: false);
LegacyRouteDocument? legacy = ReadLegacyJson(legacyKey);
if (legacy is null)
continue; // already converted (or never existed); drop the row.
string fileName = ToFileName(name);
if (VtankStorage.IsAvailable && VtankStorage.ReadText(fileName) is null)
{
var scratch = new NavigationSettings();
legacy.ApplyRouteOnly(scratch);
WriteAf(fileName, MetafSerializer.SaveNav(scratch));
}
_host.Storage.Delete(legacyKey);
migrated++;
}
if (migrated == 0)
return;
if (remaining.Count > 0)
{
_host.Storage.WriteText(
LegacyRosterKey,
JsonSerializer.Serialize(new LegacyRosterDocument { Names = remaining }, JsonOptions));
}
else
{
_host.Storage.Delete(LegacyRosterKey);
}
_host.Log.Warn($"Migrated {migrated} legacy MossTank named route profile(s) from the old roster.");
}
private List<string>? ReadRosterNames()
{
string? json = null;
try
{
json = _host.Storage.ReadText(LegacyRosterKey);
return string.IsNullOrWhiteSpace(json)
? null
: JsonSerializer.Deserialize<LegacyRosterDocument>(json, JsonOptions)?.Names;
}
catch (Exception error)
{
RecoveryNotice = MossTankProfileRecovery.Preserve(_host, "route", LegacyRosterKey, json, error);
_host.Log.Warn(RecoveryNotice);
return null;
}
}
private void MigrateLegacyIfNeeded(NavigationSettings target, ISpellCatalog spells)
{
string fileName = CurrentFileName();
@ -336,6 +426,13 @@ internal sealed class MossTankRouteProfileStore
PropertyNameCaseInsensitive = true,
};
// Read-only: the pre-cutover roster shape at LegacyRosterKey, kept
// solely so SweepLegacyRosterIfNeeded can recover it once.
private sealed class LegacyRosterDocument
{
public List<string> Names { get; set; } = [];
}
// ------------------------------------------------------------------
// Migration-only: the OLD full route document shape, kept solely so a
// not-yet-migrated JSON profile can still be read once and converted.

View file

@ -136,6 +136,90 @@ public sealed class MossTankPanelTests
Assert.True(panel.LootEnabled);
}
/// <summary>
/// Round 3 item 2: the pre-cutover "profiles/index.json" roster carried
/// EVERY named profile a character had (<c>Profiles: [{Name, Owner}]</c>),
/// not just whichever one happened to be selected. Before the fix, the
/// settings cutover only ever converted the currently-selected profile
/// (via <c>MigrateLegacyIfNeeded</c>) — every OTHER named profile in the
/// roster was silently orphaned: never converted, never listed, and (once
/// <c>SetMineOnly</c> saved) permanently unreachable once the shared key
/// got flattened to a bare <c>{MineOnly}</c> document.
/// </summary>
private static string LegacyNamedProfileKey(string name)
{
string identity = "named:" + name.Trim().ToUpperInvariant();
string hash = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(identity)));
return $"profiles/macro/{hash}.json";
}
[Fact]
public void SettingsRosterSweepConvertsEveryNamedLegacyProfileOnce()
{
var storage = new MemoryStorage();
var automation = new FakeAutomation { Name = "Barris" };
storage.Text["profiles/index.json"] = """
{
"Version": 1,
"MineOnly": false,
"Profiles": [
{ "Name": "Farming", "Owner": "Barris" },
{ "Name": "Buffing", "Owner": "Barris" }
],
"SelectedByCharacter": {}
}
""";
storage.Text[LegacyNamedProfileKey("Farming")] =
"""{ "Combat": { "MaximumRange": 42.0 } }""";
storage.Text[LegacyNamedProfileKey("Buffing")] =
"""{ "Combat": { "MaximumRange": 24.0 } }""";
var panel = new MossTankPanel(new FakeHost(automation, storage));
// MineOnly recovered from the old roster's own shape...
Assert.False(panel.MineOnlyEnabled);
// ...and both named profiles converted to real per-character
// sub-profile .usd files, each keeping its own distinct value.
string farmingUsd = VtankProfileDirectory.SubProfilePrefix("Barris", string.Empty)
+ "Farming.usd";
string buffingUsd = VtankProfileDirectory.SubProfilePrefix("Barris", string.Empty)
+ "Buffing.usd";
Assert.Equal(42d, RangeOf(storage, farmingUsd));
Assert.Equal(24d, RangeOf(storage, buffingUsd));
// Both legacy JSON keys and the whole (now fully-swept) roster key
// are gone; MineOnly now lives at its own dedicated key.
Assert.False(storage.Text.ContainsKey(LegacyNamedProfileKey("Farming")));
Assert.False(storage.Text.ContainsKey(LegacyNamedProfileKey("Buffing")));
Assert.False(storage.Text.ContainsKey("profiles/index.json"));
Assert.True(storage.Text.ContainsKey("profiles/macro/preferences.json"));
// Idempotent: a fresh panel against the same storage sweeps nothing
// more (there is no roster key left to read) and keeps both values.
var reloaded = new MossTankPanel(new FakeHost(
new FakeAutomation { Name = "Barris" }, storage));
Assert.False(reloaded.MineOnlyEnabled);
Assert.Equal(42d, RangeOf(storage, farmingUsd));
Assert.Equal(24d, RangeOf(storage, buffingUsd));
}
private static double RangeOf(MemoryStorage storage, string usdKey)
{
string text = Assert.Contains(usdKey, (IDictionary<string, string>)storage.Text);
var settings = new VtankSettingsProfileSerializer.AllSettings
{
Combat = new CombatSettings(),
Buffs = new BuffSettings(),
Vitals = new VitalSettings(),
Inventory = new InventorySettings(),
Navigation = new NavigationSettings(),
};
VtankSettingsProfileSerializer.Load(text, settings);
return settings.Combat.MaximumRange;
}
/// <summary>
/// Reproduces MossTankMetaProfileStore's pre-cutover by-character JSON
/// hash key (its own <c>Hash(string)</c> — 12-byte truncated SHA256,
@ -213,6 +297,67 @@ public sealed class MossTankPanelTests
Assert.Equal("/say real", Assert.Single(loaded.Rules).Action.Text);
}
/// <summary>
/// Reproduces MossTankMetaProfileStore's pre-cutover named-profile JSON
/// hash key (its own <c>LegacyNamedKey</c>/<c>Hash(string)</c> are
/// private; the format is the migration contract itself, reproduced
/// verbatim here).
/// </summary>
private static string LegacyMetaNamedKey(string name)
{
byte[] hash = System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(name.ToLowerInvariant()));
return $"profiles/meta/named/{Convert.ToHexString(hash.AsSpan(0, 12)).ToLowerInvariant()}.json";
}
/// <summary>
/// Round 3 item 2: like the settings roster, MossTankMetaProfileStore's
/// pre-cutover "profiles/meta/index.json" carried EVERY named Meta
/// profile's name (never owner-scoped — one shared, globally-hashed key
/// per name), and the cutover stopped reading it entirely — orphaning
/// every named profile except whichever one happened to be selected.
/// </summary>
[Fact]
public void MetaRosterSweepConvertsEveryNamedLegacyProfileOnce()
{
var storage = new MemoryStorage();
storage.Text["profiles/meta/index.json"] = """{ "Names": ["Farming", "Buffing"] }""";
storage.Text[LegacyMetaNamedKey("Farming")] = System.Text.Json.JsonSerializer.Serialize(
new MetaProfile
{
Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say farming" } }],
});
storage.Text[LegacyMetaNamedKey("Buffing")] = System.Text.Json.JsonSerializer.Serialize(
new MetaProfile
{
Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say buffing" } }],
});
var store = new MossTankMetaProfileStore(
new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
store.BindCharacter("Barris");
store.LoadCurrent();
Assert.False(storage.Text.ContainsKey(LegacyMetaNamedKey("Farming")));
Assert.False(storage.Text.ContainsKey(LegacyMetaNamedKey("Buffing")));
Assert.False(storage.Text.ContainsKey("profiles/meta/index.json"));
Assert.True(MetafSerializer.TryLoadMeta(
storage.Text["Farming.af"], NoOpSpellCatalogForExport.Instance, out MetaProfile farming, out _));
Assert.Equal("/say farming", Assert.Single(farming.Rules).Action.Text);
Assert.True(MetafSerializer.TryLoadMeta(
storage.Text["Buffing.af"], NoOpSpellCatalogForExport.Instance, out MetaProfile buffing, out _));
Assert.Equal("/say buffing", Assert.Single(buffing.Rules).Action.Text);
// Idempotent: a fresh store against the same storage sweeps nothing
// more (there is no roster key left to read).
var reopened = new MossTankMetaProfileStore(
new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
reopened.BindCharacter("Barris");
reopened.LoadCurrent();
Assert.True(storage.Text.ContainsKey("Farming.af"));
Assert.True(storage.Text.ContainsKey("Buffing.af"));
}
[Fact]
public void MetaStoreRefusesToSaveADisabledRuleAndKeepsThePriorAfContent()
{

View file

@ -534,6 +534,67 @@ public sealed class NavigationTests
Assert.Single(target.Waypoints);
}
/// <summary>
/// Reproduces MossTankRouteProfileStore's pre-cutover named-profile JSON
/// hash key (its own <c>LegacyProfileKey</c> is private; the format is
/// the migration contract itself, reproduced verbatim here).
/// </summary>
private static string LegacyRouteNamedKey(string name)
{
string identity = "named:" + name.Trim().ToUpperInvariant();
string hash = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(identity)));
return $"profiles/route/{hash}.json";
}
/// <summary>
/// Round 3 item 2: like the settings and Meta rosters,
/// MossTankRouteProfileStore's pre-cutover "profiles/route/index.json"
/// carried EVERY named route's name (never owner-scoped — one shared,
/// globally-hashed key per name), and the cutover stopped reading it
/// entirely — orphaning every named route except whichever one happened
/// to be selected.
/// </summary>
[Fact]
public void RouteRosterSweepConvertsEveryNamedLegacyProfileOnce()
{
var storage = new MemoryStorage();
storage.Text["profiles/route/index.json"] = """{ "Names": ["Farming", "Buffing"] }""";
storage.Text[LegacyRouteNamedKey("Farming")] = """
{ "Mode": 1, "Waypoints": [ { "Type": 0, "EastWest": 1.0, "NorthSouth": 2.0 } ] }
""";
storage.Text[LegacyRouteNamedKey("Buffing")] = """
{ "Mode": 1, "Waypoints": [ { "Type": 0, "EastWest": 3.0, "NorthSouth": 4.0 } ] }
""";
var store = new MossTankRouteProfileStore(new FakeHost(new FakeAutomation(), storage));
Assert.True(store.BindCharacter("Barris"));
var target = new NavigationSettings();
store.LoadCurrent(target, MetafSerializer.NoOpSpells.Instance);
Assert.False(storage.Text.ContainsKey(LegacyRouteNamedKey("Farming")));
Assert.False(storage.Text.ContainsKey(LegacyRouteNamedKey("Buffing")));
Assert.False(storage.Text.ContainsKey("profiles/route/index.json"));
var farming = new NavigationSettings();
Assert.True(MetafSerializer.TryLoadNav(
storage.Text["nav_Farming.af"], farming, MetafSerializer.NoOpSpells.Instance, out _));
Assert.Equal(1.0d, Assert.Single(farming.Waypoints).Position.EastWest, precision: 3);
var buffing = new NavigationSettings();
Assert.True(MetafSerializer.TryLoadNav(
storage.Text["nav_Buffing.af"], buffing, MetafSerializer.NoOpSpells.Instance, out _));
Assert.Equal(3.0d, Assert.Single(buffing.Waypoints).Position.EastWest, precision: 3);
// Idempotent: a fresh store against the same storage sweeps nothing
// more (there is no roster key left to read).
var reopened = new MossTankRouteProfileStore(new FakeHost(new FakeAutomation(), storage));
Assert.True(reopened.BindCharacter("Barris"));
var reloadTarget = new NavigationSettings();
reopened.LoadCurrent(reloadTarget, MetafSerializer.NoOpSpells.Instance);
Assert.True(storage.Text.ContainsKey("nav_Farming.af"));
Assert.True(storage.Text.ContainsKey("nav_Buffing.af"));
}
[Fact]
public void FollowModeRouteRoundTripsTheFollowTargetThroughAf()
{