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 } }