refactor(app): compose interaction and retained UI startup

This commit is contained in:
Erik 2026-07-22 17:20:47 +02:00
parent f663b04a54
commit aa6ffa5176
15 changed files with 2157 additions and 467 deletions

View file

@ -0,0 +1,332 @@
using System.Runtime.CompilerServices;
using AcDream.App.Combat;
using AcDream.App.Composition;
using AcDream.App.Diagnostics;
using AcDream.App.Rendering;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.App.World;
namespace AcDream.App.Tests.Composition;
public sealed class InteractionRetainedUiCompositionTests
{
private static readonly InteractionRetainedUiCompositionPoint[] UiPoints =
[
InteractionRetainedUiCompositionPoint.UiHostAcquired,
InteractionRetainedUiCompositionPoint.InputCaptureBound,
InteractionRetainedUiCompositionPoint.CursorAssetsCreated,
InteractionRetainedUiCompositionPoint.CharacterSheetCreated,
InteractionRetainedUiCompositionPoint.MagicRuntimeCreated,
InteractionRetainedUiCompositionPoint.MouseInputWired,
InteractionRetainedUiCompositionPoint.KeyboardInputWired,
InteractionRetainedUiCompositionPoint.UiAssetsCreated,
InteractionRetainedUiCompositionPoint.UiProbeCreated,
InteractionRetainedUiCompositionPoint.UiRuntimeMounted,
InteractionRetainedUiCompositionPoint.InventoryContainerBound,
];
[Fact]
public void EnabledUiPublishesOneExactResultAfterFrozenConstructionOrder()
{
var fixture = new Fixture(retailUi: true);
InteractionRetainedUiResult result = fixture.Compose();
Assert.Same(result, fixture.Publication.Result);
Assert.Equal(
[
InteractionRetainedUiCompositionPoint.LateBindingsCreated,
InteractionRetainedUiCompositionPoint.CombatAttackCreated,
InteractionRetainedUiCompositionPoint.CombatTargetCreated,
InteractionRetainedUiCompositionPoint.ExternalContainerLifecycleCreated,
InteractionRetainedUiCompositionPoint.ItemInteractionCreated,
.. UiPoints,
InteractionRetainedUiCompositionPoint.ResultPublished,
], fixture.Points);
Assert.NotNull(result.RetainedUi);
Assert.Empty(fixture.Factory.Releases);
}
[Fact]
public void DisabledUiAcquiresNoRetainedFrontendResource()
{
var fixture = new Fixture(retailUi: false);
InteractionRetainedUiResult result = fixture.Compose();
Assert.Null(result.RetainedUi);
Assert.Equal(
[
InteractionRetainedUiCompositionPoint.LateBindingsCreated,
InteractionRetainedUiCompositionPoint.CombatAttackCreated,
InteractionRetainedUiCompositionPoint.CombatTargetCreated,
InteractionRetainedUiCompositionPoint.ExternalContainerLifecycleCreated,
InteractionRetainedUiCompositionPoint.ItemInteractionCreated,
InteractionRetainedUiCompositionPoint.RetainedUiDisabled,
InteractionRetainedUiCompositionPoint.ResultPublished,
], fixture.Points);
Assert.Equal(0, fixture.Factory.RetainedUiCalls);
}
[Theory]
[MemberData(nameof(EnabledFailurePoints))]
public void FaultAtEveryEnabledBoundaryStopsSuffixAndRollsBackUnpublishedPrefix(
int pointValue)
{
var point = (InteractionRetainedUiCompositionPoint)pointValue;
var fixture = new Fixture(retailUi: true, failurePoint: point);
Assert.Throws<InvalidOperationException>(fixture.Compose);
Assert.Equal(point, fixture.Points[^1]);
if (point == InteractionRetainedUiCompositionPoint.ResultPublished)
{
Assert.Empty(fixture.Factory.Releases);
Assert.NotNull(fixture.Publication.Result);
}
else
{
Assert.Null(fixture.Publication.Result);
Assert.Equal(ExpectedRollback(point), fixture.Factory.Releases);
}
}
public static TheoryData<int> EnabledFailurePoints()
{
var data = new TheoryData<int>();
foreach (InteractionRetainedUiCompositionPoint point in
Enum.GetValues<InteractionRetainedUiCompositionPoint>())
{
if (point != InteractionRetainedUiCompositionPoint.RetainedUiDisabled)
data.Add((int)point);
}
return data;
}
private static string[] ExpectedRollback(
InteractionRetainedUiCompositionPoint point)
{
var acquired = new List<string> { "late bindings" };
if (point >= InteractionRetainedUiCompositionPoint.CombatAttackCreated)
acquired.Add("combat attack");
if (point >= InteractionRetainedUiCompositionPoint.CombatTargetCreated)
acquired.Add("combat target");
if (point >= InteractionRetainedUiCompositionPoint.ExternalContainerLifecycleCreated)
acquired.Add("external container");
if (point >= InteractionRetainedUiCompositionPoint.ItemInteractionCreated)
acquired.Add("item interaction");
if (point >= InteractionRetainedUiCompositionPoint.UiHostAcquired)
acquired.Add("retained UI lease");
acquired.Reverse();
return acquired.ToArray();
}
[Fact]
public void PublicationFailureRollsBackCompleteUnpublishedPrefix()
{
var fixture = new Fixture(retailUi: true, publicationFailure: true);
Assert.Throws<InvalidOperationException>(fixture.Compose);
Assert.Equal(
[
"retained UI lease",
"item interaction",
"external container",
"combat target",
"combat attack",
"late bindings",
], fixture.Factory.Releases);
}
[Fact]
public void GameWindowUsesPhaseAndContainsNoRetainedUiConstructionBody()
{
string source = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Rendering",
"GameWindow.cs"));
Assert.Contains("new InteractionRetainedUiCompositionPhase(", source);
Assert.DoesNotContain("new AcDream.App.UI.ItemInteractionController(", source);
Assert.DoesNotContain("_retailUiLease.AcquireHost(", source);
Assert.DoesNotContain("RetailUiRuntime.CreateUninitialized(", source);
Assert.DoesNotContain("private void UseItemByGuid(", source);
Assert.DoesNotContain("private uint? PickWorldGuidAtCursor(", source);
}
private sealed class Fixture
{
private readonly InteractionRetainedUiCompositionPoint? _failurePoint;
public Fixture(
bool retailUi,
InteractionRetainedUiCompositionPoint? failurePoint = null,
bool publicationFailure = false)
{
_failurePoint = failurePoint;
Factory = new FakeFactory();
Publication = new Publication(publicationFailure);
RuntimeOptions options = RuntimeOptions.Parse("dat", static _ => null)
with { RetailUi = retailUi };
Dependencies = new InteractionRetainedUiDependencies(
Options: options,
Gl: null!,
Window: null!,
Input: null!,
ShadersDirectory: "shaders",
Dats: null!,
DatLock: new object(),
TextureCache: null!,
DebugFont: null,
HostQuiescence: null!,
RetainedInputCapture: null!,
InputDispatcher: null,
Settings: null!,
Combat: null!,
CombatAttackOperations: null!,
Selection: null!,
ExternalContainers: null!,
Objects: null!,
MagicCatalog: null!,
Spellbook: null!,
Chat: null!,
LocalPlayer: null!,
ItemMana: null!,
StackSplitQuantity: null!,
UiRegistry: null,
CombatModeCommands: null!,
PlayerIdentity: null!,
PlayerController: null!,
PlayerMode: null!,
CharacterOptions: null!,
Shortcuts: null!,
SelectionCameraFactory: static _ => Stub<SelectionCameraSource>(),
FrameDiagnostics: new DeferredRenderFrameDiagnosticsSource(),
ExistingVitals: null,
Toast: null,
ClientTime: static () => 0d,
Log: static _ => { });
}
public InteractionRetainedUiDependencies Dependencies { get; }
public FakeFactory Factory { get; }
public Publication Publication { get; }
public List<InteractionRetainedUiCompositionPoint> Points { get; } = [];
public InteractionRetainedUiResult Compose() =>
new InteractionRetainedUiCompositionPhase(
Dependencies,
new RetailUiRuntimeLease(),
Publication,
Factory,
point =>
{
Points.Add(point);
if (_failurePoint == point)
throw new InvalidOperationException($"fault at {point}");
}).Compose();
}
private sealed class FakeFactory : IInteractionRetainedUiCompositionFactory
{
private readonly Dictionary<object, string> _names =
new(ReferenceEqualityComparer.Instance);
public List<string> Releases { get; } = [];
public int RetainedUiCalls { get; private set; }
public CombatAttackController CreateCombatAttack(
InteractionRetainedUiDependencies dependencies) =>
Resource<CombatAttackController>("combat attack");
public CombatTargetController CreateCombatTarget(
InteractionRetainedUiDependencies dependencies,
DeferredSelectionUiAuthority selection) =>
Resource<CombatTargetController>("combat target");
public ExternalContainerLifecycleController CreateExternalContainerLifecycle(
InteractionRetainedUiDependencies dependencies,
DeferredLiveSessionUiAuthority session) =>
Resource<ExternalContainerLifecycleController>("external container");
public ItemInteractionController CreateItemInteraction(
InteractionRetainedUiDependencies dependencies,
InteractionUiLateBindings lateBindings) =>
Resource<ItemInteractionController>("item interaction");
public RetainedUiComposition CreateRetainedUi(
InteractionRetainedUiDependencies dependencies,
InteractionUiLateBindings lateBindings,
RetailUiRuntimeLease lease,
CombatAttackController combatAttack,
ItemInteractionController itemInteraction,
Action<InteractionRetainedUiCompositionPoint> checkpoint)
{
RetainedUiCalls++;
_names.Add(lease, "retained UI lease");
foreach (InteractionRetainedUiCompositionPoint point in UiPoints)
checkpoint(point);
return new RetainedUiComposition(
Stub<UiHost>(),
Stub<RetailUiRuntime>(),
Stub<AcDream.UI.Abstractions.Panels.Vitals.VitalsVM>(),
Stub<AcDream.UI.Abstractions.Panels.Chat.ChatVM>(),
Stub<CharacterSheetProvider>(),
Stub<AcDream.App.Spells.MagicRuntime>(),
null);
}
public void Release(IDisposable resource)
{
string name = _names.TryGetValue(resource, out string? found)
? found
: resource switch
{
InteractionUiLateBindings => "late bindings",
RetailUiRuntimeLease => "retained UI lease",
_ => throw new InvalidOperationException(
$"Unknown test resource {resource.GetType().Name}"),
};
Releases.Add(name);
}
private T Resource<T>(string name) where T : class
{
T value = Stub<T>();
_names.Add(value, name);
return value;
}
}
private sealed class Publication(bool fail)
: IGameWindowInteractionRetainedUiPublication
{
public InteractionRetainedUiResult? Result { get; private set; }
public void PublishInteractionRetainedUi(InteractionRetainedUiResult result)
{
if (fail)
throw new InvalidOperationException("publication failed");
Result = result;
}
}
private static T Stub<T>() where T : class =>
(T)RuntimeHelpers.GetUninitializedObject(typeof(T));
private static string FindRepoRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
return directory.FullName;
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
}
}

View file

@ -0,0 +1,223 @@
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 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));
}

View file

@ -58,10 +58,9 @@ public sealed class GameWindowSlice8BoundaryTests
"this).Compose(platform, hostInputCamera);",
"new SettingsDevToolsCompositionPhase(",
"this).Compose(platform, hostInputCamera, contentEffectsAudio);",
"_uiHost = _retailUiLease.AcquireHost(",
"_uiHost.WireMouse(m)",
"_uiHost.WireKeyboard(kb)",
"_retailUiRuntime = _retailUiLease.Mount(",
"InteractionRetainedUiResult interactionUi =",
"new InteractionRetainedUiCompositionPhase(",
"this).Compose(",
"_liveEntities = new AcDream.App.World.LiveEntityRuntime(",
"_selectionInteractions ??= new AcDream.App.Interaction.SelectionInteractionController(",
"_retainedUiGameplayBinding =",
@ -220,7 +219,7 @@ public sealed class GameWindowSlice8BoundaryTests
"private AcDream.App.Net.LiveSessionHost");
string body = MethodBody(
"private void OnFramebufferResize(",
"private uint? PickWorldGuidAtCursor(");
"private (uint Claim, bool Unhydratable)? _spawnClaimRangeMemo;");
Assert.Contains("=> _framebufferResize.Resize(newSize);", body, StringComparison.Ordinal);
Assert.DoesNotContain("Viewport(", body, StringComparison.Ordinal);