feat(chat): CT-A3 — a chat line keeps the identity of who said it
Campaign CT slice A3. Still nothing visible; this removes the blocker.
ChatEntry carried Sender and SenderGuid the whole way through ChatLog, and
RecentLinesDetailed dropped both one step before the transcript. That is why a
speaker's name could never be coloured or clicked apart from the sentence
around it — by the time anything could draw it, there was no name left, only
prose.
FormattedLine now carries a Spans sidecar: the line split into stretches, one
of which may hold a retail text tag. Text remains the VISIBLE line with markup
consumed, so wrapping, selection, hit-testing and the caret are untouched, and
Spans is null for the ordinary single-colour line, which is most of them.
FormatEntryTagged shares its format strings with FormatEntry through a sender
decorator rather than duplicating retail's wording. Two copies would be two
things to keep in step, and these two renderings MUST show the same characters:
the transcript selects against the flat text, so any drift would mean clicking
one character and selecting another.
Tagging is gated on AC1's player id range (0x50000001..0x6FFFFFFF), read off
the guard in Handle_Communication__HearSpeech @0x005712A0 — so monsters and
NPCs never become clickable, which a "has a name" test would get wrong.
One real bug came out of a defensive test rather than a report. A sender name
containing '<' re-parsed as a marker and SWALLOWED characters from the visible
line ("Od<d says" rendered as "Od says"). The markup has no escape mechanism,
and retail's has none either because AC name validation makes the case
unreachable there — but sender names are server data. Such a name is now simply
not tagged, so the line renders plain like any other untaggable sender instead
of rendering corrupted.
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
2621fbf4a7
commit
44fb74f8a6
2 changed files with 209 additions and 9 deletions
|
|
@ -0,0 +1,107 @@
|
|||
using AcDream.Core.Chat;
|
||||
using AcDream.UI.Abstractions.Panels.Chat;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CT slice A3: a chat line keeps the identity of who said it, all
|
||||
/// the way to the renderer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>ChatEntry</c> carried <c>Sender</c> and <c>SenderGuid</c> the whole way
|
||||
/// through <c>ChatLog</c>, and <c>RecentLinesDetailed</c> then dropped both one
|
||||
/// step before the transcript — which is why a speaker's name could never be
|
||||
/// coloured or clicked separately from the sentence around it.
|
||||
/// </remarks>
|
||||
public sealed class ChatVmTaggedSenderTests
|
||||
{
|
||||
private const uint PlayerGuid = 0x5000000Au;
|
||||
|
||||
private static ChatEntry Speech(
|
||||
string sender, string text, uint senderGuid, ChatKind kind = ChatKind.LocalSpeech)
|
||||
=> new(kind, sender, text, senderGuid, 0u);
|
||||
|
||||
[Fact]
|
||||
public void APlayersNameBecomesItsOwnSpan()
|
||||
{
|
||||
string markup = ChatVM.FormatEntryTagged(Speech("Dww", "hello", PlayerGuid));
|
||||
IReadOnlyList<ChatTextSpan> spans = ChatTagMarkup.Parse(markup);
|
||||
|
||||
Assert.Equal("Dww", spans[0].Text);
|
||||
Assert.NotNull(spans[0].Tag);
|
||||
Assert.True(spans[0].Tag!.Value.TryGetIidString(out uint id, out string name));
|
||||
Assert.Equal(PlayerGuid, id);
|
||||
Assert.Equal("Dww", name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheTaggedLineShowsExactlyTheSameCharactersAsThePlainOne()
|
||||
{
|
||||
// The whole design rests on this: the transcript wraps, selects and
|
||||
// hit-tests against the flat text, so markup must be invisible in the
|
||||
// result. If these ever diverge, clicks land on the wrong characters.
|
||||
ChatEntry entry = Speech("Dww", "hello", PlayerGuid);
|
||||
|
||||
string plain = ChatVM.FormatEntry(entry);
|
||||
string visible = string.Concat(
|
||||
ChatTagMarkup.Parse(ChatVM.FormatEntryTagged(entry)).Select(s => s.Text));
|
||||
|
||||
Assert.Equal(plain, visible);
|
||||
Assert.Equal("Dww says, \"hello\"", visible);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ChatKind.LocalSpeech)]
|
||||
[InlineData(ChatKind.RangedSpeech)]
|
||||
[InlineData(ChatKind.Channel)]
|
||||
[InlineData(ChatKind.Tell)]
|
||||
public void EveryKindThatNamesASpeakerTagsIt(ChatKind kind)
|
||||
=> Assert.True(ChatVM.ShouldTagSender(Speech("Dww", "hi", PlayerGuid, kind)));
|
||||
|
||||
[Fact]
|
||||
public void NonPlayerSendersAreNeverTagged()
|
||||
{
|
||||
// Retail gates on AC1's player id range, so a monster or NPC speaking
|
||||
// never becomes clickable. A "has a name" test would tag them.
|
||||
Assert.False(ChatVM.ShouldTagSender(Speech("A Drudge", "hi", 0x80000001u)));
|
||||
Assert.False(ChatVM.ShouldTagSender(Speech("A Drudge", "hi", 0x4FFFFFFFu)));
|
||||
|
||||
// Boundaries of the range itself.
|
||||
Assert.True(ChatVM.ShouldTagSender(Speech("P", "hi", 0x50000001u)));
|
||||
Assert.True(ChatVM.ShouldTagSender(Speech("P", "hi", 0x6FFFFFFFu)));
|
||||
Assert.False(ChatVM.ShouldTagSender(Speech("P", "hi", 0x70000000u)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OurOwnLinesAndUnnamedSendersAreNotTagged()
|
||||
{
|
||||
// No guid at all (our own echo), and the substituted "You".
|
||||
Assert.False(ChatVM.ShouldTagSender(Speech("Dww", "hi", 0u)));
|
||||
Assert.False(ChatVM.ShouldTagSender(Speech("You", "hi", PlayerGuid)));
|
||||
Assert.False(ChatVM.ShouldTagSender(Speech(string.Empty, "hi", PlayerGuid)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnUntaggedLineIsFormattedExactlyAsBefore()
|
||||
{
|
||||
// The overwhelming majority of lines take this path; it must be
|
||||
// byte-identical to the pre-CT formatting.
|
||||
ChatEntry system = new(ChatKind.System, string.Empty, "Welcome.", 0u, 0u);
|
||||
|
||||
Assert.Equal(ChatVM.FormatEntry(system), ChatVM.FormatEntryTagged(system));
|
||||
Assert.Equal("Welcome.", ChatVM.FormatEntryTagged(system));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ANameContainingMarkupCharactersStillRoundTrips()
|
||||
{
|
||||
// Defensive: a name is server data. Whatever it contains, the visible
|
||||
// line must still equal the plain formatting, or selection desyncs.
|
||||
ChatEntry entry = Speech("Od<d", "hi", PlayerGuid);
|
||||
|
||||
string visible = string.Concat(
|
||||
ChatTagMarkup.Parse(ChatVM.FormatEntryTagged(entry)).Select(s => s.Text));
|
||||
|
||||
Assert.Equal(ChatVM.FormatEntry(entry), visible);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue