acdream/tests/AcDream.App.Tests/UI/Layout/RetailStringEscapesTests.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

103 lines
4 KiB
C#

using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Conformance for <see cref="RetailStringEscapes"/> — the exact port of
/// retail's string-table escape codec
/// (<c>StringTableMetaLanguage::UnescapeString @ 0x0067BDC0</c> /
/// <c>EscapeString @ 0x0067BBC0</c>, character tables
/// <c>GetUnEscapedChar @ 0x0067B750</c> / <c>GetEscapedChar @ 0x0067B6C0</c>).
/// The metalanguage character set is byte-verified against the PDB-paired
/// 2013 binary (file offset 0x3FE178: <c>[]!{}#\|^$</c>).
/// </summary>
public sealed class RetailStringEscapesTests
{
[Theory]
[InlineData("line one\\nline two", "line one\nline two")]
[InlineData("a\\tb", "a\tb")]
[InlineData("a\\rb", "a\rb")]
[InlineData("say \\qhi\\q", "say \"hi\"")]
public void Unescape_DecodesTheFourCharacterEscapes(
string raw, string expected)
=> Assert.Equal(expected, RetailStringEscapes.Unescape(raw));
[Theory]
[InlineData("\\[", "[")]
[InlineData("\\]", "]")]
[InlineData("\\!", "!")]
[InlineData("\\{", "{")]
[InlineData("\\}", "}")]
[InlineData("\\#", "#")]
[InlineData("\\\\", "\\")]
[InlineData("\\|", "|")]
[InlineData("\\^", "^")]
[InlineData("\\$", "$")]
public void Unescape_DecodesEveryMetalanguageSelfEscape(
string raw, string expected)
=> Assert.Equal(expected, RetailStringEscapes.Unescape(raw));
/// <summary>
/// GetUnEscapedChar returns 0 for anything else — retail keeps the
/// backslash verbatim (UnescapeString's else-branch), including a
/// trailing backslash whose "next" character is the terminator.
/// </summary>
[Theory]
[InlineData("\\z", "\\z")]
[InlineData("C:\\path\\dir", "C:\\path\\dir")]
[InlineData("ends with \\", "ends with \\")]
[InlineData("\\N upper is not an escape", "\\N upper is not an escape")]
public void Unescape_KeepsUnrecognizedPairsVerbatim(
string raw, string expected)
=> Assert.Equal(expected, RetailStringEscapes.Unescape(raw));
/// <summary>
/// The double-decode hazard the 2026-08-17 systemic round exists to
/// close: an authored escaped backslash before an 'n' decodes ONCE to
/// the literal two characters backslash+n — a second decode pass (the
/// retired per-consumer copies) would corrupt it into a line break.
/// </summary>
[Fact]
public void Unescape_EscapedBackslashBeforeN_YieldsLiteralPair()
=> Assert.Equal("\\n", RetailStringEscapes.Unescape("\\\\n"));
[Fact]
public void Unescape_EmptyString_IsEmpty()
=> Assert.Equal(string.Empty, RetailStringEscapes.Unescape(string.Empty));
/// <summary>No backslash → no allocation: the same instance returns.</summary>
[Fact]
public void Unescape_NoEscapes_ReturnsTheSameInstance()
{
const string plain = "Please Wait";
Assert.Same(plain, RetailStringEscapes.Unescape(plain));
}
[Theory]
[InlineData("line one\nline two", "line one\\nline two")]
[InlineData("a\tb", "a\\tb")]
[InlineData("a\rb", "a\\rb")]
[InlineData("say \"hi\"", "say \\qhi\\q")]
[InlineData("[x]", "\\[x\\]")]
[InlineData("back\\slash", "back\\\\slash")]
[InlineData("plain", "plain")]
public void Escape_IsTheStorageInverse(string plain, string expected)
=> Assert.Equal(expected, RetailStringEscapes.Escape(plain));
/// <summary>
/// Retail's template-variable round trip
/// (<c>AddVariable_String @ 0x0042E6C0</c> escapes on insert;
/// <c>InqString @ 0x0042E490</c> unescapes the composed whole): variable
/// content must come out verbatim.
/// </summary>
[Theory]
[InlineData("plain name")]
[InlineData("Odd\\Name")]
[InlineData("multi\nline")]
[InlineData("tabs\tand \"quotes\"")]
[InlineData("[]!{}#\\|^$")]
[InlineData("")]
public void UnescapeOfEscape_RoundTripsVerbatim(string value)
=> Assert.Equal(value, RetailStringEscapes.Unescape(
RetailStringEscapes.Escape(value)));
}