Commit 1/3: chargen-scoped, low blast-radius fixes. - New DatRichText helper: escape-normalize + word-wrap + per-segment palette color, porting UIElement_Text::SetStringInfoWithFont / AppendStringInfoWithFont's composition model. Routes the Heritage (GF-2), Town (GF-11a), and Profession (GF-3) description boxes through it instead of a raw unwrapped single-Line LinesProvider. Heritage headers use font-color palette index 1 (green), bodies index 0 (white), matching AppendStringInfoWithFont's own font-index argument. Town's diagnosed GF-11a root cause: a single un-wrapped line meant the town-specific suffix rendered past the clipped viewport, so switching towns looked like "text never changes" even though the underlying composed string genuinely differed. - GF-3: bind the Profession page's description textbox (0x100003e0, gmCGProfessionPage::InitializePage @0x00483068) and compose its per-template text (UpdateProfession @0x004821b0's CustomText/ BowText/SwashText/LifeText/WarText/WayText/SoldierText, plain SetStringInfo — no palette). - GF-4: UiButton gains a coexisting ValueLabel/ValueBox/ValueFont/ ValueColor slot alongside Label. Retail's chargen display buttons (avail/health/stamina/mana credits, 0x100003e2-e5/0x100003f9) author their caption directly on P0x17 AND carry a separate, media-less Type-12 value child that UiButton.ConsumesDatChildren used to drop entirely — pages substituted the button's own Label, destroying the caption. DatWidgetFactory.BuildButton now surfaces that child (gated on ReferenceEquals(labelInfo, info) — own-caption buttons only) instead. The six Profession slider name labels (0x100002ed, CharGenState::GetAttributeName @0x005C3A20's six hardcoded literals) resolve as UiButton in this port (live-DAT- measured Type 1 — retail's UIElement_Button is DynamicCast(0xc)- compatible with UIElement_Text) and are written once at construction, matching retail's own single InitializePage write. - GF-6/AP-218: gmCGAppearancePage::Update writes a heritage-flavored STATIC caption to the Hair/Eyes/Skin spins (plain / GearText_* / OlthoiText_* variants) — never an index. Removed the prior 1-based- ordinal/gear-name substitution entirely; the other six spins keep their DAT-authored caption untouched, matching retail exactly. - Root 1d: wire the Heritage (0x100003be, 13 states) and Profession (0x100003d8, 7 states) backdrop SetState cascades (gmCGHeritagePage::Update / gmCGProfessionPage::UpdateProfession). - AP-216/AP-217 (partial, register updated honestly): swatches beyond the current part's real color count now hide (DoColorSpots' blank- blit half); the GradCircle now blanks for Eyes (DoGradDisk's blank- plug half). The "paint with the actual represented/current color" halves stay open — they need a PalSet/Palette-id -> RGB pipeline no chargen page reads at runtime yet, judged disproportionate to add alongside this batch's other ~10 fixes. Register: AP-215 rewritten (item 2's "ordinal" framing is stale after GF-6; restated as the icon-thumbnail gap), AP-216/AP-217 rewritten (partially closed), AP-218 retired, AD-103 retired (the swallowed- child Label substitution AD-103 tracked is replaced by ValueLabel's own-geometry surfacing). 22 new tests (DatRichText unit tests, UiButton/DatWidgetFactory ValueLabel tests, live-DAT structural pins, controller behavioral tests) — all green. Full App suite (Release, live-DAT): 5300 passed / 1 pre-existing unrelated flake (PortalProjectionTests allocation test, passes in isolation) / 3 skipped, up from the baseline 5282/3. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
128 lines
4.2 KiB
C#
128 lines
4.2 KiB
C#
using System.Numerics;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
namespace AcDream.App.Tests.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Campaign CC gate round 1 Batch C: unit tests for the shared
|
|
/// escape-normalize + word-wrap + per-segment-color helper feeding
|
|
/// GF-2/GF-3/GF-11a and the Summary how-to text (Commit 3).
|
|
/// </summary>
|
|
public class DatRichTextTests
|
|
{
|
|
private static readonly Vector4 White = Vector4.One;
|
|
private static readonly Vector4 Green = new(0f, 1f, 0f, 1f);
|
|
|
|
private static UiText MakeTarget(float width) =>
|
|
new() { Width = width, Height = 200f };
|
|
|
|
[Fact]
|
|
public void Compose_NormalizesLiteralBackslashNEscape()
|
|
{
|
|
UiText target = MakeTarget(1000f); // wide enough that nothing wraps
|
|
var segments = new[] { new DatRichText.Segment("line one\\nline two", White) };
|
|
|
|
var lines = DatRichText.Compose(target, segments);
|
|
|
|
Assert.Equal(2, lines.Count);
|
|
Assert.Equal("line one", lines[0].Text);
|
|
Assert.Equal("line two", lines[1].Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void Compose_WordWrapsToTheTargetWidth()
|
|
{
|
|
// Bitmap-font-shaped measure: 8px/char, matching BuildText's own
|
|
// authored-string fallback measure.
|
|
UiText target = MakeTarget(80f); // 10 chars per line at 8px/char
|
|
var segments = new[]
|
|
{
|
|
new DatRichText.Segment("one two three four five six seven eight", White),
|
|
};
|
|
|
|
var lines = DatRichText.Compose(target, segments);
|
|
|
|
Assert.True(lines.Count > 1, "a long segment must wrap to more than one line");
|
|
foreach (UiText.Line line in lines)
|
|
Assert.True(line.Text.Length * 8f <= 80f, $"line '{line.Text}' overflowed the target width");
|
|
}
|
|
|
|
[Fact]
|
|
public void Compose_EachSegmentKeepsItsOwnColorAcrossItsWrappedLines()
|
|
{
|
|
UiText target = MakeTarget(1000f);
|
|
var segments = new[]
|
|
{
|
|
new DatRichText.Segment("Header:", Green),
|
|
new DatRichText.Segment("Body text.", White),
|
|
};
|
|
|
|
var lines = DatRichText.Compose(target, segments);
|
|
|
|
Assert.Equal(2, lines.Count);
|
|
Assert.Equal(Green, lines[0].Color);
|
|
Assert.Equal(White, lines[1].Color);
|
|
}
|
|
|
|
[Fact]
|
|
public void Compose_NullOrEmptySegmentText_IsSkipped()
|
|
{
|
|
UiText target = MakeTarget(1000f);
|
|
var segments = new[]
|
|
{
|
|
new DatRichText.Segment(null, White),
|
|
new DatRichText.Segment(string.Empty, White),
|
|
new DatRichText.Segment("real text", White),
|
|
};
|
|
|
|
var lines = DatRichText.Compose(target, segments);
|
|
|
|
Assert.Single(lines);
|
|
Assert.Equal("real text", lines[0].Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void Compose_NoSeparatorInsertedBetweenSegments()
|
|
{
|
|
// Retail's own composition calls concatenate directly
|
|
// (AppendStringInfoWithFont / append_n_chars, no interposed
|
|
// literal) — this helper must not invent one either.
|
|
UiText target = MakeTarget(1000f);
|
|
var segments = new[]
|
|
{
|
|
new DatRichText.Segment("first", White),
|
|
new DatRichText.Segment("second", White),
|
|
};
|
|
|
|
var lines = DatRichText.Compose(target, segments);
|
|
|
|
// Each segment still wraps independently (so "first"/"second" stay
|
|
// on separate output lines, not glued into "firstsecond") — but no
|
|
// BLANK line is inserted between them unless the segment's own
|
|
// text carried one.
|
|
Assert.Equal(2, lines.Count);
|
|
Assert.Equal("first", lines[0].Text);
|
|
Assert.Equal("second", lines[1].Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void PaletteColor_ReturnsAuthoredPaletteEntry_WhenPresent()
|
|
{
|
|
UiText target = new()
|
|
{
|
|
FontColorPalette = [White, Green],
|
|
};
|
|
|
|
Assert.Equal(White, DatRichText.PaletteColor(target, 0, Green));
|
|
Assert.Equal(Green, DatRichText.PaletteColor(target, 1, White));
|
|
}
|
|
|
|
[Fact]
|
|
public void PaletteColor_FallsBack_WhenPaletteTooShortOrMissing()
|
|
{
|
|
UiText target = new(); // empty palette
|
|
|
|
Assert.Equal(Green, DatRichText.PaletteColor(target, 1, Green));
|
|
Assert.Equal(Green, DatRichText.PaletteColor(target, -1, Green));
|
|
}
|
|
}
|