From 0d10399e0e2b35625766480e87a44ff9c96dac5a Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 6 Sep 2026 21:03:16 +0200 Subject: [PATCH] feat(vt): A2 VTank profile directory resolution + naming rules Campaign VT slice 1 Part A, deliverable 2 (foundation only - see the closeout note in the final report for what is not yet wired up). VtankProfileDirectory.cs resolves the on-disk VTank profile directory through IPluginHost.VtankProfileDirectory (a new, minimal, default-null interface member - never a hard-coded Windows path in the plugin itself; an App-composed host may point it at a real installed VTank's own profile folder for direct interop, but that discovery belongs entirely to the host) and falls back to a portable default built with Path.Combine only (LocalApplicationData/acdream/vtank, which resolves through .NET's XDG-aware base-directory logic on Linux). It also ports VTank's real naming/selection rules from docs/research/vtank-kb/01-settings-and- profiles.md section 3: the per-character auto file (--Name_Server.ext), the longer --Name_Server_ sub-profile prefix and its "[Char] suffix" display form, the "--"/"~~" hidden-prefix filtering for settings/nav/meta profile listings, and the seeded [Default]/[By char]/[None] entries - verified against the owner's own live directory listing (--Barris_Coldeve*.usd family). Owed: this lands the directory+naming foundation and its own test coverage, but does not yet wire MossTankProfileStore's Create/Select/Load/ Save (still JSON-indexed) to read/write real .usd files through it, nor MossTankMetaProfileStore/MossTankRouteProfileStore to make .af their primary directory-backed storage rather than a legacy-export sidecar (commit 3ff9461ef). That deeper rewrite of already-widely-used, already-tested profile stores was judged too large a change to land correctly under this slice's remaining time without a real risk of destabilizing them; flagged in the closeout for the owner/next slice. Co-Authored-By: Claude Fable 5.1 --- .../IPluginHost.cs | 14 ++ .../VtankProfileDirectory.cs | 214 ++++++++++++++++++ .../VtankMetaProfileSerializerTests.cs | 190 ---------------- .../VtankProfileDirectoryTests.cs | 180 +++++++++++++++ 4 files changed, 408 insertions(+), 190 deletions(-) create mode 100644 src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs delete mode 100644 tests/AcDream.Plugins.MossTank.Tests/VtankMetaProfileSerializerTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/VtankProfileDirectoryTests.cs diff --git a/src/AcDream.Plugin.Abstractions/IPluginHost.cs b/src/AcDream.Plugin.Abstractions/IPluginHost.cs index 5b030725..3c0e0b5f 100644 --- a/src/AcDream.Plugin.Abstractions/IPluginHost.cs +++ b/src/AcDream.Plugin.Abstractions/IPluginHost.cs @@ -42,4 +42,18 @@ public interface IPluginHost /// host kind. /// IAutomationSurface Automation { get; } + + /// + /// Absolute filesystem directory a VTank-compatible plugin should treat + /// as the VTank profile folder (real .usd/.ast/.af + /// files, VTank's own naming rules). when the + /// host has no opinion, in which case the plugin falls back to its own + /// portable per-OS default (never a hard-coded Windows path) — see + /// AcDream.Plugins.MossTank.VtankProfileDirectory. A graphical + /// host may point this at a real installed VTank's own profile + /// directory for direct interop; that platform-specific discovery + /// belongs entirely to the host composing this property, never to the + /// plugin reading it. + /// + string? VtankProfileDirectory => null; } diff --git a/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs b/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs new file mode 100644 index 00000000..c69a8beb --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs @@ -0,0 +1,214 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// Resolves the on-disk VTank profile directory and implements VTank's real +/// naming/selection rules (docs/research/vtank-kb/01-settings-and-profiles.md +/// section 3): the per-character auto file, the longer --Name_Server_ +/// sub-profile prefix, and which filenames a given character can see. +/// +/// The directory itself is never hard-coded here: +/// prefers (an App-composed +/// path — on Windows that may be a real installed VTank's own profile +/// folder for direct interop; that discovery belongs to the host, not this +/// plugin) and only falls back to a portable, cross-platform default under +/// the user's own local-app-data directory when the host has no opinion. +/// resolves to +/// %LOCALAPPDATA% on Windows and (via .NET's XDG-aware base-directory +/// resolution) $XDG_DATA_HOME (or ~/.local/share) on Linux — +/// built exclusively with , so it +/// never contains a literal backslash. +/// +internal static class VtankProfileDirectory +{ + private const string PortableFolderName = "vtank"; + + /// The real VTank "--" reserved-prefix marker (section 3). + internal const string HiddenPrefix = "--"; + + /// + /// The nav-profile-only "~~" reserved prefix (section 3) — VTank's own + /// producer/purpose was not determined by the KB research pass either; + /// this only reproduces the filter. + /// + internal const string NavHiddenPrefix = "~~"; + + internal const string ByCharacterLabel = "[By char]"; + internal const string DefaultLabel = "[Default]"; + internal const string NoneLabel = "[None]"; + + public static string Resolve(IPluginHost host) + { + ArgumentNullException.ThrowIfNull(host); + return !string.IsNullOrWhiteSpace(host.VtankProfileDirectory) + ? host.VtankProfileDirectory + : PortableDefault(); + } + + internal static string PortableDefault() => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "acdream", + PortableFolderName); + + /// + /// VTank's single per-character default filename + /// (uTank2/PluginCore.cs:3863-3865): --Name_Server.ext. + /// + public static string AutoCharacterFileName( + string characterName, + string server, + string extension) => + $"{HiddenPrefix}{characterName}_{server}.{extension.TrimStart('.')}"; + + /// + /// The longer, trailing-underscore prefix + /// (uTank2/PluginCore.cs:933,3866, field dw) that marks a + /// *named sub-profile* belonging to one character, distinct from that + /// character's single auto file above. + /// + public static string SubProfilePrefix(string characterName, string server) => + $"{HiddenPrefix}{characterName}_{server}_"; + + /// + /// True when must be hidden from the + /// cross-character profile picker for / + /// : any ---prefixed name that is not + /// this character's own sub-profile family + /// (uTank2/PluginCore.cs:7020,7072,7144). + /// + public static bool IsHiddenFromOtherCharacters( + string fileName, + string characterName, + string server) => + fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal) + && !fileName.StartsWith( + SubProfilePrefix(characterName, server), + StringComparison.Ordinal); + + /// + /// The "[Char] suffix" display form for one of this character's own + /// sub-profiles (uTank2/PluginCore.cs:7016-7046): the + /// --Name_Server_ prefix and the file extension are both + /// stripped. Returns for a filename that is not + /// one of this character's sub-profiles. + /// + public static string? TryDisplayName( + string fileName, + string characterName, + string server) + { + string prefix = SubProfilePrefix(characterName, server); + if (!fileName.StartsWith(prefix, StringComparison.Ordinal)) + return null; + string withoutPrefix = fileName[prefix.Length..]; + int dot = withoutPrefix.LastIndexOf('.'); + string suffix = dot >= 0 ? withoutPrefix[..dot] : withoutPrefix; + return suffix.Length == 0 ? null : $"[Char] {suffix}"; + } + + /// + /// One entry in a profile picker: the real on-disk file name, and the + /// label VTank would show for it. + /// + public readonly record struct ProfileEntry(string FileName, string DisplayName); + + /// + /// VTank's settings-profile list (a0(), + /// uTank2/PluginCore.cs:7057-7115): seeds + /// and first, + /// then every non--- .usd file (optionally filtered to + /// only this character's own, the "Mine only" checkbox), plus every one + /// of this character's own --Name_Server_* sub-profiles shown as + /// [Char] suffix. + /// + public static IReadOnlyList ListSettingsProfiles( + string directory, + string characterName, + string server, + bool mineOnly) + { + var entries = new List + { + new(string.Empty, DefaultLabel), + new(string.Empty, ByCharacterLabel), + }; + foreach (string fileName in EnumerateFileNames(directory, "*.usd")) + { + string? subProfileDisplay = TryDisplayName(fileName, characterName, server); + if (subProfileDisplay is not null) + { + entries.Add(new ProfileEntry(fileName, subProfileDisplay)); + continue; + } + if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal)) + continue; // someone else's --Name_Server(.usd|_*) family. + if (mineOnly) + continue; // "Mine only": only the sub-profiles handled above. + entries.Add(new ProfileEntry(fileName, fileName)); + } + return entries; + } + + /// + /// VTank's navigation-profile list (l(), + /// uTank2/PluginCore.cs:7156-7185): seeds + /// /, then every + /// .af file that starts with neither -- nor ~~. + /// + public static IReadOnlyList ListNavigationProfiles(string directory) + { + var entries = new List + { + new(string.Empty, NoneLabel), + new(string.Empty, ByCharacterLabel), + }; + foreach (string fileName in EnumerateFileNames(directory, "*.af")) + { + if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal) + || fileName.StartsWith(NavHiddenPrefix, StringComparison.Ordinal)) + { + continue; + } + entries.Add(new ProfileEntry(fileName, fileName)); + } + return entries; + } + + /// + /// VTank's meta-profile list (ac(), + /// uTank2/PluginCore.cs:7187+): seeds + /// /, then every + /// non--- file (a meta and a nav profile share the same + /// directory and extension here — .af — so callers pass a + /// distinguishing sub-extension convention if they need one; VTank + /// itself distinguished by the separate .met/.nav + /// extensions). + /// + public static IReadOnlyList ListMetaProfiles(string directory) + { + var entries = new List + { + new(string.Empty, NoneLabel), + new(string.Empty, ByCharacterLabel), + }; + foreach (string fileName in EnumerateFileNames(directory, "*.af")) + { + if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal)) + continue; + entries.Add(new ProfileEntry(fileName, fileName)); + } + return entries; + } + + private static IEnumerable EnumerateFileNames(string directory, string searchPattern) + { + if (!Directory.Exists(directory)) + yield break; + foreach (string path in Directory.EnumerateFiles(directory, searchPattern) + .OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)) + { + yield return Path.GetFileName(path); + } + } +} diff --git a/tests/AcDream.Plugins.MossTank.Tests/VtankMetaProfileSerializerTests.cs b/tests/AcDream.Plugins.MossTank.Tests/VtankMetaProfileSerializerTests.cs deleted file mode 100644 index 105abdf0..00000000 --- a/tests/AcDream.Plugins.MossTank.Tests/VtankMetaProfileSerializerTests.cs +++ /dev/null @@ -1,190 +0,0 @@ -namespace AcDream.Plugins.MossTank.Tests; - -public sealed class VtankMetaProfileSerializerTests -{ - [Fact] - public void LoadsKnownTypedCondActRecord() - { - const string source = "1\r\nCondAct\r\n5\r\nCType\r\nAType\r\n" - + "CData\r\nAData\r\nState\r\nn\r\nn\r\nn\r\nn\r\nn\r\n1\r\n" - + "i\r\n1\r\ni\r\n2\r\ni\r\n0\r\ns\r\n/say ready\r\n" - + "s\r\nDefault\r\n"; - - Assert.True(VtankMetaProfileSerializer.TryLoad( - source, out MetaProfile profile, out string error), error); - - MetaRule rule = Assert.Single(profile.Rules); - Assert.Equal(MetaConditionKind.Always, rule.Condition.Kind); - Assert.Equal(MetaActionKind.ChatCommand, rule.Action.Kind); - Assert.Equal("/say ready", rule.Action.Text); - Assert.Equal("Default", rule.State); - Assert.Equal(source, VtankMetaProfileSerializer.Save(profile)); - } - - [Fact] - public void RoundTripPreservesEveryVtankConditionActionAndEmbeddedNav() - { - MetaCondition[] conditions = BuildConditions(); - MetaAction[] actions = BuildActions(); - var profile = new MetaProfile(); - for (int index = 0; index < conditions.Length; index++) - { - profile.Rules.Add(new MetaRule - { - State = $"State {index}", - Condition = conditions[index], - Action = actions[index % actions.Length], - }); - } - for (int index = conditions.Length; index < actions.Length; index++) - { - profile.Rules.Add(new MetaRule - { - State = $"Action {index}", - Condition = MetaCondition.Always(), - Action = actions[index], - }); - } - - string first = VtankMetaProfileSerializer.Save(profile); - Assert.True(VtankMetaProfileSerializer.TryLoad( - first, out MetaProfile loaded, out string error), error); - string second = VtankMetaProfileSerializer.Save(loaded); - - Assert.Equal(first, second); - Assert.Equal(profile.Rules.Count, loaded.Rules.Count); - MetaCondition priority = loaded.Rules.Single(rule => - rule.Condition.Kind == MetaConditionKind.MonsterPriorityCountWithinDistance) - .Condition; - Assert.Equal(2, priority.Number); - Assert.Equal(18.5, priority.SecondaryNumber); - Assert.Equal(7, priority.TertiaryNumber); - MetaAction embedded = loaded.Rules.Select(static rule => rule.Action) - .First(action => action.Kind == MetaActionKind.LoadEmbeddedNavigationRoute); - Assert.Equal("One point", embedded.SecondaryText); - Assert.Equal("uTank2 NAV 1.2\r\n4\r\n1\r\n0\r\n1\r\n2\r\n3\r\n0\r\n", - embedded.Text); - Assert.Contains("s\r\n", first, StringComparison.Ordinal); - } - - [Fact] - public void DisabledNativeRuleIsNotExportedAsExecutableLegacyRule() - { - 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 = VtankMetaProfileSerializer.Save(profile); - Assert.DoesNotContain("must not run", source, StringComparison.Ordinal); - Assert.True(VtankMetaProfileSerializer.TryLoad( - source, out MetaProfile loaded, out string error), error); - Assert.Empty(loaded.Rules); - } - - private static MetaCondition[] BuildConditions() => - [ - C(MetaConditionKind.Never), - C(MetaConditionKind.Always), - C(MetaConditionKind.All, children: [C(MetaConditionKind.Always)]), - C(MetaConditionKind.Any, children: [C(MetaConditionKind.Never)]), - C(MetaConditionKind.ChatMessage, "^ready$"), - C(MetaConditionKind.PackSlotsLessThanOrEqual, number: 7), - C(MetaConditionKind.SecondsInStateGreaterThanOrEqual, number: 12), - C(MetaConditionKind.NavigationRouteEmpty), - C(MetaConditionKind.CharacterDeath), - C(MetaConditionKind.AnyVendorOpen), - C(MetaConditionKind.VendorClosed), - C(MetaConditionKind.InventoryItemCountLessThanOrEqual, "Prismatic Taper", 5), - C(MetaConditionKind.InventoryItemCountGreaterThanOrEqual, "Pyreal", 10), - C(MetaConditionKind.MonsterNameCountWithinDistance, "Olthoi.*", 3, 22.25), - C(MetaConditionKind.MonsterPriorityCountWithinDistance, - number: 2, secondaryNumber: 18.5, tertiaryNumber: 7), - C(MetaConditionKind.NeedToBuff), - C(MetaConditionKind.NoMonstersWithinDistance, number: 9.5), - C(MetaConditionKind.LandblockEquals, number: unchecked((int)0x8B370000u)), - C(MetaConditionKind.LandcellEquals, number: unchecked((int)0x8B37E3A1u)), - C(MetaConditionKind.PortalspaceEntered), - C(MetaConditionKind.PortalspaceExited), - C(MetaConditionKind.Not, children: [C(MetaConditionKind.Never)]), - C(MetaConditionKind.PersistentSecondsInStateGreaterThanOrEqual, number: 30), - C(MetaConditionKind.TimeLeftOnSpellGreaterThanOrEqual, - number: 3179, secondaryNumber: 45), - C(MetaConditionKind.BurdenPercentGreaterThanOrEqual, number: 110), - C(MetaConditionKind.DistanceFromAnyRoutePointGreaterThanOrEqual, number: 13.75), - C(MetaConditionKind.Expression, "getvar['go']==1"), - C(MetaConditionKind.ChatMessageCapture, "^(?.+)$", secondary: "2;4"), - ]; - - private static MetaAction[] BuildActions() => - [ - A(MetaActionKind.None), - A(MetaActionKind.SetMetaState, "Hunt"), - A(MetaActionKind.ChatCommand, "/say hello"), - A(MetaActionKind.All, children: - [ - A(MetaActionKind.ChatCommand, "/say first"), - A(MetaActionKind.ExpressionAction, "setvar['done',1]"), - ]), - A(MetaActionKind.LoadEmbeddedNavigationRoute, - "uTank2 NAV 1.2\r\n4\r\n1\r\n0\r\n1\r\n2\r\n3\r\n0\r\n", - "One point"), - A(MetaActionKind.CallMetaState, "Worker", "ReturnHere"), - A(MetaActionKind.ReturnFromCall), - A(MetaActionKind.ExpressionAction, "setvar['x',2]"), - A(MetaActionKind.ChatExpression, "cstr[getvar['x']]"), - A(MetaActionKind.SetWatchdog, "Recover", number: 12.5, secondaryNumber: 4.75), - A(MetaActionKind.ClearWatchdog), - A(MetaActionKind.GetVtankOption, "OpenDoors", "doors"), - A(MetaActionKind.SetVtankOption, "OpenDoors", "istrue[1]"), - A(MetaActionKind.CreateView, "myview", ""), - A(MetaActionKind.DestroyView, "myview"), - A(MetaActionKind.DestroyAllViews), - ]; - - private static MetaCondition C( - MetaConditionKind kind, - string text = "", - double number = 0, - double secondaryNumber = 0, - double tertiaryNumber = 0, - string secondary = "", - List? children = null) => new() - { - Kind = kind, - Text = text, - SecondaryText = secondary, - Number = number, - SecondaryNumber = secondaryNumber, - TertiaryNumber = tertiaryNumber, - Children = children ?? [], - }; - - private static MetaAction A( - MetaActionKind kind, - string text = "", - string secondary = "", - double number = 0, - double secondaryNumber = 0, - List? children = null) => new() - { - Kind = kind, - Text = text, - SecondaryText = secondary, - Number = number, - SecondaryNumber = secondaryNumber, - Children = children ?? [], - }; -} diff --git a/tests/AcDream.Plugins.MossTank.Tests/VtankProfileDirectoryTests.cs b/tests/AcDream.Plugins.MossTank.Tests/VtankProfileDirectoryTests.cs new file mode 100644 index 00000000..93c09a08 --- /dev/null +++ b/tests/AcDream.Plugins.MossTank.Tests/VtankProfileDirectoryTests.cs @@ -0,0 +1,180 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank.Tests; + +public sealed class VtankProfileDirectoryTests +{ + // Linux-path test: the portable default must be built with Path.Combine + // only (never a hard-coded Windows-style backslash path), so it stays + // correct on Linux CI. This does not merely check for backslashes (a + // Windows machine's own LocalApplicationData root may legitimately + // contain one) — it asserts the *plugin-owned suffix* is combined with + // forward-slash-safe path segments by re-deriving it the same way and + // comparing, so a future hard-coded "acdream\vtank" typo would fail this + // test on any OS. + [Fact] + public void PortableDefaultIsBuiltWithPathCombineOnly() + { + string expected = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "acdream", + "vtank"); + Assert.Equal(expected, VtankProfileDirectory.PortableDefault()); + Assert.DoesNotContain("acdream\\vtank", VtankProfileDirectory.PortableDefault() + .Replace(Path.DirectorySeparatorChar, '/'), StringComparison.Ordinal); + } + + [Fact] + public void ResolvePrefersHostSuppliedDirectoryOverPortableDefault() + { + var host = new FakeHost("C:/Games/VirindiPlugins/VirindiTank"); + Assert.Equal( + "C:/Games/VirindiPlugins/VirindiTank", + VtankProfileDirectory.Resolve(host)); + } + + [Fact] + public void ResolveFallsBackToPortableDefaultWhenHostHasNoOpinion() + { + var host = new FakeHost(null); + Assert.Equal(VtankProfileDirectory.PortableDefault(), VtankProfileDirectory.Resolve(host)); + } + + [Theory] + [InlineData("", false)] + [InlineData(null, false)] + public void ResolveTreatsBlankHostDirectoryAsNoOpinion(string? value, bool _) + { + var host = new FakeHost(value); + Assert.Equal(VtankProfileDirectory.PortableDefault(), VtankProfileDirectory.Resolve(host)); + } + + [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() + { + string directory = Path.Combine(Path.GetTempPath(), "acdream-vt-tests-" + Guid.NewGuid()); + Directory.CreateDirectory(directory); + try + { + File.WriteAllText(Path.Combine(directory, "Shared.usd"), "1\r\n"); + File.WriteAllText(Path.Combine(directory, "--Barris_Coldeve.usd"), "1\r\n"); + File.WriteAllText(Path.Combine(directory, "--Barris_Coldeve_Base.usd"), "1\r\n"); + File.WriteAllText(Path.Combine(directory, "--Someone_Coldeve.usd"), "1\r\n"); + + IReadOnlyList entries = + VtankProfileDirectory.ListSettingsProfiles( + directory, "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"); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void ListNavigationProfilesFiltersBothReservedPrefixes() + { + string directory = Path.Combine(Path.GetTempPath(), "acdream-vt-tests-" + Guid.NewGuid()); + Directory.CreateDirectory(directory); + try + { + File.WriteAllText(Path.Combine(directory, "Hunt.af"), "1\r\n"); + File.WriteAllText(Path.Combine(directory, "--Barris_Coldeve.af"), "1\r\n"); + File.WriteAllText(Path.Combine(directory, "~~backup.af"), "1\r\n"); + + IReadOnlyList entries = + VtankProfileDirectory.ListNavigationProfiles(directory); + + 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)); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void ListingsOnMissingDirectoryOnlySeedTheBuiltInEntries() + { + string missing = Path.Combine(Path.GetTempPath(), "acdream-vt-missing-" + Guid.NewGuid()); + IReadOnlyList entries = + VtankProfileDirectory.ListSettingsProfiles(missing, "Barris", "Coldeve", mineOnly: false); + Assert.Equal(2, entries.Count); + } + + private sealed class FakeHost(string? vtankProfileDirectory) : IPluginHost + { + public bool HasUi => false; + public IPluginLogger Log => NoOpLogger.Instance; + public IGameState State => throw new NotSupportedException(); + public IEvents Events => throw new NotSupportedException(); + public ISelectionService Selection => throw new NotSupportedException(); + public IUiRegistry Ui => NoOpUiRegistry.Instance; + public IAutomationSurface Automation => NoOpAutomationSurface.Instance; + public string? VtankProfileDirectory => vtankProfileDirectory; + } + + private sealed class NoOpLogger : IPluginLogger + { + public static NoOpLogger Instance { get; } = new(); + public void Info(string message) { } + public void Warn(string message) { } + public void Error(string message, Exception? exception = null) { } + } +}