acdream/tests/AcDream.App.Tests/UI/UiButtonTests.cs
Erik d1c60df946 fix #382: chat-window indicator buttons invisible until first hovered
Root cause (found via reference-identity-verified live-DAT probing, not
a guess): the four main-chat-window indicator buttons (0x10000522-
0x10000525) resolve their own correct ActiveState="Normal" at
construction, then get blanked to "" moments later in the SAME
LayoutImporter.Build call. The indicator column's backing panel
(0x10000600) authors PassToChildren=true on its own empty DirectState
(confirmed live: States[0xFFFFFFFF].PassToChildren == true); when
LayoutImporter.BuildWidget's post-attach state reapply runs for that
panel, UiDatElement.TrySetRetailState cascades its DirectStateId to
every IUiDatStateful child, including the already-correctly-resolved
buttons. UiButton.TrySetRetailState's DirectStateId branch used to
accept that cascade because every button structurally carries a
DirectStateId entry in its States dict as a property bag (ToggleBehavior/
RolloverEnabled/etc), independent of whether it authors any blank
sprite, so TryFindState(DirectStateId) found that entry and blanked
ActiveState even with no "" media. A hover "fixed" it only because
UiButtonStateMachine.RequestedState resolves to the same canonical
Normal id regardless of PointerOver when RolloverEnabled is false.

Retail's own decompiled UIElement::SetState @0x00464e70 does the exact
same unconditional-commit-plus-cascade; retail avoids this specific bug
purely through construction timing (UIElement::Initialize's SetState
call precedes child-tree construction, so a cascade fired during import
always iterates zero children). Our port's LayoutImporter.BuildWidget
deliberately reapplies in the opposite order to give retained
PassToChildren tabs their authored child media, so this literal
state-machine port needed a compensating guard.

Fix: UiButton.TrySetRetailState's DirectStateId branch now requires
REAL "" media (HasStateMedia("")) before accepting the transition.
Scoped to UiButton only; UiDatElement's parallel branch and the cascade
mechanism are unchanged, so CharacterStatController's own
PassToChildren-driven chrome children are unaffected. Register row
AP-206 records the divergence from retail's literal unconditional-
commit semantics. Regressed by two fast unit tests in UiButtonTests.cs
(DirectStateCascade_WithoutRealMedia_DoesNotBlankAnAlreadyResolvedState,
DirectStateTransition_WithRealMedia_StillSucceeds) plus a live-mount
probe confirming all four buttons resolve ActiveState="Normal"
immediately after import against the real installed DAT.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 23:03:31 +02:00

277 lines
9.7 KiB
C#

using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI;
public class UiButtonTests
{
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
private bool _clicked;
[Fact]
public void Click_InvokesOnClick()
{
var b = new UiButton(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex)
{ OnClick = () => _clicked = true };
b.OnEvent(new UiEvent(0, null, UiEventType.Click));
Assert.True(_clicked);
}
[Fact]
public void Click_ProvidesLocalCoordinatesToPositionAwareHandler()
{
(int X, int Y) clicked = default;
var b = new UiButton(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex)
{
OnClickAt = (x, y) => clicked = (x, y),
};
b.OnEvent(new UiEvent(0, b, UiEventType.Click, Data1: 17, Data2: 9));
Assert.Equal((17, 9), clicked);
}
[Fact]
public void PointerDownAndUp_InvokeDistinctTransitionHandlers()
{
var transitions = new List<string>();
var b = new UiButton(new ElementInfo { Type = 1, Width = 20, Height = 20 }, NoTex)
{
Width = 20,
Height = 20,
OnPressed = () => transitions.Add("pressed"),
OnReleased = () => transitions.Add("released"),
};
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 30, Data2: 30));
Assert.Equal(["pressed", "released"], transitions);
}
[Fact]
public void NotClickThrough_SoItReceivesClicks()
{
var b = new UiButton(new ElementInfo { Type = 1 }, NoTex);
Assert.False(b.ClickThrough);
}
[Fact]
public void PointerTransitions_UseRetailNormalStates()
{
var b = ButtonWithStates("Normal", "Normal_rollover", "Normal_pressed");
b.OnEvent(new UiEvent(0, b, UiEventType.HoverEnter));
Assert.Equal("Normal_rollover", b.ActiveState);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
Assert.Equal("Normal_pressed", b.ActiveState);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseMove, Data1: 50, Data2: 50));
Assert.Equal("Normal_rollover", b.ActiveState);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseMove, Data1: 5, Data2: 5));
Assert.Equal("Normal_pressed", b.ActiveState);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 5, Data2: 5));
Assert.Equal("Normal_rollover", b.ActiveState);
}
[Fact]
public void ToggleRelease_SelectsHighlightState()
{
var info = ButtonInfo("Normal", "Highlight");
AddBoolProperty(info, 0x0Bu, true);
var b = CreateButton(info);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 5, Data2: 5));
Assert.True(b.Selected);
Assert.Equal("Highlight", b.ActiveState);
}
[Fact]
public void SuppressSelfToggle_PressReleaseDoesNotFlipSelected()
{
// CH6a/b REJECT-review SHOULD-FIX 3: the chat-window 1-4 indicators
// (0x10000522-0x10000525) carry DAT property 0x0B (ToggleBehavior) =
// true — same shape as ToggleRelease_SelectsHighlightState above —
// but retail's own click dispatch has no case for their element ids
// (gmMainChatUI::ListenToElementMessage @0x004CDA80), so a click must
// NOT flip their Selected mirror. Only SetIndicatorOpen (an external
// writer) may change it.
var info = ButtonInfo("Normal", "Highlight");
AddBoolProperty(info, 0x0Bu, true);
var b = CreateButton(info);
b.SuppressSelfToggle = true;
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 5, Data2: 5));
Assert.False(b.Selected);
Assert.Equal("Normal", b.ActiveState);
// The external mirror path still works — SuppressSelfToggle only
// blocks the self-click, not a producer's own write.
b.Selected = true;
Assert.True(b.Selected);
Assert.Equal("Highlight", b.ActiveState);
}
[Fact]
public void DisabledProperty_SelectsGhostedAndSuppressesClick()
{
var info = ButtonInfo("Normal", "Ghosted");
AddBoolProperty(info, 0x0Du, true);
var b = CreateButton(info);
b.OnClick = () => _clicked = true;
b.OnEvent(new UiEvent(0, b, UiEventType.Click));
Assert.False(b.Enabled);
Assert.Equal("Ghosted", b.ActiveState);
Assert.False(_clicked);
}
[Fact]
public void MissingStandardState_PreservesCustomSemanticState()
{
var info = ButtonInfo("LockedUI");
info.DefaultStateName = "LockedUI";
var b = CreateButton(info);
b.OnEvent(new UiEvent(0, b, UiEventType.HoverEnter));
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
Assert.Equal("LockedUI", b.ActiveState);
}
[Fact]
public void PropertyOnlyPressedState_PreservesDrawableNormalFace()
{
var info = ButtonInfo("Normal", "Highlight");
info.States[UiButtonStateMachine.NormalPressed] = new UiStateInfo
{
Id = UiButtonStateMachine.NormalPressed,
Name = "Normal_pressed",
};
var b = CreateButton(info);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
Assert.Equal("Normal", b.ActiveState);
}
[Fact]
public void DirectStateCascade_WithoutRealMedia_DoesNotBlankAnAlreadyResolvedState()
{
// #382: LayoutImporter.BuildWidget's post-attach state reapply cascades a
// PARENT's PassToChildren DirectState to EVERY IUiDatStateful child (the
// chat window's indicator-button backing panel, 0x10000600, authors exactly
// this). Every button structurally carries a DirectStateId entry in its own
// States dict purely as the property bag for ToggleBehavior/RolloverEnabled/
// etc (see AddBoolProperty below) — that structural presence must NOT be
// enough to accept a DirectState transition when the button has no real ""
// media, or an ancestor's unrelated cascade blanks an already-correct
// "Normal" resolution before first paint.
var info = ButtonInfo("Normal", "Highlight");
AddBoolProperty(info, 0x13u, true); // RolloverEnabled — populates States[DirectStateId]
var b = CreateButton(info);
Assert.Equal("Normal", b.ActiveState);
bool ok = b.TrySetRetailState(UiStateInfo.DirectStateId);
Assert.False(ok);
Assert.Equal("Normal", b.ActiveState);
}
[Fact]
public void DirectStateTransition_WithRealMedia_StillSucceeds()
{
// The companion positive case: a button that legitimately authors ""
// (DirectState) media must still be able to transition to it explicitly —
// the fix narrows the check to "has real media", it does not disable the
// DirectState branch outright.
var info = ButtonInfo("Normal");
info.StateMedia[""] = (7u, 1);
var b = CreateButton(info);
bool ok = b.TrySetRetailState(UiStateInfo.DirectStateId);
Assert.True(ok);
Assert.Equal("", b.ActiveState);
}
[Fact]
public void HotClick_FiresImmediatelyRepeatsAndSuppressesReleaseClick()
{
var info = ButtonInfo("Normal");
AddBoolProperty(info, 0x0Fu, true);
AddFloatProperty(info, 0x10u, 0.10f);
AddFloatProperty(info, 0x11u, 0.05f);
int clicks = 0;
var b = CreateButton(info);
b.OnClick = () => clicks++;
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
Assert.Equal(1, clicks);
b.OnGlobalUiTime(1.00);
b.OnGlobalUiTime(1.09);
Assert.Equal(1, clicks);
b.OnGlobalUiTime(1.11);
Assert.Equal(2, clicks);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 5, Data2: 5));
b.OnEvent(new UiEvent(0, b, UiEventType.Click, Data1: 5, Data2: 5));
Assert.Equal(2, clicks);
}
private static UiButton ButtonWithStates(params string[] states)
{
var info = ButtonInfo(states);
AddBoolProperty(info, 0x13u, true);
return CreateButton(info);
}
private static ElementInfo ButtonInfo(params string[] states)
{
var info = new ElementInfo { Type = 1, Width = 20, Height = 20 };
uint file = 1;
foreach (string state in states)
info.StateMedia[state] = (file++, 1);
if (states.Length > 0)
info.DefaultStateName = states[0];
return info;
}
private static void AddBoolProperty(ElementInfo info, uint id, bool value)
{
if (!info.States.TryGetValue(UiStateInfo.DirectStateId, out var state))
{
state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
info.States[UiStateInfo.DirectStateId] = state;
}
state.Properties.Values[id] = new UiPropertyValue
{
Kind = UiPropertyKind.Bool,
BoolValue = value,
};
}
private static void AddFloatProperty(ElementInfo info, uint id, float value)
{
if (!info.States.TryGetValue(UiStateInfo.DirectStateId, out var state))
{
state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
info.States[UiStateInfo.DirectStateId] = state;
}
state.Properties.Values[id] = new UiPropertyValue
{
Kind = UiPropertyKind.Float,
FloatValue = value,
};
}
private static UiButton CreateButton(ElementInfo info)
=> new(info, NoTex) { Width = info.Width, Height = info.Height };
}