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:
parent
3ff9461efe
commit
0d10399e0e
4 changed files with 408 additions and 190 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
214
src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs
Normal file
214
src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ?? [],
|
||||
};
|
||||
}
|
||||
|
|
@ -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) { }
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue