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:
Erik 2026-08-16 20:03:24 +02:00
parent d3755eb231
commit a377b9bff7
16 changed files with 1386 additions and 9 deletions

View file

@ -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);
}
}

View file

@ -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)

View 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}");
}
}

View 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;
}
}

View file

@ -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);
}
}