Mounts host 0x2100006E slot 0x1000018C (RetailPanelCatalog.MapHouse = 16) as a two-tab UiTabPanel (Map default, House second) through the OP3/FA3 recipe (LayoutImporter.Build -> Bind -> ActivateTabBehavior). Toolbar button 0x1000019A un-ghosts (added to both RetailPanelCatalog.Mounted and .Toolbar). Combined into one commit because MapHousePanelController.Bind depends on both MapPageController and HousePageController existing — splitting them would mean landing dead code first. Map tab (gmMapUI, MapPageController): - Calendar formatter matching gmMapUI::Update's "Date: %s\nTime: %s" shape, reusing WorldTimeService.CurrentCalendar (new Func<DerethDateTime.Calendar> dependency threaded through InteractionRetainedUiDependencies/GameWindow — a stable long-lived service, not routed through the deferred-binding machinery Radar's per-session state needs). MonthName enum values already match retail display text; HourName's "AndHalf" suffix is rewritten to "-and-Half". - Coordinate math + marker placement reuse RadarCoordinates/ LandDefs.GidToLcoord verbatim (both already byte-exact ports of CPlayerSystem::InqPlayerCoords/LandDefs::gid_to_lcoord) — no re-port. PlaceMarkerOnMap's centering math (m_x0 + x - w/2) ported from gmMapUI::PlaceMarkerOnMap @0x004a18b0. Indoor gating clears the coordinate text and hides the player marker, matching gmMapUI::Update's else branch. - 53-town s_rgLocations table ported verbatim into MapLocations.cs. Markers built once at bind time via the panel's own RowTemplateResolver against m_pMap's authored hotspot-template attrs (0x47/0x48), with literal-string tooltips through AuthoredTooltipText/Enabled (RetailTooltipPresenter) — closes divergence-register row TS-85's last item, gmMapUI::AddMapNote @0x004A1C51. - Structural finding: m_pMap (0x100001EC) is itself authored as a Type-1 BUTTON (the GM click-to-teleport hook at gmMapUI::ListenToElementMessage), and the player/house icons (0x100001ED/EE) are its own NESTED children, not siblings — UiButton.ConsumesDatChildren swallows them from the normally-built tree. Both are re-resolved standalone through the same template resolver the town hotspots use and reattached under m_pMap. House tab (gmHouseUI, HousePageController): mounts the ListBox (0x100001E6) with its authored row template, wired to an empty Lines() source by default — genuinely empty until Slice 4's wire lands, matching retail's own PostInit (no Update call, no static content). 21 new tests (7 MapHousePanelControllerTests, 14 MapPageControllerTests): tab table pairing, close button, town-hotspot count/tooltips, calendar formatter golden values (Frostfell 27/119 P.Y., every HourName incl. AndHalf), player/house marker placement and indoor-gating reproduced against the real fixture via already-tested RadarCoordinates (no re-derivation). Fixture map_house_2100006E_1000018C.json captured via the shared RetailLayoutFixtureGenerator (other 34 fixtures deliberately NOT regenerated — out of scope for this batch, would touch unrelated panels' schema drift). Full solution builds clean; App suite 5391/0 failed/71 skipped (non-live; one earlier flaky streaming failure unrelated to this change, confirmed pre-existing on the branch before these commits). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
434 lines
17 KiB
C#
434 lines
17 KiB
C#
using System.Runtime.CompilerServices;
|
|
using AcDream.App.Combat;
|
|
using AcDream.App.Composition;
|
|
using AcDream.App.Diagnostics;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Spells;
|
|
using AcDream.Content;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
using AcDream.App.World;
|
|
using AcDream.Core.Combat;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Spells;
|
|
using AcDream.Runtime;
|
|
using AcDream.Runtime.Gameplay;
|
|
using AcDream.UI.Abstractions.Panels.Chat;
|
|
|
|
namespace AcDream.App.Tests.Composition;
|
|
|
|
public sealed class InteractionRetainedUiCompositionTests
|
|
{
|
|
private static readonly InteractionRetainedUiCompositionPoint[] UiPoints =
|
|
[
|
|
InteractionRetainedUiCompositionPoint.UiHostAcquired,
|
|
InteractionRetainedUiCompositionPoint.InputCaptureBound,
|
|
InteractionRetainedUiCompositionPoint.CursorAssetsCreated,
|
|
InteractionRetainedUiCompositionPoint.CharacterSheetCreated,
|
|
InteractionRetainedUiCompositionPoint.MouseInputWired,
|
|
InteractionRetainedUiCompositionPoint.KeyboardInputWired,
|
|
InteractionRetainedUiCompositionPoint.UiAssetsCreated,
|
|
InteractionRetainedUiCompositionPoint.UiProbeCreated,
|
|
InteractionRetainedUiCompositionPoint.UiRuntimeMounted,
|
|
InteractionRetainedUiCompositionPoint.InventoryContainerBound,
|
|
];
|
|
|
|
[Fact]
|
|
public void EnabledUiPublishesOneExactResultAfterFrozenConstructionOrder()
|
|
{
|
|
using var fixture = new Fixture(retailUi: true);
|
|
|
|
InteractionRetainedUiResult result = fixture.Compose();
|
|
|
|
Assert.Same(result, fixture.Publication.Result);
|
|
Assert.Equal(
|
|
[
|
|
InteractionRetainedUiCompositionPoint.LateBindingsCreated,
|
|
InteractionRetainedUiCompositionPoint.CombatTargetCreated,
|
|
InteractionRetainedUiCompositionPoint.ExternalContainerLifecycleCreated,
|
|
InteractionRetainedUiCompositionPoint.ItemInteractionCreated,
|
|
InteractionRetainedUiCompositionPoint.MagicRuntimeCreated,
|
|
.. UiPoints,
|
|
InteractionRetainedUiCompositionPoint.ResultPublished,
|
|
], fixture.Points);
|
|
Assert.NotNull(result.RetainedUi);
|
|
Assert.NotNull(result.Magic);
|
|
Assert.Empty(fixture.Factory.Releases);
|
|
}
|
|
|
|
[Fact]
|
|
public void DisabledUiAcquiresNoRetainedFrontendResource()
|
|
{
|
|
using var fixture = new Fixture(retailUi: false);
|
|
|
|
InteractionRetainedUiResult result = fixture.Compose();
|
|
|
|
Assert.Null(result.RetainedUi);
|
|
Assert.NotNull(result.Magic);
|
|
Assert.Equal(
|
|
[
|
|
InteractionRetainedUiCompositionPoint.LateBindingsCreated,
|
|
InteractionRetainedUiCompositionPoint.CombatTargetCreated,
|
|
InteractionRetainedUiCompositionPoint.ExternalContainerLifecycleCreated,
|
|
InteractionRetainedUiCompositionPoint.ItemInteractionCreated,
|
|
InteractionRetainedUiCompositionPoint.MagicRuntimeCreated,
|
|
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;
|
|
using var fixture = new Fixture(retailUi: true, failurePoint: point);
|
|
|
|
Assert.Throws<InvalidOperationException>(fixture.Compose);
|
|
|
|
Assert.Equal(point, fixture.Points[^1]);
|
|
Assert.False(
|
|
fixture.Dependencies.Runtime.CaptureOwnership().IsDisposeRequested);
|
|
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.ExternalContainerLifecycleCreated)
|
|
acquired.Add("external container");
|
|
if (point >= InteractionRetainedUiCompositionPoint.ItemInteractionCreated)
|
|
acquired.Add("item interaction");
|
|
if (point >= InteractionRetainedUiCompositionPoint.MagicRuntimeCreated)
|
|
acquired.Add("magic runtime");
|
|
if (point >= InteractionRetainedUiCompositionPoint.UiHostAcquired)
|
|
acquired.Add("retained UI lease");
|
|
acquired.Reverse();
|
|
return acquired.ToArray();
|
|
}
|
|
|
|
[Fact]
|
|
public void PublicationFailureRollsBackCompleteUnpublishedPrefix()
|
|
{
|
|
using var fixture = new Fixture(retailUi: true, publicationFailure: true);
|
|
|
|
Assert.Throws<InvalidOperationException>(fixture.Compose);
|
|
Assert.False(
|
|
fixture.Dependencies.Runtime.CaptureOwnership().IsDisposeRequested);
|
|
|
|
Assert.Equal(
|
|
[
|
|
"retained UI lease",
|
|
"magic runtime",
|
|
"item interaction",
|
|
"external container",
|
|
"late bindings",
|
|
], fixture.Factory.Releases);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Consolidated-review round (2026-08-10), SHOULD-FIX 2: the a5a7eb4f
|
|
/// defect class — a composed <see cref="ChatVM.OnInterfaceText"/> hook
|
|
/// wired but never transferred to production — had no test, because
|
|
/// <see cref="FakeFactory.CreateRetainedUi"/> above substitutes a
|
|
/// <see cref="Stub{T}"/> <see cref="ChatVM"/> for the composition-order
|
|
/// tests and never exercises the REAL wiring in
|
|
/// <see cref="RetailInteractionRetainedUiCompositionFactory.CreateChatViewModel"/>.
|
|
/// This test calls that production method directly (it only touches
|
|
/// <see cref="InteractionRetainedUiDependencies.Communication"/>, no
|
|
/// GPU/dat/UiHost dependencies, so the existing null-heavy
|
|
/// <see cref="Fixture"/> dependencies are sufficient without a full
|
|
/// <see cref="Fixture.Compose"/>) and proves the hook both EXISTS and
|
|
/// actually reaches <see cref="RuntimeCommunicationState.SpewBox"/>,
|
|
/// not merely that it was assigned.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ComposedChatViewModelWiresOnInterfaceTextToSpewBox()
|
|
{
|
|
using var fixture = new Fixture(retailUi: true);
|
|
|
|
ChatVM chat = RetailInteractionRetainedUiCompositionFactory
|
|
.CreateChatViewModel(fixture.Dependencies);
|
|
|
|
Assert.NotNull(chat.OnInterfaceText);
|
|
|
|
const string probeText = "consolidated-review SHOULD-FIX 2 probe";
|
|
chat.ShowInterfaceText(probeText);
|
|
// ShowInterfaceText only enqueues; SpewBoxState decouples enqueue
|
|
// from visibility (see its own class remarks) -- Tick drains it.
|
|
fixture.Dependencies.Runtime.CommunicationOwner.SpewBox.Tick(0);
|
|
|
|
Assert.Contains(
|
|
fixture.Dependencies.Runtime.CommunicationOwner.SpewBox.Snapshot(),
|
|
entry => entry.Text == probeText);
|
|
}
|
|
|
|
[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 : IDisposable
|
|
{
|
|
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 };
|
|
GameRuntime runtime = GameRuntimeTestFactory.Create(
|
|
new NoopCombatOperations(),
|
|
new NoopCombatTargetOperations(),
|
|
new NoopCombatModeOperations(),
|
|
new NoopSpellOperations());
|
|
Dependencies = new InteractionRetainedUiDependencies(
|
|
Options: options,
|
|
Graphics: null!,
|
|
BackbufferReader: static (_, _) => [],
|
|
Window: null!,
|
|
Input: null!,
|
|
ShadersDirectory: "shaders",
|
|
Dats: null!,
|
|
DatLock: new object(),
|
|
TextureCache: null!,
|
|
DebugFont: null,
|
|
HostQuiescence: null!,
|
|
RetainedInputCapture: null!,
|
|
InputDispatcher: null,
|
|
KeyBindingsFilePath: "keybinds.json",
|
|
Settings: null!,
|
|
Runtime: runtime,
|
|
CombatAttackOperations: new NoopCombatOperations(),
|
|
CombatTargetOperations: new RuntimeCombatTargetOperationsSlot(),
|
|
SpellCastOperations: new RuntimeSpellCastOperationsSlot(),
|
|
MagicCatalog: null!,
|
|
StackSplitQuantity: null!,
|
|
UiRegistry: null,
|
|
CombatModeCommands: null!,
|
|
PlayerIdentity: null!,
|
|
PlayerMode: null!,
|
|
SelectionCameraFactory: static _ => Stub<SelectionCameraSource>(),
|
|
FrameDiagnostics: new DeferredRenderFrameDiagnosticsSource(),
|
|
ExistingVitals: null,
|
|
Toast: null,
|
|
ClientTime: static () => 0d,
|
|
Log: static _ => { },
|
|
GpuDevice: null!,
|
|
GpuFrameSource: null!,
|
|
CurrentCalendar: static () => default);
|
|
}
|
|
|
|
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();
|
|
|
|
public void Dispose() => Dependencies.Runtime.Dispose();
|
|
}
|
|
|
|
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 IDisposable BindCombatTarget(
|
|
InteractionRetainedUiDependencies dependencies,
|
|
DeferredSelectionUiAuthority selection) =>
|
|
new NoopDisposable();
|
|
|
|
public ExternalContainerLifecycleController CreateExternalContainerLifecycle(
|
|
InteractionRetainedUiDependencies dependencies,
|
|
DeferredLiveSessionUiAuthority session) =>
|
|
Resource<ExternalContainerLifecycleController>("external container");
|
|
|
|
public ItemInteractionController CreateItemInteraction(
|
|
InteractionRetainedUiDependencies dependencies,
|
|
InteractionUiLateBindings lateBindings) =>
|
|
Resource<ItemInteractionController>("item interaction");
|
|
|
|
public MagicRuntime CreateMagicRuntime(
|
|
InteractionRetainedUiDependencies dependencies,
|
|
InteractionUiLateBindings lateBindings,
|
|
ItemInteractionController itemInteraction) =>
|
|
Resource<MagicRuntime>("magic runtime");
|
|
|
|
public RetainedUiComposition CreateRetainedUi(
|
|
InteractionRetainedUiDependencies dependencies,
|
|
InteractionUiLateBindings lateBindings,
|
|
RetailUiRuntimeLease lease,
|
|
RuntimeCombatAttackState combatAttack,
|
|
ItemInteractionController itemInteraction,
|
|
MagicRuntime magic,
|
|
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>(),
|
|
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 NoopCombatOperations
|
|
: IRuntimeCombatAttackOperations
|
|
{
|
|
public bool CanStartAttack() => false;
|
|
public void PrepareAttackRequest() { }
|
|
public bool SendAttack(AttackHeight height, float power) => false;
|
|
public void SendCancelAttack() { }
|
|
public bool IsDualWield => false;
|
|
public bool PlayerReadyForAttack => false;
|
|
public bool AutoRepeatAttack => false;
|
|
}
|
|
|
|
private sealed class NoopDisposable : IDisposable
|
|
{
|
|
public void Dispose() { }
|
|
}
|
|
|
|
private sealed class NoopSpellOperations : IRuntimeSpellCastOperations
|
|
{
|
|
public uint LocalPlayerId => 0u;
|
|
public bool CanSend => false;
|
|
public bool HasRequiredComponents(uint spellId) => false;
|
|
public bool IsTargetCompatible(
|
|
uint targetId,
|
|
SpellMetadata spell,
|
|
bool showMessage) => false;
|
|
public void StopCompletely() { }
|
|
public void SendUntargeted(uint spellId) { }
|
|
public void SendTargeted(uint targetId, uint spellId) { }
|
|
public void DisplayMessage(string message) { }
|
|
public void IncrementBusy() { }
|
|
}
|
|
|
|
private sealed class NoopCombatTargetOperations
|
|
: IRuntimeCombatTargetOperations
|
|
{
|
|
public bool AutoTarget => false;
|
|
public uint? SelectClosestTarget() => null;
|
|
}
|
|
|
|
private sealed class NoopCombatModeOperations
|
|
: IRuntimeCombatModeOperations
|
|
{
|
|
public bool IsInWorld => false;
|
|
public IReadOnlyList<ClientObject> GetOrderedEquipment() => [];
|
|
public void NotifyExplicitCombatModeRequest() { }
|
|
public void SendChangeCombatMode(CombatMode mode) { }
|
|
}
|
|
|
|
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.");
|
|
}
|
|
}
|