fix(ui): #409 tooltip review fix round — F1-F11
Opus review of a377b9bf returned architectural PASS-with-findings /
retail-fidelity FAIL with F1-F12 (F12 info-only). All eleven fixed,
each re-derived against docs/research/named-retail/acclient_2013_pseudo_c.txt:
- F1 PositionAtMouse: retail offsets BOTH axes +32px before the clamp
(StartTooltip @0x00459700, @0x00459739/@0x00459747) — was landing
flush at the cursor.
- F2 UiRoot: the dwell timer now anchors to mouse-IDLE like retail's
m_lastMouseMoveTime (MouseMoveHandler @0x0045E710), resetting on
every move within the same widget while !_tooltipFired, not just on
hover-enter.
- F3 register TS-85 rewritten: the "dynamic InqProperty(0x49) override"
framing was false — UIElement::InqProperty @0x004638D0's base impl
reads the same authored bags this port already reads. The real
second text source (m_TTText/SetTooltip, headed by the P0xD0
truncated-text auto-tooltip @0x00466F80) needs a per-line-position
truncation model UiText doesn't have — sized disproportionate for
this round and left honestly deferred rather than stubbed.
- F4 OnTooltipShow: null LayoutPolicy + Anchors=None on the popup root
and text child before resizing, mirroring RetailMessageDialogView's
sibling shape.
- F5 OnTooltipShow: return without mounting when the P0x4A text child
doesn't resolve to a UiText (retail's DynamicCast gate,
StartTooltip @0x0045DE90 @0x0045df65/@0x0045df6f) — was mounting an
empty 30x30 bevel artifact.
- F6 UiRoot.Tick: the dwell-arm branch now requires Captured is null
(CheckTooltip @0x0045B6E0 @0x0045b715) — a widget hovered before a
drag/resize/capture began must not pop mid-gesture.
- F7 UiRoot.ReleaseCapture: no longer resets _tooltipFired
(ReleaseMouseCapture @0x0045D2B0 touches only the idle timestamp) —
a mouse-up while a tooltip is shown no longer tears it down and
silently re-fires it 250ms later.
- F8 ApplyTooltipText: applies ResizeTo's own max/min width/height
clamps (P0x3C/0x3D/0x3E/0x3F, @0x00463C30) before assigning the
grown size; zeroes text.Padding to keep the measured size margin-
comparable. New ElementInfo/UiElement plumbing for the four
properties, same shape as the existing tooltip fields.
- F9 doc precision: sweep counts corrected 434->430 / 191->187 (live-
DAT re-measured), the "243 showable" claim now measured exactly
(not assumed) via a new Showable column in the sweep test, and the
MiscSettings citation split into its two real mechanisms
(RegisterPreference in Init vs. AttachPreference/SetPreferenceRange
elsewhere).
- F10 register AD-106: the topmost guarantee is versus dialogs/screens
only (the overlay popup layer and drag ghost still paint above
regardless), and the per-tick BringToFront ratchet has four rungs,
not three.
- F11 RetailUiRuntime.ResetSessionDialogs: now also calls the new
UiRoot.ResetTooltipTracking() so a post-reset hover re-shows
immediately instead of waiting out the stale fired-latch.
New pinning tests (RetailTooltipPresenterTests: F1/F2/F5/F6/F7/F8) each
verified to fail against the pre-fix behavior via a temporary revert-
and-rerun before being confirmed against the restored fix.
PortalProjectionTests.ProjectToClipLease_ReusesPooledWorkWithoutResultArrays
recurrence logged on issue #346 (already the tracking issue for this
load-sensitive flake) — hit twice under load this review, standalone
26/26, unrelated to #409.
Gates: Release build 0 errors; App suite (live-DAT env) 5416/5413
passed/3 skips (was 5410/5407/3, +6 new tests); Runtime 1735/0;
UI.Abstractions 926/0; full solution (no env, 69 skips expected)
14,623/14,554 passed/69 skipped/0 failed (was 14,617/14,548, +6).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
a377b9bff7
commit
2719782dc0
11 changed files with 515 additions and 58 deletions
|
|
@ -265,4 +265,173 @@ public sealed class RetailTooltipPresenterTests
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ public sealed class TooltipLiveDatTests
|
|||
{
|
||||
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
||||
|
||||
var withProperties = new List<(uint LayoutId, uint ElementId, bool HasText)>();
|
||||
var withProperties = new List<(uint LayoutId, uint ElementId, bool HasText, bool Showable)>();
|
||||
foreach (uint layoutId in dats.GetAllIdsOfType<LayoutDesc>())
|
||||
{
|
||||
ElementInfo? tree;
|
||||
|
|
@ -141,22 +141,37 @@ public sealed class TooltipLiveDatTests
|
|||
|| e.TooltipText.HasValue || e.TooltipEnabled
|
||||
|| e.TooltipDelaySeconds.HasValue;
|
||||
if (any)
|
||||
withProperties.Add((layoutId, e.Id, e.TooltipText.HasValue));
|
||||
{
|
||||
// F9 (2026-08-16 review round): "with literal text" alone
|
||||
// over-counts what RetailTooltipPresenter.OnTooltipShow
|
||||
// actually shows — that gate is a FULL AND across
|
||||
// TooltipEnabled (P0x4B) AND non-null text (P0x49) AND
|
||||
// both popup-locator ids (P0x47/P0x48), not just text
|
||||
// presence. Measure the real intersection instead of
|
||||
// assuming "243 with text" == "243 showable".
|
||||
bool showable = e.TooltipEnabled && e.TooltipText.HasValue
|
||||
&& e.TooltipLayoutDid != 0 && e.TooltipRootElementId != 0;
|
||||
withProperties.Add((layoutId, e.Id, e.TooltipText.HasValue, showable));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"[409-DAT] {withProperties.Count} elements author >=1 tooltip property "
|
||||
+ $"({withProperties.Count(f => f.HasText)} with literal StringInfo text).");
|
||||
+ $"({withProperties.Count(f => f.HasText)} with literal StringInfo text, "
|
||||
+ $"{withProperties.Count(f => f.Showable)} pass the full OnTooltipShow gate).");
|
||||
|
||||
// #409 investigation landmark (main game UI, Appearance rotate button).
|
||||
Assert.Contains(withProperties, f => f.LayoutId == 0x21000005u && f.ElementId == 0x100005A4u);
|
||||
// Floor: the live-DAT sweep found 434 total / 243 with literal text at
|
||||
// filing time — assert comfortably below both so a future content
|
||||
// patch that only ADDS tooltip authoring cannot flake this gate.
|
||||
// Floor: the live-DAT sweep found 430 total / 243 with literal text /
|
||||
// 243 fully showable (2026-08-16, both post-F9 measurements) —
|
||||
// assert comfortably below all three so a future content patch that
|
||||
// only ADDS tooltip authoring cannot flake this gate.
|
||||
Assert.True(withProperties.Count >= 400,
|
||||
$"expected at least 400 tooltip-property-authoring elements, found {withProperties.Count}.");
|
||||
Assert.True(withProperties.Count(f => f.HasText) >= 200,
|
||||
$"expected at least 200 elements with literal tooltip text, found {withProperties.Count(f => f.HasText)}.");
|
||||
Assert.True(withProperties.Count(f => f.Showable) >= 200,
|
||||
$"expected at least 200 fully showable elements, found {withProperties.Count(f => f.Showable)}.");
|
||||
}
|
||||
|
||||
private static IEnumerable<ElementInfo> AllDescendants(ElementInfo root)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue