acdream/tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs
Erik 967b9c57cf fix(ui): systemic escape normalization at the string source
The exit-world confirmation (ID_Client_EndCharacterSessionConfirm, table
0x23000001 key 0x0EB1C41D) rendered its literal two-character "\n" escapes
because escape decoding lived in individual consumers — Batch E centralized
it for authored captions only (DatWidgetFactory.ResolveAuthoredString), and
each new string surface had to remember its own copy. The installed DAT
carries the escape in 4,365 of 7,050 strings; per-consumer normalization
was structurally guaranteed to keep leaking.

Retail's placement is the SOURCE, not the widget: every public StringInfo
resolution ends in StringTableMetaLanguage::UnescapeString @ 0x0067BDC0
(StringInfo::InqString @ 0x0042E490, GetLiteralValue @ 0x0042CA50), the
write side escapes (SetLiteralValue @ 0x0042C980; AddVariable_String
@ 0x0042E6C0 for template variables), and widgets receive decoded text.
Ported exactly:

- NEW RetailStringEscapes: UnescapeString/EscapeString + the
  GetUnEscapedChar @ 0x0067B750 / GetEscapedChar @ 0x0067B6C0 tables
  (\n \t \r \q + the ten metalanguage self-escapes []!{}#\|^$,
  byte-verified against the PDB-paired 2013 binary at 0x3FE178;
  unrecognized pairs stay verbatim).
- DatStringResolver.Resolve/ResolveAll unescape at the source;
  ResolveTemplate escapes each variable on insert and unescapes the
  composed whole — retail's round trip, so variable content (player
  names) can never be corrupted by the final decode.
- RETIRED the consumer copies (double paths would corrupt an authored
  "\n" into a line break): DatWidgetFactory.NormalizeEscapes + BuildText's
  inline replace, RetailUiRuntime.NormalizeRetailNewlines + the
  OpenCaptureInstructions inline replace, DatRichText.Compose's replace,
  IndicatorDetailText.Shape's replace. ItemAppraisalTextLayout's replace
  stays — WIRE-domain (server strings never pass the DAT source; retail's
  ItemExamineUI::AddItemInfo @ 0x004AC050 appends wire text verbatim), now
  documented as such.
- Consumer CR-strips retired with them: the installed DATs contain ZERO
  real CR characters (sweep-measured) and UiText.WrapWords already drops
  strays.

Tests: RetailStringEscapes conformance (escape set, unknown pairs,
round trip), DatStringResolver source-decode pins (including the exact
user-reported exit-world text shape and a backslash-carrying variable),
the installed-DAT escape sweep (7,050 strings; every resolution must equal
the retail unescape of the raw entry; inventory printed), and the existing
caption/rich-text/live-DAT pins relocated to the source contract.

App 5550/3 (live-DAT), Runtime 1747/0, complete Release solution green
across all suites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 13:26:25 +02:00

156 lines
5.8 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
/// word-wrap + per-segment-color helper feeding GF-2/GF-3/GF-11a and the
/// Summary how-to text (Commit 3). Escape decoding moved to the string
/// source in the 2026-08-17 systemic round (DatStringResolver →
/// RetailStringEscapes) — segments reach Compose with real line breaks.
/// </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 };
/// <summary>Segments arrive source-decoded (real '\n'); Compose keeps
/// the authored break as a line split. A literal backslash-n pair in a
/// segment must stay VERBATIM — re-decoding here is the double-decode
/// hazard the 2026-08-17 round retired.</summary>
[Fact]
public void Compose_SplitsOnRealNewlines_AndKeepsLiteralPairsVerbatim()
{
UiText target = MakeTarget(1000f); // wide enough that nothing wraps
var segments = new[]
{
new DatRichText.Segment("line one\nliteral \\n stays", White),
};
var lines = DatRichText.Compose(target, segments);
Assert.Equal(2, lines.Count);
Assert.Equal("line one", lines[0].Text);
Assert.Equal("literal \\n stays", 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 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()
{
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));
}
}