acdream/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs
Erik ef96c55489 fix(ui,net): Campaign LA gate round 2 — char-select exit confirmation, authored row justify, world name
Finding 1 (Exit button dead): retail's gmCharacterManagementUI Exit
button (element 0x100003A4, offset 7 from the listbox base in
ListenToElementMessage@0x004ed5a0) opens MakeConfirmExitDialog
(0x004ed250), whose exact ID_CharacterManagement_ConfirmExit text
(table 0x23000002) and m_confirmExitDialogContext re-entry guard are
now ported. On confirm (matching RecvNotice_CloseDialog@0x004ed760
case 1's ConfirmationResult check) the client exits through the
EXISTING graceful window-close seam (CharacterSelectionRuntimeBindings
.RequestExit -> d.Window.Close, the same delegate
GameplayInputCommandController's Escape fallback already uses) so
disconnected/exited status events still fire via GameWindow.OnClosing
-> CompleteShutdown. Retail's real post-confirm destination is
QueueUIMode(0x10000009) -> gmEpilogueUI, an epilogue screen this round
does not port — recorded as AD-99. Credits (element 0x100003A3,
QueueUIMode(0x10000005) -> gmCreditsUI) stays visibly ghosted like
Create, same treatment, out of scope this round.

Finding 2 (row names center-aligned, retail is left): the character
row template (LayoutDesc 0x21000004, element 0x100003A5, live-DAT
confirmed HJustify=Left with three stateful Type-3 highlight-art
children and no Type-12 caption child) authors its OWN justify
directly, with no separate text child to lift a label from.
DatWidgetFactory.BuildButton's Left-justify branch required
!ReferenceEquals(labelInfo, info) — true only when a label was LIFTED
from a distinct child — so a button's own direct HJustify=Left was
silently dropped to UiButton's Center default. Widened the branch to
also honor the direct case, preserving the existing lifted-child
LabelOffsetX behavior and leaving genuinely-centered buttons
(CREATE/ENTER/DELETE/RESTORE) untouched.

Finding 3 (World box empty): parsed ACE's GameMessageServerName
(opcode 0xF7E1, ACE.Server/Network/GameMessages/Messages/
GameMessageServerName.cs; retail CM_Login::DispatchUI_WorldInfo
@0x006ad860 -> ClientUISystem::Handle_Login__WorldInfo@0x005641a0 ->
ECM_Login::SendNotice_WorldName@0x00692b10, notice 0x186a2, consumed
by gmCharacterManagementUI::UpdateWorldName@0x004ec120 /
RecvNotice_WorldName@0x004ec360 onto element 0x1000039B) as
src/AcDream.Core.Net/Messages/ServerName.cs, cross-checked against
holtburger's ServerNameData. WorldSession.ServerNameReceived fires
alongside CharacterListReceived (ACE sends both in one
SendConnectResponse batch); RuntimeCharacterSelectionState.
ApplyWorldName is the new J-owner field (ungated by lifecycle, since
either message can arrive first); CharacterManagementUiController
binds it onto the WorldTextElementId UiText. Per the LA1 status
vocabulary, the characterList STATUS event's worldName field is
intentionally NOT added this round (kept bounded to the client-side
fix) — a follow-up if the launcher UI wants it.

Also corrects AD-44, discovered stale while filing AD-99: its opening
claim ("acdream has no retained character-management screen") was
false as of this session — LA7/LA8 shipped the screen in earlier
commits without updating this row.

Tests: exit-confirm open/cancel/confirm/re-entry-guard flow;
DatWidgetFactory own-HJustify-Left/Center regression tests plus the
live-DAT pinned row-justify assertion; ServerName parse round-trip
(byte-exact vs ACE's AceWireWriter fixture, truncation/wrong-opcode
cases); WorldSession dispatch test (roster+world in one wire batch);
RuntimeCharacterSelectionState.ApplyWorldName tests (order-independent
of ApplyRoster, unchanged-value no-op, Reset clears); controller test
binding the World text element to the live snapshot. Extended the
shared RetailDialogFactoryTests.BuildDialogLayout test fixture with a
Confirmation-type branch (Accept/Reject buttons) since this is its
first RetailDialogType.Confirmation consumer.

Suites: full solution Release build green; AcDream.App.Tests 5100/6
skips, AcDream.Core.Net.Tests 965/0, AcDream.Runtime.Tests 1665/0, all
Release, 0 failures; live-DAT probes (ACDREAM_PROBE_LIVE_MOUNT=1)
green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 11:50:45 +02:00

531 lines
19 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.Runtime.Session;
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 CharacterSelectionProjectionBorrowsExactAdapterAndEveryRouteBecomesInert()
{
var source = new DeferredGameRuntimeStateCommands();
var target = new RuntimeTarget(new RuntimeGenerationToken(11));
Assert.Null(source.CharacterSelection);
Assert.Equal(RuntimeCommandStatus.Inactive,
source.CharacterSelectionEnter().Status);
IDisposable binding = source.Bind(target, target);
Assert.Same(target, source.CharacterSelection);
RuntimeCommandResult[] accepted =
[
source.CharacterSelectionHighlight(0x50000001u),
source.CharacterSelectionEnter(),
source.CharacterSelectionRequestDelete(),
source.CharacterSelectionConfirmDelete(),
source.CharacterSelectionRestore(),
source.CharacterSelectionCancel(),
];
Assert.All(accepted, result =>
{
Assert.True(result.Accepted);
Assert.Equal(new RuntimeGenerationToken(11), result.Generation);
});
binding.Dispose();
Assert.Null(source.CharacterSelection);
Assert.Equal(RuntimeCommandStatus.Inactive,
source.CharacterSelectionRestore().Status);
source.Deactivate();
Assert.Null(source.CharacterSelection);
Assert.Equal(RuntimeCommandStatus.Inactive,
source.CharacterSelectionCancel().Status);
}
[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,
IRuntimeCharacterSelectionView,
IRuntimeCharacterSelectionCommands
{
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 IRuntimeCharacterSelectionView CharacterSelection => this;
public IRuntimeFellowshipView Fellowship => null!;
public IRuntimeAllegianceView Allegiance => null!;
public IRuntimeActionView Actions => null!;
public IRuntimeMovementView Movement => null!;
public AcDream.Runtime.World.IRuntimeWorldEnvironmentView Environment =>
null!;
public IRuntimePortalView Portal => null!;
public IRuntimeSessionCommands Session => null!;
IRuntimeCharacterSelectionCommands IGameRuntimeCommands.CharacterSelection => this;
public IRuntimeSelectionCommands Selection => null!;
public IRuntimeCombatCommands Combat => null!;
public IRuntimeMagicCommands Magic => 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!;
IRuntimeFellowshipCommands IGameRuntimeCommands.Fellowship => null!;
IRuntimeAllegianceCommands IGameRuntimeCommands.Allegiance => null!;
public RuntimeStateCheckpoint CaptureCheckpoint() => default;
RuntimeCharacterSelectionSnapshot IRuntimeCharacterSelectionView.Snapshot =>
new(
Generation,
RuntimeCharacterSelectionLifecycle.AwaitingSelection,
Revision: 1,
AccountName: "account",
SlotCount: 0,
RosterCount: 0,
WorldName: string.Empty,
HighlightedCharacterId: 0u,
HighlightedDisplayIndex: -1,
PendingDeleteCharacterId: 0u,
LastRestoreRequestedCharacterId: 0u,
Operation: RuntimeCharacterSelectionOperation.None,
Error: null,
Buttons: RuntimeCharacterSelectionButtons.None);
public bool TryGetAt(
int displayIndex,
out RuntimeCharacterSelectionEntry character)
{
character = default;
return false;
}
public bool TryGet(
uint characterId,
out RuntimeCharacterSelectionEntry character)
{
character = default;
return false;
}
public void Visit(IRuntimeCharacterSelectionVisitor visitor) { }
public IDisposable Subscribe(IRuntimeCharacterSelectionObserver observer) =>
EmptyDisposable.Instance;
public RuntimeCommandResult Highlight(
RuntimeGenerationToken expectedGeneration,
uint characterId) => Accepted(expectedGeneration, characterId);
public RuntimeCommandResult Enter(
RuntimeGenerationToken expectedGeneration) =>
Accepted(expectedGeneration);
public RuntimeCommandResult RequestDelete(
RuntimeGenerationToken expectedGeneration) =>
Accepted(expectedGeneration);
public RuntimeCommandResult ConfirmDelete(
RuntimeGenerationToken expectedGeneration) =>
Accepted(expectedGeneration);
public RuntimeCommandResult Restore(
RuntimeGenerationToken expectedGeneration) =>
Accepted(expectedGeneration);
public RuntimeCommandResult Cancel(
RuntimeGenerationToken expectedGeneration) =>
Accepted(expectedGeneration);
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 SetSingleOption(
RuntimeGenerationToken expectedGeneration,
uint optionId,
bool value) =>
Accepted(expectedGeneration);
public RuntimeCommandResult SaveOptions(
RuntimeGenerationToken expectedGeneration) =>
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 EmptyDisposable : IDisposable
{
public static EmptyDisposable Instance { get; } = new();
public void Dispose() { }
}
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));
}