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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-06 21:03:16 +02:00
parent 3ff9461efe
commit 0d10399e0e
4 changed files with 408 additions and 190 deletions

View file

@ -42,4 +42,18 @@ public interface IPluginHost
/// host kind.
/// </summary>
IAutomationSurface Automation { get; }
/// <summary>
/// Absolute filesystem directory a VTank-compatible plugin should treat
/// as the VTank profile folder (real <c>.usd</c>/<c>.ast</c>/<c>.af</c>
/// files, VTank's own naming rules). <see langword="null"/> 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
/// <c>AcDream.Plugins.MossTank.VtankProfileDirectory</c>. 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.
/// </summary>
string? VtankProfileDirectory => null;
}

View file

@ -0,0 +1,214 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// Resolves the on-disk VTank profile directory and implements VTank's real
/// naming/selection rules (<c>docs/research/vtank-kb/01-settings-and-profiles.md</c>
/// section 3): the per-character auto file, the longer <c>--Name_Server_</c>
/// sub-profile prefix, and which filenames a given character can see.
///
/// The directory itself is never hard-coded here: <see cref="Resolve"/>
/// prefers <see cref="IPluginHost.VtankProfileDirectory"/> (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.
/// <see cref="Environment.SpecialFolder.LocalApplicationData"/> resolves to
/// <c>%LOCALAPPDATA%</c> on Windows and (via .NET's XDG-aware base-directory
/// resolution) <c>$XDG_DATA_HOME</c> (or <c>~/.local/share</c>) on Linux —
/// built exclusively with <see cref="Path.Combine(string, string)"/>, so it
/// never contains a literal backslash.
/// </summary>
internal static class VtankProfileDirectory
{
private const string PortableFolderName = "vtank";
/// <summary>The real VTank "--" reserved-prefix marker (section 3).</summary>
internal const string HiddenPrefix = "--";
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>
/// VTank's single per-character default filename
/// (<c>uTank2/PluginCore.cs:3863-3865</c>): <c>--Name_Server.ext</c>.
/// </summary>
public static string AutoCharacterFileName(
string characterName,
string server,
string extension) =>
$"{HiddenPrefix}{characterName}_{server}.{extension.TrimStart('.')}";
/// <summary>
/// The longer, trailing-underscore prefix
/// (<c>uTank2/PluginCore.cs:933,3866</c>, field <c>dw</c>) that marks a
/// *named sub-profile* belonging to one character, distinct from that
/// character's single auto file above.
/// </summary>
public static string SubProfilePrefix(string characterName, string server) =>
$"{HiddenPrefix}{characterName}_{server}_";
/// <summary>
/// True when <paramref name="fileName"/> must be hidden from the
/// cross-character profile picker for <paramref name="characterName"/>/
/// <paramref name="server"/>: any <c>--</c>-prefixed name that is not
/// this character's own sub-profile family
/// (<c>uTank2/PluginCore.cs:7020,7072,7144</c>).
/// </summary>
public static bool IsHiddenFromOtherCharacters(
string fileName,
string characterName,
string server) =>
fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal)
&& !fileName.StartsWith(
SubProfilePrefix(characterName, server),
StringComparison.Ordinal);
/// <summary>
/// The "[Char] suffix" display form for one of this character's own
/// sub-profiles (<c>uTank2/PluginCore.cs:7016-7046</c>): the
/// <c>--Name_Server_</c> prefix and the file extension are both
/// stripped. Returns <see langword="null"/> for a filename that is not
/// one of this character's sub-profiles.
/// </summary>
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}";
}
/// <summary>
/// One entry in a profile picker: the real on-disk file name, and the
/// label VTank would show for it.
/// </summary>
public readonly record struct ProfileEntry(string FileName, string DisplayName);
/// <summary>
/// VTank's settings-profile list (<c>a0()</c>,
/// <c>uTank2/PluginCore.cs:7057-7115</c>): seeds
/// <see cref="DefaultLabel"/> and <see cref="ByCharacterLabel"/> first,
/// then every non-<c>--</c> <c>.usd</c> file (optionally filtered to
/// only this character's own, the "Mine only" checkbox), plus every one
/// of this character's own <c>--Name_Server_*</c> sub-profiles shown as
/// <c>[Char] suffix</c>.
/// </summary>
public static IReadOnlyList<ProfileEntry> ListSettingsProfiles(
string directory,
string characterName,
string server,
bool mineOnly)
{
var entries = new List<ProfileEntry>
{
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;
}
/// <summary>
/// VTank's navigation-profile list (<c>l()</c>,
/// <c>uTank2/PluginCore.cs:7156-7185</c>): seeds
/// <see cref="NoneLabel"/>/<see cref="ByCharacterLabel"/>, then every
/// <c>.af</c> file that starts with neither <c>--</c> nor <c>~~</c>.
/// </summary>
public static IReadOnlyList<ProfileEntry> ListNavigationProfiles(string directory)
{
var entries = new List<ProfileEntry>
{
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;
}
/// <summary>
/// VTank's meta-profile list (<c>ac()</c>,
/// <c>uTank2/PluginCore.cs:7187+</c>): seeds
/// <see cref="NoneLabel"/>/<see cref="ByCharacterLabel"/>, then every
/// non-<c>--</c> file (a meta and a nav profile share the same
/// directory and extension here — <c>.af</c> — so callers pass a
/// distinguishing sub-extension convention if they need one; VTank
/// itself distinguished by the separate <c>.met</c>/<c>.nav</c>
/// extensions).
/// </summary>
public static IReadOnlyList<ProfileEntry> ListMetaProfiles(string directory)
{
var entries = new List<ProfileEntry>
{
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<string> 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);
}
}
}

View file

@ -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("<view width=\"120\" />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, "^(?<who>.+)$", 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", "<view width=\"120\" />"),
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<MetaCondition>? 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<MetaAction>? children = null) => new()
{
Kind = kind,
Text = text,
SecondaryText = secondary,
Number = number,
SecondaryNumber = secondaryNumber,
Children = children ?? [],
};
}

View file

@ -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<VtankProfileDirectory.ProfileEntry> 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<VtankProfileDirectory.ProfileEntry> 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<VtankProfileDirectory.ProfileEntry> 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) { }
}
}