Campaign CT slice A1. No behaviour change: this is the capability the rest of
Group A needs.
UiText already drew several differently-coloured runs on one line
(TextRun/RunsProvider, used by the character stat panel) but the path was gated
to OneLine == true, and the chat transcript is multi-line — so a chat line
could only ever be one colour. Retail's is not: a tagged glyph run takes the
element's TAG colour (property 0x1D) while the rest of the line takes the
ordinary one (0x1B), per UIElement_Text::InqGlyphs @0x00468EA0.
LineRunsProvider is a SIDECAR keyed by line index rather than a field on Line.
Roughly fifty files construct Line, and widening its shape would put every one
of them in the blast radius of a chat feature; a line with no runs draws
exactly as before.
The runs fold into the existing datLines list as extra entries at advancing
pen-X, so the S1 outline-then-fill batching is untouched — a multi-colour line
still submits its whole outline pass before any fill, and cannot notch the
descender of the line above.
Two things are deliberately load-bearing:
- RunsMatchLine. Selection, hit-testing and the caret all index into the FLAT
line text, so a run list that disagrees with it would draw one thing and
select another. The draw path verifies the runs say exactly the same
characters and falls back to the flat line if not, rather than trusting the
caller.
- LayoutRuns is pure. The pen-advance is the part that silently mis-renders
if it drifts, so it is testable without a font atlas or a GPU — which also
keeps its tests in the ordinary gate rather than the SystemFont lane.
Solution builds clean; full hermetic gate green, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
456 lines
16 KiB
C#
456 lines
16 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
|
|
{
|
|
// ── Campaign CT slice A1: per-line coloured runs ────────────────────
|
|
|
|
private static UiText.TextRun Run(string text)
|
|
=> new(text, Vector4.One);
|
|
|
|
[Fact]
|
|
public void LayoutRuns_AdvancesThePenAcrossRuns()
|
|
{
|
|
// 10px per character, so the offsets are checkable by hand.
|
|
var placed = UiText.LayoutRuns(
|
|
[
|
|
new UiText.TextRun("Dww", new Vector4(0f, 0.698f, 0f, 1f)),
|
|
new UiText.TextRun(" tells you", Vector4.One),
|
|
],
|
|
startX: 4f,
|
|
measure: text => text.Length * 10f);
|
|
|
|
Assert.Equal(2, placed.Count);
|
|
Assert.Equal(4f, placed[0].X); // first run starts at the line origin
|
|
Assert.Equal(34f, placed[1].X); // 4 + 3 chars * 10
|
|
Assert.Equal(new Vector4(0f, 0.698f, 0f, 1f), placed[0].Color);
|
|
Assert.Equal(Vector4.One, placed[1].Color);
|
|
}
|
|
|
|
[Fact]
|
|
public void LayoutRuns_EmptyRunsDrawNothingAndShiftNothing()
|
|
{
|
|
var placed = UiText.LayoutRuns(
|
|
[
|
|
new UiText.TextRun(string.Empty, Vector4.One),
|
|
new UiText.TextRun("ab", Vector4.One),
|
|
new UiText.TextRun(string.Empty, Vector4.One),
|
|
new UiText.TextRun("cd", Vector4.One),
|
|
],
|
|
startX: 0f,
|
|
measure: text => text.Length * 10f);
|
|
|
|
// The two empties contribute no geometry, and because they measure zero
|
|
// they cannot displace what follows.
|
|
Assert.Equal(2, placed.Count);
|
|
Assert.Equal(0f, placed[0].X);
|
|
Assert.Equal(20f, placed[1].X);
|
|
}
|
|
|
|
[Fact]
|
|
public void RunsMatchLine_AcceptsAnExactSplitOfTheLine()
|
|
{
|
|
Assert.True(UiText.RunsMatchLine(
|
|
[Run("Dww"), Run(" tells you, \"hi\"")],
|
|
"Dww tells you, \"hi\""));
|
|
|
|
// Order matters: the same pieces rearranged are a different line.
|
|
Assert.False(UiText.RunsMatchLine(
|
|
[Run(" tells you, \"hi\""), Run("Dww")],
|
|
"Dww tells you, \"hi\""));
|
|
}
|
|
|
|
[Fact]
|
|
public void RunsMatchLine_RejectsAnythingThatWouldDesyncSelection()
|
|
{
|
|
// Selection, hit-testing and the caret all index into the FLAT line, so
|
|
// a run list that says something else would draw one thing and select
|
|
// another. Every one of these must be refused.
|
|
const string line = "Dww tells you";
|
|
|
|
Assert.False(UiText.RunsMatchLine([Run("Dww")], line)); // short
|
|
Assert.False(UiText.RunsMatchLine([Run(line), Run("!")], line)); // long
|
|
Assert.False(UiText.RunsMatchLine([Run("Dwx"), Run(" tells you")], line)); // altered
|
|
Assert.False(UiText.RunsMatchLine([], line)); // empty vs text
|
|
}
|
|
|
|
[Fact]
|
|
public void RunsMatchLine_HandlesTheDegenerateCases()
|
|
{
|
|
Assert.True(UiText.RunsMatchLine([], string.Empty));
|
|
Assert.True(UiText.RunsMatchLine([Run(string.Empty)], string.Empty));
|
|
// An empty run between real ones is harmless — it contributes nothing
|
|
// to the text and advances the pen by nothing.
|
|
Assert.True(UiText.RunsMatchLine([Run("ab"), Run(string.Empty), Run("cd")], "abcd"));
|
|
}
|
|
|
|
[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));
|
|
}
|
|
}
|