User finding 1 (side-by-side vs retail): our world-object tooltips popped the instant the found object changed; retail's "lag". The night round's derivation from RecvNotice_SmartBoxObjectFound @0x004E5AD0 misread the notice as edge-MOUNTING: its immediate StartTooltipAtMouse @0x004E5DFB is inside `if (s_pInstance->m_dragElement != 0)` (@0x004E5D8E) — and m_dragElement is a real, distinct PDB field in acclient.h's UIElementManager (separate from the m_pTooltipElement family), so the immediate mount is DRAG-AND-DROP ONLY. The ordinary hover path merely STAGES the name (SetTooltip @0x004E5D74 + the |=0x20 TooltipOn bit) and the display rides the SAME UIElementManager::CheckTooltip @0x0045B6E0 mouse-idle dwell as UI tooltips: 250 ms (m_tooltipDelay @0x0045f75d) since m_lastMouseMoveTime (stamped on EVERY move, MouseMoveHandler @0x0045e736). Found swaps under an IDLE mouse replace the popup the same frame (SetTooltip's own text-change teardown @0x004617FF -> ResetTooltip @0x0045C360 tail-calling CheckTooltip); the 10 s duration expiry (@0x0045b78a) requires a fresh mouse move before re-arming (SwitchMouseOver(null) @0x0045b7b2 clears m_pElementLastEntered). Port: UiRoot gains the unconditional last-mouse-move stamp (m_lastMouseMoveTime 1:1 — the existing _hoverStartedMs stamps are deliberately conditional) exposed as MouseIdleMs/NowMs; RetailTooltipPresenter.UpdateWorldHoverTooltip now stages text at the notice edge (ShowTooltips gate + name resolve read there, @0x004E5D21/ @0x004E5D3B, empty-name SetTooltip skip @0x004E5D48 included) and mounts via the CheckTooltip dwell block (no-capture gate @0x0045b715, m_tooltipEnable via MouseHover @0x0046254C — which the drag-immediate branch faithfully bypasses). Session reset also forgets the staged text. Tests: the world-hover fixture section rewritten to the corrected model — found edge stages but never mounts before the dwell; a continuously moving mouse never mounts until it rests; idle found-swap replaces same-frame without stacking; duration auto-hide needs a move + fresh dwell to remount; drag-in-progress mounts immediately. 38/38 pass. Register TS-85 and ISSUES item 2 corrected honestly: the "edge-fired (no dwell)" conclusion is superseded by the user's retail evidence and the m_dragElement branch read. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1005 lines
41 KiB
C#
1005 lines
41 KiB
C#
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
|
|
namespace AcDream.App.Tests.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// #409 (client-wide retail tooltip system) fixture coverage for
|
|
/// <see cref="RetailTooltipPresenter"/> and its <see cref="UiRoot"/> hover-
|
|
/// timer wiring. Builds synthetic popup layouts in-memory (no dats) via
|
|
/// <see cref="LayoutImporter.BuildFromInfos"/> — see
|
|
/// <see cref="TooltipLiveDatTests"/> for the installed-DAT structural pins.
|
|
/// </summary>
|
|
public sealed class RetailTooltipPresenterTests
|
|
{
|
|
/// <summary>Minimal concrete hover target — plain <see cref="UiElement"/>
|
|
/// with default (false) ClickThrough, so it is hit-testable.</summary>
|
|
private sealed class HoverTarget : UiElement;
|
|
|
|
private const uint PopupRootId = 0x900u;
|
|
private const uint TextChildId = 0x901u;
|
|
private const uint PopupLayoutDid = 0x21000041u;
|
|
|
|
/// <summary>Builds a fresh 30x30/26x26 popup — the exact live-DAT-probed
|
|
/// shape (four-piece bevel frame around one Type-12 text child) — every
|
|
/// call, mirroring retail's own fresh-instance-per-show behaviour.</summary>
|
|
private static ImportedLayout BuildPopup()
|
|
{
|
|
var rootInfo = new ElementInfo
|
|
{
|
|
Id = PopupRootId, Type = 3, X = 0, Y = 0, Width = 30, Height = 30,
|
|
TooltipTextChildElementId = TextChildId,
|
|
};
|
|
var textInfo = new ElementInfo
|
|
{
|
|
Id = TextChildId, Type = 12, X = 2, Y = 2, Width = 26, Height = 26,
|
|
};
|
|
return LayoutImporter.BuildFromInfos(
|
|
rootInfo, [textInfo], _ => (0u, 0, 0), null);
|
|
}
|
|
|
|
private static (UiRoot Root, RetailTooltipPresenter Presenter, List<(uint, uint)> Requests)
|
|
CreateHarness()
|
|
{
|
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
|
var requests = new List<(uint LayoutDid, uint RootElementId)>();
|
|
var presenter = new RetailTooltipPresenter(root, (layoutDid, rootElementId) =>
|
|
{
|
|
requests.Add((layoutDid, rootElementId));
|
|
return BuildPopup();
|
|
});
|
|
return (root, presenter, requests);
|
|
}
|
|
|
|
private static HoverTarget AddFullyAuthoredTarget(UiRoot root, string text = "Rotate left.")
|
|
{
|
|
var target = new HoverTarget
|
|
{
|
|
Left = 100, Top = 100, Width = 40, Height = 20,
|
|
AuthoredTooltipEnabled = true,
|
|
AuthoredTooltipText = text,
|
|
AuthoredTooltipRootElementId = PopupRootId,
|
|
AuthoredTooltipLayoutDid = PopupLayoutDid,
|
|
};
|
|
root.AddChild(target);
|
|
return target;
|
|
}
|
|
|
|
[Fact]
|
|
public void NoTooltipShows_WhenPropertiesAbsent()
|
|
{
|
|
// Regression pin: an element that authors NONE of the five tooltip
|
|
// properties must never produce a popup, even after the dwell delay
|
|
// and the mouse resting on it.
|
|
var (root, _, requests) = CreateHarness();
|
|
var target = new HoverTarget { Left = 100, Top = 100, Width = 40, Height = 20 };
|
|
root.AddChild(target);
|
|
int childrenBefore = root.Children.Count;
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs + 50);
|
|
|
|
Assert.Empty(requests);
|
|
Assert.Equal(childrenBefore, root.Children.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void DelayThenShow_MountsPopupOnlyAfterTheDwellDelay()
|
|
{
|
|
var (root, _, requests) = CreateHarness();
|
|
AddFullyAuthoredTarget(root);
|
|
int childrenBefore = root.Children.Count;
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
Assert.Equal(childrenBefore, root.Children.Count); // not yet — dwell hasn't elapsed
|
|
|
|
root.Tick(0.016, root.TooltipDelayMs - 1);
|
|
Assert.Equal(childrenBefore, root.Children.Count); // still one ms short
|
|
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
Assert.Equal(childrenBefore + 1, root.Children.Count); // popup mounted
|
|
Assert.Single(requests, r => r == (PopupLayoutDid, PopupRootId));
|
|
}
|
|
|
|
[Fact]
|
|
public void GlobalEnableGateOff_SuppressesPresentation_ButTheDwellTimerStillFires()
|
|
{
|
|
// Retail gates the POPUP at UIElement::MouseHover, not the dwell
|
|
// timer itself (UIElementManager::CheckTooltip has no m_tooltipEnable
|
|
// check) — the C# TooltipShow event must still fire; only the
|
|
// presenter's own decision to build something is suppressed.
|
|
var (root, presenter, requests) = CreateHarness();
|
|
presenter.Enabled = false;
|
|
AddFullyAuthoredTarget(root);
|
|
int childrenBefore = root.Children.Count;
|
|
bool eventFired = false;
|
|
root.TooltipShow += _ => eventFired = true;
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
|
|
Assert.True(eventFired);
|
|
Assert.Empty(requests);
|
|
Assert.Equal(childrenBefore, root.Children.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void WidgetOwnTooltipDisabled_SuppressesPresentation()
|
|
{
|
|
var (root, _, requests) = CreateHarness();
|
|
var target = AddFullyAuthoredTarget(root);
|
|
target.AuthoredTooltipEnabled = false;
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
|
|
Assert.Empty(requests);
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingText_SuppressesPresentation()
|
|
{
|
|
var (root, _, requests) = CreateHarness();
|
|
var target = AddFullyAuthoredTarget(root);
|
|
target.AuthoredTooltipText = null;
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
|
|
Assert.Empty(requests);
|
|
}
|
|
|
|
[Fact]
|
|
public void DismissesOnHoverTargetChange()
|
|
{
|
|
var (root, _, _) = CreateHarness();
|
|
AddFullyAuthoredTarget(root);
|
|
var other = new HoverTarget { Left = 400, Top = 400, Width = 40, Height = 20 };
|
|
root.AddChild(other);
|
|
int childrenBeforeShow = root.Children.Count;
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
Assert.Equal(childrenBeforeShow + 1, root.Children.Count);
|
|
|
|
// Retail UIElementManager::SwitchMouseOver @0x0045B560: hovering a
|
|
// DIFFERENT element tears down the active tooltip immediately.
|
|
root.OnMouseMove(410, 410);
|
|
Assert.Equal(childrenBeforeShow, root.Children.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void DismissesWhenTheOwnerElementIsRemoved()
|
|
{
|
|
var (root, _, _) = CreateHarness();
|
|
var target = AddFullyAuthoredTarget(root);
|
|
int childrenBeforeShow = root.Children.Count;
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
Assert.Equal(childrenBeforeShow + 1, root.Children.Count);
|
|
|
|
// Retail UIElementManager::DeletingElement @0x0045E520: the tooltip's
|
|
// owner going away tears the popup down too.
|
|
root.RemoveChild(target);
|
|
Assert.Equal(childrenBeforeShow - 1, root.Children.Count); // target removed, popup removed
|
|
}
|
|
|
|
[Fact]
|
|
public void AutoHidesAfterTheDurationElapses()
|
|
{
|
|
var (root, _, _) = CreateHarness();
|
|
AddFullyAuthoredTarget(root);
|
|
int childrenBeforeShow = root.Children.Count;
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
Assert.Equal(childrenBeforeShow + 1, root.Children.Count);
|
|
|
|
root.Tick(0.016, root.TooltipDelayMs + root.TooltipDurationMs);
|
|
Assert.Equal(childrenBeforeShow, root.Children.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void PerElementDelayOverride_ReplacesTheGlobalDelay()
|
|
{
|
|
var (root, _, requests) = CreateHarness();
|
|
var target = AddFullyAuthoredTarget(root);
|
|
target.AuthoredTooltipDelaySeconds = 0f; // the live-DAT-probed override value
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0); // the very next tick after hover starts — no dwell wait
|
|
Assert.Single(requests);
|
|
}
|
|
|
|
[Fact]
|
|
public void PositionClampsToStayFullyOnTheDisplay()
|
|
{
|
|
var (root, _, _) = CreateHarness();
|
|
// Small canvas + a hover point near the bottom-right corner, so the
|
|
// 30x30 popup would overflow both edges without the clamp.
|
|
root.Width = 40f;
|
|
root.Height = 40f;
|
|
var target = new HoverTarget
|
|
{
|
|
Left = 0, Top = 0, Width = 40, Height = 40,
|
|
AuthoredTooltipEnabled = true,
|
|
AuthoredTooltipText = "hi",
|
|
AuthoredTooltipRootElementId = PopupRootId,
|
|
AuthoredTooltipLayoutDid = PopupLayoutDid,
|
|
};
|
|
root.AddChild(target);
|
|
|
|
root.OnMouseMove(38, 38);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
|
|
UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target));
|
|
Assert.True(popup.Left + popup.Width <= 40f, $"popup right edge {popup.Left + popup.Width} exceeds canvas width 40");
|
|
Assert.True(popup.Top + popup.Height <= 40f, $"popup bottom edge {popup.Top + popup.Height} exceeds canvas height 40");
|
|
Assert.True(popup.Left >= 0f);
|
|
Assert.True(popup.Top >= 0f);
|
|
}
|
|
|
|
[Fact]
|
|
public void AutoResizesTheRootByTheMeasuredTextDelta()
|
|
{
|
|
var (root, _, _) = CreateHarness();
|
|
var target = AddFullyAuthoredTarget(
|
|
root, text: "This is a much longer tooltip than the authored placeholder.");
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
|
|
UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target));
|
|
Assert.True(popup.Width > 30f, $"expected the popup to grow past its authored 30px width, got {popup.Width}");
|
|
}
|
|
|
|
// ── 2026-08-16 review fix round (F1-F11) pins ──────────────────────
|
|
|
|
[Fact]
|
|
public void F1_PositionsAtMouse_OffsetBy32PixelsOnBothAxes()
|
|
{
|
|
// UIElementManager::StartTooltip @0x00459700 adds a 32px (0x20)
|
|
// offset on BOTH axes before clamping (@0x00459739/@0x00459747) —
|
|
// the popup must not land flush at the cursor.
|
|
var (root, _, _) = CreateHarness();
|
|
var target = AddFullyAuthoredTarget(root);
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
|
|
UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target));
|
|
Assert.Equal(142f, popup.Left); // 110 + 32
|
|
Assert.Equal(142f, popup.Top); // 110 + 32
|
|
}
|
|
|
|
[Fact]
|
|
public void F2_MouseMoveWithinTheSameWidget_ResetsTheDwellClock()
|
|
{
|
|
// Retail's dwell timer anchors to mouse-IDLE, not hover-enter —
|
|
// UIElementManager::MouseMoveHandler @0x0045E710 stamps
|
|
// m_lastMouseMoveTime on EVERY move (@0x0045e729/@0x0045e736);
|
|
// CheckTooltip @0x0045B6E0 (@0x0045b747) compares against that.
|
|
// Jiggling the mouse within the SAME widget must keep pushing the
|
|
// deadline out, not leave the original hover-enter time in place.
|
|
var (root, _, requests) = CreateHarness();
|
|
AddFullyAuthoredTarget(root);
|
|
|
|
root.OnMouseMove(110, 110); // hover starts at nowMs=0
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs - 10); // nowMs=240, 10ms short
|
|
Assert.Empty(requests);
|
|
|
|
// Jiggle within the same widget at nowMs=240 — resets the deadline.
|
|
root.OnMouseMove(111, 111);
|
|
root.Tick(0.016, root.TooltipDelayMs); // nowMs=250 — the OLD deadline
|
|
Assert.Empty(requests); // must NOT have fired yet if the clock reset
|
|
|
|
root.Tick(0.016, (root.TooltipDelayMs - 10) + root.TooltipDelayMs); // nowMs=490 (240+250)
|
|
Assert.Single(requests);
|
|
}
|
|
|
|
[Fact]
|
|
public void F5_TextChildResolvesToSomethingOtherThanUiText_NeverMounts()
|
|
{
|
|
// Retail requires GetChildRecursive to resolve AND DynamicCast to
|
|
// UIElement_Text (type 0xc) before StartTooltip @0x0045DE90's
|
|
// positioning/show half ever runs (@0x0045df59/@0x0045df65/
|
|
// @0x0045df6f) — a text-child id that resolves to something else
|
|
// must produce NO popup at all, not an empty unsized bevel.
|
|
var rootInfo = new ElementInfo
|
|
{
|
|
Id = PopupRootId, Type = 3, X = 0, Y = 0, Width = 30, Height = 30,
|
|
TooltipTextChildElementId = TextChildId,
|
|
};
|
|
var notTextInfo = new ElementInfo
|
|
{
|
|
Id = TextChildId, Type = 3, X = 2, Y = 2, Width = 26, Height = 26, // type 3, NOT 12 -> UiDatElement
|
|
};
|
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
|
var requests = new List<(uint, uint)>();
|
|
var presenter = new RetailTooltipPresenter(root, (layoutDid, rootElementId) =>
|
|
{
|
|
requests.Add((layoutDid, rootElementId));
|
|
return LayoutImporter.BuildFromInfos(rootInfo, [notTextInfo], _ => (0u, 0, 0), null);
|
|
});
|
|
AddFullyAuthoredTarget(root);
|
|
int childrenBefore = root.Children.Count;
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
|
|
Assert.Single(requests); // the layout WAS built...
|
|
Assert.Equal(childrenBefore, root.Children.Count); // ...but never mounted
|
|
}
|
|
|
|
[Fact]
|
|
public void F6_MouseCaptureElsewhere_SuppressesTheDwellArm_UntilCaptureReleases()
|
|
{
|
|
// CheckTooltip @0x0045B6E0's arm branch is gated
|
|
// `m_pElementWithMouseCapture == 0` (@0x0045b715) — a widget
|
|
// hovered before a drag/resize/scrollbar-thumb capture began must
|
|
// not pop a tooltip mid-gesture.
|
|
var (root, _, requests) = CreateHarness();
|
|
AddFullyAuthoredTarget(root);
|
|
var other = new HoverTarget { Left = 400, Top = 400, Width = 40, Height = 20 };
|
|
root.AddChild(other);
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
|
|
root.SetCapture(other);
|
|
root.Tick(0.016, root.TooltipDelayMs + 50);
|
|
Assert.Empty(requests); // captured elsewhere — must not arm
|
|
|
|
root.ReleaseCapture(); // restarts the idle deadline at nowMs=300
|
|
root.Tick(0.016, root.TooltipDelayMs + 50 + root.TooltipDelayMs + 50);
|
|
Assert.Single(requests); // now arms normally
|
|
}
|
|
|
|
[Fact]
|
|
public void F7_ReleaseCaptureWhileATooltipIsAlreadyShown_DoesNotHideAndReshowIt()
|
|
{
|
|
// ReleaseMouseCapture @0x0045D2B0 touches ONLY the idle timestamp
|
|
// (m_lastMouseMoveTime), never m_bHoverStarted — a mouse-up while a
|
|
// tooltip is already up must leave it up, not clear-then-re-fire it
|
|
// 250ms later without ever going through TooltipHide.
|
|
var (root, _, requests) = CreateHarness();
|
|
var target = AddFullyAuthoredTarget(root);
|
|
int childrenBeforeShow = root.Children.Count;
|
|
bool hideFired = false;
|
|
root.TooltipHide += _ => hideFired = true;
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
Assert.Equal(childrenBeforeShow + 1, root.Children.Count); // tooltip showing
|
|
Assert.Single(requests);
|
|
|
|
root.SetCapture(target);
|
|
root.ReleaseCapture(); // restarts the idle deadline at nowMs=TooltipDelayMs
|
|
|
|
// Advance PAST where the buggy old code's re-armed deadline would
|
|
// land (TooltipDelayMs + TooltipDelayMs) — the bug reset
|
|
// _tooltipFired=false here, so the dwell-arm branch would refire
|
|
// OnTooltipShow (RemovePopup + rebuild) a full delay later, even
|
|
// though nothing about the hover ever changed.
|
|
root.Tick(0.016, root.TooltipDelayMs + root.TooltipDelayMs + 10);
|
|
Assert.False(hideFired, "tooltip must not hide on a mere capture release");
|
|
Assert.Equal(childrenBeforeShow + 1, root.Children.Count); // still showing
|
|
Assert.Single(requests); // still exactly one OnTooltipShow call, not re-fired
|
|
}
|
|
|
|
[Fact]
|
|
public void F8_AutoResizeAppliesTheAuthoredMaxWidthClamp()
|
|
{
|
|
// UIElement::ResizeTo @0x00463C30 clamps the auto-grown size
|
|
// against P0x3C/0x3D/0x3E/0x3F BEFORE assigning it.
|
|
var rootInfo = new ElementInfo
|
|
{
|
|
Id = PopupRootId, Type = 3, X = 0, Y = 0, Width = 30, Height = 30,
|
|
TooltipTextChildElementId = TextChildId,
|
|
MaxWidth = 40, // P0x3D
|
|
};
|
|
var textInfo = new ElementInfo
|
|
{
|
|
Id = TextChildId, Type = 12, X = 2, Y = 2, Width = 26, Height = 26,
|
|
};
|
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
|
var presenter = new RetailTooltipPresenter(root, (_, _) =>
|
|
LayoutImporter.BuildFromInfos(rootInfo, [textInfo], _ => (0u, 0, 0), null));
|
|
var target = AddFullyAuthoredTarget(
|
|
root,
|
|
text: "This is a much longer tooltip than the authored placeholder, long "
|
|
+ "enough to want to grow well past forty pixels wide.");
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
|
|
UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target));
|
|
Assert.Equal(40f, popup.Width); // clamped, not the larger natural measured size
|
|
}
|
|
|
|
// ── #409 live-failure round: the RUNTIME text family ────────────────────
|
|
|
|
/// <summary>A hover target that carries retail's runtime <c>m_TTText</c>
|
|
/// (this port's <see cref="UiElement.GetTooltipText"/>) instead of an
|
|
/// authored <c>P0x49</c> — the exact shape of every Options-panel row, the
|
|
/// Configure-Keyboard key buttons, and the social pages' checkboxes.</summary>
|
|
private sealed class RuntimeTextTarget : UiElement
|
|
{
|
|
public string? Runtime { get; set; }
|
|
public override string? GetTooltipText() => Runtime;
|
|
}
|
|
|
|
private static RuntimeTextTarget AddRuntimeTextTarget(
|
|
UiRoot root, string? runtime, bool authoredOn = true,
|
|
uint layoutDid = PopupLayoutDid, uint sourceLayoutDid = 0u)
|
|
{
|
|
var target = new RuntimeTextTarget
|
|
{
|
|
Left = 100, Top = 100, Width = 40, Height = 20,
|
|
Runtime = runtime,
|
|
AuthoredTooltipEnabled = authoredOn,
|
|
AuthoredTooltipRootElementId = PopupRootId,
|
|
AuthoredTooltipLayoutDid = layoutDid,
|
|
SourceLayoutDid = sourceLayoutDid,
|
|
};
|
|
root.AddChild(target);
|
|
return target;
|
|
}
|
|
|
|
private static void HoverAndDwell(UiRoot root)
|
|
{
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
}
|
|
|
|
[Fact]
|
|
public void RuntimeText_ShowsEvenWithNoAuthoredP0x49()
|
|
{
|
|
// THE live-failure root cause. Retail StartTooltipAtMouse @0x00460DA3
|
|
// takes m_TTText verbatim when it is valid and only falls back to the
|
|
// authored P0x49 at @0x00460DDF. Live-DAT measured: the Options toggle
|
|
// row's checkbox (0x2100002B/0x10000219) authors P0x47/P0x48/P0x4B but
|
|
// NO P0x49 — its help text arrives at runtime, exactly like retail's
|
|
// UIOption_CheckboxBitfield64::CreateChildren @0x00485E65 siTooltip.
|
|
var (root, _, requests) = CreateHarness();
|
|
var target = AddRuntimeTextTarget(
|
|
root, "When this option is chosen, you will always appear as offline.");
|
|
int childrenBefore = root.Children.Count;
|
|
|
|
HoverAndDwell(root);
|
|
|
|
Assert.Single(requests, r => r == (PopupLayoutDid, PopupRootId));
|
|
Assert.Equal(childrenBefore + 1, root.Children.Count);
|
|
UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target));
|
|
UiText text = Assert.IsType<UiText>(FindById(popup, TextChildId));
|
|
Assert.Contains(
|
|
"appear as offline",
|
|
string.Join(' ', text.LinesProvider!().Select(l => l.Text)));
|
|
}
|
|
|
|
[Fact]
|
|
public void RuntimeText_WinsOverTheAuthoredP0x49()
|
|
{
|
|
// Precedence, not merge: @0x00460DA3's IsValid(m_TTText) branch skips
|
|
// the InqProperty(0x49) read entirely.
|
|
var (root, _, _) = CreateHarness();
|
|
var target = AddRuntimeTextTarget(root, "runtime wins");
|
|
target.AuthoredTooltipText = "authored loses";
|
|
|
|
HoverAndDwell(root);
|
|
|
|
UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target));
|
|
UiText text = Assert.IsType<UiText>(FindById(popup, TextChildId));
|
|
string shown = string.Join(' ', text.LinesProvider!().Select(l => l.Text));
|
|
Assert.Contains("runtime wins", shown);
|
|
Assert.DoesNotContain("authored loses", shown);
|
|
}
|
|
|
|
[Fact]
|
|
public void RuntimeText_DoesNotNeedTheAuthoredTooltipOnBit()
|
|
{
|
|
// Every game-code SetTooltip site sets the bit itself in the same
|
|
// breath as the text (UIElement_UIItem::UpdateTooltip @0x004E1D5E and
|
|
// seven siblings, all `__bitfield164 |= 0x20`), so runtime text is
|
|
// tooltip-on by construction. The AUTHORED-text path still consults
|
|
// the authored bit — pinned by the next test.
|
|
var (root, _, requests) = CreateHarness();
|
|
AddRuntimeTextTarget(root, "runtime text, authored bit off", authoredOn: false);
|
|
|
|
HoverAndDwell(root);
|
|
|
|
Assert.Single(requests);
|
|
}
|
|
|
|
[Fact]
|
|
public void AuthoredText_StillRequiresTheAuthoredTooltipOnBit()
|
|
{
|
|
var (root, _, requests) = CreateHarness();
|
|
var target = AddRuntimeTextTarget(root, runtime: null, authoredOn: false);
|
|
target.AuthoredTooltipText = "authored, but P0x4B is off";
|
|
|
|
HoverAndDwell(root);
|
|
|
|
Assert.Empty(requests);
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingP0x48_FallsBackToTheElementsOwnSourceLayout()
|
|
{
|
|
// StartTooltipAtMouse @0x00460E7E: when GetAttribute_DataID(0x48)
|
|
// yields INVALID, retail substitutes this->m_layout->m_DID before
|
|
// dispatching, and only gives up when both are absent (@0x00460E91).
|
|
var (root, _, requests) = CreateHarness();
|
|
AddRuntimeTextTarget(
|
|
root, "no P0x48 authored", layoutDid: 0u, sourceLayoutDid: 0x21000099u);
|
|
|
|
HoverAndDwell(root);
|
|
|
|
Assert.Single(requests, r => r == (0x21000099u, PopupRootId));
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingP0x48_AndNoSourceLayout_ShowsNothing()
|
|
{
|
|
var (root, _, requests) = CreateHarness();
|
|
AddRuntimeTextTarget(root, "nowhere to build the popup", layoutDid: 0u);
|
|
|
|
HoverAndDwell(root);
|
|
|
|
Assert.Empty(requests);
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingP0x47_ShowsNothing_EvenWithRuntimeText()
|
|
{
|
|
// The popup ROOT id is retail's hard gate (@0x00460E44's
|
|
// GetAttribute_Enum(0x47) short-circuits the whole function).
|
|
var (root, _, requests) = CreateHarness();
|
|
var target = AddRuntimeTextTarget(root, "text but no popup root");
|
|
target.AuthoredTooltipRootElementId = 0u;
|
|
|
|
HoverAndDwell(root);
|
|
|
|
Assert.Empty(requests);
|
|
}
|
|
|
|
private static UiElement? FindById(UiElement root, uint datElementId)
|
|
{
|
|
if (root.DatElementId == datElementId) return root;
|
|
foreach (UiElement child in root.Children)
|
|
if (FindById(child, datElementId) is { } found) return found;
|
|
return null;
|
|
}
|
|
|
|
// ── World-object hover tooltip (docs/ISSUES.md #409 follow-on) ─────────
|
|
// Port of UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound
|
|
// @0x004E5AD0's tooltip half, CORRECTED at the 2026-08-17 morning gate
|
|
// round (user finding 1: retail world tooltips "lag"; ours popped
|
|
// instantly). The notice STAGES the name (SetTooltip @0x004E5D74) and
|
|
// the DISPLAY rides UIElementManager::CheckTooltip @0x0045B6E0's
|
|
// mouse-idle dwell (m_lastMouseMoveTime + m_tooltipDelay, 250 ms
|
|
// default); the notice's own immediate StartTooltipAtMouse @0x004E5DFB
|
|
// fires ONLY inside the `m_dragElement != 0` branch (@0x004E5D8E —
|
|
// drag-and-drop in progress). Gated by PlayerModule::ShowTooltips at
|
|
// the edge; uses the fixed popup-skin pair every game-code SetTooltip
|
|
// caller in this family shares (see RetailTooltipPresenter's own doc
|
|
// note on why UIElement_SmartBoxWrapper's own P0x47/P0x48 cannot be
|
|
// read from the installed DAT).
|
|
|
|
private const uint WorldFoundGuid = 0x80000123u;
|
|
|
|
/// <summary>A hit-testable drag SOURCE — presses on it become drag-drop
|
|
/// candidates and a captured move past the threshold starts the drag
|
|
/// (for the <c>m_dragElement != 0</c> immediate-mount branch).</summary>
|
|
private sealed class DragSourceTarget : UiElement
|
|
{
|
|
public override bool IsDragSource => true;
|
|
public override object? GetDragPayload() => "payload";
|
|
}
|
|
|
|
private static (UiRoot Root, RetailTooltipPresenter Presenter, List<(uint, uint)> Requests)
|
|
CreateWorldHarness(Func<uint?> guidProvider, Func<uint, string?>? nameResolver = null,
|
|
Func<bool>? enabled = null)
|
|
{
|
|
var (root, presenter, requests) = CreateHarness();
|
|
presenter.WorldHoverGuidProvider = guidProvider;
|
|
presenter.WorldHoverNameResolver = nameResolver ?? (_ => "A Drudge");
|
|
presenter.WorldTooltipsEnabled = enabled ?? (() => true);
|
|
return (root, presenter, requests);
|
|
}
|
|
|
|
[Fact]
|
|
public void WorldHover_StagesOnTheFoundEdge_MountsOnlyAfterTheIdleDwell()
|
|
{
|
|
// THE morning-gate finding-1 pin: a found-object change stages the
|
|
// name but mounts NOTHING until the mouse has been idle for the
|
|
// dwell delay (CheckTooltip @0x0045b747's m_lastMouseMoveTime +
|
|
// m_tooltipDelay test) — the night round's "edge-fired, no dwell"
|
|
// reading mounted immediately, which retail only does mid-drag.
|
|
var (root, presenter, requests) = CreateWorldHarness(() => WorldFoundGuid);
|
|
int childrenBefore = root.Children.Count;
|
|
|
|
root.Tick(0.016, 0);
|
|
presenter.Tick(); // the found edge fires here — staged, not shown
|
|
Assert.Empty(requests);
|
|
Assert.Equal(childrenBefore, root.Children.Count);
|
|
|
|
root.Tick(0.016, root.TooltipDelayMs - 1);
|
|
presenter.Tick();
|
|
Assert.Empty(requests); // one ms short of the idle deadline
|
|
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
presenter.Tick();
|
|
Assert.Single(requests, r => r == (0x21000041u, 0x10000395u));
|
|
Assert.Equal(childrenBefore + 1, root.Children.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void WorldHover_MouseMovingContinuously_NeverMountsUntilItRests()
|
|
{
|
|
// The user-visible half of finding 1: sweeping the cursor across
|
|
// NPCs shows NO tooltips in retail — every move restamps
|
|
// m_lastMouseMoveTime (MouseMoveHandler @0x0045e736) so the dwell
|
|
// deadline never arrives; the popup appears only once the mouse
|
|
// RESTS for the delay.
|
|
var (root, presenter, requests) = CreateWorldHarness(() => WorldFoundGuid);
|
|
|
|
for (long t = 0; t <= 2000; t += 100) // 100 ms between moves < 250 ms dwell
|
|
{
|
|
root.Tick(0.016, t);
|
|
root.OnMouseMove(100 + (int)(t / 10), 100);
|
|
presenter.Tick();
|
|
}
|
|
Assert.Empty(requests);
|
|
|
|
// Rest: no further moves; the dwell elapses from the LAST move.
|
|
root.Tick(0.016, 2000 + root.TooltipDelayMs);
|
|
presenter.Tick();
|
|
Assert.Single(requests);
|
|
}
|
|
|
|
[Fact]
|
|
public void WorldHover_HidesWhenTheFoundGuidClears()
|
|
{
|
|
// found -> 0 stages EMPTY text (ClearTooltip @0x004E5E30 =
|
|
// SetTooltip(empty)) whose text-change teardown (@0x004617FF)
|
|
// removes the showing popup IMMEDIATELY — the teardown edge is not
|
|
// dwell-delayed, only the mount is.
|
|
uint? found = WorldFoundGuid;
|
|
var (root, presenter, _) = CreateWorldHarness(() => found);
|
|
int childrenBefore = root.Children.Count;
|
|
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
presenter.Tick();
|
|
Assert.Equal(childrenBefore + 1, root.Children.Count);
|
|
|
|
found = null;
|
|
presenter.Tick();
|
|
|
|
Assert.Equal(childrenBefore, root.Children.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void WorldHover_ShowTooltipsOff_ShowsNothing()
|
|
{
|
|
// PlayerModule::ShowTooltips @0x004E5D21 gates the whole staging
|
|
// block — UpdateCursorState (the found-cursor swap) is NOT gated by
|
|
// it, but that is a separate mechanism this presenter does not own.
|
|
var (root, presenter, requests) = CreateWorldHarness(
|
|
() => WorldFoundGuid, enabled: () => false);
|
|
int childrenBefore = root.Children.Count;
|
|
|
|
root.Tick(0.016, root.TooltipDelayMs + 50);
|
|
presenter.Tick();
|
|
presenter.Tick();
|
|
|
|
Assert.Empty(requests);
|
|
Assert.Equal(childrenBefore, root.Children.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void WorldHover_NoNameResolved_ShowsNothing()
|
|
{
|
|
// @0x004E5D48: an empty resolved name skips SetTooltip entirely —
|
|
// with nothing previously staged, nothing ever mounts.
|
|
var (root, presenter, requests) = CreateWorldHarness(
|
|
() => WorldFoundGuid, nameResolver: _ => null);
|
|
|
|
root.Tick(0.016, root.TooltipDelayMs + 50);
|
|
presenter.Tick();
|
|
presenter.Tick();
|
|
|
|
Assert.Empty(requests);
|
|
}
|
|
|
|
[Fact]
|
|
public void WorldHover_SuppressedWhileHoveringAUiElement()
|
|
{
|
|
// FindObject @0x004E5430: m_pElementLastOver != null routes through
|
|
// the UI-item special case or falls through to the 3D raycast —
|
|
// either way the found-object pipeline here must not also fire for
|
|
// whatever the mouse is currently over. This port narrows that to
|
|
// "no UI element hovered at all" (see the class's own doc note).
|
|
var (root, presenter, requests) = CreateWorldHarness(() => WorldFoundGuid);
|
|
var uiElement = new HoverTarget { Left = 100, Top = 100, Width = 40, Height = 20 };
|
|
root.AddChild(uiElement);
|
|
root.OnMouseMove(110, 110);
|
|
|
|
root.Tick(0.016, root.TooltipDelayMs + 50);
|
|
presenter.Tick();
|
|
|
|
Assert.Empty(requests);
|
|
}
|
|
|
|
[Fact]
|
|
public void WorldHover_IdleFoundSwap_ReplacesThePopupTheSameFrame_WithoutStacking()
|
|
{
|
|
// Two mechanisms in one scenario. (1) Timing: with the mouse IDLE
|
|
// and a popup up, a found A -> B change swaps the popup the SAME
|
|
// frame — SetTooltip's text-change teardown (@0x004617FF
|
|
// ResetTooltip) tail-calls CheckTooltip, whose dwell deadline
|
|
// passed long ago, so the replacement mounts with no new wait.
|
|
// (2) The single-slot invariant (#409 follow-on, 2026-08-16
|
|
// overnight round Batch A bug 1): walking past a run of NPCs/
|
|
// doors/lifestones never produces a "nothing found" frame — A->B->C
|
|
// must swap ONE mounted popup, never orphan-stack the old ones.
|
|
const uint otherGuid = 0x80000456u;
|
|
uint current = WorldFoundGuid;
|
|
var (root, presenter, requests) = CreateWorldHarness(
|
|
() => current,
|
|
nameResolver: guid => guid == WorldFoundGuid ? "A Drudge" : "A Door");
|
|
int childrenBefore = root.Children.Count;
|
|
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
presenter.Tick();
|
|
Assert.Equal(childrenBefore + 1, root.Children.Count);
|
|
|
|
current = otherGuid;
|
|
presenter.Tick(); // same frame: teardown + idle remount
|
|
|
|
Assert.Equal(childrenBefore + 1, root.Children.Count);
|
|
Assert.Equal(2, requests.Count);
|
|
|
|
current = WorldFoundGuid;
|
|
presenter.Tick();
|
|
current = otherGuid;
|
|
presenter.Tick();
|
|
current = WorldFoundGuid;
|
|
presenter.Tick();
|
|
|
|
// Several more A/B/A swaps still leave exactly one popup mounted —
|
|
// this is the "dozens of stacked name boxes" scenario, minus the bug.
|
|
Assert.Equal(childrenBefore + 1, root.Children.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void WorldHover_AutoHidesAfterTheDuration_AndRemountsOnlyAfterAMouseMovePlusDwell()
|
|
{
|
|
// CheckTooltip's duration expiry (@0x0045b78a, m_tooltipDuration =
|
|
// 10 s @0x0045f767) tears the popup down AND runs
|
|
// SwitchMouseOver(null) (@0x0045b7b2) — m_pElementLastEntered goes
|
|
// null, so the dwell CANNOT re-arm until the next real mouse move
|
|
// re-enters the wrapper. Without that latch the port would remount
|
|
// one frame later (text still staged, mouse still idle) in a 10 s
|
|
// flicker loop.
|
|
var (root, presenter, requests) = CreateWorldHarness(() => WorldFoundGuid);
|
|
int childrenBefore = root.Children.Count;
|
|
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
presenter.Tick();
|
|
Assert.Equal(childrenBefore + 1, root.Children.Count);
|
|
|
|
long expiry = root.TooltipDelayMs + root.TooltipDurationMs;
|
|
root.Tick(0.016, expiry);
|
|
presenter.Tick();
|
|
Assert.Equal(childrenBefore, root.Children.Count); // auto-hidden
|
|
|
|
root.Tick(0.016, expiry + 500);
|
|
presenter.Tick();
|
|
Assert.Equal(childrenBefore, root.Children.Count); // idle but latched — no flicker remount
|
|
Assert.Single(requests);
|
|
|
|
long moveAt = expiry + 600;
|
|
root.Tick(0.016, moveAt);
|
|
root.OnMouseMove(5, 5); // re-enter; dwell restarts from this move
|
|
presenter.Tick();
|
|
Assert.Single(requests); // dwell not yet elapsed
|
|
|
|
root.Tick(0.016, moveAt + root.TooltipDelayMs);
|
|
presenter.Tick();
|
|
Assert.Equal(2, requests.Count);
|
|
Assert.Equal(childrenBefore + 1, root.Children.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void WorldHover_DragInProgress_MountsImmediatelyOnTheFoundEdge_NoDwell()
|
|
{
|
|
// The ONE immediate path in RecvNotice_SmartBoxObjectFound:
|
|
// @0x004E5D8E gates ResetTooltip + StartTooltipAtMouse
|
|
// (@0x004E5DF0/@0x004E5DFB) on UIElementManager's m_dragElement —
|
|
// while dragging an item over the world, the drop target's name
|
|
// shows at once, dwell or no dwell.
|
|
uint? found = null;
|
|
var (root, presenter, requests) = CreateWorldHarness(() => found);
|
|
var source = new DragSourceTarget { Left = 100, Top = 100, Width = 40, Height = 20 };
|
|
root.AddChild(source);
|
|
int childrenBefore = root.Children.Count;
|
|
|
|
root.Tick(0.016, 0);
|
|
root.OnMouseDown(UiMouseButton.Left, 110, 110);
|
|
root.OnMouseMove(130, 130); // beyond the 3px threshold -> BeginDrag
|
|
Assert.NotNull(root.DragSource);
|
|
root.OnMouseMove(300, 300); // over the world, mid-drag, mouse JUST moved
|
|
|
|
found = WorldFoundGuid;
|
|
presenter.Tick(); // the found edge, zero idle time
|
|
|
|
Assert.Single(requests);
|
|
Assert.Equal(childrenBefore + 1, root.Children.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void WorldHover_GlobalEnableOff_SuppressesTheDwellMount()
|
|
{
|
|
// The dwell-mounted path goes through UIElement::MouseHover, whose
|
|
// m_tooltipEnable gate (@0x0046254C) this presenter models as
|
|
// Enabled — unlike the drag-immediate branch, which calls
|
|
// StartTooltipAtMouse directly and bypasses MouseHover entirely.
|
|
var (root, presenter, requests) = CreateWorldHarness(() => WorldFoundGuid);
|
|
presenter.Enabled = false;
|
|
|
|
root.Tick(0.016, root.TooltipDelayMs + 50);
|
|
presenter.Tick();
|
|
presenter.Tick();
|
|
|
|
Assert.Empty(requests);
|
|
}
|
|
|
|
[Fact]
|
|
public void WorldHover_ThenUiDwellTooltip_ReplacesRatherThanStacks()
|
|
{
|
|
// The other half of the "no stacking" contract: a world tooltip
|
|
// showing, then the mouse settles on a real UI element (dwell path)
|
|
// — OnTooltipShow's own unconditional RemovePopup() must clear the
|
|
// world popup, leaving exactly one popup (the UI one), not two.
|
|
var (root, presenter, _) = CreateWorldHarness(() => WorldFoundGuid);
|
|
int childrenBefore = root.Children.Count;
|
|
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
presenter.Tick();
|
|
Assert.Equal(childrenBefore + 1, root.Children.Count); // world tooltip up
|
|
|
|
var target = AddFullyAuthoredTarget(root);
|
|
long moveAt = root.TooltipDelayMs + 10;
|
|
root.Tick(0.016, moveAt);
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, moveAt + root.TooltipDelayMs);
|
|
|
|
UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target));
|
|
Assert.NotNull(popup);
|
|
// childrenBefore world-target(none) + target(1) + popup(1) == +2 total,
|
|
// never +3 (world popup replaced, not stacked).
|
|
Assert.Equal(childrenBefore + 2, root.Children.Count);
|
|
|
|
// Night-round review F6: the test previously stopped here, which
|
|
// only proved OnTooltipShow's OWN unconditional RemovePopup()
|
|
// cleared the world popup — it never actually exercised what
|
|
// happens on the NEXT presenter.Tick() (UpdateWorldHoverTooltip
|
|
// still thinks a world-hover target exists, since its own
|
|
// _worldHoverGuid/_worldTooltipShowing bookkeeping was never
|
|
// re-evaluated after the transition). The mouse is now over the UI
|
|
// target, so Pick(...) finds it and WorldHoverGuidProvider is
|
|
// ignored (found=0u) — this must leave the UI popup exactly as-is,
|
|
// no incorrect extra removal or re-mount.
|
|
presenter.Tick();
|
|
Assert.Equal(childrenBefore + 2, root.Children.Count);
|
|
Assert.Same(popup, root.Children.Single(c => !ReferenceEquals(c, target)));
|
|
}
|
|
|
|
[Fact]
|
|
public void UiDwellTooltip_ThenModalStealsHitTesting_WorldHoverReplacesRatherThanStacks()
|
|
{
|
|
// Night-round review F5's own reproduction: the UI->world hole. A
|
|
// UI element's dwell tooltip is showing; a modal then opens WITHOUT
|
|
// the mouse moving (UiRoot.Modal claims EXCLUSIVE hit-testing —
|
|
// HitTestTopDown @0x... "Modal gets exclusive hit-test" — so
|
|
// Pick(MouseX, MouseY) now returns null even though the tooltip's
|
|
// owner widget is still mounted, still visible, and its popup is
|
|
// still up). UpdateWorldHoverTooltip's own clear is gated on
|
|
// _worldTooltipShowing, which is FALSE here (the currently-mounted
|
|
// popup is UI-owned, not world-owned) — pre-fix, this let the world
|
|
// path mount a SECOND popup on top without ever clearing the first.
|
|
// Post-finding-1: the mouse has been idle since the UI dwell fired,
|
|
// so the world dwell deadline is ALSO already met and the world
|
|
// popup mounts on this same Tick (through TryBuildAndMountPopup's
|
|
// unconditional clear).
|
|
var (root, presenter, _) = CreateHarness();
|
|
var target = AddFullyAuthoredTarget(root);
|
|
int childrenBefore = root.Children.Count;
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
Assert.Equal(childrenBefore + 1, root.Children.Count); // UI tooltip up
|
|
|
|
// Modal opens elsewhere on screen, stealing exclusive hit-testing —
|
|
// the mouse never moves.
|
|
root.Modal = new UiPanel { Left = 0, Top = 0, Width = 10, Height = 10 };
|
|
presenter.WorldHoverGuidProvider = () => WorldFoundGuid;
|
|
presenter.WorldHoverNameResolver = _ => "A Drudge";
|
|
presenter.WorldTooltipsEnabled = () => true;
|
|
|
|
presenter.Tick();
|
|
|
|
// Exactly one popup (the world one, having replaced the UI one) —
|
|
// never two stacked.
|
|
Assert.Equal(childrenBefore + 1, root.Children.Count);
|
|
UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target));
|
|
Assert.NotNull(popup);
|
|
}
|
|
|
|
[Fact]
|
|
public void WorldHover_ReEvaluatesGateAndTextOnlyOnTheFoundGuidEdge()
|
|
{
|
|
// RecvNotice_SmartBoxObjectFound only re-runs when SmartBox::
|
|
// set_found_object's target actually changes — a per-frame poll of
|
|
// the SAME found id must not re-read ShowTooltips or re-resolve the
|
|
// name every tick, and the dwell mount must not rebuild the popup
|
|
// on later ticks either.
|
|
int gateReads = 0, nameReads = 0;
|
|
var (root, presenter, requests) = CreateWorldHarness(
|
|
() => WorldFoundGuid,
|
|
nameResolver: _ => { nameReads++; return "A Drudge"; },
|
|
enabled: () => { gateReads++; return true; });
|
|
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
presenter.Tick();
|
|
presenter.Tick();
|
|
presenter.Tick();
|
|
|
|
Assert.Equal(1, gateReads);
|
|
Assert.Equal(1, nameReads);
|
|
Assert.Single(requests);
|
|
}
|
|
|
|
[Fact]
|
|
public void WorldHover_TextIsPlainAppropriateName_NoStackCountPrefix()
|
|
{
|
|
// RecvNotice_SmartBoxObjectFound's own text-building block
|
|
// (@0x004E5D3B-@0x004E5D74) has no "%d %s" stack-count logic —
|
|
// unlike UIElement_UIItem::UpdateTooltip's item-cell tooltip. The
|
|
// resolver contract here is plain GetAppropriateName, not
|
|
// GetTooltipDisplayName; this pin just documents the caller's
|
|
// resolver is free to return whatever plain text it wants and the
|
|
// presenter applies it verbatim (no separate count formatting is
|
|
// ever added by this class).
|
|
var (root, presenter, _) = CreateWorldHarness(
|
|
() => WorldFoundGuid, nameResolver: _ => "Iron Bars");
|
|
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
presenter.Tick();
|
|
|
|
UiElement popup = Assert.Single(root.Children);
|
|
UiElement? textChild = FindById(popup, TextChildId);
|
|
Assert.IsType<UiText>(textChild);
|
|
}
|
|
}
|