acdream/tests/AcDream.App.Tests/UI/ChatTranscriptLogWriterTests.cs
Erik 0e0a77c9b1 feat(chat): CT-B4 — @log, and the research block that was a wrong question
CT-B4 was filed as "the plain-text session chat log, path and rotation
UNKNOWN, needs a live check." Both unknowns dissolve once you read the
handler: there is no automatic session log. Retail's @log is a COMMAND.
DoSetOutput @0x0057E4F0 takes a filename, StartCopyOutputToFile @0x0057C8A0
does the fopen(name, "a+"), and running it again with no argument closes it.
Nothing rotates because it appends forever, and nothing has a fixed path
because the player names the file.

The path question that DOES exist — where a bare name lands — was answered
all along by retail's own help text, which CH4 extracted verbatim into our
help table a fortnight ago and nobody read: "a log file named Aclog.txt in
your Asheron's Call directory." A blocked question sat on top of a committed
answer.

We cannot use the install directory: the launcher replaces it atomically on
update, so a log written there is wiped by the next update or blocks it. The
client's own log directory is the equivalent that survives. Rooted paths are
honoured verbatim, as retail's fopen would. Register CT-5.

The verb was registered in the help table but NOT in the command catalog, so
/log printed help and did nothing — and the CH4 conformance registry recorded
it as a "server passthrough" precisely because that shape is indistinguishable
from an unimplemented client command. It never went on the wire at all. Both
are corrected, with the totals moved in the same commit rather than left to
drift.

Moving it into the catalog also moves which help table answers for it, so
retail's real text moved to the catalog-verb table in the same change. Without
that, /help log would have silently started printing acdream's own invented
one-line summary — caught by the coverage test, and now pinned by a test that
names the text.

All five replies are byte-decoded from the PDB-paired binary rather than read
off Binary Ninja's previews, which truncate at ~33 characters and would have
lost the second half of every one of them (including the two spaces retail
puts after "Copying chat to %s.").

The writer attaches on OPEN, not at startup — retail's help is explicit that
only what appears after the command is copied — and detaches from the
transcript it actually attached to, so a session teardown cannot leave a live
handler writing into a file the player believes is closed. What gets written
is the composed display line with the shared timestamp, because retail's
fprintf sits inside AddTextToScroll: downstream of composition, upstream of
glyph layout. Logging the raw entry text would have produced a file of bare
fragments with no speakers.

acdream's logs carry no inline tag markup where retail's do, since tags live
beside the text as spans here rather than inside it. Registered as CT-6 rather
than reconstructed purely to write it to a file.

Register: CT-5, CT-6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:28:20 +02:00

121 lines
4.3 KiB
C#

using System;
using System.IO;
using AcDream.App.UI;
using AcDream.Core.Chat;
namespace AcDream.App.Tests.UI;
/// <summary>
/// CT-B4: what actually reaches the <c>@log</c> file.
/// </summary>
public sealed class ChatTranscriptLogWriterTests : IDisposable
{
private readonly string _directory =
Path.Combine(Path.GetTempPath(), "acdream-logwriter-" + Guid.NewGuid().ToString("N"));
public void Dispose()
{
try { Directory.Delete(_directory, recursive: true); }
catch (IOException) { /* nothing left to say */ }
}
private string Read() => File.ReadAllText(Path.Combine(_directory, "session.txt"));
[Fact]
public void TheComposedDisplayLineIsLoggedRatherThanTheRawMessage()
{
// The entry's own Text is just 'hello' — the speaker and the quotes
// are composition. Retail's log write sits downstream of that
// (fprintf inside AddTextToScroll), so logging entry.Text would give
// a file full of bare fragments with no idea who said them.
using var log = new ChatSessionLog(_directory);
var transcript = new ChatLog();
var writer = new ChatTranscriptLogWriter(log);
log.Open("session", out _);
writer.Attach(transcript);
transcript.OnLocalSpeech("Dww", "hello", 0x02u, false, 0u);
log.Close();
Assert.Equal("Dww says, \"hello\"\n", Read());
}
[Fact]
public void TheTimestampFollowsTheSameOptionTheWindowUses()
{
// Retail computes the stamp ONCE and hands the same string to the
// window and to the file, so the two can never disagree.
using var log = new ChatSessionLog(_directory);
bool stamps = false;
var transcript = new ChatLog { DisplayTimestampsSource = () => stamps };
var writer = new ChatTranscriptLogWriter(log);
log.Open("session", out _);
writer.Attach(transcript);
transcript.OnLocalSpeech("Dww", "before", 0x02u, false, 0u);
stamps = true;
transcript.OnLocalSpeech("Dww", "after", 0x02u, false, 0u);
log.Close();
string[] lines = Read().Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Equal("Dww says, \"before\"", lines[0]);
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} Dww says, ""after""$", lines[1]);
}
[Fact]
public void NothingIsLoggedBeforeAttachOrAfterDetach()
{
// Retail's help is explicit that logging starts when you type the
// command: only what appears AFTER it is copied.
using var log = new ChatSessionLog(_directory);
var transcript = new ChatLog();
var writer = new ChatTranscriptLogWriter(log);
log.Open("session", out _);
transcript.OnLocalSpeech("Dww", "before attach", 0x02u, false, 0u);
writer.Attach(transcript);
transcript.OnLocalSpeech("Dww", "during", 0x02u, false, 0u);
writer.Detach();
transcript.OnLocalSpeech("Dww", "after detach", 0x02u, false, 0u);
log.Close();
Assert.Equal("Dww says, \"during\"\n", Read());
}
[Fact]
public void AttachingTwiceDoesNotWriteEveryLineTwice()
{
using var log = new ChatSessionLog(_directory);
var transcript = new ChatLog();
var writer = new ChatTranscriptLogWriter(log);
log.Open("session", out _);
writer.Attach(transcript);
writer.Attach(transcript);
transcript.OnLocalSpeech("Dww", "once", 0x02u, false, 0u);
log.Close();
Assert.Equal("Dww says, \"once\"\n", Read());
}
[Fact]
public void DetachReleasesTheTranscriptItActuallyAttachedTo()
{
// A session teardown replaces the transcript. Detaching from the
// CURRENT one would leave a live handler on the old one, which then
// keeps writing into a file the player believes is closed.
using var log = new ChatSessionLog(_directory);
var first = new ChatLog();
var second = new ChatLog();
var writer = new ChatTranscriptLogWriter(log);
log.Open("session", out _);
writer.Attach(first);
writer.Attach(second); // switches transcripts
first.OnLocalSpeech("Dww", "stale", 0x02u, false, 0u);
second.OnLocalSpeech("Dww", "live", 0x02u, false, 0u);
log.Close();
Assert.Equal("Dww says, \"live\"\n", Read());
}
}