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: 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);
}
[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(textChild);
}
}