using System.Collections.Generic; using System.Linq; using AcDream.App.UI; using AcDream.App.UI.Layout; namespace AcDream.App.Tests.UI.Layout; /// /// #409 (client-wide retail tooltip system) fixture coverage for /// and its hover- /// timer wiring. Builds synthetic popup layouts in-memory (no dats) via /// — see /// for the installed-DAT structural pins. /// public sealed class RetailTooltipPresenterTests { /// Minimal concrete hover target — plain /// with default (false) ClickThrough, so it is hit-testable. private sealed class HoverTarget : UiElement; private const uint PopupRootId = 0x900u; private const uint TextChildId = 0x901u; private const uint PopupLayoutDid = 0x21000041u; /// 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. 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 ──────────────────── /// A hover target that carries retail's runtime m_TTText /// (this port's ) instead of an /// authored P0x49 — the exact shape of every Options-panel row, the /// Configure-Keyboard key buttons, and the social pages' checkboxes. 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(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(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; /// A hit-testable drag SOURCE — presses on it become drag-drop /// candidates and a captured move past the threshold starts the drag /// (for the m_dragElement != 0 immediate-mount branch). 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 guidProvider, Func? nameResolver = null, Func? 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(textChild); } }