Campaign CT slice B1.
CORRECTION TO THE PLAN: this slice was written as "the transcript grows for the
life of the session — a slow leak". That was wrong, and the plan said it
because I read the retail-side finding and inferred our side without checking.
ChatLog has always been bounded (ConcurrentQueue, maxEntries default 500, with
a dequeue loop in Append). There was no leak.
The real gap is the UNIT. Retail bounds the rendered transcript by CHARACTERS —
0x2710, beheaded toward 0x1D4C at a newline boundary — while we bounded the
model by messages. Two different things: a window of 500 messages is far more
scrollback than 10,000 characters, and the message cap is a safety limit on the
log rather than a display rule.
So the budget is applied where retail applies it: on the rendered window, not
the model. ChatLog's entry cap stays as the model-level bound.
Two deliberate simplifications, both registered as CT-1 rather than left
implicit:
- ONE threshold, not retail's two. The hysteresis exists to stop retail
re-trimming an accumulating buffer on every append; we rebuild the visible
list each time, so there is nothing to damp, and a second threshold would
only make the oldest visible line jump around as messages arrive.
- Whole-line cutting rather than a newline search near an offset — our unit
already IS the line, which is what retail's newline preference is for.
Filtered-out lines deliberately do not consume budget: a line this window
filters out is not in retail's buffer at all, so counting it would mean turning
a filter OFF silently shortened the visible history.
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
231 lines
8.7 KiB
C#
231 lines
8.7 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);
|
|
}
|