acdream/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs
Erik 35abbe1d0d
All checks were successful
CI / linux-portable (push) Successful in 3m29s
CI / windows-gate (push) Successful in 5m44s
CI / release (push) Successful in 2m19s
fix #430: tooltip wrap and draw honor the text child's authored margins
Third owner-screenshot round: acdream fit one more word per line than
retail and drew glyphs flush against the popup's right border. Retail's
InqSizewMargins @0x00469660 wraps the glyph list at
(bound - m_margL - m_margR) and adds the margins back into the measured
width; the popup skins' shared text child 0x10000396 authors margins
L=2/R=2 (U=2/D=2 on three of the four skins — live-DAT probed). The
presenter now subtracts the horizontal margins from both wrap passes,
re-adds them into the measured width used for root sizing, and counts
the vertical margins in the measured/re-wrapped heights; the widget's
own draw already insets by all four margins (UiText ContentOffsetX +
the top/bottom inset), so the right-side spacing returns for free.

TooltipSkinLiveDatTests pins the authored margins per skin alongside
the P0x3D=256 wrap bound; a new presenter test proves margins shrink
the wrap bound and survive onto the widget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 18:44:28 +02:00

1129 lines
47 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
[Fact]
public void LongTooltip_RewrapsAtTheClampedPopupWidth_AndGrowsHeightForTheExtraLines()
{
// CA5-gate correction: retail sizes tooltips in TWO passes
// (StartTooltip @0x0045DE90) — measure-wrap at the max width, resize
// the root through the authored clamps, then RE-WRAP at the final
// clamped width and grow the root's HEIGHT for the extra lines.
// Pre-correction, a description longer than the clamped popup stayed
// one clipped line (the owner's retail client shows three).
var (root, _, _) = CreateHarness();
string longText = string.Join(
" ", Enumerable.Repeat("description", 40)); // far wider than 200px
var target = new HoverTarget
{
Left = 100, Top = 100, Width = 40, Height = 20,
AuthoredTooltipEnabled = true,
AuthoredTooltipText = longText,
AuthoredTooltipRootElementId = PopupRootId,
AuthoredTooltipLayoutDid = PopupLayoutDid,
};
root.AddChild(target);
int childrenBefore = root.Children.Count;
// Clamp the popup to 200px wide via a max-width-authored skin.
HoverAndDwellWithClampedPopup(root, maxWidth: 200);
UiElement popup = root.Children[^1];
Assert.True(root.Children.Count > childrenBefore, "popup did not mount");
// Root = authored 30 frame grown to hold the <=200 text plus padding.
Assert.True(popup.Width <= 200f + 30f, $"popup width {popup.Width} escaped the clamp");
var text = FindText(popup);
Assert.NotNull(text);
// Retail's unauthored justification default is LEFT (#410 class).
Assert.False(text!.Centered);
Assert.False(text.RightAligned);
var lines = text!.LinesProvider();
Assert.True(lines.Count >= 3,
$"expected the clamped width to force >=3 lines, got {lines.Count}");
// Every re-wrapped line must fit the clamped popup.
Assert.All(lines, l => Assert.True(l.Text.Length * 8f <= 200f + 8f, $"line escaped wrap: {l.Text}"));
// The root grew to hold the extra lines.
float lineHeight = 14f;
Assert.True(popup.Height >= lines.Count * lineHeight,
$"popup height {popup.Height} does not fit {lines.Count} lines");
}
[Fact]
public void AuthoredMargins_ShrinkTheWrapBound_AndInsetTheMeasuredWidth()
{
// The real skins' text child (0x10000396) authors margins L=2/R=2
// (U=2/D=2 on three of four skins). Retail's InqSizewMargins
// @0x00469660 wraps at (bound margL margR) and adds the margins
// back into the measured width; without this, line one fit one more
// word than retail and the glyphs drew flush against the popup's
// right border (CA5 owner screenshot pair, 2026-08-24).
var (root, _, _) = CreateHarness();
string longText = string.Join(
" ", Enumerable.Repeat("description", 40));
var target = new HoverTarget
{
Left = 100, Top = 100, Width = 40, Height = 20,
AuthoredTooltipEnabled = true,
AuthoredTooltipText = longText,
AuthoredTooltipRootElementId = PopupRootId,
AuthoredTooltipLayoutDid = PopupLayoutDid,
};
root.AddChild(target);
// Wide horizontal margins make the difference unmissable at the
// 8px-per-char test measure: the wrap bound is 20080=120.
HoverAndDwellWithClampedPopup(root, maxWidth: 200, marginX: 40, marginY: 8);
var text = FindText(root.Children[^1]);
Assert.NotNull(text);
var lines = text!.LinesProvider();
Assert.True(lines.Count >= 3, $"expected >=3 wrapped lines, got {lines.Count}");
// Glyphs wrap INSIDE the margins...
Assert.All(lines, l => Assert.True(
l.Text.Length * 8f <= 200f - 80f + 8f,
$"line ignored the margins: {l.Text}"));
// ...and the widget keeps the margins so the draw insets by them
// (the right-side spacing the owner's retail screenshot shows).
Assert.Equal(40f, text.MarginLeft);
Assert.Equal(40f, text.MarginRight);
// Height accounts for the vertical margins on top of the lines.
float lineHeight = 14f;
Assert.True(root.Children[^1].Height >= lines.Count * lineHeight + 16f,
$"popup height {root.Children[^1].Height} lost the vertical margins");
}
private static void HoverAndDwellWithClampedPopup(
UiRoot root, int maxWidth, int marginX = 0, int marginY = 0)
{
// The REAL skins bound the wrap on the TEXT CHILD: 0x10000396
// authors P0x3D=256 on all four popup skins (live-DAT probed
// 2026-08-24) — retail's InqSizewMargins UITS_MAX_WIDTH reads the
// text element's 0x3D before the display-width fallback.
var presenter = new RetailTooltipPresenter(root, (_, _) =>
{
ImportedLayout layout = BuildPopup();
if (layout.FindElement(TextChildId) is { } textChild)
{
textChild.AuthoredResizeMaxWidth = maxWidth;
if (textChild is UiText t)
{
t.MarginLeft = marginX;
t.MarginRight = marginX;
t.MarginTop = marginY;
t.MarginBottom = marginY;
}
}
return layout;
});
HoverAndDwell(root);
}
private static UiText? FindText(UiElement element)
{
if (element is UiText text) return text;
foreach (UiElement child in element.Children)
if (FindText(child) is { } found) return found;
return null;
}
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);
}
}