acdream/tests/AcDream.Core.Tests/Chat/ChatLogTests.cs
Erik ac0304dcf0 fix(ui,runtime): OP4 re-review residuals R1-R4 (coordinator pass) — OP4 CLOSED
R1: the timestamp prefix moves from ChatLog.Append (which stamped the
stored BODY, rendering 'Alice says, "13:05:09 hi"') to ChatVM's display
composition — FormatTimestampPrefix(entry.Received) prepends the COMPOSED
line, matching retail's separate-leading-string model (fprintf("%ls%ls",
ts, text) @0x00563e5b; AddTextToScroll receives composed lines). The
prefix renders entry.Received in LOCAL time (retail strftime), invariant
literal colons. The ten defect-pinning test cases across
ChatLogTests/RuntimeCommunicationStateTests are rewritten to pin the
corrected contract (stored bodies stay clean; the composed line carries
the stamp outside the quotes — ChatVMTests).

R2: open option-bearing panels converge on every PlayerDescription seed:
OptionPage.ReloadFromLive (per-row live re-read + gating re-eval, NO
AfterApply flush — the seed just cleared the dirty module),
OptionsPanelController.OnServerOptionsSeeded (active page),
CombatUiController.OnServerOptionsSeeded (SyncControls), wired through
RuntimeSettingsController.ServerOptionsSeeded from the same factory hook
LockUI already uses. Retail cannot reach this state (its panels close
across login); the adaptation exists because retained panels survive the
session boundary — documented at the seam.

R3: tests drive the refresh widget push (model AND checkbox converge) and
ReloadFromLive's no-flush contract. R4: AP-196 addendum names the
headless AutoRepeatAttack false->true effective-default flip and the
characterOptions escape hatch.

Full Release suite: 13,083 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 06:36:14 +02:00

381 lines
14 KiB
C#

using System.Globalization;
using System.Threading;
using AcDream.Core.Chat;
using AcDream.Core.Combat;
using Xunit;
namespace AcDream.Core.Tests.Chat;
public sealed class ChatLogTests
{
[Fact]
public void OnLocalSpeech_AppendsEntry_FiresEvent()
{
var log = new ChatLog();
ChatEntry? seen = null;
log.EntryAppended += e => seen = e;
log.OnLocalSpeech("Alice", "hi", 0xAA, isRanged: false, logTextType: 0x02u);
Assert.Equal(1, log.Count);
Assert.NotNull(seen);
Assert.Equal(ChatKind.LocalSpeech, seen!.Value.Kind);
Assert.Equal("Alice", seen.Value.Sender);
Assert.Equal("hi", seen.Value.Text);
}
[Fact]
public void OnLocalSpeech_Ranged_SetsRangedKind()
{
var log = new ChatLog();
log.OnLocalSpeech("Bob", "SHOUT", 0xBB, isRanged: true, logTextType: 0x02u);
Assert.Equal(ChatKind.RangedSpeech, log.Snapshot()[0].Kind);
}
[Fact]
public void OnChannelBroadcast_SetsChannelId()
{
var log = new ChatLog();
log.OnChannelBroadcast(channelId: 42, sender: "Alice", text: "allegiance motd");
var e = log.Snapshot()[0];
Assert.Equal(42u, e.ChannelId);
Assert.Equal(ChatKind.Channel, e.Kind);
}
[Fact]
public void OnTellReceived_SetsTellKind()
{
var log = new ChatLog();
log.OnTellReceived("Alice", "psst", 0xAA, logTextType: 0x03u);
Assert.Equal(ChatKind.Tell, log.Snapshot()[0].Kind);
}
[Fact]
public void OnSystemMessage_EncodesChatType_AsChannelId()
{
var log = new ChatLog();
log.OnSystemMessage("Your spell fizzled!", chatType: 5);
var e = log.Snapshot()[0];
Assert.Equal(ChatKind.System, e.Kind);
Assert.Equal(5u, e.ChannelId);
}
[Fact]
public void OnSelfSent_EchoesOutbound()
{
var log = new ChatLog();
log.OnSelfSent(ChatKind.Tell, "hey", logTextType: 0x04u, targetOrChannel: "Alice");
var e = log.Snapshot()[0];
Assert.Equal("Alice", e.Sender);
Assert.Equal("hey", e.Text);
}
[Fact]
public void RingBuffer_DropsOldestBeyondCapacity()
{
var log = new ChatLog(maxEntries: 3);
log.OnLocalSpeech("A", "1", 0, false, logTextType: 0x02u);
log.OnLocalSpeech("B", "2", 0, false, logTextType: 0x02u);
log.OnLocalSpeech("C", "3", 0, false, logTextType: 0x02u);
log.OnLocalSpeech("D", "4", 0, false, logTextType: 0x02u);
var snap = log.Snapshot();
Assert.Equal(3, snap.Length);
Assert.Equal("2", snap[0].Text); // "1" was dropped
Assert.Equal("4", snap[2].Text);
}
[Fact]
public void Clear_EmptiesBuffer()
{
var log = new ChatLog();
log.OnLocalSpeech("A", "1", 0, false, logTextType: 0x02u);
log.Clear();
Assert.Equal(0, log.Count);
}
// ── Phase I.5: emote / soul-emote / killed / weenie-error adapters ──
[Fact]
public void OnEmote_AppendsEmoteEntry()
{
var log = new ChatLog();
log.OnEmote("Caith", "waves at you", 0xCAFE);
var e = log.Snapshot()[0];
Assert.Equal(ChatKind.Emote, e.Kind);
Assert.Equal("Caith", e.Sender);
Assert.Equal("waves at you", e.Text);
Assert.Equal(0xCAFEu, e.SenderGuid);
}
[Fact]
public void OnSoulEmote_AppendsSoulEmoteEntry()
{
var log = new ChatLog();
log.OnSoulEmote("Bob", "dances", 0xBEEF);
var e = log.Snapshot()[0];
Assert.Equal(ChatKind.SoulEmote, e.Kind);
Assert.Equal("Bob", e.Sender);
Assert.Equal("dances", e.Text);
Assert.Equal(0xBEEFu, e.SenderGuid);
}
[Fact]
public void OnPlayerKilled_AppendsSystemEntry_StoresGuids()
{
var log = new ChatLog();
log.OnPlayerKilled("Caith was killed by a Drudge.",
victimGuid: 0x12345678u, killerGuid: 0x90ABCDEFu);
var e = log.Snapshot()[0];
Assert.Equal(ChatKind.System, e.Kind);
Assert.Equal("Caith was killed by a Drudge.", e.Text);
Assert.Equal(0x12345678u, e.SenderGuid);
Assert.Equal(0x90ABCDEFu, e.ChannelId); // killer guid stashed here
}
// OnWeenieError-specific tests (plain code, interpolation, silent
// client-control statuses) were removed here — REJECT-review rework
// (SHOULD-FIX 3, docs/research/2026-08-09-ch2-review-findings.md)
// deletes ChatLog.OnWeenieError itself; every producer now resolves via
// WeenieErrorMessages and calls the AddText chokepoint / OnSystemMessage
// directly. Equivalent coverage lives in GameEventWiringTests.cs (the
// inbound wire path) and WeenieErrorMessagesTests.cs (the resolve
// table), including the silent-client-control-status behavior.
[Fact]
public void OnLocalSpeech_EmptySender_SubstitutesYou()
{
// Holtburger client/messages.rs lines 476-487 — empty sender
// means the player is the speaker (echo back of their own
// ranged shout). Substitute "You" so the chat line reads
// "You: hello" instead of ": hello".
var log = new ChatLog();
log.OnLocalSpeech(sender: "", text: "hello", senderGuid: 0, isRanged: false, logTextType: 0x02u);
var e = log.Snapshot()[0];
Assert.Equal("You", e.Sender);
Assert.Equal("hello", e.Text);
}
[Fact]
public void OnLocalSpeech_NonEmptySender_KeepsAsIs()
{
var log = new ChatLog();
log.OnLocalSpeech(sender: "Alice", text: "hi", senderGuid: 0xAA, isRanged: false, logTextType: 0x02u);
Assert.Equal("Alice", log.Snapshot()[0].Sender);
}
// ── Phase I.7: combat-line adapter ────────────────────────────────────
[Fact]
public void OnCombatLine_DefaultsInfoKind_TagsEntryAsCombat()
{
var log = new ChatLog();
log.OnCombatLine("You hit Mosswart for 5 slashing damage (50.0%).", logTextType: 0x06u);
var e = log.Snapshot()[0];
Assert.Equal(ChatKind.Combat, e.Kind);
Assert.Equal(CombatLineKind.Info, e.CombatKind);
Assert.Equal("You hit Mosswart for 5 slashing damage (50.0%).", e.Text);
}
[Fact]
public void OnCombatLine_PreservesExplicitKind()
{
var log = new ChatLog();
log.OnCombatLine("Mosswart hit you for 8 fire damage to your chest.",
logTextType: 0x06u, kind: CombatLineKind.Warning);
Assert.Equal(CombatLineKind.Warning, log.Snapshot()[0].CombatKind);
log.OnCombatLine("Attack sequence finished with WeenieError 0x1234.",
logTextType: 0x06u, kind: CombatLineKind.Error);
Assert.Equal(CombatLineKind.Error, log.Snapshot()[1].CombatKind);
}
// ── Campaign CH slice CH1: LogTextType ingestion-site mapping ──────────
[Fact]
public void OnLocalSpeech_ExplicitLogTextType_PinsSpeechValue()
{
var log = new ChatLog();
log.OnLocalSpeech("Alice", "hi", 0xAA, isRanged: false, logTextType: 0x02u);
Assert.Equal(0x02u, log.Snapshot()[0].LogTextType);
}
[Fact]
public void OnLocalSpeech_PassesWireChatTypeVerbatim()
{
// HearSpeech/HearRangedSpeech carry the LogTextType on the wire —
// zero remapping (research doc §3.3 / HearSpeech.cs doc comment).
var log = new ChatLog();
log.OnLocalSpeech("Mosswart", "grumble", 0x5000_1234u, isRanged: false, logTextType: 0x0Cu);
Assert.Equal(0x0Cu, log.Snapshot()[0].LogTextType);
}
[Fact]
public void OnEmote_HardCodesLogTextType_0x0C()
{
var log = new ChatLog();
log.OnEmote("Caith", "waves at you", 0xCAFE);
Assert.Equal(0x0Cu, log.Snapshot()[0].LogTextType);
}
[Fact]
public void OnSoulEmote_HardCodesLogTextType_0x0C()
{
var log = new ChatLog();
log.OnSoulEmote("Bob", "dances", 0xBEEF);
Assert.Equal(0x0Cu, log.Snapshot()[0].LogTextType);
}
[Fact]
public void OnPlayerKilled_LogTextType_IsDefault()
{
var log = new ChatLog();
log.OnPlayerKilled("Caith was killed by a Drudge.", 0x1u, 0x2u);
Assert.Equal(0x00u, log.Snapshot()[0].LogTextType);
}
[Fact]
public void OnPopup_LogTextType_IsDefault()
{
var log = new ChatLog();
log.OnPopup("A modal message.");
var e = log.Snapshot()[0];
Assert.Equal(ChatKind.Popup, e.Kind);
Assert.Equal(0x00u, e.LogTextType);
}
[Fact]
public void OnTellReceived_ExplicitLogTextType_PinsTellValue()
{
var log = new ChatLog();
log.OnTellReceived("Alice", "psst", 0xAA, logTextType: 0x03u);
Assert.Equal(0x03u, log.Snapshot()[0].LogTextType);
}
[Fact]
public void OnTellReceived_PassesWireChatTypeVerbatim()
{
var log = new ChatLog();
log.OnTellReceived("Alice", "psst", 0xAA, logTextType: 0x1Fu);
Assert.Equal(0x1Fu, log.Snapshot()[0].LogTextType);
}
[Fact]
public void OnSelfSent_Tell_ExplicitLogTextType_PinsSpeechDirectSendValue()
{
var log = new ChatLog();
log.OnSelfSent(ChatKind.Tell, "hey", logTextType: 0x04u, targetOrChannel: "Alice");
Assert.Equal(0x04u, log.Snapshot()[0].LogTextType);
}
[Fact]
public void OnSelfSent_Channel_ExplicitLogTextType_PinsSocialSendValue()
{
var log = new ChatLog();
log.OnSelfSent(ChatKind.Channel, "hi all", logTextType: 0x0Bu, targetOrChannel: "Fellowship");
Assert.Equal(0x0Bu, log.Snapshot()[0].LogTextType);
}
[Fact]
public void OnSelfSent_ExplicitLogTextType_Overrides()
{
var log = new ChatLog();
log.OnSelfSent(ChatKind.Channel, "hi all", logTextType: 0x13u, targetOrChannel: "Fellowship");
Assert.Equal(0x13u, log.Snapshot()[0].LogTextType);
}
[Theory]
// Fellowship — same type hear + send.
[InlineData(0x0800u, "Fellowship", 0x13u)]
// Patron/Vassal/Follower — hear is Social (0xA).
[InlineData(0x1000u, "Patron", 0x0Au)]
[InlineData(0x2000u, "Vassal", 0x0Au)]
[InlineData(0x4000u, "Follower", 0x0Au)]
// Co-Vassals / Allegiance Broadcast.
[InlineData(0x1000000u, "Co-Vassals", 0x0Au)]
[InlineData(0x2000000u, "Allegiance Broadcast", 0x0Au)]
// FellowBroadcast — hear is the plain Channel slot (corrected
// 2026-08-09, Opus review of 172c6f9a — was wrongly 0x13).
[InlineData(0x4000000u, "?", 0x08u)]
// The one named non-family bit inside the generic bucket (Help).
[InlineData(0x0400u, "Help", 0x0Fu)]
// Generic admin/audit/sentinel catch-all — Channel (hear), NOT Abuse
// (corrected 2026-08-09, Opus review of 172c6f9a — Abuse is retail's
// ONLY 0x0E producer, bit 0x0001, and this test's 0x0900 doesn't hit it).
[InlineData(0x0900u, "Audit", 0x08u)]
public void OnChannelBroadcast_DerivesLogTextType_FromLegacyChannelBit(
uint channelBit, string channelName, uint expectedLogTextType)
{
var log = new ChatLog();
log.OnChannelBroadcast(channelBit, sender: "Someone", text: "hi", channelName: channelName);
Assert.Equal(expectedLogTextType, log.Snapshot()[0].LogTextType);
}
[Fact]
public void OnChannelBroadcast_ExplicitLogTextType_OverridesLegacyDerivation()
{
// TurbineChat rooms share the ChannelId slot with an opaque room
// GUID (not a legacy bitflag) — callers MUST override.
var log = new ChatLog();
log.OnChannelBroadcast(
channelId: 0x7000_0001u, sender: "Someone", text: "hi",
logTextType: 0x1Bu, channelName: "General");
Assert.Equal(0x1Bu, log.Snapshot()[0].LogTextType);
}
[Fact]
public void OnSystemMessage_LogTextType_MatchesChatType()
{
var log = new ChatLog();
log.OnSystemMessage("Your spell fizzled!", chatType: 5);
Assert.Equal(5u, log.Snapshot()[0].LogTextType);
}
[Fact]
public void OnCombatLine_ExplicitLogTextType_PinsGenericCombatValue()
{
var log = new ChatLog();
log.OnCombatLine("You hit Mosswart for 5 slashing damage (50.0%).", logTextType: 0x06u);
Assert.Equal(0x06u, log.Snapshot()[0].LogTextType);
}
// ── OP4 re-review R1 (2026-08-11): the timestamp NEVER touches the stored
// body — retail composes the display line first and prepends the stamp
// as a separate leading string at display time (fprintf("%ls%ls", ts,
// text) @0x00563e5b). ChatVM's display composition owns the prefix;
// these tests pin that the LOG stays clean and the format is invariant.
[Theory]
[InlineData(false)]
[InlineData(true)]
public void StoredEntryText_NeverCarriesTheTimestampPrefix(bool timestampsOn)
{
var log = new ChatLog { DisplayTimestampsSource = () => timestampsOn };
log.OnLocalSpeech("Alice", "hi", 0xAAu, isRanged: false, logTextType: 0x02u);
// The stored body is ALWAYS the raw message — the option gates the
// display composition (ChatVM), never the log content.
Assert.Equal("hi", log.Snapshot()[0].Text);
}
[Fact]
public void FormatTimestampPrefix_UsesLiteralColons_RegardlessOfCurrentCulture()
{
CultureInfo original = Thread.CurrentThread.CurrentCulture;
try
{
Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("fi-FI");
string prefix = ChatLog.FormatTimestampPrefix(
new DateTime(2026, 8, 11, 13, 5, 9, DateTimeKind.Utc));
// fi-FI's time separator is '.', so a culture-dependent format
// would emit "16.05.09 " here; retail's strftime is a literal ':'.
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} $", prefix);
}
finally
{
Thread.CurrentThread.CurrentCulture = original;
}
}
}