Move the live-session reset and routing graph, combat and diagnostic command targets, and the sole gameplay input subscriber into Phase 7 before frame publication. Add exact retryable ownership for late bindings so partial startup cannot strand session or component teardown edges. Co-authored-by: Codex <codex@openai.com>
240 lines
7.8 KiB
C#
240 lines
7.8 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.Core.Net;
|
|
using AcDream.Core.World;
|
|
using AcDream.UI.Abstractions;
|
|
|
|
namespace AcDream.App.Tests.Composition;
|
|
|
|
public sealed class InteractionUiRuntimeSourcesTests
|
|
{
|
|
[Fact]
|
|
public void DesiredComponentSnapshotNormalizesNullAndClearsExactly()
|
|
{
|
|
var state = new DesiredComponentSnapshotState();
|
|
IReadOnlyList<(uint Id, uint Amount)> items =
|
|
[(0x01020304u, 7u)];
|
|
|
|
Assert.Empty(state.Items);
|
|
state.Items = items;
|
|
Assert.Same(items, state.Items);
|
|
|
|
state.Clear();
|
|
Assert.Empty(state.Items);
|
|
state.Items = null!;
|
|
Assert.Empty(state.Items);
|
|
}
|
|
|
|
[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 RadarNeverCachesAnUnboundBootstrapSnapshot()
|
|
{
|
|
var source = new DeferredRadarSnapshotSource();
|
|
Assert.Same(UiRadarSnapshot.Empty, source.Snapshot());
|
|
|
|
var provider = new RadarSnapshotProvider(
|
|
new AcDream.Core.Items.ClientObjectTable(),
|
|
static () => new Dictionary<uint, WorldEntity>(),
|
|
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.TryWriteCheckpoint("early", out string earlyError));
|
|
Assert.Contains("not bound", earlyError);
|
|
|
|
var target = new AutomationRuntime();
|
|
IDisposable binding = source.Bind(target);
|
|
Assert.True(source.IsWorldReady);
|
|
Assert.True(source.TryWriteCheckpoint("ready", 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 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
|
|
{
|
|
public bool IsWorldReady => true;
|
|
public bool IsWorldViewportVisible => true;
|
|
public int PortalMaterializationCount => 2;
|
|
public string? LastCheckpoint { get; private set; }
|
|
|
|
public bool TryWriteCheckpoint(string name, out string error)
|
|
{
|
|
LastCheckpoint = name;
|
|
error = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
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 static T Stub<T>() where T : class =>
|
|
(T)RuntimeHelpers.GetUninitializedObject(typeof(T));
|
|
}
|