Campaign CT slices C2 and C3. **C2 — Escape in the chat input did nothing at all.** Not "did the wrong thing": nothing. Two independent facts had to hold for that. UiField has no Escape case, AND a focused field reports IsEditControl, which makes UiRoot skip its own fallback and the input dispatcher withhold game actions — so the player had no way out of the bar except the mouse. Retail maps Escape to input action 0x0B, which runs ChatInterface::DeactivateChatEntry @0x004F2FC0: RelinquishFocus, then Deactivate. It does NOT clear the field. That is worth stating because the obvious guess — "Escape clears the input" — is wrong and would have looked perfectly reasonable; a half-written message survives stepping away from the bar, and the test pins that rather than just pinning "handled". **C3 — the timestamp took the message's colour.** Retail appends it as its own run at a FIXED colour index (0x0C, which BuildChatColorLookupTable @0x004F31C0 fills with colorGrey) rather than the line's, so it stays grey whether the message is red combat text or white speech. Most of C3 was already done and stayed untouched: the DisplayTimeStamps option is polled, and FormatTimestampPrefix already matches retail's "%#H:%M:%S ". Only the colour was wrong, and it was only fixable now because A1/A4 made a line able to carry more than one colour. The stamp is a span ROLE rather than a second tag type: it is not clickable and carries no payload, so modelling it as a tag would have made it hit-testable for no reason. Its colour comes from the same runtime table every message colour comes from, unlike the tagged-name colour, which is authored per element (0x1D) and deliberately lives elsewhere. One consequence worth naming: a timestamped line now needs runs even when its sender is not tagged, because the stamp alone is reason enough. Before this, only tagged lines got runs. Also verified and NOT changed, having checked rather than assumed: C1's auto-scroll half is already retail-faithful — UiScrollable.SetExtents samples "was at the end" BEFORE applying new extents and only re-sticks if so, which is exactly retail's IsAtVerticalEnd rule, and chat gets it by default. C1 reduces to the unread indicator, which does not exist yet. Solution builds clean; full hermetic gate green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
295 lines
11 KiB
C#
295 lines
11 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Numerics;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
using AcDream.Core.Chat;
|
|
using AcDream.UI.Abstractions.Panels.Chat;
|
|
|
|
namespace AcDream.App.Tests.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Campaign CT slice A4: a tagged speaker name draws in the element's authored
|
|
/// TAG colour while the rest of its line keeps the message colour.
|
|
/// </summary>
|
|
public sealed class ChatTranscriptRunsTests
|
|
{
|
|
private static readonly Vector4 LineColor = new(0.8f, 0.8f, 0.8f, 1f);
|
|
|
|
/// <summary>Measured from the installed dats: chat 0x2100006F / 0x10000011.</summary>
|
|
private static readonly Vector4 TagGreen = new(0f, 178f / 255f, 0f, 1f);
|
|
|
|
/// <summary>10px per character, so wrap points are predictable.</summary>
|
|
private static float Measure(string text) => text.Length * 10f;
|
|
|
|
private static IReadOnlyList<ChatTextSpan> TellSpans(string name, string rest)
|
|
=> new[]
|
|
{
|
|
new ChatTextSpan(name, new ChatTextTag("Tell", "IIDString", $"1342177290:{name}")),
|
|
new ChatTextSpan(rest, null),
|
|
};
|
|
|
|
[Fact]
|
|
public void TheNameTakesTheTagColourAndTheRestTakesTheLineColour()
|
|
{
|
|
IReadOnlyList<UiText.TextRun>? runs = ChatTranscriptRenderer.RunsForFragment(
|
|
TellSpans("Dww", " tells you"),
|
|
fragmentStart: 0,
|
|
fragmentLength: "Dww tells you".Length,
|
|
LineColor,
|
|
TagGreen);
|
|
|
|
Assert.NotNull(runs);
|
|
Assert.Equal(2, runs!.Count);
|
|
Assert.Equal(("Dww", TagGreen), (runs[0].Text, runs[0].Color));
|
|
Assert.Equal((" tells you", LineColor), (runs[1].Text, runs[1].Color));
|
|
}
|
|
|
|
[Fact]
|
|
public void AFragmentWithNoTagInItGetsNoRunsAtAll()
|
|
{
|
|
// A single-colour fragment must take the ordinary flat draw path
|
|
// rather than a one-run list that means the same thing.
|
|
IReadOnlyList<ChatTextSpan> spans = TellSpans("Dww", " tells you, hello there");
|
|
|
|
Assert.Null(ChatTranscriptRenderer.RunsForFragment(
|
|
spans,
|
|
fragmentStart: 10, // well past the name
|
|
fragmentLength: 5,
|
|
LineColor,
|
|
TagGreen));
|
|
}
|
|
|
|
[Fact]
|
|
public void ATagStraddlingAWrapBreakIsSplitAcrossBothFragments()
|
|
{
|
|
// The name is long enough to be cut in half by the wrap; both halves
|
|
// must still be green, or a name would change colour mid-word.
|
|
IReadOnlyList<ChatTextSpan> spans = TellSpans("Bartholomew", " tells you");
|
|
|
|
IReadOnlyList<UiText.TextRun>? first = ChatTranscriptRenderer.RunsForFragment(
|
|
spans, fragmentStart: 0, fragmentLength: 5, LineColor, TagGreen);
|
|
IReadOnlyList<UiText.TextRun>? second = ChatTranscriptRenderer.RunsForFragment(
|
|
spans, fragmentStart: 5, fragmentLength: 6, LineColor, TagGreen);
|
|
|
|
Assert.Equal("Barth", Assert.Single(first!).Text);
|
|
Assert.Equal(TagGreen, first![0].Color);
|
|
Assert.Equal("olomew", Assert.Single(second!).Text);
|
|
Assert.Equal(TagGreen, second![0].Color);
|
|
}
|
|
|
|
[Fact]
|
|
public void RunsAlwaysReproduceTheFragmentTheyCover()
|
|
{
|
|
// UiText.RunsMatchLine refuses to draw runs that disagree with the
|
|
// line, so a mapping bug here would silently fall back to flat text
|
|
// rather than fail loudly. Assert the contract directly.
|
|
IReadOnlyList<ChatTextSpan> spans = TellSpans("Dww", " tells you, \"hi\"");
|
|
string line = string.Concat(spans.Select(s => s.Text));
|
|
|
|
for (int start = 0; start < line.Length; start++)
|
|
{
|
|
for (int len = 1; len <= line.Length - start; len++)
|
|
{
|
|
IReadOnlyList<UiText.TextRun>? runs =
|
|
ChatTranscriptRenderer.RunsForFragment(
|
|
spans, start, len, LineColor, TagGreen);
|
|
if (runs is null)
|
|
continue;
|
|
|
|
string fragment = line.Substring(start, len);
|
|
Assert.True(
|
|
UiText.RunsMatchLine(runs, fragment),
|
|
$"runs disagree with fragment [{start}..{start + len})");
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildLines_EmitsOneRunEntryPerWrappedFragment()
|
|
{
|
|
// The run list is index-aligned with the lines the transcript draws;
|
|
// if the two ever fall out of step, names colour on the wrong rows.
|
|
var detailed = new List<FormattedLine>
|
|
{
|
|
new("Dww tells you, hello there friend", ChatKind.Tell, null, 0x03u,
|
|
TellSpans("Dww", " tells you, hello there friend")),
|
|
new("Welcome.", ChatKind.System, null, 0x05u),
|
|
};
|
|
var runs = new List<IReadOnlyList<UiText.TextRun>?>();
|
|
|
|
List<UiText.Line> lines = ChatTranscriptRenderer.BuildLines(
|
|
detailed,
|
|
maxW: 150f, // forces the first entry to wrap
|
|
Measure,
|
|
accept: null,
|
|
defaultColor: LineColor,
|
|
tagColor: TagGreen,
|
|
runsPerLine: runs);
|
|
|
|
Assert.True(lines.Count > 2, "the first line should have wrapped");
|
|
Assert.Equal(lines.Count, runs.Count);
|
|
|
|
// The very first fragment holds the name, so it is the one with runs.
|
|
Assert.NotNull(runs[0]);
|
|
Assert.Equal(TagGreen, runs[0]![0].Color);
|
|
|
|
// The untagged system line never gets runs.
|
|
Assert.Null(runs[^1]);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildLines_WithoutATagColourFallsBackToTheLineColour()
|
|
{
|
|
// An element that authors no 0x1D must not render the name in a
|
|
// colour nobody chose; it renders like the rest of the line.
|
|
var detailed = new List<FormattedLine>
|
|
{
|
|
new("Dww tells you", ChatKind.Tell, null, 0x03u,
|
|
TellSpans("Dww", " tells you")),
|
|
};
|
|
var runs = new List<IReadOnlyList<UiText.TextRun>?>();
|
|
|
|
ChatTranscriptRenderer.BuildLines(
|
|
detailed, maxW: 1000f, Measure, accept: null,
|
|
defaultColor: LineColor, tagColor: null, runsPerLine: runs);
|
|
|
|
IReadOnlyList<UiText.TextRun> line = Assert.Single(runs)!;
|
|
Assert.All(line, run => Assert.Equal(line[0].Color, run.Color));
|
|
}
|
|
|
|
// ── CT-B1: retail's transcript character budget ─────────────────────
|
|
|
|
private static FormattedLine Plain(string text, uint logTextType = 0x02u)
|
|
=> new(text, ChatKind.LocalSpeech, null, logTextType);
|
|
|
|
[Fact]
|
|
public void AShortHistoryIsKeptWhole()
|
|
{
|
|
var detailed = new List<FormattedLine> { Plain("one"), Plain("two") };
|
|
|
|
Assert.Equal(0, ChatTranscriptRenderer.FirstLineWithinBudget(detailed, accept: null));
|
|
}
|
|
|
|
[Fact]
|
|
public void TheOldestLinesDropOnceTheBudgetIsExceeded()
|
|
{
|
|
// Four lines of 10 characters (+1 newline each = 11) against a budget
|
|
// of 25 keeps the newest two and drops the older two.
|
|
var detailed = new List<FormattedLine>
|
|
{
|
|
Plain(new string('a', 10)),
|
|
Plain(new string('b', 10)),
|
|
Plain(new string('c', 10)),
|
|
Plain(new string('d', 10)),
|
|
};
|
|
|
|
Assert.Equal(
|
|
2,
|
|
ChatTranscriptRenderer.FirstLineWithinBudget(detailed, accept: null, budget: 25));
|
|
}
|
|
|
|
[Fact]
|
|
public void FilteredOutLinesDoNotConsumeBudget()
|
|
{
|
|
// A line this window filters out is not in its buffer at all, so it
|
|
// must not push older lines off the top — otherwise turning a filter
|
|
// OFF would silently shorten the visible history.
|
|
var detailed = new List<FormattedLine>
|
|
{
|
|
Plain(new string('a', 10), logTextType: 0x02u),
|
|
Plain(new string('x', 100), logTextType: 0x06u), // filtered
|
|
Plain(new string('b', 10), logTextType: 0x02u),
|
|
};
|
|
|
|
Assert.Equal(
|
|
0,
|
|
ChatTranscriptRenderer.FirstLineWithinBudget(
|
|
detailed, accept: type => type == 0x02u, budget: 25));
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildLines_RendersOnlyTheLinesInsideTheBudget()
|
|
{
|
|
var detailed = new List<FormattedLine>();
|
|
for (int i = 0; i < 40; i++)
|
|
detailed.Add(Plain(new string((char)('a' + (i % 26)), 500)));
|
|
|
|
List<UiText.Line> lines = ChatTranscriptRenderer.BuildLines(
|
|
detailed, maxW: 100000f, Measure, accept: null, defaultColor: LineColor);
|
|
|
|
// 40 * 501 = 20,040 characters against retail's 10,000 budget, so
|
|
// roughly half survive — and crucially the NEWEST half.
|
|
Assert.True(lines.Count < detailed.Count, "the oldest lines should have dropped");
|
|
Assert.Equal(detailed[^1].Text, lines[^1].Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void TheBudgetIsRetailsOwnNumber()
|
|
=> Assert.Equal(0x2710, ChatTranscriptRenderer.MaxTranscriptCharacters);
|
|
|
|
// ── CT-C3: the timestamp is its own colour ──────────────────────────
|
|
|
|
[Fact]
|
|
public void TheTimestampIsGreyWhateverColourTheMessageIs()
|
|
{
|
|
// Retail appends the stamp at a FIXED colour index (0x0C, colorGrey)
|
|
// rather than the message's, so a red combat line and a white say line
|
|
// carry the same grey stamp.
|
|
var spans = new[]
|
|
{
|
|
new ChatTextSpan("13:05:09 ", null, ChatSpanRole.Timestamp),
|
|
new ChatTextSpan("Dww says, \"hi\"", null),
|
|
};
|
|
|
|
IReadOnlyList<UiText.TextRun>? runs = ChatTranscriptRenderer.RunsForFragment(
|
|
spans,
|
|
fragmentStart: 0,
|
|
fragmentLength: spans[0].Text.Length + spans[1].Text.Length,
|
|
LineColor,
|
|
TagGreen);
|
|
|
|
Assert.NotNull(runs);
|
|
Assert.Equal(2, runs!.Count);
|
|
Assert.Equal("13:05:09 ", runs[0].Text);
|
|
Assert.NotEqual(LineColor, runs[0].Color); // NOT the message colour
|
|
Assert.Equal(LineColor, runs[1].Color); // the body still is
|
|
}
|
|
|
|
[Fact]
|
|
public void ATimestampedLineGetsRunsEvenWithNoTaggedSender()
|
|
{
|
|
// The stamp alone is reason enough to need runs — before CT-C3 a line
|
|
// only got them when its sender was tagged, so an untagged line's
|
|
// stamp took the message colour.
|
|
var spans = new[]
|
|
{
|
|
new ChatTextSpan("13:05:09 ", null, ChatSpanRole.Timestamp),
|
|
new ChatTextSpan("Welcome.", null),
|
|
};
|
|
|
|
Assert.NotNull(ChatTranscriptRenderer.RunsForFragment(
|
|
spans, 0, spans[0].Text.Length + spans[1].Text.Length,
|
|
LineColor, TagGreen));
|
|
}
|
|
|
|
[Fact]
|
|
public void ATimestampAndATaggedNameKeepSeparateColours()
|
|
{
|
|
var spans = new[]
|
|
{
|
|
new ChatTextSpan("13:05:09 ", null, ChatSpanRole.Timestamp),
|
|
new ChatTextSpan("Dww", new ChatTextTag("Tell", "IIDString", "1:Dww")),
|
|
new ChatTextSpan(" tells you", null),
|
|
};
|
|
|
|
IReadOnlyList<UiText.TextRun> runs = ChatTranscriptRenderer.RunsForFragment(
|
|
spans, 0, 9 + 3 + 10, LineColor, TagGreen)!;
|
|
|
|
Assert.Equal(3, runs.Count);
|
|
Assert.Equal(TagGreen, runs[1].Color);
|
|
Assert.NotEqual(TagGreen, runs[0].Color);
|
|
Assert.NotEqual(runs[0].Color, runs[2].Color);
|
|
}
|
|
}
|