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>
376 lines
17 KiB
C#
376 lines
17 KiB
C#
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
|
|
namespace AcDream.App.Tests.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Pure unit tests for <see cref="LayoutImporter.BuildFromInfos"/> — no dats, no GL.
|
|
/// Verifies the tree-builder: widget dispatch, Type-12 skipping, and meter child consumption.
|
|
/// </summary>
|
|
public class LayoutImporterTests
|
|
{
|
|
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
|
|
|
|
// ── Test 1: Health meter element → UiMeter with correct rect ─────────────
|
|
|
|
/// <summary>
|
|
/// A Type-7 (meter) child element with X=5,Y=5,W=150,H=16 must produce a UiMeter
|
|
/// that is findable by its id, positioned at Left=5, Width=150.
|
|
/// The resolve lambda is a 1-arg Func<uint,(uint,int,int)>.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildFromInfos_HealthMeter_IsUiMeterAtRect()
|
|
{
|
|
var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 160, Height = 58 };
|
|
var health = new ElementInfo { Id = 0x100000E6, Type = 7, X = 5, Y = 5, Width = 150, Height = 16 };
|
|
|
|
var tree = LayoutImporter.BuildFromInfos(root, new[] { health }, NoTex, null);
|
|
|
|
var found = tree.FindElement(0x100000E6);
|
|
Assert.IsType<UiMeter>(found);
|
|
Assert.Equal(5f, found!.Left);
|
|
Assert.Equal(150f, found.Width);
|
|
}
|
|
|
|
// ── Test 2: Type-12 child builds a UiText; Type-3 sibling is also present ──
|
|
|
|
/// <summary>
|
|
/// A root with two children: one Type-12 UIElement_Text and one Type-3 container.
|
|
/// The Type-12 must appear as a <see cref="UiText"/> in the tree (transparent,
|
|
/// draws nothing until a controller binds its <c>LinesProvider</c>);
|
|
/// the Type-3 must also be present.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildFromInfos_StampsDatElementId_OnRootAndItemList()
|
|
{
|
|
const uint RootId = 0x100005F9u;
|
|
const uint ListId = 0x100001C6u;
|
|
|
|
var root = new ElementInfo { Id = RootId, Type = 3, Width = 160, Height = 58 };
|
|
var itemList = new ElementInfo { Id = ListId, Type = 0x10000031u, X = 5, Y = 5, Width = 72, Height = 72 };
|
|
|
|
var tree = LayoutImporter.BuildFromInfos(root, new[] { itemList }, NoTex, null);
|
|
|
|
Assert.Equal(RootId, tree.Root.DatElementId);
|
|
var found = Assert.IsType<UiItemList>(tree.FindElement(ListId));
|
|
Assert.Equal(ListId, found.DatElementId);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildFromInfos_Type12Child_IsSkipped_Type3Present()
|
|
{
|
|
var root = new ElementInfo { Id = 0x10000001, Type = 3, Width = 160, Height = 58 };
|
|
var prototype = new ElementInfo { Id = 0x20000001, Type = 12, Width = 0, Height = 0 };
|
|
var container = new ElementInfo { Id = 0x20000002, Type = 3, Width = 100, Height = 20 };
|
|
|
|
var tree = LayoutImporter.BuildFromInfos(root, new[] { prototype, container }, NoTex, null);
|
|
|
|
// Type-12 is now a UiText (transparent, no lines) — present in the tree.
|
|
Assert.IsType<UiText>(tree.FindElement(0x20000001));
|
|
// Type-3 must also be present.
|
|
Assert.NotNull(tree.FindElement(0x20000002));
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildFromInfos_TextPassToChildren_PropagatesDefaultAndRuntimeState()
|
|
{
|
|
var tab = new ElementInfo
|
|
{
|
|
Id = 0x100002A9u,
|
|
Type = 12,
|
|
Width = 100,
|
|
Height = 25,
|
|
DefaultStateId = RetailUiStateIds.Closed,
|
|
DefaultStateName = "Closed",
|
|
};
|
|
tab.States[RetailUiStateIds.Closed] = new UiStateInfo
|
|
{ Id = RetailUiStateIds.Closed, Name = "Closed", PassToChildren = true };
|
|
tab.States[RetailUiStateIds.Open] = new UiStateInfo
|
|
{ Id = RetailUiStateIds.Open, Name = "Open", PassToChildren = true };
|
|
|
|
var cap = new ElementInfo { Id = 0x10000439u, Type = 3, Width = 17, Height = 25 };
|
|
cap.States[RetailUiStateIds.Closed] = new UiStateInfo
|
|
{
|
|
Id = RetailUiStateIds.Closed,
|
|
Name = "Closed",
|
|
Image = new UiImageMedia(0x06005D93u, 3),
|
|
};
|
|
cap.States[RetailUiStateIds.Open] = new UiStateInfo
|
|
{
|
|
Id = RetailUiStateIds.Open,
|
|
Name = "Open",
|
|
Image = new UiImageMedia(0x06005D92u, 3),
|
|
};
|
|
cap.StateMedia["Closed"] = (0x06005D93u, 3);
|
|
cap.StateMedia["Open"] = (0x06005D92u, 3);
|
|
tab.Children.Add(cap);
|
|
|
|
var root = new ElementInfo { Id = 1u, Type = 3, Width = 100, Height = 25 };
|
|
ImportedLayout layout = LayoutImporter.BuildFromInfos(root, [tab], NoTex, null);
|
|
UiText text = Assert.IsType<UiText>(layout.FindElement(tab.Id));
|
|
UiDatElement child = Assert.IsType<UiDatElement>(layout.FindElement(cap.Id));
|
|
|
|
Assert.Single(text.Children);
|
|
Assert.Equal(RetailUiStateIds.Closed, child.ActiveRetailStateId);
|
|
Assert.Equal(0x06005D93u, child.ActiveMedia().File);
|
|
|
|
Assert.True(text.TrySetRetailState(RetailUiStateIds.Open));
|
|
Assert.Equal(RetailUiStateIds.Open, child.ActiveRetailStateId);
|
|
Assert.Equal(0x06005D92u, child.ActiveMedia().File);
|
|
}
|
|
|
|
// ── Test 3: Meter consumes Type-3 slice children — child ids not in byId ────
|
|
|
|
/// <summary>
|
|
/// A meter (Type 7) whose children are ONLY the 3-slice back/front containers (Type 3).
|
|
/// The meter itself must be findable; its Type-3 children must NOT appear as separate
|
|
/// nodes in the tree (Fix 5: only non-Type-3 children are built as separate widgets).
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildFromInfos_MeterWithSliceChildren_MeterPresent_SliceChildrenNotInTree()
|
|
{
|
|
const uint MeterId = 0x100000E6u;
|
|
const uint BackLayerId = 0x100000E7u;
|
|
const uint FrontLayerId = 0x00000002u;
|
|
|
|
// Build a minimal meter with back + front containers, each with 3 slice children.
|
|
var backContainer = BuildSliceContainer(BackLayerId, ReadOrder: 0,
|
|
l: 0x0600747Eu, t: 0x0600747Fu, r: 0x06007480u);
|
|
var frontContainer = BuildSliceContainer(FrontLayerId, ReadOrder: 1,
|
|
l: 0x06007481u, t: 0x06007482u, r: 0x06007483u);
|
|
|
|
var meter = new ElementInfo { Id = MeterId, Type = 7, Width = 150, Height = 16 };
|
|
meter.Children.Add(backContainer);
|
|
meter.Children.Add(frontContainer);
|
|
|
|
var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 160, Height = 58 };
|
|
|
|
var tree = LayoutImporter.BuildFromInfos(root, new[] { meter }, NoTex, null);
|
|
|
|
// The meter widget is present.
|
|
Assert.IsType<UiMeter>(tree.FindElement(MeterId));
|
|
// The meter's Type-3 slice children are NOT separate UiElement nodes.
|
|
Assert.Null(tree.FindElement(BackLayerId));
|
|
Assert.Null(tree.FindElement(FrontLayerId));
|
|
// The UiMeter itself has no UiElement children (all children were Type-3, consumed).
|
|
var uiMeter = (UiMeter)tree.FindElement(MeterId)!;
|
|
Assert.Empty(uiMeter.Children);
|
|
}
|
|
|
|
// ── Test 5: Fix 5 — meter text children (Type-12) are built and registered ─
|
|
|
|
/// <summary>
|
|
/// Fix 5: a meter (Type 7) with BOTH Type-3 slice containers AND a Type-12 text
|
|
/// child. The Type-3 containers must be consumed (not in byId); the Type-12 text
|
|
/// child must be built as a UiText, registered in byId, and attached as a UiElement
|
|
/// child of the meter (so it renders as an overlay).
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildFromInfos_MeterWithTextChild_TextChildInTreeAsUiTextChildOfMeter()
|
|
{
|
|
const uint MeterId = 0x10000236u;
|
|
const uint BackId = 0x10000237u; // Type-3 slice
|
|
const uint FrontId = 0x10000238u; // Type-3 slice
|
|
const uint LabelId = 0x10000239u; // Type-12 text child
|
|
const uint ValueId = 0x1000023Au; // Type-12 text child
|
|
|
|
var backContainer = BuildSliceContainer(BackId, ReadOrder: 0,
|
|
l: 0x0600747Eu, t: 0x0600747Fu, r: 0x06007480u);
|
|
var frontContainer = BuildSliceContainer(FrontId, ReadOrder: 1,
|
|
l: 0x06007481u, t: 0x06007482u, r: 0x06007483u);
|
|
|
|
// Two Type-12 text children (the XP "label" and "value" overlay).
|
|
var labelInfo = new ElementInfo { Id = LabelId, Type = 12, X = 0, Y = 0, Width = 120, Height = 13 };
|
|
var valueInfo = new ElementInfo { Id = ValueId, Type = 12, X = 0, Y = 0, Width = 200, Height = 13,
|
|
HJustify = HJustify.Right };
|
|
|
|
var meter = new ElementInfo { Id = MeterId, Type = 7, Width = 200, Height = 13 };
|
|
meter.Children.Add(backContainer);
|
|
meter.Children.Add(frontContainer);
|
|
meter.Children.Add(labelInfo);
|
|
meter.Children.Add(valueInfo);
|
|
|
|
var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 210, Height = 20 };
|
|
|
|
var tree = LayoutImporter.BuildFromInfos(root, new[] { meter }, NoTex, null);
|
|
|
|
// Meter is present.
|
|
Assert.IsType<UiMeter>(tree.FindElement(MeterId));
|
|
|
|
// Type-3 slice containers are NOT in byId (consumed by BuildMeter).
|
|
Assert.Null(tree.FindElement(BackId));
|
|
Assert.Null(tree.FindElement(FrontId));
|
|
|
|
// Type-12 text children ARE in byId as UiText.
|
|
Assert.IsType<UiText>(tree.FindElement(LabelId));
|
|
Assert.IsType<UiText>(tree.FindElement(ValueId));
|
|
|
|
// The UiMeter has exactly 2 UiElement children (the two text overlays).
|
|
var uiMeter = (UiMeter)tree.FindElement(MeterId)!;
|
|
Assert.Equal(2, uiMeter.Children.Count);
|
|
Assert.All(uiMeter.Children, c => Assert.IsType<UiText>(c));
|
|
|
|
// The value child should have RightAligned=true (HJustify.Right in the dat).
|
|
var valueWidget = (UiText)tree.FindElement(ValueId)!;
|
|
Assert.True(valueWidget.RightAligned, "Type-12 text child with HJustify.Right must build as RightAligned=true");
|
|
}
|
|
|
|
// ── Test 6: Fix 5 — vitals meters (Type-3 only) are unaffected ────────────
|
|
|
|
/// <summary>
|
|
/// Fix 5 regression guard: vitals health/stamina/mana meters have ONLY Type-3 slice
|
|
/// children (no Type-12 text children). The meter must have zero UiElement children
|
|
/// after build — VitalsController injects its number overlay at runtime, not the importer.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildFromInfos_VitalsMeter_NoTextChildren_MeterHasNoUiChildren()
|
|
{
|
|
const uint MeterId = 0x100000E6u; // vitals health meter
|
|
const uint BackId = 0x100000E7u;
|
|
const uint FrontId = 0x100000E8u;
|
|
|
|
var backContainer = BuildSliceContainer(BackId, ReadOrder: 0,
|
|
l: 0x0600747Eu, t: 0x0600747Fu, r: 0x06007480u);
|
|
var frontContainer = BuildSliceContainer(FrontId, ReadOrder: 1,
|
|
l: 0x06007481u, t: 0x06007482u, r: 0x06007483u);
|
|
|
|
var meter = new ElementInfo { Id = MeterId, Type = 7, Width = 150, Height = 16 };
|
|
meter.Children.Add(backContainer);
|
|
meter.Children.Add(frontContainer);
|
|
|
|
var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 160, Height = 58 };
|
|
|
|
var tree = LayoutImporter.BuildFromInfos(root, new[] { meter }, NoTex, null);
|
|
|
|
Assert.IsType<UiMeter>(tree.FindElement(MeterId));
|
|
|
|
// Vitals meter has no UiElement children — only Type-3 children were present,
|
|
// all consumed, none built as UiText overlays.
|
|
var uiMeter = (UiMeter)tree.FindElement(MeterId)!;
|
|
Assert.Empty(uiMeter.Children);
|
|
}
|
|
|
|
// ── Test 4: Prototype-skip in BuildFromInfos ─────────────────────────────
|
|
|
|
/// <summary>
|
|
/// When one top-level element is referenced as a BaseElement by a sibling
|
|
/// (mirroring the toolbar slot prototype pattern), and the prototype element
|
|
/// has no own state media, the importer must NOT produce a widget for the
|
|
/// prototype id (FindElement returns null), but MUST produce the derived element.
|
|
///
|
|
/// NOTE: This test exercises <see cref="LayoutImporter.BuildFromInfos"/> (the pure
|
|
/// layer), where prototype detection is done by inspecting the pre-resolved
|
|
/// ElementInfo tree rather than the raw dat ElementDesc. The pure layer skips
|
|
/// an element if its Id is in a sibling's (or child's) <c>Children</c> chain
|
|
/// as a BaseElement — but actually the pure layer has no BaseElement knowledge
|
|
/// at this stage (that's resolved before Build). The prototype-skip in the real
|
|
/// world occurs in <c>ImportInfos</c> (the dat shell), BEFORE calling Build.
|
|
///
|
|
/// This test verifies the INVARIANT that holds AFTER ImportInfos filters prototypes:
|
|
/// a pure template element that was skipped is absent from FindElement, while the
|
|
/// derived element (which inherited from it) IS present.
|
|
///
|
|
/// We model this by simply NOT adding the prototype to the ElementInfo tree passed
|
|
/// to BuildFromInfos — as if ImportInfos already filtered it out.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildFromInfos_PrototypeSkipped_DerivedPresent_PrototypeAbsent()
|
|
{
|
|
// Simulate what ImportInfos does AFTER filtering: the prototype 0xBBB00001 is
|
|
// absent (already skipped by ImportInfos), the derived element 0xCCC00001 is
|
|
// present with its own media inherited from the prototype.
|
|
var root = new ElementInfo { Id = 0x10000001, Type = 3, Width = 200, Height = 100 };
|
|
// The derived element has its own size + media (prototype was merged into it already).
|
|
var derived = new ElementInfo
|
|
{
|
|
Id = 0xCCC00001u,
|
|
Type = 0x10000031u, // UIElement_ItemList (toolbar slot type)
|
|
X = 10, Y = 10, Width = 32, Height = 32,
|
|
};
|
|
derived.StateMedia[""] = (0x06001234u, 1);
|
|
|
|
// Only the derived element appears in the tree (prototype was filtered by ImportInfos).
|
|
var tree = LayoutImporter.BuildFromInfos(root, new[] { derived }, NoTex, null);
|
|
|
|
// The derived element is present in the built tree.
|
|
Assert.NotNull(tree.FindElement(0xCCC00001u));
|
|
// The prototype id is NOT in the tree (was never added).
|
|
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)
|
|
{
|
|
var c = new ElementInfo { Id = id, Type = 3, ReadOrder = ReadOrder };
|
|
c.Children.Add(new ElementInfo { X = 0, StateMedia = { [""] = (l, 1) } });
|
|
c.Children.Add(new ElementInfo { X = 10, StateMedia = { [""] = (t, 1) } });
|
|
c.Children.Add(new ElementInfo { X = 140, StateMedia = { [""] = (r, 1) } });
|
|
return c;
|
|
}
|
|
}
|