acdream/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs
Erik ab84b54dfa fix(ui): night-round review — F5/F6 structural single-tooltip invariant
F5: moved the unconditional RemovePopup() call into
RetailTooltipPresenter.TryBuildAndMountPopup itself so the single-
popup invariant (retail's own single m_pTooltipElement slot) is
enforced structurally rather than relying on every caller to have
already cleared a stale popup. Closes a real hole: UpdateWorldHoverTooltip's
own clear is gated on _worldTooltipShowing (only true when the WORLD
path itself mounted the current popup), and its "a UI popup cannot be
showing here" comment assumed the host's hover query is null whenever
that branch runs — an assumption that breaks the instant a modal opens
over a stationary cursor. UiRoot.Modal claims EXCLUSIVE hit-testing, so
Pick(MouseX, MouseY) can return null even though a UI-dwell tooltip is
still mounted underneath; UpdateWorldHoverTooltip would then mount a
second popup on top without ever clearing the first.

F6: fixed WorldHover_ThenUiDwellTooltip_ReplacesRatherThanStacks to
actually exercise the transition with a follow-up presenter.Tick()
(the old test only proved OnTooltipShow's own clear worked, never
checked the world-side bookkeeping after). Added
UiDwellTooltip_ThenModalStealsHitTesting_WorldHoverReplacesRatherThanStacks
for F5's own case, using UiRoot.Modal to reproduce the exclusive-hit-
testing hole precisely — empirically verified this new test fails
(2 popups instead of 1) with the structural RemovePopup() reverted,
confirming it is a real regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 04:45:49 +02:00

858 lines
34 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: edge-fired (no dwell wait), gated by PlayerModule::
// ShowTooltips, 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;
[Fact]
public void WorldHover_ShowsImmediately_NoDwellWait()
{
var (root, presenter, requests) = CreateHarness();
presenter.WorldHoverGuidProvider = () => WorldFoundGuid;
presenter.WorldHoverNameResolver = guid => guid == WorldFoundGuid ? "A Drudge" : null;
presenter.WorldTooltipsEnabled = () => true;
int childrenBefore = root.Children.Count;
// A single Tick — no root.Tick dwell timer involved at all, unlike
// every UI-element case above.
presenter.Tick();
Assert.Equal(childrenBefore + 1, root.Children.Count);
Assert.Single(requests, r => r == (0x21000041u, 0x10000395u));
}
[Fact]
public void WorldHover_HidesWhenTheFoundGuidClears()
{
var (root, presenter, _) = CreateHarness();
uint? found = WorldFoundGuid;
presenter.WorldHoverGuidProvider = () => found;
presenter.WorldHoverNameResolver = _ => "A Drudge";
presenter.WorldTooltipsEnabled = () => true;
int childrenBefore = root.Children.Count;
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 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) = CreateHarness();
presenter.WorldHoverGuidProvider = () => WorldFoundGuid;
presenter.WorldHoverNameResolver = _ => "A Drudge";
presenter.WorldTooltipsEnabled = () => false;
int childrenBefore = root.Children.Count;
presenter.Tick();
Assert.Empty(requests);
Assert.Equal(childrenBefore, root.Children.Count);
}
[Fact]
public void WorldHover_NoNameResolved_ShowsNothing()
{
var (_, presenter, requests) = CreateHarness();
presenter.WorldHoverGuidProvider = () => WorldFoundGuid;
presenter.WorldHoverNameResolver = _ => null;
presenter.WorldTooltipsEnabled = () => true;
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) = CreateHarness();
var uiElement = new HoverTarget { Left = 100, Top = 100, Width = 40, Height = 20 };
root.AddChild(uiElement);
root.OnMouseMove(110, 110);
presenter.WorldHoverGuidProvider = () => WorldFoundGuid;
presenter.WorldHoverNameResolver = _ => "A Drudge";
presenter.WorldTooltipsEnabled = () => true;
presenter.Tick();
Assert.Empty(requests);
}
[Fact]
public void WorldHover_FoundObjectChangesDirectly_ReplacesThePopupWithoutStacking()
{
// #409 follow-on (2026-08-16 overnight hover/UI round, Batch A bug 1):
// the regression that filled the user's screen with dozens of
// stacked tooltips. Walking past a run of NPCs/doors/lifestones never
// produces a frame where the found guid is 0 — it goes straight from
// A to B to C. RecvNotice_SmartBoxObjectFound-equivalent must still
// only ever have ONE popup mounted: found A, then found B (no
// intervening "nothing found" tick) must swap the popup, not add a
// second one on top of the first.
const uint otherGuid = 0x80000456u;
var (root, presenter, requests) = CreateHarness();
uint current = WorldFoundGuid;
presenter.WorldHoverGuidProvider = () => current;
presenter.WorldHoverNameResolver = guid =>
guid == WorldFoundGuid ? "A Drudge" : "A Door";
presenter.WorldTooltipsEnabled = () => true;
int childrenBefore = root.Children.Count;
presenter.Tick();
Assert.Equal(childrenBefore + 1, root.Children.Count);
current = otherGuid;
presenter.Tick();
// Exactly one popup, not two stacked.
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_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, _) = CreateHarness();
presenter.WorldHoverGuidProvider = () => WorldFoundGuid;
presenter.WorldHoverNameResolver = _ => "A Drudge";
presenter.WorldTooltipsEnabled = () => true;
int childrenBefore = root.Children.Count;
presenter.Tick();
Assert.Equal(childrenBefore + 1, root.Children.Count); // world tooltip up
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.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.
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.
var (_, presenter, requests) = CreateHarness();
int gateReads = 0, nameReads = 0;
presenter.WorldHoverGuidProvider = () => WorldFoundGuid;
presenter.WorldHoverNameResolver = _ => { nameReads++; return "A Drudge"; };
presenter.WorldTooltipsEnabled = () => { gateReads++; return true; };
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, _) = CreateHarness();
presenter.WorldHoverGuidProvider = () => WorldFoundGuid;
presenter.WorldHoverNameResolver = _ => "Iron Bars";
presenter.WorldTooltipsEnabled = () => true;
presenter.Tick();
UiElement popup = Assert.Single(root.Children);
UiElement? textChild = FindById(popup, TextChildId);
Assert.IsType<UiText>(textChild);
}
}