R2-1/R2-6 (description-box text clipped left of the frame, regressed from
Batch C's frame un-consume): root cause was never the un-consume change
itself — the Heritage/Profession/Town/Summary description boxes
(0x100003C4/0x100003E0/0x10000409/0x10000404) all author retail's four
independent text-inset margins (dat properties 0x23-0x26,
UIElement_Text::OnSetAttribute cases 0xf-0x12: margL=9/margR=26/margU=15/
margD=15), which this codebase never read at all, before or after Batch C.
Un-consuming the gold-frame children just made the pre-existing missing-
margin bug visible for the first time (the frame's own left border now
draws around the same x=0 origin text always used). Fixed end to end:
ElementInfo.MarginLeft/Right/Top/Bottom (read in
ApplyCanonicalLegacyProjection, propagated in Merge), UiText.MarginLeft/
Right/Top/Bottom (additive with the pre-existing Padding), a new pure
UiText.ContentOffsetX static consumed by the multi-line draw path's
per-line placement, and matching wrap-width shrinkage in
DatRichText.Compose and BuildText's own authored-multiline path. Scoped to
the multi-line (non-OneLine) path only.
R2-2/R2-3 (Attribute\n Credits renders the literal backslash-n; the live
credit value overlaps mid-caption): two stacked gaps. (1) UiButton
captions never escape-normalized the DAT's literal "\n" — centralized the
normalize into DatWidgetFactory's ResolveAuthoredString (the one choke
point every P0x17 resolution already shares) plus a NormalizeEscapes
helper for the per-state caption loop, so every caller normalizes
identically. (2) UiButton.Label only ever drew one line — retail's
UIElement_Button IS a UIElement_Text with OneLine=false on these buttons,
so a caption should word-wrap/stack like any other Type-12 box. Added
UiButton.DrawBlockLabel + the pure, unit-tested WrapBlockLines. The
value-overlap itself: ValueBox was never wrong (live-DAT-measured correct
child rects) — the caption was drawing unconfined across the button's
full width ("Available Skill Credits" measures 193px in a 231px button
whose value box starts at x=116). Fixed by confining the caption's own
drawable width to stop before ValueBox.X whenever a ValueLabel coexists.
R2-7a (Summary overview listbox missing its scrollbar): pure wiring gap —
the listbox authors a linked scrollbar via dat property 0x72
(ScrollbarElementId=0x10000401) that CharacterCreationSummaryPage's
constructor never resolved, unlike every other UiTemplateListBox owner in
the codebase. Fixed with the same resolve-and-wire pattern.
R2-7b (how-to box scrollbar overlaps text, no thumb): traced to a
downstream symptom of R2-1, not an independent bug — UiScrollbar only
paints its thumb when the linked model has overflow, and the pre-fix wrap
width (un-inset) produced fewer/shorter lines than fit the view. Pinned
directly against the real installed strings/font (Aluvian's how-to text)
that the margin-correct width overflows. No UiScrollbar code changed.
R2-8 (name field should show "[ Name ]"): re-checked the one hypothesis
Batch A's GF-15 closure left open — an authored initial-text string on
the field's own P0x17. Confirmed absent on every state in the installed
DAT. No code change; Batch A's closure stands, now pinned as a live-DAT
regression test.
App suite 5334/3 (was 5321/3, +13, zero regressions). Runtime 1735/0
unchanged. Full solution Release build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
601 lines
24 KiB
C#
601 lines
24 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 -> 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);
|
|
}
|
|
|
|
// ── R2-2/R2-3 (Campaign CC gate round 1 Batch E): WrapBlockLines ────
|
|
|
|
private static float BitmapMeasure(string text) => text.Length * 8f;
|
|
|
|
/// <summary>
|
|
/// A single line that already fits its box draws with the SAME
|
|
/// centered-block geometry the pre-fix unconditional one-line math
|
|
/// produced — the fix is a strict superset for every already-working
|
|
/// button caption.
|
|
/// </summary>
|
|
[Fact]
|
|
public void WrapBlockLines_SingleLineThatFits_MatchesPriorOneLineGeometry()
|
|
{
|
|
var lines = UiButton.WrapBlockLines(
|
|
"Health", BitmapMeasure, lineHeight: 24f,
|
|
boxX: 0f, boxY: 0f, boxWidth: 150f, boxHeight: 50f,
|
|
UiButton.LabelAlignment.Left, leftOffset: 3f);
|
|
|
|
Assert.Single(lines);
|
|
Assert.Equal("Health", lines[0].Text);
|
|
Assert.Equal(3f, lines[0].X); // boxX + leftOffset
|
|
Assert.Equal((50f - 24f) * 0.5f, lines[0].Y); // vertically centered, one line
|
|
}
|
|
|
|
/// <summary>
|
|
/// R2-2: an authored newline (already normalized to a real '\n' by
|
|
/// DatWidgetFactory's ResolveAuthoredString) splits into stacked lines
|
|
/// even when EACH half individually fits the box — "Attribute\nCredits"
|
|
/// must become two lines, not one literal run.
|
|
/// </summary>
|
|
[Fact]
|
|
public void WrapBlockLines_EmbeddedNewline_ProducesTwoStackedLines()
|
|
{
|
|
var lines = UiButton.WrapBlockLines(
|
|
"Attribute\nCredits", BitmapMeasure, lineHeight: 24f,
|
|
boxX: 0f, boxY: 0f, boxWidth: 90f, boxHeight: 50f,
|
|
UiButton.LabelAlignment.Left, leftOffset: 3f);
|
|
|
|
Assert.Equal(2, lines.Count);
|
|
Assert.Equal("Attribute", lines[0].Text);
|
|
Assert.Equal("Credits", lines[1].Text);
|
|
// Block-centered: total height 48 in a 50-tall box -> start Y = 1.
|
|
Assert.Equal(1f, lines[0].Y);
|
|
Assert.Equal(25f, lines[1].Y); // startY + 1*lineHeight
|
|
}
|
|
|
|
/// <summary>
|
|
/// R2-3: a single-paragraph caption with NO authored newline still
|
|
/// word-wraps when it doesn't fit the available width — the exact
|
|
/// live-DAT shape of the Skills credits button's own "Available Skill
|
|
/// Credits" caption (measured 193px in a 231px-wide button whose value
|
|
/// box starts at local x=116, i.e. only ~113px of caption width is
|
|
/// actually available once R2-2/R2-3's confinement applies).
|
|
/// </summary>
|
|
[Fact]
|
|
public void WrapBlockLines_LongSingleParagraph_WordWrapsToFitAvailableWidth()
|
|
{
|
|
var lines = UiButton.WrapBlockLines(
|
|
"Available Skill Credits", BitmapMeasure, lineHeight: 24f,
|
|
boxX: 0f, boxY: 0f, boxWidth: 113f, boxHeight: 28f,
|
|
UiButton.LabelAlignment.Left, leftOffset: 3f);
|
|
|
|
Assert.True(lines.Count > 1, "a 193px caption must wrap within a 110px available width");
|
|
foreach (var line in lines)
|
|
Assert.True(BitmapMeasure(line.Text) <= 110f, $"line '{line.Text}' overflowed");
|
|
}
|
|
|
|
/// <summary>
|
|
/// R2-2/R2-3 confinement itself, exercised through OnDraw's own gate:
|
|
/// a button with BOTH Label and a coexisting ValueBox shrinks the
|
|
/// caption's OWN drawable width to stop before the value box starts —
|
|
/// this is what the two live-DAT overlap reports (R2-2 "24dits", R2-3
|
|
/// "Credit0Credits") trace to: the caption used to draw across the
|
|
/// WHOLE button width regardless of where the value sat.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildButton_OwnCaptionWithCoexistingValueBox_ConfinesLabelWidthBeforeValueBox()
|
|
{
|
|
uint captionStringId = 333u;
|
|
var info = new ElementInfo { Type = 1, Width = 231, Height = 28 };
|
|
info.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
|
info.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue
|
|
{
|
|
Kind = UiPropertyKind.StringInfo,
|
|
StringInfoValue = new UiStringInfoValue(0, captionStringId, 0, 0, 0, 0),
|
|
};
|
|
info.StateMedia[""] = (0x06000001u, 1);
|
|
|
|
var valueChild = new ElementInfo { Type = 12, X = 116, Y = 0, Width = 34, Height = 28 };
|
|
info.Children.Add(valueChild);
|
|
|
|
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
|
|
info, NoTex, null,
|
|
stringResolve: value => value.StringId == captionStringId ? "Available Skill Credits" : null));
|
|
|
|
Assert.Equal("Available Skill Credits", button.Label);
|
|
Assert.Equal((116f, 0f, 34f, 28f), button.ValueBox);
|
|
|
|
// The caption's own available width for WrapBlockLines is bounded by
|
|
// ValueBox.X (116), NOT the button's full Width (231) — reproducing
|
|
// OnDraw's own confinement math here (private OnDraw isn't directly
|
|
// callable, so this pins the INPUT the fix computes for it).
|
|
float confinedWidth = System.MathF.Min(button.Width, button.ValueBox!.Value.X - 0f);
|
|
Assert.Equal(116f, confinedWidth);
|
|
Assert.True(confinedWidth < button.Width, "the confined width must be narrower than the full button");
|
|
}
|
|
|
|
// ── R2-2 escape-normalize ────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// R2-2: BuildButton's own P0x17 caption escape-normalizes the same way
|
|
/// BuildText's authored-string path always has — the DAT stores the
|
|
/// LITERAL two-character escape "\n" (0x5C 0x6E), and the Profession
|
|
/// credits button's own authored caption is exactly this shape.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildButton_OwnCaption_NormalizesLiteralBackslashNEscape()
|
|
{
|
|
uint stringId = 444u;
|
|
var info = new ElementInfo { Type = 1, Width = 150, Height = 50 };
|
|
info.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
|
info.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue
|
|
{
|
|
Kind = UiPropertyKind.StringInfo,
|
|
StringInfoValue = new UiStringInfoValue(0, stringId, 0, 0, 0, 0),
|
|
};
|
|
|
|
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
|
|
info, NoTex, null,
|
|
// The raw resolved string carries the LITERAL two characters
|
|
// '\' and 'n', matching what the installed DAT actually stores.
|
|
stringResolve: value => value.StringId == stringId ? "Attribute\\n Credits" : null));
|
|
|
|
Assert.Equal("Attribute\n Credits", button.Label);
|
|
}
|
|
|
|
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 };
|
|
}
|