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>
This commit is contained in:
Erik 2026-08-16 14:07:19 +02:00
parent 2ad805469d
commit e24ec20882
11 changed files with 895 additions and 23 deletions

View file

@ -164,6 +164,52 @@ public sealed class CharacterCreationLiveDatTests
UiElement.FindDescendant(heritageRoot, 0x100003C4u));
}
/// <summary>
/// R2-1 (Campaign CC gate round 1 Batch E): the Heritage/Profession/
/// Town/Summary description boxes author retail's four text-inset
/// margins (dat properties 0x23-0x26 — live-DAT-probe-confirmed
/// margL=9/margR=26/margU=15/margD=15 on all four, shared box
/// template) — this codebase never read them before this fix, so every
/// one of these boxes drew its first glyph flush against x=0 (Padding
/// alone, always 0 for DAT-built text), under the authored gold-frame's
/// own left border piece. Pins BOTH halves: the margins land on the
/// built <see cref="UiText"/> (not just the raw <see cref="ElementInfo"/>),
/// and <see cref="UiText.ContentOffsetX"/> computed with those margins
/// places the first line's origin at the authored interior (x=9), not
/// the box's outer edge (x=0).
/// </summary>
[InstalledDatFact]
public void HeritageDescription_MarginsMatchAuthoredInset_AndFirstLineOriginRespectsThem()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement heritageRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.HeritagePageElementId));
UiText description = Assert.IsType<UiText>(
UiElement.FindDescendant(heritageRoot, 0x100003C4u));
Assert.Equal(9f, description.MarginLeft);
Assert.Equal(26f, description.MarginRight);
Assert.Equal(15f, description.MarginTop);
Assert.Equal(15f, description.MarginBottom);
// First glyph of a left-justified line: Padding (0, DAT-built text
// never sets it) + MarginLeft (9) = x=9, NOT x=0 — the exact
// regression the user's "rained Starting Skills"/"OW HUNTERS"
// reports describe (the leading 1-2 characters clipped under the
// frame's left border because text used to start at x=0).
float firstLineX = UiText.ContentOffsetX(
description.Width, description.Padding,
description.MarginLeft, description.MarginRight,
lineWidth: 40f, centered: false, rightAligned: false);
Assert.Equal(9f, firstLineX);
Assert.NotEqual(0f, firstLineX);
}
/// <summary>Seven template buttons, six attribute sliders (each with a
/// lock button + scrollbar + value text), and the four derived
/// displays (Profession page —
@ -854,6 +900,52 @@ public sealed class CharacterCreationLiveDatTests
Assert.IsType<UiText>(UiElement.FindDescendant(pairRow, 0x100002FDu));
}
/// <summary>
/// R2-8 (Campaign CC gate round 1 Batch E): re-checks the AUTHORED-
/// initial-text hypothesis the user's re-test raised for the
/// <c>[ Name ]</c> the retail screenshot shows — Batch A's GF-15
/// closure already byte-verified retail's CODE never writes it
/// (<c>CharGenState::RandomizeCharacter</c>,
/// <c>gmCGSummaryPage::InitializePage</c>), but did not check whether
/// the field's own dat property <c>0x17</c> (the SAME authored-caption
/// mechanism <c>DatWidgetFactory.BuildText</c>/<c>BuildField</c> already
/// reads for every other element) carries a display-only placeholder.
/// It does not: the name field (<c>0x10000402</c>) authors NO <c>0x17</c>
/// on its default state or on ANY of its named states in the installed
/// EoR dat. Per this batch's own investigation contract ("if NOT
/// authored, STOP on this item"), this pins that negative result as a
/// durable regression check rather than leaving it as a one-off probe
/// finding — CONFIRMS Batch A's closure honestly, it does not change
/// acdream's behavior (the field stays genuinely empty, matching
/// retail's own code-empty field).
/// </summary>
[InstalledDatFact]
public void SummaryNameField_AuthorsNoP0x17OnAnyState()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ElementInfo rootInfo = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(
dats, layoutId, CharacterCreationUiController.RootElementId));
ElementInfo nameField = Assert.IsType<ElementInfo>(
FindInfo(rootInfo, CharacterCreationSummaryPage.NameTextId));
Assert.False(
nameField.TryGetEffectiveProperty(0x17u, out _),
"the name field must not author a P0x17 caption on its effective "
+ "default state — if this starts failing, the DAT now carries an "
+ "authored placeholder and R2-8 should be revisited as a real fix.");
foreach (var (stateId, state) in nameField.States)
{
Assert.False(
state.Properties.Values.TryGetValue(0x17u, out var stateCaption)
&& stateCaption.Kind == UiPropertyKind.StringInfo,
$"the name field's state 0x{stateId:X} ('{state.Name}') must not "
+ "author a P0x17 caption either.");
}
}
/// <summary>
/// CC5 re-review residual round, R3 (2026-08-16): MEASURES the
/// installed global SkillTable's (portal.dat <c>0x0E000004</c>)
@ -1315,6 +1407,122 @@ public sealed class CharacterCreationLiveDatTests
dialogs.Dispose();
}
/// <summary>
/// R2-7a (Campaign CC gate round 1 Batch E): the Summary OVERVIEW
/// listbox (<c>0x10000400</c>) authors a linked scrollbar via dat
/// property <c>0x72</c> — live-DAT-probe-confirmed
/// <c>ScrollbarElementId=0x10000401</c>, a SIBLING element under the
/// Summary page root, not a descendant of the listbox itself.
/// <see cref="CharacterCreationSummaryPage"/>'s constructor used to wire
/// only the how-to box's own scrollbar (Commit 3) and never resolved
/// this one, so the listbox never scrolled despite carrying more rows
/// than fit its 435px-tall view. Same linkage pattern every other
/// UiTemplateListBox owner in this codebase already uses
/// (SocialFriendsPageController, ConfigOptionsPageController, etc).
/// </summary>
[InstalledDatFact]
public void SummaryListbox_ScrollbarBuildsAndLinksToListboxScroll()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement summaryRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.SummaryPageElementId));
UiTemplateListBox list = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.ListBoxId));
Assert.Equal(CharacterCreationSummaryPage.ScrollId, list.ScrollbarElementId);
UiScrollbar overviewScroll = Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.ScrollId));
var host = new UiRoot();
var dialogs = MakeDialogFactory(dats, host);
var bindings = new CharacterCreationRuntimeBindings(
() => null,
_ => default, _ => default, _ => default, (_, _) => default, (_, _) => default,
_ => default, _ => default, _ => default, _ => default, _ => default, () => { });
UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId) =>
LayoutImporter.Import(
dats, templateLayoutId, templateElementId, _ => (0u, 0, 0), null)?.Root;
CharacterCreationUiController? controller =
CharacterCreationUiController.CreateDetached(
host, screen, ResolveTemplate, dialogs, bindings,
new CharacterCreationUiController.DialogStrings(
"Are you sure?", "No name", "Unspent credits", "Randomize?", "Name too long"));
Assert.NotNull(controller);
controller!.AttachAndTick();
Assert.Same(list.Scroll, overviewScroll.Model);
controller.Dispose();
dialogs.Dispose();
}
/// <summary>
/// R2-7b (Campaign CC gate round 1 Batch E): the how-to box's scrollbar
/// THUMB only draws <c>if (m.HasOverflow)</c>
/// (<see cref="UiScrollbar"/>'s own draw gate) — the reported "no
/// thumb" symptom traces to R2-1's bug, not an independent defect: with
/// the pre-fix wrap width (the box's raw Width, ignoring the authored
/// margL=9/margR=26 inset), the Aluvian how-to text (the LONGEST
/// composed variant — SummaryHowTo + the male name-suggestion list +
/// SummaryHowToEnd) wrapped to fewer/shorter lines than the correctly
/// inset width does. This pins the causal claim directly against the
/// real installed strings/font: composing with the CORRECT (margin-
/// inset) width produces content taller than the view, so
/// <see cref="UiScrollable.HasOverflow"/> — which is exactly what
/// <see cref="UiScrollbar"/> gates the thumb on — is true.
/// </summary>
[InstalledDatFact]
public void SummaryHowToText_Aluvian_WithCorrectMarginInsetWidth_Overflows()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
var strings = new DatStringResolver(dats);
const uint table = 0x23000002u;
string? howTo = strings.Resolve(table, DatStringResolver.ComputeHash("ID_CharGen_SummaryHowTo"));
string? names = strings.Resolve(table, DatStringResolver.ComputeHash("ID_CharGen_AluMaleNames"));
string? howToEnd = strings.Resolve(table, DatStringResolver.ComputeHash("ID_CharGen_SummaryHowToEnd"));
Assert.NotNull(howTo);
Assert.NotNull(names);
Assert.NotNull(howToEnd);
string composed = howTo + names + howToEnd;
// Real font metrics (0x40000009 — live-DAT-probe-confirmed FontDid
// on 0x10000404), no GL/texture needed for MeasureWidth.
Assert.True(dats.TryGet<DatReaderWriter.DBObjs.Font>(0x40000009u, out var font) && font is not null);
var glyphs = new Dictionary<char, DatReaderWriter.Types.FontCharDesc>(font!.CharDescs.Count);
foreach (var cd in font.CharDescs) glyphs[(char)cd.Unicode] = cd;
var datFont = new UiDatFont(0, 0, 0, 0, 0, 0, font.MaxCharHeight, font.BaselineOffset, glyphs);
// Live-DAT-measured box geometry (0x10000404): 247x380,
// margL=9/margR=26/margU=15/margD=15.
var target = new UiText
{
Width = 247f,
Height = 380f,
DatFont = datFont,
MarginLeft = 9f,
MarginRight = 26f,
MarginTop = 15f,
MarginBottom = 15f,
};
var segments = new[] { new DatRichText.Segment(composed, Vector4.One) };
var lines = DatRichText.Compose(target, segments);
float viewHeight = target.Height - target.Padding - target.MarginTop - target.Padding - target.MarginBottom;
float contentHeight = lines.Count * datFont.LineHeight;
Assert.True(
contentHeight > viewHeight,
$"expected the correctly-inset composition ({lines.Count} lines, "
+ $"{contentHeight}px) to overflow the {viewHeight}px view — if it "
+ "doesn't, the how-to scrollbar's thumb has nothing to gate on "
+ "regardless of the R2-1 margin fix");
}
private static void AssertButton(ImportedLayout layout, uint elementId) =>
Assert.IsType<UiButton>(layout.FindElement(elementId));

View file

@ -105,6 +105,25 @@ public class DatRichTextTests
Assert.Equal("second", lines[1].Text);
}
[Fact]
public void Compose_WordWrapsToTheTargetWidth_MinusTheFourRetailMargins()
{
// R2-1 (Campaign CC gate round 1 Batch E): the wrap width must
// shrink by BOTH Padding and the four retail margins (properties
// 0x23-0x26 — MarginLeft's own doc comment on UiText), not just the
// element's raw Width. A 100px-wide box with margL=10/margR=10
// leaves only 80px of usable width — one 10-char/8px-per-char word
// ("aaaaaaaaaa", 80px) must fit on one line, but appending an 11th
// 'a' (88px) must force a wrap.
UiText fits = new() { Width = 100f, Height = 200f, MarginLeft = 10f, MarginRight = 10f };
var fitsLines = DatRichText.Compose(fits, [new DatRichText.Segment("aaaaaaaaaa", White)]);
Assert.Single(fitsLines);
UiText overflows = new() { Width = 100f, Height = 200f, MarginLeft = 10f, MarginRight = 10f };
var overflowLines = DatRichText.Compose(overflows, [new DatRichText.Segment("aaaaaaaaaaa", White)]);
Assert.True(overflowLines.Count > 1, "an 88px word in an 80px content width must wrap");
}
[Fact]
public void PaletteColor_ReturnsAuthoredPaletteEntry_WhenPresent()
{

View file

@ -414,6 +414,142 @@ public class UiButtonTests
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);

View file

@ -278,6 +278,69 @@ public class UiTextTests
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()
{