Applies docs/research/2026-08-10-ch6ab-review-findings.md in full:
- BLOCKER 1: UiResizeGrip now carries its ElementInfo/resolve pair and
draws its own authored DirectState media (a synthetic parameterless
grip still draws nothing, preserving existing resize-drag tests).
DatWidgetFactory.BuildResizeGrip threads resolve through. All seven
live grips on the main chat window now resolve a non-zero sprite,
restoring the visible borders/corners CH6a silently dropped.
- SHOULD-FIX 2: ChatWindowState gains BroadcastTargetWindow, a sentinel
distinct from every real window id (0-4), fixing the bug where the
main window's explicit-addressing branch coincided with the broadcast
check (both were literal 0). SetFilter's main-window no-op is dropped
— the main window's filter is now genuinely settable. ChatWindowController
.Bind takes a ChatWindowState (the same canonical instance the floating
windows already share) and GetTranscriptLines builds a real accept
predicate instead of accept:null. Verified safe: ClientLocal (0x1A)
never reaches ChatLog (AddText routes it to the SpewBox and returns),
so nothing observable regresses.
- SHOULD-FIX 3: UiButton.SuppressSelfToggle stops the four chat-window
indicator buttons (DAT property 0x0B=true, no retail click handler)
from flipping their own Selected mirror on a stray click.
- SHOULD-FIX 4: generated and committed chat_floaty_2100005b.json from
the real installed dats; added the permanent RetailLayoutFixtureGenerator
entry. All three flagged FloatingChatWindowController assumptions
(input field, title bar, close button) are confirmed correct against
real data — no controller code changes needed. New finding: unlike the
main window, ALL EIGHT floaty border/corner elements are live Type-9
grips (the floaty's own title bar is its move handle), so a floaty
window resizes from every edge and corner.
- SHOULD-FIX 5: register row AP-189 documents the shared-500-entry/
200-line-tail vs retail's per-window 10,000-line scrollback depth gap.
- NITs 1-5: documented the filter-persistence-only-on-/saveautoui
asymmetry and the reconnect-preserves-filters intent; corrected the
research doc's modifier-mask mislabel and the "ONLY function" false
superlative; moved WrapText off ChatWindowController onto
ChatTranscriptRenderer, closing the circular dependency.
Full Release suite: 12,420 passed / 4 skipped / 0 failed (baseline
12,392/4/0 at 22020ef2; net +28 tests, zero regressions).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
237 lines
7.9 KiB
C#
237 lines
7.9 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 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 };
|
|
}
|