Move selection, combat, and interaction target mode under one Runtime owner; make plugins, retained UI, session routing, and typed runtime views borrow its exact children; and add failure-safe reset, instance isolation, source ownership, and normalized checkpoint coverage without changing retail ordering. Co-authored-by: Codex <codex@openai.com>
408 lines
14 KiB
C#
408 lines
14 KiB
C#
using System.Numerics;
|
|
using System.Runtime.CompilerServices;
|
|
using AcDream.App.Composition;
|
|
using AcDream.App.Input;
|
|
using AcDream.App.Interaction;
|
|
using AcDream.App.Net;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
using AcDream.App.World;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.World;
|
|
using AcDream.Runtime;
|
|
using AcDream.UI.Abstractions;
|
|
|
|
namespace AcDream.App.Tests.Composition;
|
|
|
|
public sealed class InteractionUiRuntimeSourcesTests
|
|
{
|
|
[Fact]
|
|
public void SessionAuthorityDefaultsBindUnbindRebindAndDeactivateExactly()
|
|
{
|
|
var source = new DeferredLiveSessionUiAuthority();
|
|
var first = new SessionTarget(true);
|
|
var second = new SessionTarget(false);
|
|
|
|
Assert.False(source.IsInWorld);
|
|
Assert.Same(NullCommandBus.Instance, source.Commands);
|
|
|
|
IDisposable stale = source.Bind(first);
|
|
Assert.True(source.IsInWorld);
|
|
Assert.Same(first.Commands, source.Commands);
|
|
Assert.Throws<InvalidOperationException>(() => source.Bind(second));
|
|
|
|
stale.Dispose();
|
|
IDisposable current = source.Bind(second);
|
|
stale.Dispose();
|
|
Assert.False(source.IsInWorld);
|
|
Assert.Same(second.Commands, source.Commands);
|
|
|
|
source.Deactivate();
|
|
current.Dispose();
|
|
Assert.Same(NullCommandBus.Instance, source.Commands);
|
|
Assert.Throws<ObjectDisposedException>(() => source.Bind(first));
|
|
}
|
|
|
|
[Fact]
|
|
public void SelectionAuthorityUsesRetailSafeDefaultsAndExactBindingLifetime()
|
|
{
|
|
var source = new DeferredSelectionUiAuthority();
|
|
var query = new SelectionQuery();
|
|
SelectionInteractionController interactions =
|
|
Stub<SelectionInteractionController>();
|
|
|
|
Assert.True(source.IsWithinExternalContainerUseRange(1u));
|
|
Assert.False(source.ShouldShowHealth(1u));
|
|
Assert.Null(source.ResolveVividTargetInfo(1u));
|
|
|
|
IDisposable binding = source.Bind(query, interactions);
|
|
Assert.False(source.IsWithinExternalContainerUseRange(1u));
|
|
Assert.True(source.ShouldShowHealth(1u));
|
|
Assert.Equal(3f, source.ResolveVividTargetInfo(1u)?.SelectionSphereRadius);
|
|
Assert.Throws<InvalidOperationException>(() => source.Bind(query, interactions));
|
|
|
|
binding.Dispose();
|
|
Assert.True(source.IsWithinExternalContainerUseRange(1u));
|
|
source.Deactivate();
|
|
Assert.Throws<ObjectDisposedException>(() => source.Bind(query, interactions));
|
|
}
|
|
|
|
[Fact]
|
|
public void GameRuntimeCommandsCaptureTheExactCurrentGeneration()
|
|
{
|
|
var source = new DeferredGameRuntimeStateCommands();
|
|
var first = new RuntimeTarget(new RuntimeGenerationToken(7));
|
|
var second = new RuntimeTarget(new RuntimeGenerationToken(9));
|
|
|
|
Assert.False(source.IsInWorld);
|
|
Assert.Equal(
|
|
RuntimeCommandStatus.Inactive,
|
|
source.Advance(RuntimeAdvancementKind.Skill, 6u, 100u).Status);
|
|
|
|
IDisposable stale = source.Bind(first, first);
|
|
Assert.True(source.IsInWorld);
|
|
Assert.True(source.Advance(
|
|
RuntimeAdvancementKind.Skill,
|
|
6u,
|
|
100u).Accepted);
|
|
Assert.Equal(new RuntimeGenerationToken(7), first.LastGeneration);
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
source.Bind(second, second));
|
|
|
|
stale.Dispose();
|
|
using IDisposable current = source.Bind(second, second);
|
|
stale.Dispose();
|
|
Assert.True(source.AddFavorite(0, 2, 42u).Accepted);
|
|
Assert.Equal(new RuntimeGenerationToken(9), second.LastGeneration);
|
|
Assert.Equal(
|
|
RuntimeCommandStatus.Rejected,
|
|
source.RemoveShortcut(uint.MaxValue).Status);
|
|
|
|
source.Deactivate();
|
|
Assert.Equal(
|
|
RuntimeCommandStatus.Inactive,
|
|
source.RemoveShortcut(3u).Status);
|
|
Assert.Throws<ObjectDisposedException>(() => source.Bind(first, first));
|
|
}
|
|
|
|
[Fact]
|
|
public void RadarNeverCachesAnUnboundBootstrapSnapshot()
|
|
{
|
|
var source = new DeferredRadarSnapshotSource();
|
|
Assert.Same(UiRadarSnapshot.Empty, source.Snapshot());
|
|
|
|
var provider = new RadarSnapshotProvider(
|
|
new AcDream.Core.Items.ClientObjectTable(),
|
|
EmptyRadarSource.Instance,
|
|
static () => new Dictionary<uint, WorldSession.EntitySpawn>(),
|
|
static () => 0u,
|
|
static () => 0f,
|
|
static () => 0u,
|
|
static () => null,
|
|
static () => false,
|
|
static () => true);
|
|
IDisposable binding = source.Bind(provider);
|
|
|
|
Assert.True(source.Snapshot().UiLocked);
|
|
binding.Dispose();
|
|
Assert.False(source.Snapshot().UiLocked);
|
|
}
|
|
|
|
[Fact]
|
|
public void AutomationProxyIsInertUntilExactRuntimeBinds()
|
|
{
|
|
var source = new DeferredWorldLifecycleAutomationRuntime();
|
|
Assert.False(source.TryRequestCheckpoint(
|
|
"early", out _, out string earlyError));
|
|
Assert.Contains("not bound", earlyError);
|
|
|
|
var target = new AutomationRuntime();
|
|
IDisposable binding = source.Bind(target);
|
|
Assert.True(source.IsWorldReady);
|
|
Assert.True(source.TryRequestCheckpoint("ready", out _, out _));
|
|
Assert.Equal("ready", target.LastCheckpoint);
|
|
|
|
binding.Dispose();
|
|
Assert.False(source.IsWorldReady);
|
|
source.Deactivate();
|
|
Assert.Throws<ObjectDisposedException>(() => source.Bind(target));
|
|
}
|
|
|
|
[Fact]
|
|
public void ExpectedOwnerBindingReleasesOnce()
|
|
{
|
|
object target = new();
|
|
int releases = 0;
|
|
var binding = new ExpectedOwnerBinding<object>(target, value =>
|
|
{
|
|
Assert.Same(target, value);
|
|
releases++;
|
|
});
|
|
|
|
binding.Dispose();
|
|
binding.Dispose();
|
|
|
|
Assert.Equal(1, releases);
|
|
}
|
|
|
|
[Fact]
|
|
public void RetainedInputCaptureBindingCannotClearAReplacement()
|
|
{
|
|
var slot = new RetainedUiInputCaptureSlot();
|
|
var first = new UiRoot();
|
|
var second = new UiRoot();
|
|
|
|
IDisposable stale = slot.Bind(first);
|
|
Assert.Same(first, slot.Root);
|
|
Assert.Throws<InvalidOperationException>(() => slot.Bind(second));
|
|
stale.Dispose();
|
|
IDisposable current = slot.Bind(second);
|
|
stale.Dispose();
|
|
Assert.Same(second, slot.Root);
|
|
current.Dispose();
|
|
Assert.Null(slot.Root);
|
|
}
|
|
|
|
[Fact]
|
|
public void LateBindingCleanupRetriesOnlyTheFailedSuffix()
|
|
{
|
|
var bindings = new InteractionUiLateBindings();
|
|
var first = new RetryBinding(failures: 0);
|
|
var second = new RetryBinding(failures: 1);
|
|
bindings.AdoptLateOwnerBinding("first", first);
|
|
bindings.AdoptLateOwnerBinding("second", second);
|
|
|
|
Assert.Throws<AggregateException>(bindings.Dispose);
|
|
Assert.Equal(1, first.Calls);
|
|
Assert.Equal(1, second.Calls);
|
|
|
|
bindings.Dispose();
|
|
Assert.Equal(1, first.Calls);
|
|
Assert.Equal(2, second.Calls);
|
|
Assert.Throws<ObjectDisposedException>(() =>
|
|
bindings.AdoptLateOwnerBinding("late", new RetryBinding(0)));
|
|
}
|
|
|
|
private sealed class SessionTarget(bool inWorld) : ILiveUiSessionTarget
|
|
{
|
|
public bool IsInWorld { get; } = inWorld;
|
|
public WorldSession? CurrentSession => null;
|
|
public ICommandBus Commands { get; } = new LiveCommandBus();
|
|
}
|
|
|
|
private sealed class RuntimeTarget
|
|
: IGameRuntimeView,
|
|
IGameRuntimeCommands,
|
|
IRuntimeInventoryStateCommands,
|
|
IRuntimeSpellbookCommands,
|
|
IRuntimeCharacterCommands
|
|
{
|
|
public RuntimeTarget(RuntimeGenerationToken generation)
|
|
{
|
|
Generation = generation;
|
|
}
|
|
|
|
public RuntimeGenerationToken LastGeneration { get; private set; }
|
|
public RuntimeGenerationToken Generation { get; }
|
|
public RuntimeLifecycleSnapshot Lifecycle =>
|
|
new(Generation, RuntimeLifecycleState.InWorld, 1u, true);
|
|
public IGameRuntimeClock Clock => null!;
|
|
public IRuntimeEntityView Entities => null!;
|
|
public IRuntimeInventoryView Inventory => null!;
|
|
public IRuntimeInventoryStateView InventoryState => null!;
|
|
public IRuntimeCharacterView Character => null!;
|
|
public IRuntimeSocialView Social => null!;
|
|
public IRuntimeChatView Chat => null!;
|
|
public IRuntimeActionView Actions => null!;
|
|
public IRuntimeMovementView Movement => null!;
|
|
public IRuntimePortalView Portal => null!;
|
|
public IRuntimeSessionCommands Session => null!;
|
|
public IRuntimeSelectionCommands Selection => null!;
|
|
public IRuntimeCombatCommands Combat => null!;
|
|
IRuntimeMovementCommands IGameRuntimeCommands.Movement => null!;
|
|
IRuntimeChatCommands IGameRuntimeCommands.Chat => null!;
|
|
IRuntimePortalCommands IGameRuntimeCommands.Portal => null!;
|
|
IRuntimeInventoryStateCommands IGameRuntimeCommands.InventoryState => this;
|
|
IRuntimeSpellbookCommands IGameRuntimeCommands.Spellbook => this;
|
|
IRuntimeCharacterCommands IGameRuntimeCommands.Character => this;
|
|
IRuntimeSocialCommands IGameRuntimeCommands.Social => null!;
|
|
|
|
public RuntimeStateCheckpoint CaptureCheckpoint() => default;
|
|
|
|
public RuntimeCommandResult AddShortcut(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
in RuntimeShortcutCommand command) =>
|
|
Accepted(expectedGeneration, command.ObjectId);
|
|
|
|
public RuntimeCommandResult RemoveShortcut(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
int index) =>
|
|
Accepted(expectedGeneration);
|
|
|
|
public RuntimeCommandResult AddFavorite(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
int tabIndex,
|
|
int position,
|
|
uint spellId) =>
|
|
Accepted(expectedGeneration, spellId);
|
|
|
|
public RuntimeCommandResult RemoveFavorite(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
int tabIndex,
|
|
uint spellId) =>
|
|
Accepted(expectedGeneration, spellId);
|
|
|
|
public RuntimeCommandResult SetFilter(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
uint filters) =>
|
|
Accepted(expectedGeneration);
|
|
|
|
public RuntimeCommandResult ForgetSpell(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
uint spellId) =>
|
|
Accepted(expectedGeneration, spellId);
|
|
|
|
public RuntimeCommandResult SetDesiredComponent(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
uint componentId,
|
|
uint amount) =>
|
|
Accepted(expectedGeneration, componentId);
|
|
|
|
public RuntimeCommandResult ClearDesiredComponents(
|
|
RuntimeGenerationToken expectedGeneration) =>
|
|
Accepted(expectedGeneration);
|
|
|
|
public RuntimeCommandResult Advance(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
in RuntimeAdvancementCommand command) =>
|
|
Accepted(expectedGeneration, command.StatId);
|
|
|
|
public RuntimeCommandResult SetOptions1(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
uint options) =>
|
|
Accepted(expectedGeneration);
|
|
|
|
private RuntimeCommandResult Accepted(
|
|
RuntimeGenerationToken generation,
|
|
uint objectId = 0u)
|
|
{
|
|
LastGeneration = generation;
|
|
return new RuntimeCommandResult(
|
|
RuntimeCommandStatus.Accepted,
|
|
generation,
|
|
objectId);
|
|
}
|
|
}
|
|
|
|
private sealed class SelectionQuery : IRetainedUiSelectionQuery
|
|
{
|
|
public bool ShouldShowHealth(uint serverGuid) => true;
|
|
public VividTargetInfo? ResolveVividTargetInfo(uint serverGuid) =>
|
|
new(Vector3.Zero, 3f, 0u, 0u);
|
|
public bool IsWithinExternalContainerUseRange(uint serverGuid) => false;
|
|
}
|
|
|
|
private sealed class AutomationRuntime
|
|
: AcDream.App.UI.Testing.IRetailUiAutomationRuntime
|
|
{
|
|
private sealed record CompletedCheckpoint(int Sequence, string Name)
|
|
: AcDream.App.UI.Testing.IRetailUiAutomationCheckpoint
|
|
{
|
|
public AcDream.App.UI.Testing.RetailUiAutomationCheckpointStatus Status =>
|
|
AcDream.App.UI.Testing.RetailUiAutomationCheckpointStatus.Succeeded;
|
|
public string? Error => null;
|
|
}
|
|
|
|
public bool IsWorldReady => true;
|
|
public bool IsWorldViewportVisible => true;
|
|
public int PortalMaterializationCount => 2;
|
|
public string? LastCheckpoint { get; private set; }
|
|
|
|
public bool TryRequestCheckpoint(
|
|
string name,
|
|
out AcDream.App.UI.Testing.IRetailUiAutomationCheckpoint? checkpoint,
|
|
out string error)
|
|
{
|
|
LastCheckpoint = name;
|
|
checkpoint = new CompletedCheckpoint(1, name);
|
|
error = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
public void CancelCheckpoint(
|
|
AcDream.App.UI.Testing.IRetailUiAutomationCheckpoint checkpoint)
|
|
{
|
|
}
|
|
|
|
public bool TryRequestScreenshot(string name, out string error)
|
|
{
|
|
error = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
public bool IsScreenshotComplete(string name) => true;
|
|
}
|
|
|
|
private sealed class RetryBinding(int failures) : IDisposable
|
|
{
|
|
private int _failures = failures;
|
|
public int Calls { get; private set; }
|
|
|
|
public void Dispose()
|
|
{
|
|
Calls++;
|
|
if (_failures > 0)
|
|
{
|
|
_failures--;
|
|
throw new InvalidOperationException("retry");
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed class EmptyRadarSource : ILiveEntityRadarSource
|
|
{
|
|
public static EmptyRadarSource Instance { get; } = new();
|
|
|
|
public bool TryGetMaterialized(
|
|
uint serverGuid,
|
|
out WorldEntity entity)
|
|
{
|
|
entity = null!;
|
|
return false;
|
|
}
|
|
|
|
public bool TryGetVisible(uint serverGuid, out WorldEntity entity)
|
|
{
|
|
entity = null!;
|
|
return false;
|
|
}
|
|
|
|
public void CopyVisibleTo(
|
|
List<KeyValuePair<uint, WorldEntity>> destination) =>
|
|
destination.Clear();
|
|
}
|
|
|
|
private static T Stub<T>() where T : class =>
|
|
(T)RuntimeHelpers.GetUninitializedObject(typeof(T));
|
|
}
|