feat(mosstank): add VTank-style automation PoC
This commit is contained in:
parent
f6fe0f2a4f
commit
4e6e9bc9d9
212 changed files with 49462 additions and 416 deletions
|
|
@ -34,6 +34,21 @@ public sealed class DispatcherMovementInputSourceTests
|
|||
Assert.False(captured.Run);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommandInputIsMarkedPersistentWithoutChangingStoredSnapshot()
|
||||
{
|
||||
using var movement = new RuntimeLocalPlayerMovementState();
|
||||
var command = new MovementInput(TurnRight: true);
|
||||
movement.SetCommandInput(command);
|
||||
var source = new DispatcherMovementInputSource(movement);
|
||||
|
||||
MovementInput captured = source.Capture();
|
||||
|
||||
Assert.True(captured.TurnRight);
|
||||
Assert.True(captured.IsPersistentCommand);
|
||||
Assert.Equal(command, movement.CommandInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetainedKeyboardCaptureSilencesHeldKeysButDoesNotCancelAutorun()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
using AcDream.App.Platform;
|
||||
|
||||
namespace AcDream.App.Tests.Platform;
|
||||
|
||||
public sealed class Win32GlfwActiveWindowGuardTests
|
||||
{
|
||||
[Fact]
|
||||
public void CurrentProcessWindowRemainsVisibleToGlfw()
|
||||
{
|
||||
nint window = (nint)0x1234;
|
||||
|
||||
Assert.Equal(
|
||||
window,
|
||||
Win32GlfwActiveWindowGuard.AcceptWindow(
|
||||
window,
|
||||
ownerProcessId: 47,
|
||||
currentProcessId: 47));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForeignProcessWindowBecomesGlfwsExistingNoWindowPath()
|
||||
{
|
||||
Assert.Equal(
|
||||
0,
|
||||
Win32GlfwActiveWindowGuard.AcceptWindow(
|
||||
(nint)0x1234,
|
||||
ownerProcessId: 48,
|
||||
currentProcessId: 47));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 47, 47)]
|
||||
[InlineData(0x1234, 0, 47)]
|
||||
public void MissingOrUnownedWindowIsRejected(
|
||||
long window,
|
||||
uint ownerProcessId,
|
||||
uint currentProcessId)
|
||||
{
|
||||
Assert.Equal(
|
||||
0,
|
||||
Win32GlfwActiveWindowGuard.AcceptWindow(
|
||||
(nint)window,
|
||||
ownerProcessId,
|
||||
currentProcessId));
|
||||
}
|
||||
}
|
||||
277
tests/AcDream.App.Tests/Plugins/AppAutomationSurfaceTests.cs
Normal file
277
tests/AcDream.App.Tests/Plugins/AppAutomationSurfaceTests.cs
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
using AcDream.App.Plugins;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Tests.Plugins;
|
||||
|
||||
public sealed class AppAutomationSurfaceTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProjectileDebugSamplesAreDetachedValidatedAndClearedOnUnbind()
|
||||
{
|
||||
using var surface = new AppAutomationSurface();
|
||||
PluginProjectileDebugSample[] source =
|
||||
[
|
||||
new(new Vector3(1f, 2f, 3f), true, 0.4f),
|
||||
new(new Vector3(float.NaN, 0f, 0f), false, 0.4f),
|
||||
];
|
||||
|
||||
surface.Projectiles.ShowDebugSamples(source);
|
||||
source[0] = default;
|
||||
|
||||
PluginProjectileDebugSample sample = Assert.Single(
|
||||
surface.CaptureProjectileDebugSamples());
|
||||
Assert.Equal(new Vector3(1f, 2f, 3f), sample.WorldPosition);
|
||||
Assert.True(sample.IsClear);
|
||||
|
||||
surface.Unbind();
|
||||
Assert.Empty(surface.CaptureProjectileDebugSamples());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectionAutomationUsesTheBoundCanonicalActionRoute()
|
||||
{
|
||||
using var surface = new AppAutomationSurface();
|
||||
var actions = new List<PluginSelectionAction>();
|
||||
surface.BindSelectionActions(action =>
|
||||
{
|
||||
actions.Add(action);
|
||||
return true;
|
||||
});
|
||||
|
||||
Assert.True(surface.Selection.Execute(
|
||||
PluginSelectionAction.PreviousSelection));
|
||||
Assert.True(surface.Selection.Execute(
|
||||
PluginSelectionAction.NextPlayer));
|
||||
Assert.Equal(
|
||||
[
|
||||
PluginSelectionAction.PreviousSelection,
|
||||
PluginSelectionAction.NextPlayer,
|
||||
],
|
||||
actions);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((uint)ItemType.MeleeWeapon, 0u, PluginObjectClass.MeleeWeapon)]
|
||||
[InlineData((uint)ItemType.Armor, 0u, PluginObjectClass.Armor)]
|
||||
[InlineData((uint)ItemType.Creature, 0x10u, PluginObjectClass.Monster)]
|
||||
[InlineData((uint)ItemType.Creature, 0u, PluginObjectClass.Npc)]
|
||||
[InlineData((uint)ItemType.Creature, 0x04000010u, PluginObjectClass.CombatPet)]
|
||||
[InlineData((uint)ItemType.Creature, 0x8u, PluginObjectClass.Player)]
|
||||
[InlineData((uint)ItemType.Misc, 0x200u, PluginObjectClass.Vendor)]
|
||||
[InlineData((uint)ItemType.Misc, 0x1000u, PluginObjectClass.Door)]
|
||||
public void ObjectClassProjectionMatchesVirindiPriority(
|
||||
uint itemType,
|
||||
uint publicFlags,
|
||||
PluginObjectClass expected)
|
||||
{
|
||||
var item = new ClientObject
|
||||
{
|
||||
ObjectId = 1u,
|
||||
Type = (ItemType)itemType,
|
||||
PublicWeenieBitfield = publicFlags,
|
||||
};
|
||||
|
||||
Assert.Equal(expected, AppAutomationSurface.ClassifyObject(item));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigationProjectionUsesVtankMapCoordinatesAndCompassHeading()
|
||||
{
|
||||
PluginNavigationPosition center =
|
||||
AppAutomationSurface.ProjectNavigationPosition(new Position(
|
||||
0x7F7F0001u,
|
||||
new Vector3(84f, 84f, 240f),
|
||||
Quaternion.Identity));
|
||||
|
||||
Assert.Equal(0d, center.EastWest, 8);
|
||||
Assert.Equal(0d, center.NorthSouth, 8);
|
||||
Assert.Equal(1d, center.Elevation, 8);
|
||||
Assert.Equal(0f, center.HeadingDegrees, 4);
|
||||
Assert.True(center.IsOutdoor);
|
||||
|
||||
PluginNavigationPosition nextBlock =
|
||||
AppAutomationSurface.ProjectNavigationPosition(new Position(
|
||||
0x80800041u,
|
||||
new Vector3(84f, 84f, 0f),
|
||||
Quaternion.Identity));
|
||||
|
||||
Assert.Equal(0.8d, nextBlock.EastWest, 8);
|
||||
Assert.Equal(0.8d, nextBlock.NorthSouth, 8);
|
||||
Assert.False(nextBlock.IsOutdoor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChatCapture_isOrderedCursorBasedAndDetachesAcrossSessions()
|
||||
{
|
||||
using var first = GameRuntimeTestFactory.Create();
|
||||
using var second = GameRuntimeTestFactory.Create();
|
||||
using var surface = new AppAutomationSurface();
|
||||
surface.Bind(
|
||||
first,
|
||||
first.CharacterOwner,
|
||||
first.ActionOwner.SpellCast);
|
||||
|
||||
first.CommunicationOwner.AddText(
|
||||
"You cast Imperil Other VII on Olthoi.",
|
||||
RetailLogTextType.Magic);
|
||||
PluginChatMessage one = Assert.Single(surface.CaptureMessages(0));
|
||||
Assert.Equal("You cast Imperil Other VII on Olthoi.", one.Text);
|
||||
Assert.Empty(surface.CaptureMessages(one.Sequence));
|
||||
|
||||
surface.Bind(
|
||||
second,
|
||||
second.CharacterOwner,
|
||||
second.ActionOwner.SpellCast);
|
||||
first.CommunicationOwner.AddText(
|
||||
"stale first-session line",
|
||||
RetailLogTextType.Magic);
|
||||
second.CommunicationOwner.AddText(
|
||||
"You cast Fester Other VII on Olthoi.",
|
||||
RetailLogTextType.Magic);
|
||||
|
||||
PluginChatMessage two = Assert.Single(
|
||||
surface.CaptureMessages(one.Sequence));
|
||||
Assert.True(two.Sequence > one.Sequence);
|
||||
Assert.Equal("You cast Fester Other VII on Olthoi.", two.Text);
|
||||
Assert.Equal(1, second.CommunicationOwner.SubscriberCount);
|
||||
|
||||
surface.Dispose();
|
||||
Assert.Equal(0, second.CommunicationOwner.SubscriberCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InventoryCompletionProjectsTheCanonicalRequestReceipt()
|
||||
{
|
||||
using var runtime = GameRuntimeTestFactory.Create();
|
||||
using var surface = new AppAutomationSurface();
|
||||
surface.Bind(runtime, runtime.CharacterOwner, runtime.ActionOwner.SpellCast);
|
||||
ClientObjectTable objects = runtime.InventoryOwner.Objects;
|
||||
const uint itemId = 0x50000123u;
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = itemId,
|
||||
Name = "Stack",
|
||||
StackSize = 10,
|
||||
StackSizeMax = 100,
|
||||
});
|
||||
|
||||
Assert.True(runtime.InventoryOwner.Transactions.TryDispatch(
|
||||
InventoryRequestKind.Merge,
|
||||
itemId,
|
||||
static () => true));
|
||||
Assert.True(objects.UpdateStackSize(itemId, 9, 0));
|
||||
|
||||
PluginInventoryCompletion completion =
|
||||
surface.Items.LastInventoryCompletion;
|
||||
Assert.True(completion.Revision > 0);
|
||||
Assert.Equal(PluginInventoryCommandKind.Merge, completion.Kind);
|
||||
Assert.Equal(itemId, completion.SourceObjectId);
|
||||
Assert.True(completion.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecoveryClearsExactlyOneCanonicalBusyReference()
|
||||
{
|
||||
using var runtime = GameRuntimeTestFactory.Create();
|
||||
using var surface = new AppAutomationSurface();
|
||||
surface.Bind(runtime, runtime.CharacterOwner, runtime.ActionOwner.SpellCast);
|
||||
runtime.InventoryOwner.Transactions.IncrementBusyCount();
|
||||
runtime.InventoryOwner.Transactions.IncrementBusyCount();
|
||||
|
||||
PluginRecoveryResult first = surface.Recovery.ClearOneBusyReference();
|
||||
PluginRecoveryResult second = surface.Recovery.ClearOneBusyReference();
|
||||
PluginRecoveryResult alreadyClear =
|
||||
surface.Recovery.ClearOneBusyReference();
|
||||
|
||||
Assert.True(first.Accepted);
|
||||
Assert.Equal((2, 1), (first.PreviousCount, first.CurrentCount));
|
||||
Assert.Equal((1, 0), (second.PreviousCount, second.CurrentCount));
|
||||
Assert.Equal((0, 0),
|
||||
(alreadyClear.PreviousCount, alreadyClear.CurrentCount));
|
||||
Assert.Equal(0, runtime.InventoryOwner.Transactions.BusyCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnchantmentLedgerSharesReportedAndConfirmedLocalDurationCasts()
|
||||
{
|
||||
var operations = new SpellOperations();
|
||||
using var runtime = GameRuntimeTestFactory.Create(spellCast: operations);
|
||||
runtime.CharacterOwner.InstallSpellMetadata(SpellTable.Create([DurationSpell()]));
|
||||
runtime.CharacterOwner.Spellbook.OnSpellLearned(42u);
|
||||
using var surface = new AppAutomationSurface();
|
||||
surface.Bind(runtime, runtime.CharacterOwner, runtime.ActionOwner.SpellCast);
|
||||
|
||||
Assert.True(surface.Enchantments.ReportCast(100u, 42u, 30d));
|
||||
PluginTrackedEnchantment reported = Assert.Single(
|
||||
surface.Enchantments.Capture(100u));
|
||||
Assert.Equal(7u, reported.Family);
|
||||
Assert.Equal(350, reported.Quality);
|
||||
Assert.InRange(reported.SecondsRemaining, 29d, 30d);
|
||||
|
||||
runtime.ActionOwner.Selection.Select(
|
||||
200u,
|
||||
SelectionChangeSource.Plugin);
|
||||
Assert.Equal(
|
||||
CastRequestResult.Sent,
|
||||
runtime.ActionOwner.SpellCast.Cast(42u));
|
||||
Assert.True(runtime.ActionOwner.SpellCast.CompleteUse(0u));
|
||||
|
||||
Assert.True(surface.Magic.LastCompletion.IsSuccess);
|
||||
PluginTrackedEnchantment local = Assert.Single(
|
||||
surface.Enchantments.Capture(200u));
|
||||
Assert.Equal(42u, local.SpellId);
|
||||
Assert.InRange(local.SecondsRemaining, 59d, 60d);
|
||||
|
||||
surface.Unbind();
|
||||
Assert.Empty(surface.Enchantments.Capture(100u));
|
||||
Assert.Empty(surface.Enchantments.Capture(200u));
|
||||
}
|
||||
|
||||
private static SpellMetadata DurationSpell() => new(
|
||||
42u,
|
||||
"Fire Vulnerability Other VII",
|
||||
"Life Magic",
|
||||
7u,
|
||||
0u,
|
||||
string.Empty,
|
||||
60f,
|
||||
10,
|
||||
true,
|
||||
false,
|
||||
string.Empty,
|
||||
0,
|
||||
350,
|
||||
0u,
|
||||
7,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
0f,
|
||||
0u,
|
||||
0u,
|
||||
1u,
|
||||
0);
|
||||
|
||||
private sealed class SpellOperations : IRuntimeSpellCastOperations
|
||||
{
|
||||
public uint LocalPlayerId => 1u;
|
||||
public bool CanSend => true;
|
||||
public bool HasRequiredComponents(uint spellId) => true;
|
||||
public bool IsTargetCompatible(
|
||||
uint targetId,
|
||||
SpellMetadata spell,
|
||||
bool showMessage) => true;
|
||||
public void StopCompletely() { }
|
||||
public void SendUntargeted(uint spellId) { }
|
||||
public void SendTargeted(uint targetId, uint spellId) { }
|
||||
public void DisplayMessage(string message) { }
|
||||
public void IncrementBusy() { }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using AcDream.App.Plugins;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.App.Tests.Plugins;
|
||||
|
||||
|
|
@ -32,6 +33,8 @@ public class BufferedUiRegistryTests
|
|||
var element = new UiPanel();
|
||||
root.AddChild(element);
|
||||
registry.CompleteMount(pending, root, element);
|
||||
bool windowRemoved = false;
|
||||
registry.CompleteWindowMount(pending, () => windowRemoved = true);
|
||||
|
||||
Assert.Contains(element, root.Children);
|
||||
Assert.Equal(1, registry.RegistrationCount);
|
||||
|
|
@ -40,5 +43,92 @@ public class BufferedUiRegistryTests
|
|||
|
||||
Assert.DoesNotContain(element, root.Children);
|
||||
Assert.Equal(0, registry.RegistrationCount);
|
||||
Assert.True(windowRemoved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FirstClassPanelCarriesManifestOwnerAndStableWindowIdentity()
|
||||
{
|
||||
var registry = new BufferedUiRegistry();
|
||||
var descriptor = new PluginPanelDescriptor("main", "MossTank")
|
||||
{
|
||||
IconText = "MT",
|
||||
StartVisible = false,
|
||||
};
|
||||
|
||||
registry.RegisterPanel(
|
||||
new PluginUiOwner("acdream.mosstank", "MossTank"),
|
||||
descriptor,
|
||||
"mosstank.xml",
|
||||
new object());
|
||||
|
||||
BufferedUiRegistry.Pending pending = Assert.Single(registry.Drain());
|
||||
Assert.Equal("acdream.mosstank", pending.Owner.Id);
|
||||
Assert.Same(descriptor, pending.Descriptor);
|
||||
Assert.Equal("plugin:acdream.mosstank:main", pending.WindowName);
|
||||
Assert.False(pending.Descriptor.StartVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InlinePanelContentHasAnIndependentlyRemovableLifetime()
|
||||
{
|
||||
var registry = new BufferedUiRegistry();
|
||||
IDisposable token = registry.RegisterPanelContent(
|
||||
new PluginUiOwner("acdream.mosstank", "MossTank"),
|
||||
new PluginPanelDescriptor("meta-status", "Status"),
|
||||
"<panel w=\"100\" h=\"50\" />",
|
||||
new object());
|
||||
|
||||
BufferedUiRegistry.Pending pending = Assert.Single(registry.Drain());
|
||||
Assert.Equal("<panel w=\"100\" h=\"50\" />", pending.MarkupContent);
|
||||
Assert.Equal("plugin:acdream.mosstank:meta-status", pending.WindowName);
|
||||
|
||||
token.Dispose();
|
||||
Assert.Equal(0, registry.RegistrationCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LateWindowPublicationCleansUpAfterConcurrentDisposal()
|
||||
{
|
||||
var registry = new BufferedUiRegistry();
|
||||
IDisposable token = registry.RegisterMarkupPanel("late.xml", new object());
|
||||
BufferedUiRegistry.Pending pending = Assert.Single(registry.Drain());
|
||||
token.Dispose();
|
||||
bool cleaned = false;
|
||||
|
||||
registry.CompleteWindowMount(pending, () => cleaned = true);
|
||||
|
||||
Assert.True(cleaned);
|
||||
Assert.Equal(0, registry.RegistrationCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScopedViewsExposeOnlyTheirOwnNamedControls()
|
||||
{
|
||||
var registry = new BufferedUiRegistry();
|
||||
var owner = new PluginUiOwner("acdream.mosstank", "MossTank");
|
||||
registry.RegisterPanelContent(
|
||||
owner,
|
||||
new PluginPanelDescriptor("meta", "Status View"),
|
||||
"<panel />",
|
||||
new object());
|
||||
BufferedUiRegistry.Pending pending = Assert.Single(registry.Drain());
|
||||
var root = new UiRoot();
|
||||
var panel = new UiPanel();
|
||||
var button = new UiSimpleButton { Name = "Action", Text = "Old" };
|
||||
panel.AddChild(button);
|
||||
root.AddChild(panel);
|
||||
registry.CompleteMount(pending, root, panel);
|
||||
|
||||
Assert.True(registry.ViewExists(owner, "Status View"));
|
||||
Assert.True(registry.IsViewVisible(owner, "meta"));
|
||||
Assert.True(registry.ControlExists(owner, "Status View", "Action"));
|
||||
Assert.True(registry.SetControlLabel(owner, "Status View", "Action", "New"));
|
||||
Assert.Equal("New", button.Text);
|
||||
Assert.True(registry.SetControlVisible(
|
||||
owner, "Status View", "Action", false));
|
||||
Assert.False(button.Visible);
|
||||
Assert.False(registry.ViewExists(
|
||||
new PluginUiOwner("another.plugin", "Other"), "Status View"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -526,20 +526,29 @@ public sealed class ExternalRenderPackPackageLifecycleTests
|
|||
|
||||
private static string FixtureAssemblyPath()
|
||||
{
|
||||
const string projectName = "AcDream.Plugin.Tests.Fixtures.HostPlugin";
|
||||
string colocated = Path.Combine(AppContext.BaseDirectory, projectName + ".dll");
|
||||
if (File.Exists(colocated))
|
||||
return colocated;
|
||||
|
||||
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
||||
.Parent!.Name;
|
||||
return Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"tests",
|
||||
"AcDream.Plugin.Tests.Fixtures.HostPlugin",
|
||||
projectName,
|
||||
"bin",
|
||||
configuration,
|
||||
"net10.0",
|
||||
"AcDream.Plugin.Tests.Fixtures.HostPlugin.dll");
|
||||
projectName + ".dll");
|
||||
}
|
||||
|
||||
private static string FixtureAssemblyPath(string projectName)
|
||||
{
|
||||
string colocated = Path.Combine(AppContext.BaseDirectory, projectName + ".dll");
|
||||
if (File.Exists(colocated))
|
||||
return colocated;
|
||||
|
||||
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
||||
.Parent!.Name;
|
||||
return Path.Combine(
|
||||
|
|
|
|||
35
tests/AcDream.App.Tests/Plugins/FilePluginStorageTests.cs
Normal file
35
tests/AcDream.App.Tests/Plugins/FilePluginStorageTests.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
using AcDream.App.Plugins;
|
||||
|
||||
namespace AcDream.App.Tests.Plugins;
|
||||
|
||||
public sealed class FilePluginStorageTests
|
||||
{
|
||||
[Fact]
|
||||
public void WriteReadReplaceAndDeleteStayUnderConfiguredRoot()
|
||||
{
|
||||
string root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"acdream-plugin-storage-{Guid.NewGuid():N}");
|
||||
try
|
||||
{
|
||||
var storage = new FilePluginStorage(root);
|
||||
storage.WriteText("plugin/profile.json", "one");
|
||||
storage.WriteText("plugin/imports/route.nav", "nav");
|
||||
Assert.Equal("one", storage.ReadText("plugin/profile.json"));
|
||||
storage.WriteText("plugin/profile.json", "two");
|
||||
Assert.Equal("two", storage.ReadText("plugin/profile.json"));
|
||||
Assert.Equal(
|
||||
["plugin/imports/route.nav"],
|
||||
storage.List("plugin/imports"));
|
||||
Assert.True(storage.Delete("plugin/profile.json"));
|
||||
Assert.Null(storage.ReadText("plugin/profile.json"));
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
storage.WriteText("../escape.json", "bad"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root))
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -232,6 +232,10 @@ public sealed class GraphicalPluginSessionTests
|
|||
"fixture-panel.xml",
|
||||
panel.MarkupPath,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Equal(FixtureId, panel.Owner.Id);
|
||||
Assert.Equal("Host fixture", panel.Owner.DisplayName);
|
||||
Assert.Equal("fixture-panel", panel.Descriptor.WindowId);
|
||||
Assert.Equal("Host fixture", panel.Descriptor.Title);
|
||||
Assert.Equal(
|
||||
"AcDream.Plugin.Tests.Fixtures.HostPlugin",
|
||||
panel.Binding.GetType().Assembly.GetName().Name);
|
||||
|
|
@ -271,6 +275,11 @@ public sealed class GraphicalPluginSessionTests
|
|||
|
||||
private static string FixtureAssemblyPath()
|
||||
{
|
||||
string fileName = "AcDream.Plugin.Tests.Fixtures.HostPlugin.dll";
|
||||
string colocated = Path.Combine(AppContext.BaseDirectory, fileName);
|
||||
if (File.Exists(colocated))
|
||||
return colocated;
|
||||
|
||||
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
||||
.Parent!.Name;
|
||||
string root = FindRepoRoot(AppContext.BaseDirectory);
|
||||
|
|
@ -281,7 +290,7 @@ public sealed class GraphicalPluginSessionTests
|
|||
"bin",
|
||||
configuration,
|
||||
"net10.0",
|
||||
"AcDream.Plugin.Tests.Fixtures.HostPlugin.dll");
|
||||
fileName);
|
||||
}
|
||||
|
||||
private static string FindRepoRoot(string start)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
using AcDream.App.Plugins;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.App.Tests.Plugins;
|
||||
|
||||
public sealed class LocalPluginPeerRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void PublishesRemoteClientsIgnoresSelfAndExpiresStaleHeartbeat()
|
||||
{
|
||||
string root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"acdream-plugin-peers-{Guid.NewGuid():N}");
|
||||
var time = new ManualTimeProvider(
|
||||
new DateTimeOffset(2026, 8, 27, 12, 0, 0, TimeSpan.Zero));
|
||||
try
|
||||
{
|
||||
using var first = new LocalPluginPeerRegistry(
|
||||
root,
|
||||
time,
|
||||
Guid.Parse("11111111-1111-1111-1111-111111111111"));
|
||||
using var second = new LocalPluginPeerRegistry(
|
||||
root,
|
||||
time,
|
||||
Guid.Parse("22222222-2222-2222-2222-222222222222"));
|
||||
first.Publish(Client(first.ClientId, 10u, "Alpha", ["one"]));
|
||||
second.Publish(Client(second.ClientId, 20u, "Beta", ["two"]));
|
||||
|
||||
PluginNetworkClient remote = Assert.Single(
|
||||
first.CaptureRemoteClients());
|
||||
Assert.Equal(second.ClientId, remote.ClientId);
|
||||
Assert.Equal("Beta", remote.Name);
|
||||
Assert.Equal(["two"], remote.Tags);
|
||||
Assert.Equal(33.5d, remote.Position.EastWest);
|
||||
|
||||
time.Advance(LocalPluginPeerRegistry.StaleAfter
|
||||
+ TimeSpan.FromMilliseconds(1));
|
||||
Assert.Empty(first.CaptureRemoteClients());
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root))
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static PluginNetworkClient Client(
|
||||
uint clientId,
|
||||
uint playerId,
|
||||
string name,
|
||||
IReadOnlyList<string> tags) => new(
|
||||
clientId,
|
||||
playerId,
|
||||
name,
|
||||
"Coldeve",
|
||||
new PluginNavigationPosition(
|
||||
0x7F7F0001u, 33.5d, -72.8d, 1d, 90f, true),
|
||||
tags,
|
||||
90u,
|
||||
70u,
|
||||
80u,
|
||||
100u,
|
||||
100u,
|
||||
100u,
|
||||
90f);
|
||||
|
||||
private sealed class ManualTimeProvider(DateTimeOffset utcNow) : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow = utcNow;
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
public void Advance(TimeSpan elapsed) => _utcNow += elapsed;
|
||||
}
|
||||
}
|
||||
|
|
@ -109,7 +109,7 @@ public sealed class LinuxPlatformBoundaryTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void SmokePluginCopyUsesRidAwarePortableBuildAndPublishPaths()
|
||||
public void ShippedPluginCopiesUseResolvedTargetPathsForBuildAndPublish()
|
||||
{
|
||||
string project = File.ReadAllText(Path.Combine(
|
||||
AppSourceRoot(),
|
||||
|
|
@ -119,8 +119,16 @@ public sealed class LinuxPlatformBoundaryTests
|
|||
Assert.Contains("$(RuntimeIdentifier)", project, StringComparison.Ordinal);
|
||||
Assert.Contains("$(OutputPath)plugins/", project, StringComparison.Ordinal);
|
||||
Assert.Contains("$(PublishDir)plugins/", project, StringComparison.Ordinal);
|
||||
Assert.Equal(
|
||||
4,
|
||||
project.Split("Targets=\"GetTargetPath\"", StringSplitOptions.None)
|
||||
.Length - 1);
|
||||
Assert.Contains(
|
||||
"../AcDream.Plugins.MossTank/mosstank.xml",
|
||||
project,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
@"bin\$(Configuration)\net10.0",
|
||||
"/bin/$(Configuration)",
|
||||
project,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,6 +167,22 @@ public sealed class RuntimeOptionsTests
|
|||
Assert.False(opts.ExactAutomationFramebuffer);
|
||||
Assert.False(opts.UiProbeEnabled);
|
||||
Assert.False(opts.HasLiveCredentials);
|
||||
Assert.Empty(opts.PluginTags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PluginPeerTagsAreParsedOnceBoundedAndCaseInsensitive()
|
||||
{
|
||||
string oversized = new('x', 129);
|
||||
RuntimeOptions options = RuntimeOptions.Parse(
|
||||
AnyDatDir,
|
||||
Env(new()
|
||||
{
|
||||
["ACDREAM_PLUGIN_TAGS"] =
|
||||
$" healer,Leader,HEALER,,{oversized}, scout ",
|
||||
}));
|
||||
|
||||
Assert.Equal(["healer", "Leader", "scout"], options.PluginTags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ public sealed class ItemInteractionControllerTests
|
|||
public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items, uint AlternateCurrencyId)> BuyAlls = new();
|
||||
public bool SendBuyAllSucceeds = true;
|
||||
public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items)> Sells = new();
|
||||
public readonly List<(uint ToolGuid, IReadOnlyList<uint> ItemGuids)> Salvages = new();
|
||||
public bool SendSellSucceeds = true;
|
||||
public readonly List<string> Toasts = new();
|
||||
public readonly List<string> SystemMessages = new();
|
||||
|
|
@ -133,7 +134,12 @@ public sealed class ItemInteractionControllerTests
|
|||
},
|
||||
interfaceText: (text, type) => InterfaceTexts.Add((text, type)),
|
||||
sendStackableMerge: (source, target, amount) =>
|
||||
Merges.Add((source, target, amount)));
|
||||
Merges.Add((source, target, amount)),
|
||||
sendSalvage: (tool, items) =>
|
||||
{
|
||||
Salvages.Add((tool, items.ToArray()));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public ItemInteractionController Controller { get; }
|
||||
|
|
@ -171,6 +177,86 @@ public sealed class ItemInteractionControllerTests
|
|||
Assert.Empty(h.UseWithTarget);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutomationApply_dispatchesDirectlyWithoutInstallingTargetMode()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint source = 0x50000A21u;
|
||||
h.AddContained(source, item =>
|
||||
{
|
||||
item.Useability = HealthKitUseability;
|
||||
item.TargetType = (uint)ItemType.Creature;
|
||||
});
|
||||
|
||||
Assert.True(h.Controller.TryApplyItem(source, Player));
|
||||
|
||||
Assert.Equal(new[] { (source, Player) }, h.UseWithTarget);
|
||||
Assert.False(h.Controller.IsAnyTargetModeActive);
|
||||
Assert.Equal(1, h.Controller.BusyCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutomationApply_reportsRefusalWhenTargetIsIncompatible()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint source = 0x50000A21u;
|
||||
const uint coat = 0x50000A22u;
|
||||
h.AddContained(source, item =>
|
||||
{
|
||||
item.Useability = HealthKitUseability;
|
||||
item.TargetType = (uint)ItemType.Creature;
|
||||
});
|
||||
h.AddContained(coat, item => item.Type = ItemType.Armor);
|
||||
|
||||
Assert.False(h.Controller.TryApplyItem(source, coat));
|
||||
|
||||
Assert.Empty(h.UseWithTarget);
|
||||
Assert.Equal(0, h.Controller.BusyCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutomationUse_dispatchesOnlyAnOrdinaryWireUse()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint item = 0x50000A23u;
|
||||
h.AddContained(item, candidate =>
|
||||
candidate.Useability = ItemUseability.Contained);
|
||||
|
||||
Assert.True(h.Controller.TryUseItemForAutomation(item));
|
||||
|
||||
Assert.Equal(new[] { item }, h.Uses);
|
||||
Assert.Equal(1, h.Controller.BusyCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutomationUse_refusesTargetedItemInsteadOfOpeningModalCursor()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint item = 0x50000A24u;
|
||||
h.AddContained(item, candidate =>
|
||||
candidate.Useability = HealthKitUseability);
|
||||
|
||||
Assert.False(h.Controller.TryUseItemForAutomation(item));
|
||||
|
||||
Assert.Empty(h.Uses);
|
||||
Assert.False(h.Controller.IsAnyTargetModeActive);
|
||||
Assert.Equal(0, h.Controller.BusyCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutomationAppraisalUsesCanonicalOwnerWithoutChangingSelectionMode()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint item = 0x50000A25u;
|
||||
h.AddContained(item);
|
||||
|
||||
Assert.True(h.Controller.TryAppraiseForAutomation(item));
|
||||
|
||||
Assert.Equal(new[] { item }, h.Examines);
|
||||
Assert.False(h.Controller.IsAnyTargetModeActive);
|
||||
Assert.Equal(1, h.Controller.BusyCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetSession_RetryNotifiesEveryStateObserver()
|
||||
{
|
||||
|
|
@ -2085,6 +2171,110 @@ public sealed class ItemInteractionControllerTests
|
|||
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutomationMoveUsesWholeOrExactPartialRetailRequest()
|
||||
{
|
||||
var whole = new Harness();
|
||||
const uint wholeItem = 0x50000A32u;
|
||||
whole.AddContained(wholeItem, item => item.StackSize = 10);
|
||||
|
||||
Assert.True(whole.Controller.TryMoveItemForAutomation(
|
||||
wholeItem, Player, amount: 0u, placement: 7));
|
||||
Assert.Equal(new[] { (wholeItem, Player, 7) }, whole.Puts);
|
||||
Assert.True(whole.Controller.TryGetPendingInventoryRequest(out var put));
|
||||
Assert.Equal(InventoryRequestKind.PutInContainer, put.Kind);
|
||||
|
||||
var partial = new Harness();
|
||||
const uint partialItem = 0x50000A33u;
|
||||
partial.AddContained(partialItem, item => item.StackSize = 10);
|
||||
|
||||
Assert.True(partial.Controller.TryMoveItemForAutomation(
|
||||
partialItem, Player, amount: 2u, placement: 3));
|
||||
Assert.Equal(
|
||||
new[] { (partialItem, Player, 3u, 2u) },
|
||||
partial.SplitPuts);
|
||||
Assert.True(partial.Controller.TryGetPendingInventoryRequest(out var split));
|
||||
Assert.Equal(InventoryRequestKind.SplitToContainer, split.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutomationMergeUsesRetailPlannerAndSharedGate()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint source = 0x50000A34u;
|
||||
const uint target = 0x50000A35u;
|
||||
h.AddContained(source, item =>
|
||||
{
|
||||
item.WeenieClassId = 77u;
|
||||
item.StackSize = 8;
|
||||
item.StackSizeMax = 10;
|
||||
});
|
||||
h.AddContained(target, item =>
|
||||
{
|
||||
item.WeenieClassId = 77u;
|
||||
item.StackSize = 7;
|
||||
item.StackSizeMax = 10;
|
||||
});
|
||||
|
||||
Assert.True(h.Controller.TryMergeItemsForAutomation(source, target));
|
||||
|
||||
Assert.Equal(new[] { (source, target, 3u) }, h.Merges);
|
||||
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending));
|
||||
Assert.Equal(InventoryRequestKind.Merge, pending.Kind);
|
||||
Assert.False(h.Controller.TryMoveItemForAutomation(source, Player));
|
||||
Assert.Single(h.Merges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutomationDropAndGivePreserveExactStackAmounts()
|
||||
{
|
||||
var drop = new Harness();
|
||||
const uint dropItem = 0x50000A36u;
|
||||
drop.AddContained(dropItem, item => item.StackSize = 10);
|
||||
|
||||
Assert.True(drop.Controller.TryDropItemForAutomation(dropItem, 2u));
|
||||
Assert.Equal(new[] { (dropItem, 2u) }, drop.SplitDrops);
|
||||
Assert.Empty(drop.Drops);
|
||||
|
||||
var give = new Harness();
|
||||
const uint giveItem = 0x50000A37u;
|
||||
const uint recipient = 0x70000A38u;
|
||||
give.AddContained(giveItem, item => item.StackSize = 10);
|
||||
give.Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = recipient,
|
||||
Name = "Recipient",
|
||||
Type = ItemType.Creature,
|
||||
});
|
||||
|
||||
Assert.True(give.Controller.TryGiveItemForAutomation(
|
||||
giveItem, recipient, 4u));
|
||||
Assert.Equal(new[] { (recipient, giveItem, 4u) }, give.Gives);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutomationSalvageRequiresRetailToolAndSuitableOwnedItems()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint tool = 0x50000A40u;
|
||||
const uint source = 0x50000A41u;
|
||||
h.AddContained(tool, item => item.Type = ItemType.TinkeringTool);
|
||||
h.AddContained(source, item =>
|
||||
{
|
||||
item.MaterialType = 12u;
|
||||
item.Structure = 50;
|
||||
});
|
||||
|
||||
Assert.True(h.Controller.TrySalvageItemsForAutomation(tool, [source]));
|
||||
Assert.Single(h.Salvages);
|
||||
Assert.Equal(tool, h.Salvages[0].ToolGuid);
|
||||
Assert.Equal(new[] { source }, h.Salvages[0].ItemGuids);
|
||||
|
||||
h.Objects.Get(source)!.Structure = 100;
|
||||
Assert.False(h.Controller.TrySalvageItemsForAutomation(tool, [source]));
|
||||
Assert.Single(h.Salvages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchingInventoryFailureReleasesGlobalRequest()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public sealed class ProjectileDebugOverlayControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProjectsTransientClearAndBlockedSamplesWithoutConsumingInput()
|
||||
{
|
||||
IReadOnlyList<PluginProjectileDebugSample> samples =
|
||||
[
|
||||
new(new Vector3(0f, 0f, -10f), true, 0.4f),
|
||||
new(new Vector3(1f, 0f, -10f), false, 0.4f),
|
||||
];
|
||||
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||
Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView(
|
||||
MathF.PI / 2f,
|
||||
4f / 3f,
|
||||
0.1f,
|
||||
100f);
|
||||
ProjectileDebugOverlayController controller =
|
||||
ProjectileDebugOverlayController.Mount(
|
||||
root,
|
||||
() => samples,
|
||||
() => (Matrix4x4.Identity, projection, new Vector2(800f, 600f)));
|
||||
|
||||
controller.Tick();
|
||||
|
||||
UiPanel overlay = Assert.IsType<UiPanel>(Assert.Single(root.Children));
|
||||
Assert.True(overlay.Visible);
|
||||
Assert.True(overlay.ClickThrough);
|
||||
Assert.Equal(2, overlay.Children.Count);
|
||||
UiPanel clear = Assert.IsType<UiPanel>(overlay.Children[0]);
|
||||
UiPanel blocked = Assert.IsType<UiPanel>(overlay.Children[1]);
|
||||
Assert.True(clear.Visible);
|
||||
Assert.True(blocked.Visible);
|
||||
Assert.Equal(new Vector4(0f, 1f, 0f, 0.95f), clear.BorderColor);
|
||||
Assert.Equal(new Vector4(1f, 0f, 0f, 0.95f), blocked.BorderColor);
|
||||
Assert.InRange(clear.Left, 380f, 400f);
|
||||
Assert.InRange(clear.Top, 280f, 300f);
|
||||
|
||||
samples = [];
|
||||
controller.Tick();
|
||||
|
||||
Assert.False(overlay.Visible);
|
||||
Assert.All(overlay.Children, static child => Assert.False(child.Visible));
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,79 @@ namespace AcDream.App.Tests.UI;
|
|||
|
||||
public class MarkupDocumentTests
|
||||
{
|
||||
private sealed class EditorBinding
|
||||
{
|
||||
public string Draft { get; private set; } = "initial";
|
||||
public string Submitted { get; private set; } = string.Empty;
|
||||
public string Selected { get; private set; } = "First";
|
||||
public int SelectedIndex { get; private set; }
|
||||
public IReadOnlyList<string> Choices => ["First", "Second"];
|
||||
public IReadOnlyList<uint> ChoiceColors => [0xFF0000u, 0x00FF00u];
|
||||
public Action<string> ChangeDraft => value => Draft = value;
|
||||
public Action<string> SubmitDraft => value => Submitted = value;
|
||||
public Action<string> SelectChoice => value => Selected = value;
|
||||
public Action<int> SelectIndex => value => SelectedIndex = value;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FieldAndMenuBindEditablePluginState()
|
||||
{
|
||||
const string xml = """
|
||||
<panel x="0" y="0" w="240" h="120">
|
||||
<field x="4" y="4" w="120" h="20" text="{Draft}"
|
||||
onchange="{ChangeDraft}" onsubmit="{SubmitDraft}" />
|
||||
<menu x="4" y="32" w="120" h="20" items="{Choices}"
|
||||
selected="{Selected}" onchange="{SelectChoice}" />
|
||||
<list x="132" y="4" w="100" h="40" items="{Choices}"
|
||||
colors="{ChoiceColors}"
|
||||
selected="{SelectedIndex}" onchange="{SelectIndex}" />
|
||||
</panel>
|
||||
""";
|
||||
var binding = new EditorBinding();
|
||||
|
||||
UiNineSlicePanel panel = MarkupDocument.Build(
|
||||
xml,
|
||||
binding,
|
||||
_ => (1u, 32, 32));
|
||||
|
||||
UiField field = Assert.IsType<UiField>(panel.Children[0]);
|
||||
UiMenu menu = Assert.IsType<UiMenu>(panel.Children[1]);
|
||||
UiMarkupList list = Assert.IsType<UiMarkupList>(panel.Children[2]);
|
||||
field.SetText("named profile");
|
||||
field.OnSubmit?.Invoke(field.Text);
|
||||
menu.OnSelect?.Invoke("Second");
|
||||
list.OnEvent(new UiEvent
|
||||
{
|
||||
Type = UiEventType.MouseDown,
|
||||
Data2 = 19,
|
||||
});
|
||||
|
||||
Assert.Equal("named profile", binding.Draft);
|
||||
Assert.Equal("named profile", binding.Submitted);
|
||||
Assert.Equal("Second", binding.Selected);
|
||||
Assert.Equal(1, binding.SelectedIndex);
|
||||
Assert.Equal(2, menu.Items.Count);
|
||||
Assert.Equal([0xFF0000u, 0x00FF00u], list.ItemColorsSource());
|
||||
Assert.False(menu.OpenUpward);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ControlIdAndNameBecomeStablePluginControlNames()
|
||||
{
|
||||
const string xml = """
|
||||
<panel x="0" y="0" w="200" h="80">
|
||||
<button id="ById" x="4" y="4" w="80" h="20" text="One" />
|
||||
<label name="ByName" x="4" y="28" text="Two" />
|
||||
</panel>
|
||||
""";
|
||||
|
||||
UiNineSlicePanel panel = MarkupDocument.Build(
|
||||
xml, new object(), _ => (1u, 32, 32));
|
||||
|
||||
Assert.Equal("ById", panel.Children[0].Name);
|
||||
Assert.Equal("ByName", panel.Children[1].Name);
|
||||
}
|
||||
|
||||
private sealed class FakeBinding
|
||||
{
|
||||
public float HealthPercent => 0.5f;
|
||||
|
|
@ -82,6 +155,14 @@ public class MarkupDocumentTests
|
|||
public string Status { get; set; } = "idle";
|
||||
public Action Go => () => Clicks++;
|
||||
public Action? Missing => null;
|
||||
public bool CanGo { get; set; } = true;
|
||||
public bool OptionsSelected { get; set; } = true;
|
||||
public bool OptionsVisible { get; set; } = true;
|
||||
public bool CombatEnabled { get; set; }
|
||||
public float AttackPower { get; private set; } = 0.5f;
|
||||
public Action ShowOptions => () => OptionsSelected = true;
|
||||
public Action ToggleCombat => () => CombatEnabled = !CombatEnabled;
|
||||
public Action<float> SetAttackPower => value => AttackPower = value;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -148,4 +229,89 @@ public class MarkupDocumentTests
|
|||
var label = Assert.IsType<UiLabel>(panel.Children[0]);
|
||||
Assert.Equal("MossTank", label.TextSource!());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ProjectsBoundEnabledAndButtonColors()
|
||||
{
|
||||
const string xml =
|
||||
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
||||
"<button x=\"0\" y=\"0\" w=\"50\" h=\"20\" text=\"Go\" " +
|
||||
"onclick=\"{Go}\" enabled=\"{CanGo}\" " +
|
||||
"background=\"#FF112233\" border=\"#FF445566\"/>" +
|
||||
"</panel>";
|
||||
var binding = new ButtonBinding();
|
||||
UiNineSlicePanel panel = MarkupDocument.Build(
|
||||
xml, binding, _ => ((uint)1, 32, 32));
|
||||
var button = Assert.IsType<UiSimpleButton>(panel.Children[0]);
|
||||
|
||||
Assert.NotNull(button.EnabledSource);
|
||||
Assert.True(button.EnabledSource!());
|
||||
binding.CanGo = false;
|
||||
Assert.False(button.EnabledSource!());
|
||||
Assert.Equal(new System.Numerics.Vector4(
|
||||
0x11 / 255f, 0x22 / 255f, 0x33 / 255f, 1f),
|
||||
button.BackgroundColor);
|
||||
Assert.Equal(new System.Numerics.Vector4(
|
||||
0x44 / 255f, 0x55 / 255f, 0x66 / 255f, 1f),
|
||||
button.BorderColor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_RuntimeTooltipUsesRetailPopupLocatorAndLiveBinding()
|
||||
{
|
||||
const string xml =
|
||||
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
||||
"<button x=\"0\" y=\"0\" w=\"50\" h=\"20\" text=\"Go\" " +
|
||||
"tooltip=\"{Status}\"/>" +
|
||||
"</panel>";
|
||||
var binding = new ButtonBinding();
|
||||
UiNineSlicePanel panel = MarkupDocument.Build(
|
||||
xml, binding, _ => ((uint)1, 32, 32));
|
||||
var button = Assert.IsType<UiSimpleButton>(panel.Children[0]);
|
||||
|
||||
Assert.Equal("idle", button.GetTooltipText());
|
||||
Assert.True(button.AuthoredTooltipEnabled);
|
||||
Assert.Equal(0x10000397u, button.AuthoredTooltipRootElementId);
|
||||
Assert.Equal(0x21000041u, button.AuthoredTooltipLayoutDid);
|
||||
|
||||
binding.Status = "casting";
|
||||
Assert.Equal("casting", button.GetTooltipText());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_NestedGroupTabToggleAndSliderStayLiveAndInteractive()
|
||||
{
|
||||
const string xml =
|
||||
"<panel x=\"0\" y=\"0\" w=\"300\" h=\"180\">" +
|
||||
"<tab x=\"4\" y=\"4\" w=\"60\" h=\"18\" text=\"Options\" " +
|
||||
"selected=\"{OptionsSelected}\" onclick=\"{ShowOptions}\"/>" +
|
||||
"<group x=\"4\" y=\"28\" w=\"280\" h=\"140\" visible=\"{OptionsVisible}\">" +
|
||||
"<toggle x=\"2\" y=\"2\" w=\"140\" h=\"20\" text=\"Enable Combat\" " +
|
||||
"checked=\"{CombatEnabled}\" onclick=\"{ToggleCombat}\"/>" +
|
||||
"<slider x=\"2\" y=\"30\" w=\"140\" h=\"16\" value=\"{AttackPower}\" " +
|
||||
"onchange=\"{SetAttackPower}\"/>" +
|
||||
"</group></panel>";
|
||||
var binding = new ButtonBinding();
|
||||
|
||||
UiNineSlicePanel panel = MarkupDocument.Build(
|
||||
xml,
|
||||
binding,
|
||||
_ => ((uint)1, 16, 16));
|
||||
|
||||
var tab = Assert.IsType<UiMarkupTabButton>(panel.Children[0]);
|
||||
var group = Assert.IsType<UiPanel>(panel.Children[1]);
|
||||
var toggle = Assert.IsType<UiMarkupToggle>(group.Children[0]);
|
||||
var slider = Assert.IsType<UiScrollbar>(group.Children[1]);
|
||||
Assert.True(tab.IsSelected);
|
||||
Assert.True(group.VisibleSource!());
|
||||
Assert.False(toggle.IsChecked);
|
||||
|
||||
toggle.OnEvent(new UiEvent { Type = UiEventType.Click });
|
||||
Assert.True(binding.CombatEnabled);
|
||||
Assert.True(toggle.IsChecked);
|
||||
|
||||
slider.ScalarChanged!(0.78f);
|
||||
Assert.Equal(0.78f, binding.AttackPower);
|
||||
Assert.Equal(0.78f, slider.ScalarPositionSource!());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
132
tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs
Normal file
132
tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public sealed class PluginSidePanelTests
|
||||
{
|
||||
[Fact]
|
||||
public void ManyPluginsWrapIntoReachableColumnsWithinTheLiveScreenHeight()
|
||||
{
|
||||
var root = new UiRoot { Width = 800f, Height = 260f };
|
||||
using var shelf = new PluginSidePanel(
|
||||
root.WindowManager,
|
||||
_ => (0u, 0, 0),
|
||||
font: null);
|
||||
root.AddChild(shelf);
|
||||
|
||||
for (int i = 0; i < 12; i++)
|
||||
{
|
||||
var frame = new UiPanel { Width = 200f, Height = 100f };
|
||||
root.AddChild(frame);
|
||||
RetailWindowHandle handle = root.WindowManager.Register(
|
||||
$"plugin:test:{i}",
|
||||
frame);
|
||||
shelf.Add(
|
||||
new PluginUiOwner($"test.{i}", $"Plugin {i}"),
|
||||
new PluginPanelDescriptor("main", $"Plugin {i}"),
|
||||
handle);
|
||||
}
|
||||
|
||||
root.Tick(0.016d, 16L);
|
||||
|
||||
Assert.Equal(12, shelf.EntryCount);
|
||||
Assert.True(shelf.Width > 36f);
|
||||
Assert.True(shelf.Top + shelf.Height <= root.Height);
|
||||
Assert.All(
|
||||
shelf.Children,
|
||||
child => Assert.True(child.Top + child.Height <= shelf.Height));
|
||||
Assert.Equal(root.Width - shelf.Width - 4f, shelf.Left);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShelfAndMinimizeButtonsHideAndRestoreWithoutUnregisteringWindow()
|
||||
{
|
||||
var root = new UiRoot { Width = 1280f, Height = 720f };
|
||||
var frame = new UiPanel
|
||||
{
|
||||
Width = 320f,
|
||||
Height = 180f,
|
||||
Visible = true,
|
||||
};
|
||||
root.AddChild(frame);
|
||||
RetailWindowHandle handle = root.WindowManager.Register(
|
||||
"plugin:acdream.test:main",
|
||||
frame);
|
||||
using var shelf = new PluginSidePanel(
|
||||
root.WindowManager,
|
||||
_ => (0u, 0, 0),
|
||||
font: null);
|
||||
root.AddChild(shelf);
|
||||
|
||||
shelf.Add(
|
||||
new PluginUiOwner("acdream.test", "Test Plugin"),
|
||||
new PluginPanelDescriptor("main", "Test Plugin")
|
||||
{
|
||||
IconText = "TP",
|
||||
},
|
||||
handle);
|
||||
|
||||
Assert.True(shelf.Visible);
|
||||
Assert.Equal(1, shelf.EntryCount);
|
||||
UiSimpleButton shelfButton = Assert.IsAssignableFrom<UiSimpleButton>(
|
||||
Assert.Single(shelf.Children));
|
||||
|
||||
shelfButton.OnEvent(new UiEvent { Type = UiEventType.Click });
|
||||
Assert.False(handle.IsVisible);
|
||||
Assert.True(handle.IsRegistered);
|
||||
|
||||
shelfButton.OnEvent(new UiEvent { Type = UiEventType.Click });
|
||||
Assert.True(handle.IsVisible);
|
||||
|
||||
UiSimpleButton minimize = Assert.IsAssignableFrom<UiSimpleButton>(
|
||||
Assert.Single(frame.Children));
|
||||
minimize.OnEvent(new UiEvent { Type = UiEventType.Click });
|
||||
Assert.False(handle.IsVisible);
|
||||
Assert.True(handle.IsRegistered);
|
||||
|
||||
root.WindowManager.Unregister(handle.Name);
|
||||
Assert.Equal(0, shelf.EntryCount);
|
||||
Assert.False(shelf.Visible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullWidthPluginWindowStartsAndStaysReachableAtMinimumCanvas()
|
||||
{
|
||||
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||
var frame = new UiPanel
|
||||
{
|
||||
Left = 28f,
|
||||
Top = 42f,
|
||||
Width = 800f,
|
||||
Height = 244f,
|
||||
Visible = true,
|
||||
};
|
||||
root.AddChild(frame);
|
||||
RetailWindowHandle handle = root.WindowManager.Register(
|
||||
"plugin:acdream.mosstank:main",
|
||||
frame);
|
||||
using var shelf = new PluginSidePanel(
|
||||
root.WindowManager,
|
||||
_ => (0u, 0, 0),
|
||||
font: null);
|
||||
root.AddChild(shelf);
|
||||
|
||||
shelf.Add(
|
||||
new PluginUiOwner("acdream.mosstank", "MossTank"),
|
||||
new PluginPanelDescriptor("main", "MossTank"),
|
||||
handle);
|
||||
|
||||
Assert.Equal(0f, handle.Left);
|
||||
Assert.Equal(42f, handle.Top);
|
||||
Assert.True(frame.ConstrainDragToParent);
|
||||
Assert.True(frame.ConstrainResizeToParent);
|
||||
|
||||
frame.Left = 700f;
|
||||
frame.Top = 590f;
|
||||
root.Tick(0.016d, 16L);
|
||||
|
||||
Assert.Equal(0f, handle.Left);
|
||||
Assert.Equal(356f, handle.Top);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue