chore(chat): CT-D1/D2 — delete the dead ChatPanel, reconcile the stale digest

Campaign CT Group D, closing the campaign.

D1. The ImGui-era ChatPanel has not been constructed anywhere in src/ since
Campaign V deleted AcDream.UI.ImGui. Verified that directly rather than on the
audit's word, then removed it with its three panel-only test files. Those tests
passed, which is exactly the problem: they made the real input surface look
better covered than it is.

ChatVMCombatTests was KEPT — three of its four tests are genuine ChatVM
coverage and only one exercised ChatPanel, so just that method went. Deleting
the file would have quietly dropped real coverage along with the dead kind.
Three doc comments referencing the deleted type were rewritten rather than left
as dangling crefs.

D2. docs/ISSUES.md turned out to be ACCURATE already — #358 and #363 are
recorded CLOSED there, contrary to the audit's summary. What was stale was the
chat DIGEST's "Open" section, which still named four closed issues and claimed
Campaign CH's connected gate was owed. Corrected against ISSUES: genuinely open
are #359, #360, #361 and #366.

The digest also gained a Campaign CT section (the tag mechanism, the MEASURED
tag colour, and what shipped) and three DO-NOT-RETRY rows earned this session:

  - Do not model authored state media with one image per state — the unseen
    indicator's Normal state carries SIX frames and that IS retail's blink.
  - Do not read an element's role from a Binary Ninja field NAME — the names in
    ChatInterface's binder are shifted badly enough to assign a UIElement* into
    a float field.
  - Do not assume our side has a gap because retail has a mechanism. That cost
    this campaign twice in one session: the transcript was claimed unbounded
    when ChatLog has always capped at 500 entries, and C1's auto-scroll was
    planned as a port when UiScrollable already did it.

CT-C4 is deferred and marked so: pure test coverage over behaviour the audit
confirmed already works, changing nothing a user can see.

Solution builds clean; full hermetic gate green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-21 10:27:43 +02:00
parent cbab79d70c
commit 7aae5ba939
9 changed files with 30 additions and 844 deletions

View file

@ -1,71 +0,0 @@
using AcDream.Core.Chat;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
/// <summary>
/// Phase K.2 — Tab fires <see cref="AcDream.UI.Abstractions.Input.InputAction.ToggleChatEntry"/>,
/// which calls <see cref="ChatPanel.FocusInput"/>. The chat panel honors
/// the request on the very next <see cref="ChatPanel.Render"/> by emitting
/// a <c>SetKeyboardFocusHere</c> immediately before the input field. After
/// it fires once, subsequent renders without another <c>FocusInput</c>
/// call must not re-fire (one-shot semantics) — otherwise the chat field
/// would steal focus on every frame and the user could never click out.
/// </summary>
public sealed class ChatPanelFocusTests
{
private sealed class NullBus : AcDream.Runtime.Chat.ICommandBus
{
public void Publish<T>(T command) where T : notnull { }
}
[Fact]
public void FocusInput_NextRender_EmitsSetKeyboardFocusHereBeforeInput()
{
var panel = new ChatPanel(new ChatVM(new ChatLog()));
var renderer = new FakePanelRenderer();
panel.FocusInput();
panel.Render(new PanelContext(0.016f, new NullBus()), renderer);
// Find the SetKeyboardFocusHere call; it must come before the
// InputTextSubmit call so ImGui applies the focus to that widget.
int focusIdx = -1, inputIdx = -1;
for (int i = 0; i < renderer.Calls.Count; i++)
{
if (renderer.Calls[i].Method == "SetKeyboardFocusHere") focusIdx = i;
else if (renderer.Calls[i].Method == "InputTextSubmit") inputIdx = i;
}
Assert.True(focusIdx >= 0, "ChatPanel must call SetKeyboardFocusHere when FocusInput requested.");
Assert.True(inputIdx >= 0, "ChatPanel must still render the InputTextSubmit field.");
Assert.True(focusIdx < inputIdx, "SetKeyboardFocusHere must precede the InputTextSubmit it targets.");
}
[Fact]
public void Render_WithoutFocusInputCall_DoesNotEmitSetKeyboardFocusHere()
{
var panel = new ChatPanel(new ChatVM(new ChatLog()));
var renderer = new FakePanelRenderer();
panel.Render(new PanelContext(0.016f, new NullBus()), renderer);
Assert.DoesNotContain(renderer.Calls, c => c.Method == "SetKeyboardFocusHere");
}
[Fact]
public void FocusInput_OnlyAffectsTheNextRender_OneShot()
{
var panel = new ChatPanel(new ChatVM(new ChatLog()));
// Frame 1 — FocusInput requested → expect a SetKeyboardFocusHere.
var r1 = new FakePanelRenderer();
panel.FocusInput();
panel.Render(new PanelContext(0.016f, new NullBus()), r1);
Assert.Contains(r1.Calls, c => c.Method == "SetKeyboardFocusHere");
// Frame 2 — no further FocusInput call → must NOT re-fire.
var r2 = new FakePanelRenderer();
panel.Render(new PanelContext(0.016f, new NullBus()), r2);
Assert.DoesNotContain(r2.Calls, c => c.Method == "SetKeyboardFocusHere");
}
}

View file

@ -1,358 +0,0 @@
using AcDream.Core.Chat;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
/// <summary>
/// Phase I.4: when the user submits text via the chat input field, the
/// panel must publish the appropriate typed intent to the command bus.
/// We exercise the full Render path with the <see cref="FakePanelRenderer"/>
/// pre-loading a "submitted" string and a recording bus capturing the
/// resulting command.
/// </summary>
public sealed class ChatPanelInputTests
{
private sealed class RecordingBus : ICommandBus
{
public List<object> Published { get; } = new();
public void Publish<T>(T command) where T : notnull => Published.Add(command);
}
[Fact]
public void Submit_HelpCommand_RendersLocalHelpAndDoesNotPublish()
{
// Phase J follow-up: client-side commands (/help, /?, /h) are
// intercepted before the parser. They render local text via
// ChatLog.OnSystemMessage and do NOT round-trip the server — that's
// what prevented the "Unknown command: help" duplicate ACE was
// firing back.
//
// Campaign CH user-gate round 3 (2026-08-10): retail's DoHelp
// prints via exactly TWO scroll entries (Note, then the 13-item
// "Available help:" listing), never one acdream-invented blob — see
// RetailCommandHelpTable's class remarks for the full trace.
var log = new ChatLog();
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = "/help",
InputTextSubmitNextBufferAfter = "",
};
panel.Render(new PanelContext(0.016f, bus), renderer);
Assert.Empty(bus.Published);
var entries = log.Snapshot();
Assert.Equal(2, entries.Length);
Assert.All(entries, entry => Assert.Equal(ChatKind.System, entry.Kind));
Assert.Equal(RetailCommandHelpTable.HelpPrefixNote, entries[0].Text);
Assert.Equal(RetailCommandHelpTable.AvailableHelpListing, entries[1].Text);
}
[Theory]
[InlineData("/?")]
// "/h" is DELETED (Campaign CH slice CH4, 2026-08-09) — it is not a
// retail-registered verb (registry doc §4's removal list).
[InlineData("/HELP")]
public void Submit_HelpAliases_AlsoRenderLocalHelp(string raw)
{
var log = new ChatLog();
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = raw,
InputTextSubmitNextBufferAfter = "",
};
panel.Render(new PanelContext(0.016f, bus), renderer);
Assert.Empty(bus.Published);
Assert.Equal(2, log.Snapshot().Length);
}
[Fact]
public void Submit_FramerateCommand_PublishesTypedClientCommand()
{
var log = new ChatLog();
var vm = new ChatVM(log) { FpsProvider = () => 60f };
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = "/framerate",
InputTextSubmitNextBufferAfter = "",
};
panel.Render(new PanelContext(0.016f, bus), renderer);
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
Assert.Equal(ClientCommandId.ToggleFrameRate, command.Command);
Assert.Empty(log.Snapshot());
}
[Fact]
public void Submit_LocCommand_PublishesTypedClientCommand()
{
var log = new ChatLog();
var vm = new ChatVM(log)
{
PositionProvider = () => new System.Numerics.Vector3(10f, 20f, 30f),
};
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = "@loc",
InputTextSubmitNextBufferAfter = "",
};
panel.Render(new PanelContext(0.016f, bus), renderer);
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
Assert.Equal(ClientCommandId.ShowLocation, command.Command);
Assert.Empty(log.Snapshot());
}
[Theory]
[InlineData("/foo", "@foo")]
[InlineData("/genio public", "@genio public")]
public void Submit_UnknownSlashCommand_RoutesToExplicitServerCommand(string raw, string expectedText)
{
// Phase J Tier 4 held: /-prefixed text is still NEVER broadcast
// as plain speech. Retail treats / and @ as equivalent command
// prefixes, so unknown verbs now go to the SERVER as @commands
// (ACE's GameActionTalk intercepts @ on the Say action and
// answers "Unknown command: x" itself) instead of a local guess.
var log = new ChatLog();
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = raw,
InputTextSubmitNextBufferAfter = "",
};
panel.Render(new PanelContext(0.016f, bus), renderer);
var sendCmd = Assert.IsType<SendServerCommandCmd>(Assert.Single(bus.Published));
Assert.Equal(expectedText, sendCmd.Text);
Assert.Empty(log.Snapshot()); // no local "Unknown command" guess
}
[Theory]
[InlineData("/lifestone")]
[InlineData("/lif")]
[InlineData("/ls")]
[InlineData("@LS")]
public void Submit_LifestoneAlias_PublishesTypedClientCommand(string raw)
{
var vm = new ChatVM(new ChatLog());
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = raw,
InputTextSubmitNextBufferAfter = "",
};
panel.Render(new PanelContext(0.016f, bus), renderer);
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
Assert.Equal(ClientCommandId.LifestoneRecall, command.Command);
}
[Theory]
[InlineData("/")]
[InlineData("//shrug")]
public void Submit_CommandShapedWithoutVerb_ShowsUnknownAndDoesNotPublish(string raw)
{
// Command-shaped but no letter verb: refused locally — this is
// the remaining Tier-4 guard (never broadcast /-text as speech,
// and don't put junk @-rewrites on the wire either).
var log = new ChatLog();
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = raw,
InputTextSubmitNextBufferAfter = "",
};
panel.Render(new PanelContext(0.016f, bus), renderer);
Assert.Empty(bus.Published);
var entry = Assert.Single(log.Snapshot());
Assert.Equal(ChatKind.System, entry.Kind);
Assert.Contains("Unknown command", entry.Text);
Assert.Contains("/help", entry.Text);
}
[Fact]
public void Submit_AtAcehelp_PublishesExplicitServerCommand()
{
// Unknown @-verb falls through to the default channel with the
// literal "@acehelp" text intact so ACE's CommandManager
// intercepts it server-side. The explicit server-command record keeps
// it distinct from ordinary Say text.
var log = new ChatLog();
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = "@acehelp",
InputTextSubmitNextBufferAfter = "",
};
panel.Render(new PanelContext(0.016f, bus), renderer);
var sendCmd = Assert.IsType<SendServerCommandCmd>(Assert.Single(bus.Published));
Assert.Equal("@acehelp", sendCmd.Text);
}
[Fact]
public void Submit_ClearCommand_PublishesTypedClientCommand()
{
var log = new ChatLog();
log.OnSystemMessage("seed line", chatType: 0);
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = "/clear",
InputTextSubmitNextBufferAfter = "",
};
panel.Render(new PanelContext(0.016f, bus), renderer);
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
Assert.Equal(ClientCommandId.ClearChat, command.Command);
Assert.Single(log.Snapshot());
}
[Fact]
public void Submit_PlainText_PublishesSayCommand()
{
var log = new ChatLog();
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = "hello world",
InputTextSubmitNextBufferAfter = "",
};
panel.Render(new PanelContext(0.016f, bus), renderer);
var cmd = Assert.Single(bus.Published);
var sendCmd = Assert.IsType<SendChatCmd>(cmd);
Assert.Equal(ChatChannelKind.Say, sendCmd.Channel);
Assert.Null(sendCmd.TargetName);
Assert.Equal("hello world", sendCmd.Text);
}
[Fact]
public void Submit_TellSlashCommand_PublishesTellCommand()
{
var log = new ChatLog();
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = "/t Bestie ping",
InputTextSubmitNextBufferAfter = "",
};
panel.Render(new PanelContext(0.016f, bus), renderer);
var sendCmd = Assert.IsType<SendChatCmd>(Assert.Single(bus.Published));
Assert.Equal(ChatChannelKind.Tell, sendCmd.Channel);
Assert.Equal("Bestie", sendCmd.TargetName);
Assert.Equal("ping", sendCmd.Text);
}
[Fact]
public void Submit_ReplySlashCommand_UsesLastIncomingTellSender()
{
var log = new ChatLog();
var vm = new ChatVM(log);
log.OnTellReceived("Bestie", "ping", senderGuid: 0x5000_00AAu, logTextType: 0x03u);
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = "/r back at you",
InputTextSubmitNextBufferAfter = "",
};
panel.Render(new PanelContext(0.016f, bus), renderer);
var sendCmd = Assert.IsType<SendChatCmd>(Assert.Single(bus.Published));
Assert.Equal(ChatChannelKind.Tell, sendCmd.Channel);
Assert.Equal("Bestie", sendCmd.TargetName);
Assert.Equal("back at you", sendCmd.Text);
}
[Fact]
public void Submit_EmptyOrWhitespace_PublishesNothing()
{
var log = new ChatLog();
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = " ",
InputTextSubmitNextBufferAfter = "",
};
panel.Render(new PanelContext(0.016f, bus), renderer);
Assert.Empty(bus.Published);
}
[Fact]
public void NoSubmit_PublishesNothing()
{
// Most frames: user is typing or idle; submitted == null.
var log = new ChatLog();
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = null,
};
panel.Render(new PanelContext(0.016f, bus), renderer);
Assert.Empty(bus.Published);
}
[Fact]
public void Render_AlwaysCallsInputTextSubmit_ToShowTheField()
{
var log = new ChatLog();
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = null,
};
panel.Render(new PanelContext(0.016f, bus), renderer);
Assert.Contains(renderer.Calls, c => c.Method == "InputTextSubmit");
}
}

View file

@ -1,129 +0,0 @@
using AcDream.Core.Chat;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
/// <summary>
/// Phase J Tier 3: <see cref="ChatPanel.Render"/> must reserve footer
/// space for the separator + input field so the input stays anchored
/// at the bottom across window resizes (the user reported the input
/// disappearing when the window shrank). The pattern is the standard
/// ImGui chat-window layout: a scrollable child filling
/// <c>(0, -footerHeight)</c>, then the separator + input below it.
/// </summary>
public sealed class ChatPanelLayoutTests
{
private sealed class NoBus : ICommandBus
{
public void Publish<T>(T command) where T : notnull { /* no-op */ }
}
[Fact]
public void Render_OrderIs_Begin_BeginChild_EndChild_Separator_InputTextSubmit_End()
{
var log = new ChatLog();
log.OnSystemMessage("seed", chatType: 0);
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var renderer = new FakePanelRenderer();
panel.Render(new PanelContext(0.016f, new NoBus()), renderer);
var methods = renderer.Calls.Select(c => c.Method).ToList();
int beginIdx = methods.IndexOf("Begin");
int beginChildIdx = methods.IndexOf("BeginChild");
int endChildIdx = methods.IndexOf("EndChild");
// L.0 follow-up: Copy-mode toggle adds a Separator above the
// chat tail, so multiple Separators now exist. The footer
// separator (the one we care about for input layout) is the
// LAST one — between EndChild and the input field.
int separatorIdx = methods.LastIndexOf("Separator");
int inputSubmitIdx = methods.IndexOf("InputTextSubmit");
int endIdx = methods.IndexOf("End");
// All present
Assert.True(beginIdx >= 0, "Begin missing");
Assert.True(beginChildIdx >= 0, "BeginChild missing");
Assert.True(endChildIdx >= 0, "EndChild missing");
Assert.True(separatorIdx >= 0, "Separator missing");
Assert.True(inputSubmitIdx >= 0, "InputTextSubmit missing");
Assert.True(endIdx >= 0, "End missing");
// Order: Begin < BeginChild < EndChild < Separator < InputTextSubmit < End
Assert.True(beginIdx < beginChildIdx);
Assert.True(beginChildIdx < endChildIdx);
Assert.True(endChildIdx < separatorIdx);
Assert.True(separatorIdx < inputSubmitIdx);
Assert.True(inputSubmitIdx < endIdx);
}
[Fact]
public void Render_BeginChild_ReservesNegativeFooterFromFrameHeight()
{
var log = new ChatLog();
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var renderer = new FakePanelRenderer { FrameHeightWithSpacingValue = 24f };
panel.Render(new PanelContext(0.016f, new NoBus()), renderer);
// L.0 follow-up: the chat panel now wraps its body in an outer
// ##chatbody BeginChild (so empty-space clicks can't drag the
// parent window). The inner ##chattail BeginChild is the one
// that reserves the footer; that's what this test asserts.
var chattailCall = renderer.Calls.Single(c => c.Method == "BeginChild"
&& (string)c.Args[0]! == "##chattail");
var size = (System.Numerics.Vector2)chattailCall.Args[1]!;
// Width 0 = fill available; height < 0 = "fill minus this".
// Reserved height should equal FrameHeightWithSpacing + a small
// separator pad (~6f) so the input never visually clips the
// last chat line.
Assert.Equal(0f, size.X);
Assert.True(size.Y < 0, $"expected negative reserve, got {size.Y}");
Assert.True(size.Y <= -24f, $"expected at least -24f reserve, got {size.Y}");
}
[Fact]
public void Render_NewEntries_ScrollsToBottom()
{
// First render establishes the baseline (no auto-scroll because
// _lastRenderedCount == lines.Count == 0). Then a second render
// after a new entry should fire SetScrollHereY(1.0f).
var log = new ChatLog();
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var renderer = new FakePanelRenderer();
var ctx = new PanelContext(0.016f, new NoBus());
panel.Render(ctx, renderer);
Assert.DoesNotContain(renderer.Calls, c => c.Method == "SetScrollHereY");
// Append a new entry, render again — auto-scroll should fire.
log.OnLocalSpeech("Caith", "hello", senderGuid: 0xAA, isRanged: false, logTextType: 0x02u);
renderer.Calls.Clear();
panel.Render(ctx, renderer);
var scrollCall = renderer.Calls.Single(c => c.Method == "SetScrollHereY");
Assert.Equal(1.0f, (float)scrollCall.Args[0]!);
}
[Fact]
public void Render_NoNewEntries_DoesNotForceScroll()
{
var log = new ChatLog();
log.OnSystemMessage("seed", chatType: 0);
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var renderer = new FakePanelRenderer();
var ctx = new PanelContext(0.016f, new NoBus());
// First render establishes count baseline (1 entry). The first
// render auto-scrolls because lines.Count (1) > _lastRenderedCount
// (0). Subsequent renders without new entries should NOT scroll.
panel.Render(ctx, renderer);
renderer.Calls.Clear();
panel.Render(ctx, renderer);
Assert.DoesNotContain(renderer.Calls, c => c.Method == "SetScrollHereY");
}
}

View file

@ -56,41 +56,6 @@ public sealed class ChatVMCombatTests
Assert.Equal("Alice says, \"hi\"", line.Text);
}
[Fact]
public void ChatPanel_RendersCombatLine_ViaTextColored()
{
var log = new ChatLog();
var vm = new ChatVM(log);
log.OnLocalSpeech("Alice", "hi", senderGuid: 0xAA, isRanged: false, logTextType: 0x02u);
log.OnCombatLine("You hit Mosswart for 5 slashing damage (50.0%).",
logTextType: 0x06u, kind: CombatLineKind.Info);
var panel = new ChatPanel(vm);
var bus = new RecordingChatBus();
var renderer = new FakePanelRenderer { InputTextSubmitNextSubmitted = null };
panel.Render(new PanelContext(0.016f, bus), renderer);
// Plain LocalSpeech entry → Text; combat entry → TextColored, now
// sourced from RetailChatColorTable (Campaign CH slice CH1) keyed
// by LogTextType, not ChatPanel.ColorForCombat's severity bucket.
// The 0x06 generic Combat slot (colorDarkRed) is passed explicitly
// above — a registered approximation of retail's per-message
// dispatch (register row AP-176), not a ChatLog default.
Assert.Contains(renderer.Calls, c =>
c.Method == "Text" && (string?)c.Args[0] == "Alice says, \"hi\"");
var coloredCall = Assert.Single(
renderer.Calls,
c => c.Method == "TextColored");
Assert.Equal(
"You hit Mosswart for 5 slashing damage (50.0%).",
(string?)coloredCall.Args[1]);
RetailChatColorTable.TryGetColor(0x06u, out var expectedColor);
Assert.Equal(
expectedColor,
(System.Numerics.Vector4)coloredCall.Args[0]!);
}
private sealed class RecordingChatBus : ICommandBus
{
public void Publish<T>(T command) where T : notnull { /* no-op */ }