diff --git a/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs b/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs index e58e6e9a..44f2d482 100644 --- a/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs +++ b/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs @@ -186,9 +186,34 @@ public sealed class RetailTooltipPresenter : IDisposable /// family resolves to). Extracted unchanged from the pre-#411-follow-on /// OnTooltipShow body — same F4/F5/F8 fixes, same failure /// handling. + /// + /// + /// Night-round review F5: the single-popup invariant (retail's own + /// single m_pTooltipElement slot) is now enforced HERE, + /// structurally, rather than relying on every caller to have already + /// cleared a stale popup before reaching this method. Both existing + /// callers already clear on their own early-return paths too (a hover + /// change that resolves to no valid tooltip text must still tear down + /// the PREVIOUS popup, which never reaches this method at all), so + /// those calls stay — this is a belt-and-braces guarantee, not a + /// replacement for them. It closes a real hole: '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 's hover + /// query is null whenever that branch runs — an assumption that does + /// not hold the instant a modal dialog opens over a stationary cursor: + /// the UI dwell popup from stays mounted + /// (_owner/_popupRoot set, _worldTooltipShowing + /// still false) while the world path could independently find an + /// object and call this method, mounting a second popup on top. Now it + /// cannot: this call clears whatever is mounted, UI-owned or + /// world-owned, before either ever gets a chance to layer. + /// /// private bool TryBuildAndMountPopup(uint rootElementId, uint layoutDid, string tooltipText) { + RemovePopup(); + ImportedLayout? layout; try { diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs index 0410533c..4cdc83a0 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs @@ -757,6 +757,58 @@ public sealed class RetailTooltipPresenterTests // 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]