feat(ui): #409 — client-wide retail tooltip system
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>
This commit is contained in:
parent
d3755eb231
commit
a377b9bff7
16 changed files with 1386 additions and 9 deletions
|
|
@ -478,4 +478,130 @@ public class ElementReaderTests
|
|||
|
||||
Assert.Equal(0u, info.ScrollbarElementId);
|
||||
}
|
||||
|
||||
// ── #409: the six per-element tooltip property readers ─────────────────
|
||||
|
||||
private static UiPropertyValue DataIdProp(uint value) => new()
|
||||
{
|
||||
Kind = UiPropertyKind.DataId,
|
||||
UnsignedValue = value,
|
||||
};
|
||||
|
||||
private static UiPropertyValue FloatProp(float value) => new()
|
||||
{
|
||||
Kind = UiPropertyKind.Float,
|
||||
FloatValue = value,
|
||||
};
|
||||
|
||||
private static UiPropertyValue StringInfoProp(uint tableId, uint stringId) => new()
|
||||
{
|
||||
Kind = UiPropertyKind.StringInfo,
|
||||
StringInfoValue = new UiStringInfoValue(0, stringId, tableId, 0, 0, 0),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void TooltipRootElementId_0x47_DecodesEnumProperty()
|
||||
{
|
||||
ElementInfo info = WithDirectProperty(0x47u, EnumProp(0x10000487u));
|
||||
ElementReader.ApplyCanonicalLegacyProjection(info);
|
||||
Assert.Equal(0x10000487u, info.TooltipRootElementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TooltipLayoutDid_0x48_DecodesDataIdProperty()
|
||||
{
|
||||
ElementInfo info = WithDirectProperty(0x48u, DataIdProp(0x21000041u));
|
||||
ElementReader.ApplyCanonicalLegacyProjection(info);
|
||||
Assert.Equal(0x21000041u, info.TooltipLayoutDid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TooltipText_0x49_KeptRawAsStringInfo()
|
||||
{
|
||||
ElementInfo info = WithDirectProperty(0x49u, StringInfoProp(0x23000003u, 0x0AAAAAAAu));
|
||||
ElementReader.ApplyCanonicalLegacyProjection(info);
|
||||
Assert.Equal(0x23000003u, info.TooltipText!.Value.TableId);
|
||||
Assert.Equal(0x0AAAAAAAu, info.TooltipText!.Value.StringId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TooltipTextChildElementId_0x4A_DecodesEnumProperty()
|
||||
{
|
||||
ElementInfo info = WithDirectProperty(0x4Au, EnumProp(0x10000396u));
|
||||
ElementReader.ApplyCanonicalLegacyProjection(info);
|
||||
Assert.Equal(0x10000396u, info.TooltipTextChildElementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TooltipEnabled_0x4B_DecodesBoolProperty()
|
||||
{
|
||||
ElementInfo info = WithDirectProperty(0x4Bu, BoolProp(true));
|
||||
ElementReader.ApplyCanonicalLegacyProjection(info);
|
||||
Assert.True(info.TooltipEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TooltipEnabled_0x4B_AbsentDefaultsFalse()
|
||||
{
|
||||
var info = new ElementInfo();
|
||||
ElementReader.ApplyCanonicalLegacyProjection(info);
|
||||
Assert.False(info.TooltipEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TooltipDelaySeconds_0x50_DecodesFloatProperty()
|
||||
{
|
||||
ElementInfo info = WithDirectProperty(0x50u, FloatProp(0f));
|
||||
ElementReader.ApplyCanonicalLegacyProjection(info);
|
||||
Assert.Equal(0f, info.TooltipDelaySeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TooltipDelaySeconds_0x50_AbsentStaysNull()
|
||||
{
|
||||
var info = new ElementInfo();
|
||||
ElementReader.ApplyCanonicalLegacyProjection(info);
|
||||
Assert.Null(info.TooltipDelaySeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllSixTooltipProperties_AbsentTogether_LeaveEveryFieldAtItsDefault()
|
||||
{
|
||||
// Regression pin: an element authoring NONE of the tooltip properties
|
||||
// must never accidentally pick up a nonzero id/flag from unrelated
|
||||
// property parsing.
|
||||
var info = new ElementInfo();
|
||||
ElementReader.ApplyCanonicalLegacyProjection(info);
|
||||
|
||||
Assert.False(info.TooltipEnabled);
|
||||
Assert.Null(info.TooltipText);
|
||||
Assert.Equal(0u, info.TooltipRootElementId);
|
||||
Assert.Equal(0u, info.TooltipLayoutDid);
|
||||
Assert.Equal(0u, info.TooltipTextChildElementId);
|
||||
Assert.Null(info.TooltipDelaySeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_TooltipProperties_RecomputeFromTheMergedStateBag()
|
||||
{
|
||||
// ApplyCanonicalLegacyProjection recomputes every tooltip field fresh
|
||||
// from Merge's own combined base+derived state bag (the same "no
|
||||
// separate scalar-merge rule needed" shape TabTable/ScrollbarElementId
|
||||
// already rely on) — a base-authored tooltip survives an unrelated
|
||||
// derived override.
|
||||
var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId, Name = "" };
|
||||
direct.Properties.Values[0x47u] = EnumProp(0x10000487u);
|
||||
direct.Properties.Values[0x48u] = DataIdProp(0x21000041u);
|
||||
direct.Properties.Values[0x4Bu] = BoolProp(true);
|
||||
var base_ = new ElementInfo();
|
||||
base_.States[UiStateInfo.DirectStateId] = direct;
|
||||
ElementReader.ApplyCanonicalLegacyProjection(base_);
|
||||
|
||||
var derived = new ElementInfo(); // authors nothing of its own
|
||||
ElementInfo merged = ElementReader.Merge(base_, derived);
|
||||
|
||||
Assert.Equal(0x10000487u, merged.TooltipRootElementId);
|
||||
Assert.Equal(0x21000041u, merged.TooltipLayoutDid);
|
||||
Assert.True(merged.TooltipEnabled);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -298,6 +298,71 @@ public class LayoutImporterTests
|
|||
Assert.Null(tree.FindElement(0xBBB00001u));
|
||||
}
|
||||
|
||||
// ── #409: the six tooltip fields are a pure passthrough onto UiElement ──
|
||||
|
||||
[Fact]
|
||||
public void BuildWidget_TooltipProperties_CopyOntoTheWidget_AndTextResolves()
|
||||
{
|
||||
var root = new ElementInfo { Id = 0x1, Type = 3, Width = 100, Height = 40 };
|
||||
var trigger = new ElementInfo
|
||||
{
|
||||
Id = 0x2, Type = 3, X = 0, Y = 0, Width = 40, Height = 20,
|
||||
TooltipEnabled = true,
|
||||
TooltipRootElementId = 0x10000487u,
|
||||
TooltipLayoutDid = 0x21000041u,
|
||||
TooltipTextChildElementId = 0x10000396u,
|
||||
TooltipDelaySeconds = 0.5f,
|
||||
TooltipText = new UiStringInfoValue(0, 0x0AAAAAAAu, 0x23000003u, 0, 0, 0),
|
||||
};
|
||||
|
||||
string? Resolve(UiStringInfoValue info)
|
||||
=> info.TableId == 0x23000003u && info.StringId == 0x0AAAAAAAu ? "Rotate left." : null;
|
||||
|
||||
var tree = LayoutImporter.BuildFromInfos(
|
||||
root, [trigger], NoTex, null, fontResolve: null, stringResolve: Resolve);
|
||||
|
||||
UiElement found = tree.FindElement(0x2)!;
|
||||
Assert.True(found.AuthoredTooltipEnabled);
|
||||
Assert.Equal(0x10000487u, found.AuthoredTooltipRootElementId);
|
||||
Assert.Equal(0x21000041u, found.AuthoredTooltipLayoutDid);
|
||||
Assert.Equal(0x10000396u, found.AuthoredTooltipTextChildElementId);
|
||||
Assert.Equal(0.5f, found.AuthoredTooltipDelaySeconds);
|
||||
Assert.Equal("Rotate left.", found.AuthoredTooltipText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildWidget_TooltipText_NoStringResolver_StaysNull()
|
||||
{
|
||||
var root = new ElementInfo { Id = 0x1, Type = 3, Width = 100, Height = 40 };
|
||||
var trigger = new ElementInfo
|
||||
{
|
||||
Id = 0x2, Type = 3, X = 0, Y = 0, Width = 40, Height = 20,
|
||||
TooltipText = new UiStringInfoValue(0, 0x0AAAAAAAu, 0x23000003u, 0, 0, 0),
|
||||
};
|
||||
|
||||
var tree = LayoutImporter.BuildFromInfos(root, [trigger], NoTex, null);
|
||||
|
||||
UiElement found = tree.FindElement(0x2)!;
|
||||
Assert.Null(found.AuthoredTooltipText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildWidget_NoTooltipProperties_EveryWidgetFieldStaysAtItsDefault()
|
||||
{
|
||||
var root = new ElementInfo { Id = 0x1, Type = 3, Width = 100, Height = 40 };
|
||||
var trigger = new ElementInfo { Id = 0x2, Type = 3, X = 0, Y = 0, Width = 40, Height = 20 };
|
||||
|
||||
var tree = LayoutImporter.BuildFromInfos(root, [trigger], NoTex, null);
|
||||
|
||||
UiElement found = tree.FindElement(0x2)!;
|
||||
Assert.False(found.AuthoredTooltipEnabled);
|
||||
Assert.Null(found.AuthoredTooltipText);
|
||||
Assert.Equal(0u, found.AuthoredTooltipRootElementId);
|
||||
Assert.Equal(0u, found.AuthoredTooltipLayoutDid);
|
||||
Assert.Equal(0u, found.AuthoredTooltipTextChildElementId);
|
||||
Assert.Null(found.AuthoredTooltipDelaySeconds);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static ElementInfo BuildSliceContainer(uint id, uint ReadOrder, uint l, uint t, uint r)
|
||||
|
|
|
|||
268
tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs
Normal file
268
tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
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}");
|
||||
}
|
||||
}
|
||||
169
tests/AcDream.App.Tests/UI/Layout/TooltipLiveDatTests.cs
Normal file
169
tests/AcDream.App.Tests/UI/Layout/TooltipLiveDatTests.cs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Content;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Options;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// #409 (client-wide retail tooltip system) installed-DAT acceptance gate.
|
||||
/// Opt in with <c>ACDREAM_PROBE_LIVE_MOUNT=1</c>; <c>ACDREAM_DAT_DIR</c> can
|
||||
/// override the ordinary Documents/Asheron's Call location. Pins retail's
|
||||
/// tooltip popup LayoutDesc <c>0x21000041</c> structure and a client-wide
|
||||
/// sweep landmark, mirroring <see cref="CharacterManagementLiveDatTests"/>'s
|
||||
/// pattern.
|
||||
/// </summary>
|
||||
public sealed class TooltipLiveDatTests
|
||||
{
|
||||
private static string DatDirectory =>
|
||||
System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(
|
||||
System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
|
||||
"Documents",
|
||||
"Asheron's Call");
|
||||
|
||||
/// <summary>The tooltip popup catalog LayoutDesc — every probed
|
||||
/// tooltip-bearing element's P0x48 resolves here (with one exception,
|
||||
/// 0x21000026, an alternate skin used by a handful of elements —
|
||||
/// RetailTooltipPresenter is data-driven off each element's OWN P0x48
|
||||
/// so neither this constant nor 0x21000026 is hardcoded in production
|
||||
/// code; it is cited here only to pin the probe's own finding).</summary>
|
||||
private const uint TooltipCatalogLayoutId = 0x21000041u;
|
||||
|
||||
/// <summary>The four popup "skin" root element ids live-DAT-probed inside
|
||||
/// <see cref="TooltipCatalogLayoutId"/>. Each is a 30x30 four-piece bevel
|
||||
/// frame around the SAME text child id (<see cref="TooltipTextChildId"/>).</summary>
|
||||
private static readonly uint[] PopupSkinRootIds =
|
||||
[0x10000487u, 0x10000395u, 0x10000397u, 0x10000398u];
|
||||
|
||||
private const uint TooltipTextChildId = 0x10000396u;
|
||||
|
||||
[InstalledDatFact]
|
||||
public void TooltipCatalog_EveryPopupSkin_SharesTheSameTextChild()
|
||||
{
|
||||
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
||||
|
||||
ElementInfo? root = LayoutImporter.ImportInfos(dats, TooltipCatalogLayoutId);
|
||||
Assert.NotNull(root);
|
||||
Assert.Equal(0u, root!.Id);
|
||||
Assert.Equal(800f, root.Width);
|
||||
Assert.Equal(600f, root.Height);
|
||||
|
||||
foreach (uint skinRootId in PopupSkinRootIds)
|
||||
{
|
||||
ElementInfo skin = Assert.Single(root.Children, c => c.Id == skinRootId);
|
||||
// UIElementManager::StartTooltip @0x0045DE90's own fallback read:
|
||||
// GetAttribute_Enum(tooltipRootElement, 0x4a, &textChildId) off the
|
||||
// freshly-instantiated POPUP root itself.
|
||||
Assert.Equal(TooltipTextChildId, skin.TooltipTextChildElementId);
|
||||
|
||||
ElementInfo textChild = Assert.Single(
|
||||
AllDescendants(skin), e => e.Id == TooltipTextChildId);
|
||||
Assert.Equal(12u, textChild.Type); // UIElement_Text
|
||||
}
|
||||
}
|
||||
|
||||
[InstalledDatFact]
|
||||
public void TooltipCatalog_ImportsThroughLayoutImporter_WithTextChildResolvable()
|
||||
{
|
||||
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
||||
|
||||
ImportedLayout? popup = LayoutImporter.Import(
|
||||
dats,
|
||||
TooltipCatalogLayoutId,
|
||||
0x10000487u,
|
||||
_ => (0u, 0, 0),
|
||||
null);
|
||||
Assert.NotNull(popup);
|
||||
Assert.Equal(30f, popup!.Root.Width);
|
||||
Assert.Equal(30f, popup.Root.Height);
|
||||
Assert.Equal(TooltipTextChildId, popup.Root.AuthoredTooltipTextChildElementId);
|
||||
|
||||
UiElement? textChild = popup.FindElement(TooltipTextChildId);
|
||||
Assert.IsType<UiText>(textChild);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A known tooltip-bearing element (the Appearance page's left rotate
|
||||
/// button, live-DAT-probed) authors all five trigger properties with
|
||||
/// literal StringInfo text — the "core" case
|
||||
/// <see cref="RetailTooltipPresenter"/> can show end to end.
|
||||
/// </summary>
|
||||
[InstalledDatFact]
|
||||
public void KnownElement_AuthorsAllFiveTooltipProperties_WithResolvableText()
|
||||
{
|
||||
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
||||
|
||||
ElementInfo? tree = LayoutImporter.ImportInfos(dats, 0x21000005u);
|
||||
Assert.NotNull(tree);
|
||||
ElementInfo rotateLeft = Assert.Single(AllDescendants(tree!), e => e.Id == 0x100005A4u);
|
||||
|
||||
Assert.True(rotateLeft.TooltipEnabled);
|
||||
Assert.Equal(0x10000487u, rotateLeft.TooltipRootElementId);
|
||||
Assert.Equal(TooltipCatalogLayoutId, rotateLeft.TooltipLayoutDid);
|
||||
Assert.NotNull(rotateLeft.TooltipText);
|
||||
|
||||
var strings = new DatStringResolver(dats);
|
||||
string? resolved = DatWidgetFactory.ResolveTooltipText(rotateLeft, strings.Resolve);
|
||||
Assert.Equal("Rotate left.", resolved);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Landmark client-wide sweep (mirrors
|
||||
/// <see cref="LayoutImporterMediaBearingChildSweepTests"/>'s own shape):
|
||||
/// every installed LayoutDesc, counting elements authoring at least one
|
||||
/// of the five tooltip-trigger properties. Asserts landmarks + a floor,
|
||||
/// not a brittle exact total, so the gate survives a future DAT
|
||||
/// revision.
|
||||
/// </summary>
|
||||
[InstalledDatFact]
|
||||
public void ClientWideSweep_FindsKnownLandmarksAndAFloorCount()
|
||||
{
|
||||
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
||||
|
||||
var withProperties = new List<(uint LayoutId, uint ElementId, bool HasText)>();
|
||||
foreach (uint layoutId in dats.GetAllIdsOfType<LayoutDesc>())
|
||||
{
|
||||
ElementInfo? tree;
|
||||
try { tree = LayoutImporter.ImportInfos(dats, layoutId); }
|
||||
catch { continue; }
|
||||
if (tree is null) continue;
|
||||
|
||||
foreach (ElementInfo e in AllDescendants(tree))
|
||||
{
|
||||
bool any = e.TooltipRootElementId != 0 || e.TooltipLayoutDid != 0
|
||||
|| e.TooltipText.HasValue || e.TooltipEnabled
|
||||
|| e.TooltipDelaySeconds.HasValue;
|
||||
if (any)
|
||||
withProperties.Add((layoutId, e.Id, e.TooltipText.HasValue));
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"[409-DAT] {withProperties.Count} elements author >=1 tooltip property "
|
||||
+ $"({withProperties.Count(f => f.HasText)} with literal StringInfo text).");
|
||||
|
||||
// #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.
|
||||
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)}.");
|
||||
}
|
||||
|
||||
private static IEnumerable<ElementInfo> AllDescendants(ElementInfo root)
|
||||
{
|
||||
yield return root;
|
||||
foreach (ElementInfo child in root.Children)
|
||||
foreach (ElementInfo descendant in AllDescendants(child))
|
||||
yield return descendant;
|
||||
}
|
||||
}
|
||||
|
|
@ -631,4 +631,57 @@ public sealed class SettingsStoreTests : System.IDisposable
|
|||
Assert.Equal(crafting, store.LoadNamedWindowLayout("crafting", "chat", default));
|
||||
Assert.Null(store.LoadNamedWindowLayout("missing", "chat", default));
|
||||
}
|
||||
|
||||
// -- #409: Misc section (Misc.TooltipEnable / Misc.TooltipDelay) --------
|
||||
|
||||
[Fact]
|
||||
public void LoadMisc_returns_defaults_when_file_is_missing()
|
||||
{
|
||||
var store = new SettingsStore(_tempPath);
|
||||
Assert.Equal(MiscSettings.Default, store.LoadMisc());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveMisc_then_LoadMisc_round_trips_all_fields()
|
||||
{
|
||||
var store = new SettingsStore(_tempPath);
|
||||
var original = new MiscSettings(TooltipEnable: false, TooltipDelaySeconds: 1.5f);
|
||||
|
||||
store.SaveMisc(original);
|
||||
var loaded = store.LoadMisc();
|
||||
|
||||
Assert.Equal(original, loaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadMisc_falls_back_per_field_when_keys_missing()
|
||||
{
|
||||
File.WriteAllText(_tempPath, """
|
||||
{
|
||||
"version": 3,
|
||||
"misc": { "tooltipEnable": false }
|
||||
}
|
||||
""");
|
||||
var store = new SettingsStore(_tempPath);
|
||||
|
||||
var loaded = store.LoadMisc();
|
||||
|
||||
Assert.False(loaded.TooltipEnable);
|
||||
Assert.Equal(MiscSettings.Default.TooltipDelaySeconds, loaded.TooltipDelaySeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveMisc_preserves_display_section_and_vice_versa()
|
||||
{
|
||||
var store = new SettingsStore(_tempPath);
|
||||
store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" });
|
||||
store.SaveMisc(MiscSettings.Default with { TooltipDelaySeconds = 2f });
|
||||
|
||||
Assert.Equal("2560x1440", store.LoadDisplay().Resolution);
|
||||
Assert.Equal(2f, store.LoadMisc().TooltipDelaySeconds);
|
||||
|
||||
store.SaveDisplay(DisplaySettings.Default with { ShowFps = true });
|
||||
Assert.Equal(2f, store.LoadMisc().TooltipDelaySeconds);
|
||||
Assert.True(store.LoadDisplay().ShowFps);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue