acdream/tests/AcDream.Plugins.MossTank.Tests/VtankProfileDirectoryTests.cs
Erik e53507396f fix(vt): G real mineOnly predicate, .cdf character binding, .ast naming
Item G (slice-1 fix round).

- ListSettingsProfiles's "Mine only" filter previously hid EVERY shared
  (non-sub-profile) file when checked. Real VTank's predicate
  (uTank2/PluginCore.cs:7020-7024, cSettingsShowAll/field a9) is
  "!checked || file == current" — a shared file is hidden only when the
  box is checked AND it is not the file currently assigned to the
  character, so the profile actually in use never disappears out from
  under the user just because they ticked the box. New optional
  currentFileName parameter carries that exemption.
- New VtankProfileDirectory.CdfFileName/TryReadCharacterBinding: the
  real per-character binding file (da class, da.cs:15,105-164) —
  filename Server_CharacterName.cdf (Server-then-Name order), literal
  "uTank2 CDF 1.0" version-header line 1 (a mismatch is treated
  identically to a missing file, per da.cs:113-121, and the method
  returns null in both cases rather than fabricating a default binding),
  lines 2-4 the settings/loot/nav filenames currently assigned to that
  character, optional line 5 the meta filename (present only when the
  stream wasn't already at EOF — an older .cdf predating meta support
  has no line 5 at all). The legacy .uts->.usd settings-filename
  rewrite (da.cs:130-141) is applied here so callers never see a stale
  extension.
- New VtankProfileDirectory.AstFileName: the per-character spell-
  tracking cache name (dm class, dm.cs:391) — CharacterName_Server.ast,
  no "--" prefix, not user-selectable. Note the concatenation order is
  the REVERSE of CdfFileName's (Name-then-Server vs Server-then-Name) —
  both are pinned by dedicated tests so a future edit can't silently
  swap one for the other.
- New RealAstFixturesParseAsTheSpellsTable theory over the three
  committed owner-{a,b,c}.ast fixtures: parses via the existing
  VtankDatabase.Parse (same "y" grammar as .usd) and asserts the real
  Spells table's four columns (SpellID/EndTime/Target/CastTime), per
  live inspection of +Horan_sawato.ast recorded in
  docs/research/vtank-kb/01-settings-and-profiles.md section 3.

No production caller of these new members exists yet — same as item F,
this is contract/rule-implementation work per A2's "foundation only, not
yet wired into the profile stores" scope; wiring belongs to the store
cutover explicitly deferred to round 2.

Full MossTank suite: 562 -> 574 (12 new tests, no regressions). App.Tests
(Plugin|LaunchOptions filter): 82/82.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 23:07:11 +02:00

276 lines
11 KiB
C#

using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class VtankProfileDirectoryTests
{
[Fact]
public void AutoCharacterFileNameMatchesRealInstalledConvention()
{
// Confirmed live: --Barris_Coldeve.usd is the auto per-character
// settings profile for character "Barris" on server "Coldeve".
Assert.Equal(
"--Barris_Coldeve.usd",
VtankProfileDirectory.AutoCharacterFileName("Barris", "Coldeve", "usd"));
Assert.Equal(
"--Barris_Coldeve.usd",
VtankProfileDirectory.AutoCharacterFileName("Barris", "Coldeve", ".usd"));
}
[Fact]
public void SubProfilesAreDisplayedAsCharBracketSuffix()
{
// Confirmed live: --Barris_Coldeve_Base.usd, --Barris_Coldeve_Blank.usd,
// --Barris_Coldeve_viridian.usd are Barris's own named sub-profiles,
// distinct from the single auto file --Barris_Coldeve.usd.
Assert.Equal(
"[Char] Base",
VtankProfileDirectory.TryDisplayName("--Barris_Coldeve_Base.usd", "Barris", "Coldeve"));
Assert.Equal(
"[Char] viridian",
VtankProfileDirectory.TryDisplayName("--Barris_Coldeve_viridian.usd", "Barris", "Coldeve"));
// The character's own single auto file has nothing after the
// trailing "_", so it is not a sub-profile.
Assert.Null(
VtankProfileDirectory.TryDisplayName("--Barris_Coldeve.usd", "Barris", "Coldeve"));
// A different character's file is not a sub-profile of this one.
Assert.Null(
VtankProfileDirectory.TryDisplayName("--Someone_Coldeve_Base.usd", "Barris", "Coldeve"));
}
[Fact]
public void OtherCharactersHiddenPrefixedFilesAreInvisible()
{
Assert.True(VtankProfileDirectory.IsHiddenFromOtherCharacters(
"--Someone_Coldeve.usd", "Barris", "Coldeve"));
Assert.False(VtankProfileDirectory.IsHiddenFromOtherCharacters(
"--Barris_Coldeve_Base.usd", "Barris", "Coldeve"));
Assert.False(VtankProfileDirectory.IsHiddenFromOtherCharacters(
"MyNamedProfile.usd", "Barris", "Coldeve"));
}
[Fact]
public void ListSettingsProfilesSeedsDefaultAndByCharFirst()
{
var storage = new MemoryStorage();
storage.WriteText("Shared.usd", "1\r\n");
storage.WriteText("--Barris_Coldeve.usd", "1\r\n");
storage.WriteText("--Barris_Coldeve_Base.usd", "1\r\n");
storage.WriteText("--Someone_Coldeve.usd", "1\r\n");
IReadOnlyList<VtankProfileDirectory.ProfileEntry> entries =
VtankProfileDirectory.ListSettingsProfiles(
storage, "Barris", "Coldeve", mineOnly: false);
Assert.Equal(VtankProfileDirectory.DefaultLabel, entries[0].DisplayName);
Assert.Equal(VtankProfileDirectory.ByCharacterLabel, entries[1].DisplayName);
Assert.Contains(entries, static e => e.DisplayName == "Shared.usd");
Assert.Contains(entries, static e => e.DisplayName == "[Char] Base");
Assert.DoesNotContain(entries, static e => e.FileName == "--Someone_Coldeve.usd");
Assert.DoesNotContain(entries, static e => e.FileName == "--Barris_Coldeve.usd");
}
[Fact]
public void ListNavigationProfilesFiltersBothReservedPrefixes()
{
var storage = new MemoryStorage();
storage.WriteText("Hunt.af", "1\r\n");
storage.WriteText("--Barris_Coldeve.af", "1\r\n");
storage.WriteText("~~backup.af", "1\r\n");
IReadOnlyList<VtankProfileDirectory.ProfileEntry> entries =
VtankProfileDirectory.ListNavigationProfiles(storage);
Assert.Equal(VtankProfileDirectory.NoneLabel, entries[0].DisplayName);
Assert.Equal(VtankProfileDirectory.ByCharacterLabel, entries[1].DisplayName);
Assert.Contains(entries, static e => e.DisplayName == "Hunt.af");
Assert.DoesNotContain(entries, static e => e.FileName.StartsWith("--", StringComparison.Ordinal));
Assert.DoesNotContain(entries, static e => e.FileName.StartsWith("~~", StringComparison.Ordinal));
}
[Fact]
public void ListingsOnUnavailableStorageOnlySeedTheBuiltInEntries()
{
IReadOnlyList<VtankProfileDirectory.ProfileEntry> entries =
VtankProfileDirectory.ListSettingsProfiles(
NoOpPluginStorage.Instance, "Barris", "Coldeve", mineOnly: false);
Assert.Equal(2, entries.Count);
}
// Item F (Campaign VT slice-1 fix round): a nested-path key (as a real
// IPluginStorage implementation might return for a sub-directory) must
// never surface as a bogus profile name — VTank's own profile
// directory is flat.
[Fact]
public void NestedPathKeysAreNotTreatedAsProfileFiles()
{
var storage = new MemoryStorage();
storage.WriteText("subdir/Nested.usd", "1\r\n");
storage.WriteText("Flat.usd", "1\r\n");
IReadOnlyList<VtankProfileDirectory.ProfileEntry> entries =
VtankProfileDirectory.ListSettingsProfiles(
storage, "Barris", "Coldeve", mineOnly: false);
Assert.Contains(entries, static e => e.FileName == "Flat.usd");
Assert.DoesNotContain(entries, static e => e.FileName.Contains('/'));
}
// Item G (Campaign VT slice-1 fix round): VTank's real "Mine only"
// predicate (uTank2/PluginCore.cs:7020-7024) is "!checked || file ==
// current", not "hide every shared file" — the profile actually
// assigned to the character must stay visible even under the
// checkbox, or the user's own current selection would vanish out of
// the list the moment they ticked it.
[Fact]
public void MineOnlyKeepsTheCurrentlySelectedSharedFileVisible()
{
var storage = new MemoryStorage();
storage.WriteText("Shared.usd", "1\r\n");
storage.WriteText("OtherShared.usd", "1\r\n");
IReadOnlyList<VtankProfileDirectory.ProfileEntry> withoutCurrent =
VtankProfileDirectory.ListSettingsProfiles(
storage, "Barris", "Coldeve", mineOnly: true);
Assert.DoesNotContain(withoutCurrent, static e => e.FileName == "Shared.usd");
Assert.DoesNotContain(withoutCurrent, static e => e.FileName == "OtherShared.usd");
IReadOnlyList<VtankProfileDirectory.ProfileEntry> withCurrent =
VtankProfileDirectory.ListSettingsProfiles(
storage, "Barris", "Coldeve", mineOnly: true, currentFileName: "Shared.usd");
Assert.Contains(withCurrent, static e => e.FileName == "Shared.usd");
Assert.DoesNotContain(withCurrent, static e => e.FileName == "OtherShared.usd");
}
[Fact]
public void MineOnlyUncheckedIgnoresCurrentFileAndKeepsEverything()
{
var storage = new MemoryStorage();
storage.WriteText("Shared.usd", "1\r\n");
IReadOnlyList<VtankProfileDirectory.ProfileEntry> entries =
VtankProfileDirectory.ListSettingsProfiles(
storage, "Barris", "Coldeve", mineOnly: false, currentFileName: null);
Assert.Contains(entries, static e => e.FileName == "Shared.usd");
}
[Fact]
public void AstFileNameHasNoHiddenPrefixAndNameServerOrder()
{
Assert.Equal(
"Barris_Coldeve.ast",
VtankProfileDirectory.AstFileName("Barris", "Coldeve"));
}
[Fact]
public void CdfFileNameUsesServerNameOrderReversedFromAst()
{
Assert.Equal(
"Coldeve_Barris.cdf",
VtankProfileDirectory.CdfFileName("Barris", "Coldeve"));
}
[Fact]
public void TryReadCharacterBindingParsesAllFourLines()
{
var storage = new MemoryStorage();
storage.WriteText(
"Coldeve_Barris.cdf",
"uTank2 CDF 1.0\r\n--Barris_Coldeve.usd\r\nLoot.utl\r\n--Barris_Coldeve.nav\r\nHunt.met\r\n");
VtankProfileDirectory.VtankCharacterBinding? binding =
VtankProfileDirectory.TryReadCharacterBinding(storage, "Barris", "Coldeve");
Assert.NotNull(binding);
Assert.Equal("--Barris_Coldeve.usd", binding!.Value.SettingsFileName);
Assert.Equal("Loot.utl", binding.Value.LootFileName);
Assert.Equal("--Barris_Coldeve.nav", binding.Value.NavFileName);
Assert.Equal("Hunt.met", binding.Value.MetaFileName);
}
[Fact]
public void TryReadCharacterBindingWithoutMetaLineLeavesMetaNull()
{
var storage = new MemoryStorage();
storage.WriteText(
"Coldeve_Barris.cdf",
"uTank2 CDF 1.0\r\n--Barris_Coldeve.usd\r\nLoot.utl\r\n--Barris_Coldeve.nav\r\n");
VtankProfileDirectory.VtankCharacterBinding? binding =
VtankProfileDirectory.TryReadCharacterBinding(storage, "Barris", "Coldeve");
Assert.NotNull(binding);
Assert.Null(binding!.Value.MetaFileName);
}
[Fact]
public void TryReadCharacterBindingRewritesLegacyUtsSettingsExtension()
{
var storage = new MemoryStorage();
storage.WriteText(
"Coldeve_Barris.cdf",
"uTank2 CDF 1.0\r\n--Barris_Coldeve.uts\r\nLoot.utl\r\n--Barris_Coldeve.nav\r\n");
VtankProfileDirectory.VtankCharacterBinding? binding =
VtankProfileDirectory.TryReadCharacterBinding(storage, "Barris", "Coldeve");
Assert.Equal("--Barris_Coldeve.usd", binding!.Value.SettingsFileName);
}
[Fact]
public void TryReadCharacterBindingReturnsNullOnHeaderMismatch()
{
var storage = new MemoryStorage();
storage.WriteText(
"Coldeve_Barris.cdf",
"uTank2 CDF 0.9\r\n--Barris_Coldeve.usd\r\nLoot.utl\r\n--Barris_Coldeve.nav\r\n");
Assert.Null(VtankProfileDirectory.TryReadCharacterBinding(storage, "Barris", "Coldeve"));
}
[Fact]
public void TryReadCharacterBindingReturnsNullWhenFileMissing()
{
Assert.Null(VtankProfileDirectory.TryReadCharacterBinding(
new MemoryStorage(), "Barris", "Coldeve"));
}
// Real owner-*.ast fixtures (Fixtures/vtank/owner-{a,b,c}.ast): same
// "y" database grammar as .usd, one "Spells" table, four columns
// (docs/research/vtank-kb/01-settings-and-profiles.md section 3,
// dm.cs:391 for the naming, live inspection of +Horan_sawato.ast for
// the schema).
[Theory]
[InlineData("owner-a.ast")]
[InlineData("owner-b.ast")]
[InlineData("owner-c.ast")]
public void RealAstFixturesParseAsTheSpellsTable(string fileName)
{
string text = File.ReadAllText(
Path.Combine(AppContext.BaseDirectory, "Fixtures", "vtank", fileName));
VtankDatabase database = VtankDatabase.Parse(text);
VtankTable? spells = database.Find("Spells");
Assert.NotNull(spells);
Assert.Equal(
["SpellID", "EndTime", "Target", "CastTime"],
spells!.ColumnNames);
}
private sealed class MemoryStorage : IPluginStorage
{
private readonly Dictionary<string, string> _text = new(StringComparer.Ordinal);
public bool IsAvailable => true;
public string? ReadText(string key) =>
_text.TryGetValue(key, out string? value) ? value : null;
public IReadOnlyList<string> List(string prefix) => _text.Keys
.Where(key => prefix.Length == 0
|| key.StartsWith(prefix + "/", StringComparison.Ordinal))
.OrderBy(static key => key, StringComparer.Ordinal)
.ToArray();
public void WriteText(string key, string content) => _text[key] = content;
public bool Delete(string key) => _text.Remove(key);
}
}