acdream/tests/AcDream.App.Tests/UI/UiTextTests.cs
Erik e24ec20882 fix(chargen): Campaign CC gate round 1 Batch E — text origin, caption escapes, value rects, scrollbars, name prefill
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>
2026-08-16 14:07:19 +02:00

375 lines
13 KiB
C#

using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI;
public class UiTextTests
{
[Fact]
public void WrapWords_UsesMeasuredWidthAndPreservesParagraphs()
{
IReadOnlyList<string> lines = UiText.WrapWords(
"one two three\nfour",
text => text.Length * 10f,
maximumWidth: 75f);
Assert.Equal(["one two", "three", "four"], lines);
}
[Fact]
public void ClampScroll_PinsToZero_WhenContentFitsView()
{
// 5 lines of content in a taller view → nothing to scroll, pinned at 0.
Assert.Equal(0f, UiText.ClampScroll(50f, contentHeight: 80f, viewHeight: 200f));
Assert.Equal(0f, UiText.ClampScroll(0f, contentHeight: 80f, viewHeight: 200f));
}
[Fact]
public void ClampScroll_CapsAtContentMinusView_WhenOverflowing()
{
// Content 500, view 200 → max scrollback is 300px (oldest line at top).
Assert.Equal(300f, UiText.ClampScroll(1000f, contentHeight: 500f, viewHeight: 200f));
Assert.Equal(120f, UiText.ClampScroll(120f, contentHeight: 500f, viewHeight: 200f));
}
[Fact]
public void ClampScroll_NeverNegative()
{
Assert.Equal(0f, UiText.ClampScroll(-50f, contentHeight: 500f, viewHeight: 200f));
}
// ── Char-index hit-testing (x → col) with a synthetic 10px monospace advance ──
private static readonly Func<char, float> Mono10 = static _ => 10f;
[Fact]
public void CharIndexAt_ZeroOrNegative_IsColumnZero()
{
Assert.Equal(0, UiText.CharIndexAt("hello", Mono10, 0f));
Assert.Equal(0, UiText.CharIndexAt("hello", Mono10, -5f));
}
[Fact]
public void CharIndexAt_SnapsToGlyphMidpoint()
{
// glyph[0] spans 0..10 (midpoint 5), glyph[1] 10..20 (midpoint 15), ...
Assert.Equal(0, UiText.CharIndexAt("hello", Mono10, 4f)); // before mid of glyph 0
Assert.Equal(1, UiText.CharIndexAt("hello", Mono10, 6f)); // past mid of glyph 0
Assert.Equal(1, UiText.CharIndexAt("hello", Mono10, 14f)); // before mid of glyph 1
Assert.Equal(2, UiText.CharIndexAt("hello", Mono10, 16f)); // past mid of glyph 1
}
[Fact]
public void CharIndexAt_PastEnd_IsLength()
{
Assert.Equal(5, UiText.CharIndexAt("hello", Mono10, 1000f));
}
[Fact]
public void CharIndexAt_EmptyString_IsZero()
{
Assert.Equal(0, UiText.CharIndexAt("", Mono10, 50f));
}
// ── SelectedText assembly ────────────────────────────────────────────
private static IReadOnlyList<UiText.Line> Lines(params string[] texts)
{
var list = new List<UiText.Line>(texts.Length);
foreach (var t in texts)
list.Add(new UiText.Line(t, new Vector4(1, 1, 1, 1)));
return list;
}
[Fact]
public void SelectedText_SingleLine_Substring()
{
var lines = Lines("hello world");
var s = UiText.SelectedText(lines, new UiText.Pos(0, 6), new UiText.Pos(0, 11));
Assert.Equal("world", s);
}
[Fact]
public void SelectedText_SingleLine_ReversedAnchorCaret_IsNormalised()
{
var lines = Lines("hello world");
// caret BEFORE anchor — Order() must normalise.
var s = UiText.SelectedText(lines, new UiText.Pos(0, 11), new UiText.Pos(0, 6));
Assert.Equal("world", s);
}
[Fact]
public void SelectedText_SamePosition_IsEmpty()
{
var lines = Lines("hello");
Assert.Equal("", UiText.SelectedText(lines, new UiText.Pos(0, 3), new UiText.Pos(0, 3)));
}
[Fact]
public void SelectedText_MultiLine_JoinsWithNewline()
{
var lines = Lines("first line", "second line", "third line");
// from col 6 of line 0 ("line") through col 5 of line 2 ("third")
var s = UiText.SelectedText(lines, new UiText.Pos(0, 6), new UiText.Pos(2, 5));
Assert.Equal("line\nsecond line\nthird", s);
}
[Fact]
public void SelectedText_MultiLine_TwoLines_NoMiddle()
{
var lines = Lines("alpha", "bravo");
var s = UiText.SelectedText(lines, new UiText.Pos(0, 2), new UiText.Pos(1, 3));
Assert.Equal("pha\nbra", s);
}
[Fact]
public void SelectedText_MultiLine_ReversedAnchorCaret_IsNormalised()
{
var lines = Lines("alpha", "bravo");
// end before start → Order() swaps them.
var s = UiText.SelectedText(lines, new UiText.Pos(1, 3), new UiText.Pos(0, 2));
Assert.Equal("pha\nbra", s);
}
[Fact]
public void SelectedText_EmptyLineList_IsEmpty()
{
Assert.Equal("", UiText.SelectedText(Array.Empty<UiText.Line>(),
new UiText.Pos(0, 0), new UiText.Pos(0, 0)));
}
[Fact]
public void Order_SortsByLineThenColumn()
{
var (s1, e1) = UiText.Order(new UiText.Pos(2, 1), new UiText.Pos(0, 5));
Assert.Equal(new UiText.Pos(0, 5), s1);
Assert.Equal(new UiText.Pos(2, 1), e1);
var (s2, e2) = UiText.Order(new UiText.Pos(1, 8), new UiText.Pos(1, 2));
Assert.Equal(new UiText.Pos(1, 2), s2);
Assert.Equal(new UiText.Pos(1, 8), e2);
}
[Fact]
public void WrapWords_PreservesNewlinesAndWrapsAtSpaces()
{
IReadOnlyList<string> lines = UiText.WrapWords(
"one two three\nfour",
static text => text.Length,
maximumWidth: 7f);
Assert.Equal(["one two", "three", "four"], lines);
}
[Fact]
public void WrapWords_SplitsAnOverWidthWordAtCharacterBoundaries()
{
IReadOnlyList<string> lines = UiText.WrapWords(
"go abcdef",
static text => text.Length,
maximumWidth: 5f);
Assert.Equal(["go ab", "cdef"], lines);
}
// ── VOffset: vertical positioning for single-line mode ───────────────────
/// <summary>
/// VJustify.Center: vertical offset = (height - lineHeight) / 2.
/// This is the original formula used by both the Centered and RightAligned paths
/// before VerticalJustify was introduced. Must remain unchanged (backward compat).
/// </summary>
[Fact]
public void VOffset_Center_ReturnsHalfHeightMinusLineHeight()
{
float y = UiText.VOffset(height: 55f, lineHeight: 12f, padding: 0f, VJustify.Center);
Assert.Equal((55f - 12f) * 0.5f, y);
}
/// <summary>
/// VJustify.Top: vertical offset = padding (top of the content area).
/// Used for footer title elements whose dat box is the full footer height (55 px)
/// but the text should render near the top.
/// </summary>
[Fact]
public void VOffset_Top_ReturnsPadding()
{
float y = UiText.VOffset(height: 55f, lineHeight: 12f, padding: 0f, VJustify.Top);
Assert.Equal(0f, y);
float yWithPadding = UiText.VOffset(height: 55f, lineHeight: 12f, padding: 4f, VJustify.Top);
Assert.Equal(4f, yWithPadding);
}
/// <summary>
/// VJustify.Bottom: vertical offset = height - lineHeight - padding.
/// </summary>
[Fact]
public void VOffset_Bottom_ReturnsHeightMinusLineHeightMinusPadding()
{
float y = UiText.VOffset(height: 55f, lineHeight: 12f, padding: 2f, VJustify.Bottom);
Assert.Equal(55f - 12f - 2f, y);
}
/// <summary>
/// Center with non-zero padding: padding does NOT affect the center formula —
/// VJustify.Center always uses (height - lineHeight) / 2 regardless of padding,
/// matching the original behavior.
/// </summary>
[Fact]
public void VOffset_Center_IgnoresPadding()
{
float yNoPad = UiText.VOffset(height: 40f, lineHeight: 12f, padding: 0f, VJustify.Center);
float yWithPad = UiText.VOffset(height: 40f, lineHeight: 12f, padding: 4f, VJustify.Center);
Assert.Equal(yNoPad, yWithPad); // Center is padding-independent
Assert.Equal((40f - 12f) * 0.5f, yNoPad);
}
[Theory]
[InlineData(VJustify.Top, 0f)]
[InlineData(VJustify.Center, 4.5f)]
[InlineData(VJustify.Bottom, 9f)]
public void ContentBaseY_FittingAuthoredText_UsesRetailVerticalJustification(
VJustify justification,
float expected)
{
float y = UiText.ContentBaseY(
top: 0f,
bottom: 25f,
contentHeight: 16f,
maxScroll: 0f,
scrollY: 0f,
justification,
honorJustification: true);
Assert.Equal(expected, y);
}
[Fact]
public void ContentBaseY_OverflowingAuthoredText_PreservesScrollPosition()
{
float y = UiText.ContentBaseY(
top: 0f,
bottom: 25f,
contentHeight: 64f,
maxScroll: 39f,
scrollY: 12f,
VJustify.Center,
honorJustification: true);
Assert.Equal(-12f, y);
}
[Fact]
public void ContentBaseY_SynthesizedText_RetainsBottomPinWhenContentFits()
{
float y = UiText.ContentBaseY(
top: 0f,
bottom: 25f,
contentHeight: 16f,
maxScroll: 0f,
scrollY: 0f,
VJustify.Center,
honorJustification: false);
Assert.Equal(9f, y);
}
/// <summary>
/// R2-1 (Campaign CC gate round 1 Batch E): with zero margins,
/// ContentOffsetX is byte-identical to the pre-fix bare-Padding math —
/// every existing DAT-imported multi-line box (margins default 0 unless
/// DatWidgetFactory seeds them) is unaffected by this change.
/// </summary>
[Fact]
public void ContentOffsetX_ZeroMargins_MatchesBarePaddingMath()
{
float left = UiText.ContentOffsetX(
elementWidth: 200f, padding: 4f, marginLeft: 0f, marginRight: 0f,
lineWidth: 30f, centered: false, rightAligned: false);
Assert.Equal(4f, left);
float centered = UiText.ContentOffsetX(
elementWidth: 200f, padding: 4f, marginLeft: 0f, marginRight: 0f,
lineWidth: 30f, centered: true, rightAligned: false);
Assert.Equal(Math.Max(4f, (200f - 30f) * 0.5f), centered);
float right = UiText.ContentOffsetX(
elementWidth: 200f, padding: 4f, marginLeft: 0f, marginRight: 0f,
lineWidth: 30f, centered: false, rightAligned: true);
Assert.Equal(200f - 4f - 30f, right);
}
/// <summary>
/// The exact live-DAT shape (Campaign CC gate round 1 Batch E, R2-1):
/// Heritage/Profession/Town/Summary description boxes author
/// margL=9/margR=26 — a left-justified line must start at x=9 (Padding
/// 0 + MarginLeft 9), not x=0. This is the regression: pre-fix, every
/// one of these boxes drew its first glyph at x=0, clipping under the
/// authored gold-frame's left border piece.
/// </summary>
[Fact]
public void ContentOffsetX_LeftJustified_HonorsAuthoredMarginLeft()
{
float x = UiText.ContentOffsetX(
elementWidth: 265f, padding: 0f, marginLeft: 9f, marginRight: 26f,
lineWidth: 100f, centered: false, rightAligned: false);
Assert.Equal(9f, x);
}
/// <summary>
/// A right-aligned line must stop before MarginRight, not at the raw
/// element edge — the R2-1 fix's other half (the wrap width shrinks by
/// the same inset so text no longer overflows the visible right edge
/// either).
/// </summary>
[Fact]
public void ContentOffsetX_RightAligned_HonorsAuthoredMarginRight()
{
float x = UiText.ContentOffsetX(
elementWidth: 265f, padding: 0f, marginLeft: 9f, marginRight: 26f,
lineWidth: 50f, centered: false, rightAligned: false);
_ = x; // left case covered above
float right = UiText.ContentOffsetX(
elementWidth: 265f, padding: 0f, marginLeft: 9f, marginRight: 26f,
lineWidth: 50f, centered: false, rightAligned: true);
// contentRight = 265 - 0 - 26 = 239; right-aligned x = 239 - 50 = 189.
Assert.Equal(189f, right);
}
[Fact]
public void LineIntersectsViewport_PartialLineRemainsDrawable()
{
// Component owned-count boxes are 15px tall while Font 0x40000000
// advances 16px. Retail clips the final pixel instead of dropping the line.
Assert.True(UiText.LineIntersectsViewport(
lineTop: 0f,
lineHeight: 16f,
viewportTop: 0f,
viewportBottom: 15f));
Assert.True(UiText.LineIntersectsViewport(
lineTop: -1f,
lineHeight: 16f,
viewportTop: 0f,
viewportBottom: 15f));
}
[Theory]
[InlineData(-16f, 16f)]
[InlineData(15f, 16f)]
public void LineIntersectsViewport_FullyClippedLineIsSkipped(
float lineTop,
float lineHeight)
{
Assert.False(UiText.LineIntersectsViewport(
lineTop,
lineHeight,
viewportTop: 0f,
viewportBottom: 15f));
}
}