Full re-derivation from named-retail decomp: UIElement::StartTooltipAtMouse @0x00460D70 -> UIElementManager::StartTooltip @0x0045DE90/@0x00459700, UIElement::MouseHover @0x00462520 (P0x4B TooltipOn gate + global m_tooltipEnable), UIElementManager::CheckTooltip @0x0045B6E0 (dwell/ auto-hide timer, default 0.25s/10s), SwitchMouseOver/DeletingElement (dismissal). Corrects the earlier GF-16 investigation: P0x47 is the element-desc id WITHIN the popup LayoutDesc (P0x48), not a "behavior enum"; P0x4A is read off the popup's own instantiated root, not the trigger element. - ElementInfo/UiElement gain six tooltip data fields (P0x47/48/49/4A/4B/50), read generically by ElementReader and copied through LayoutImporter, mirroring the existing AuthoredInvisible passthrough pattern. - UiRoot's existing CheckTooltip-derived hover timer gains TooltipShow/ TooltipHide events, a per-element P0x50 delay override, and dismissal wiring at every retail-confirmed teardown site. - RetailTooltipPresenter (owned by RetailUiRuntime, mounted alongside RetailDialogFactory) builds the popup via the existing LayoutImporter dat-lock seam, auto-resizes by the measured-vs-authored text delta (word-wrapped via the existing UiText.WrapWords primitive), positions at the mouse clamped to the display, and stays topmost over dialogs via its own later per-tick BringToFront (register AD-106). - Misc.TooltipEnable/Misc.TooltipDelay are client-local UserPreferences (retail's own 2013 Config tab authors no visible row for either) — SettingsStore gains a MiscSettings section, no new options-panel row. - Live-DAT sweep: 434 elements author >=1 trigger property (243 with literal text this port shows; 191 rely on retail's dynamic InqProperty(0x49) override, deferred as register TS-85 alongside the unmodeled P0x3D wrap-width override). Gates: Release build 0 errors; App suite (live-DAT env) 5410/5407 passed/ 3 skipped (was 5379/3); Runtime 1735/0 unchanged; UI.Abstractions 926/0; full solution 14,617/14,548 passed/69 skipped/0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
268 lines
9.7 KiB
C#
268 lines
9.7 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}");
|
|
}
|
|
}
|