acdream/tests/AcDream.App.Tests/UI/UiButtonTests.cs
Erik 7d09821fdc fix(chargen): Campaign CC gate round 1 Batch B — authored selection states, label state, zoom/swatch feedback
GF-1/GF-8: UiButton now recognizes retail's custom Unselected/Selected
radio-pair (0x10000016/0x10000017), bypassing the standard Normal/
Highlight machine that never admitted those state names — .Selected now
lights the heritage/template/gender/Face-Clothes rows it was always a
no-op for.

AP-222/GF-11b: per-state label color/outline (dat 0x1B/0x21) now applies
off the REQUESTED retail state id, not the art-gated committed
ActiveState — resolves the Appearance spins' current-part highlight
(text recolors even though no Highlight art exists on either client) and
the Town caption's Normal-to-white swap.

GF-11c: UiButton.LabelBox lets a lifted caption with its own authored
rect draw there instead of the face-relative offset that's only correct
when the label is authored directly on the button (heritage/template
family, unchanged).

GF-9: wires the real nine companion overlay elements (SetColor's
SetVisible mechanism) that swatch clicks were always meant to drive,
retiring AP-215 item 1 (the swatch.Selected substitution was a permanent
no-op — swatches author no Highlight media at all).

GF-10: zoom buttons now set the retail-mirrored mutual-exclusive
Highlight/Normal pair on click; InitializePage carries no initial
SetState for either button, so both stay at "Normal" until first click.

Register: AP-222 retired (mechanism identified and ported), AP-215
narrowed (item 1 retired, item 2 unrelated and unchanged), row count
recount corrected 164 (was already one high before this batch).

App suite 5282/3 (was 5266/3), Runtime 1735/0 unchanged. Fixture + live-
DAT tests only — no graphical client launch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 11:37:09 +02:00

465 lines
17 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 DoubleClick_IsOptInAndDisabledButtonsSwallowWithoutInvoking()
{
int activations = 0;
var button = new UiButton(
new ElementInfo { Type = 1, Width = 46, Height = 18 },
NoTex);
var doubleClick = new UiEvent(
0,
button,
UiEventType.DoubleClick);
Assert.False(button.OnEvent(doubleClick));
button.OnDoubleClick = () => activations++;
Assert.True(button.OnEvent(doubleClick));
Assert.Equal(1, activations);
button.Enabled = false;
Assert.True(button.OnEvent(doubleClick));
Assert.Equal(1, activations);
}
[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);
}
/// <summary>
/// GF-1/GF-8 (Campaign CC gate round 1 Batch B): the "gender button"
/// shape — retail's custom Unselected/Selected radio-pair media authored
/// DIRECTLY on the button's own StateMedia (no separate face-segment
/// child), live-DAT-measured on 0x100003A7/0x100003A8 (Female/Male).
/// Before this fix, .Selected committed nothing: the standard
/// AddAvailableStates loop never recognized the "Unselected"/"Selected"
/// names, so _availableStates was empty and UpdateVisualState's
/// RequestedState (which only ever returns Normal/Highlight/Ghosted ids)
/// could never match anyway.
/// </summary>
[Fact]
public void CustomSelectionPair_MediaDirectlyOnButton_SelectedTogglesActiveState()
{
var info = ButtonInfo("Unselected", "Selected");
var b = CreateButton(info);
Assert.Equal("Unselected", b.ActiveState);
b.Selected = true;
Assert.Equal("Selected", b.ActiveState);
b.Selected = false;
Assert.Equal("Unselected", b.ActiveState);
}
/// <summary>
/// The "heritage/template/sub-tab row" shape — the parent authors the
/// Unselected/Selected state DESCRIPTORS (property bag only, no media),
/// and a single stateful child (the radio dot / icon) carries the
/// actual per-state art, matching <c>FindStatefulFaceChildren</c>'s
/// name-overlap detection. Live-DAT-measured on the Heritage row
/// (0x100003BF, dot child 0x100003C0) and the Profession template row
/// (0x100003D9, icon child 0x100002E9).
/// </summary>
[Fact]
public void CustomSelectionPair_MediaOnFaceChild_SelectedTogglesActiveState()
{
var info = new ElementInfo { Type = 1, Width = 305, Height = 32 };
info.States[UiButtonStateMachine.NormalPressed] = new UiStateInfo
{
Id = UiButtonStateMachine.NormalPressed,
Name = "Normal_pressed",
};
info.States[RetailUiStateIds.Unselected] = new UiStateInfo
{
Id = RetailUiStateIds.Unselected,
Name = "Unselected",
};
info.States[RetailUiStateIds.Selected] = new UiStateInfo
{
Id = RetailUiStateIds.Selected,
Name = "Selected",
};
info.DefaultStateName = "Unselected";
var dot = new ElementInfo { Type = 3, Width = 32, Height = 32 };
dot.StateMedia["Unselected"] = (0x06006E35u, 1);
dot.StateMedia["Selected"] = (0x06006E21u, 1);
info.Children.Add(dot);
// Face-child discovery (FindStatefulFaceChildren) is DatWidgetFactory's
// job, not UiButton's own constructor — go through the real factory
// path so this fixture matches production exactly (raw CreateButton
// below bypasses that discovery entirely).
var b = Assert.IsType<UiButton>(DatWidgetFactory.Create(info, NoTex, null));
Assert.Equal("Unselected", b.ActiveState);
b.Selected = true;
Assert.Equal("Selected", b.ActiveState);
Assert.Equal(RetailUiStateIds.Selected, b.ActiveRetailStateId);
b.Selected = false;
Assert.Equal("Unselected", b.ActiveState);
}
/// <summary>
/// Regression pin: a STANDARD ToggleBehavior button (no Unselected/
/// Selected states authored at all — the overwhelming majority of
/// buttons, including every pre-existing ToggleBehavior consumer) keeps
/// behaving exactly as before the custom-pair bypass was added.
/// </summary>
[Fact]
public void CustomSelectionPair_Absent_StandardToggleBehaviorUnchanged()
{
var info = ButtonInfo("Normal", "Highlight");
AddBoolProperty(info, 0x0Bu, true);
var b = CreateButton(info);
b.Selected = true;
Assert.Equal("Highlight", b.ActiveState);
b.Selected = false;
Assert.Equal("Normal", b.ActiveState);
}
/// <summary>
/// AP-222 / GF-11b (Campaign CC gate round 1 Batch B): per-state label
/// color/outline reacts to the REQUESTED retail state id even when the
/// standard art-availability gate never lets ActiveState reach it — the
/// Appearance spins' exact shape (their arrow face segments carry no
/// Highlight media at all, so ActiveState is permanently stuck at
/// "Normal", but the label text must still recolor). Live-DAT-measured
/// values: Normal (218,167,85), Highlight (255,221,131), outline
/// off -&gt; on.
/// </summary>
[Fact]
public void PerStateLabelStyle_AppliesEvenWhenActiveStateCannotReachIt()
{
var info = ButtonInfo("Normal"); // no Highlight media at all
AddBoolProperty(info, 0x0Bu, true); // ToggleBehavior
var b = CreateButton(info);
b.Label = "Hair Style";
b.LabelColor = new System.Numerics.Vector4(1f, 1f, 1f, 1f);
var colors = new Dictionary<uint, System.Numerics.Vector4>
{
[UiButtonStateMachine.Normal] = new(218f / 255f, 167f / 255f, 85f / 255f, 1f),
[UiButtonStateMachine.Highlight] = new(255f / 255f, 221f / 255f, 131f / 255f, 1f),
};
var outlines = new Dictionary<uint, bool>
{
[UiButtonStateMachine.Normal] = false,
[UiButtonStateMachine.Highlight] = true,
};
b.SetPerStateLabelStyle(colors, outlines);
Assert.Equal(colors[UiButtonStateMachine.Normal], b.LabelColor);
Assert.False(b.Outline);
b.Selected = true;
// The art stays "Normal" (no Highlight media exists to commit to) —
// this is the exact AP-222 no-op the standard gate always produced —
// but the label color/outline must still reach the Highlight values.
Assert.Equal("Normal", b.ActiveState);
Assert.Equal(colors[UiButtonStateMachine.Highlight], b.LabelColor);
Assert.True(b.Outline);
}
/// <summary>
/// Regression pin: a button with NO per-state color map (null, the
/// overwhelming majority — every existing external post-construction
/// LabelColor assignment such as ChatWindowController's Send caption or
/// PaperdollController's Slots label) never has its LabelColor touched
/// by a state change.
/// </summary>
[Fact]
public void PerStateLabelStyle_Absent_ExternalLabelColorAssignmentSurvivesStateChanges()
{
var info = ButtonInfo("Normal", "Highlight");
AddBoolProperty(info, 0x0Bu, true);
var b = CreateButton(info);
var externalColor = new System.Numerics.Vector4(1f, 0.92f, 0.72f, 1f);
b.LabelColor = externalColor;
b.Selected = true;
Assert.Equal("Highlight", b.ActiveState);
Assert.Equal(externalColor, b.LabelColor);
b.Selected = false;
Assert.Equal(externalColor, b.LabelColor);
}
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 };
}