diff --git a/docs/plans/2026-08-21-chat-text-tag-campaign.md b/docs/plans/2026-08-21-chat-text-tag-campaign.md index cd7ab07c..b8cde931 100644 --- a/docs/plans/2026-08-21-chat-text-tag-campaign.md +++ b/docs/plans/2026-08-21-chat-text-tag-campaign.md @@ -1,6 +1,14 @@ # Campaign CT — complete chat parity (system + GUI) -**Status:** PROPOSED (2026-08-21). Not started. +**Status:** Groups A, B, C and D COMPLETE 2026-08-21, each user-gated. Two +items deliberately not shipped: CT-B3 (word filtering — dropped by user +direction, register row CT-2) and CT-B4 (session chat log — research-blocked). + +**Carried forward:** multi-frame state media. Retail's unseen-text indicator +blinks because its `Normal` state authors SIX image frames; our importer keeps +one file per state, so nothing authored can animate. The indicator is +state-driven and correct today and gains the blink for free once that +capability lands. It is the first known customer, not the only one. **Goal, set by the user 2026-08-21: complete retail parity for the chat system AND the chat GUI.** Not "fix the green name" — that was the symptom @@ -135,16 +143,21 @@ Nothing is user-visible until A4. grey), gated on `PlayerModule::DisplayTimeStamps()`. - **CT-C4** Input-bar editing parity: clipboard and selection paths (Ctrl+C/X/V, shift-selection) work but are untested; `ToggleMaximize` and the - floating-window Close button have zero coverage. + floating-window Close button have zero coverage. **DEFERRED** — pure test + coverage over behaviour the audit confirmed already works, so it changes + nothing a user can see. Worth doing; not worth blocking the campaign on. ### Group D — hygiene -- **CT-D1** Delete the dead ImGui-era `ChatPanel` (never constructed since - Campaign V deleted `AcDream.UI.ImGui`), and its three test files, which - currently make the real input surface look better covered than it is. -- **CT-D2** Reconcile the chat digest and `docs/ISSUES.md`: #358, #362, #363, - #367, #372, #379, #380, #382 are DONE in code but still listed open. #359, - #360, #361, #366 remain genuinely open. +- **CT-D1** ~~Delete the dead ImGui-era `ChatPanel`~~ **DONE.** Verified never + constructed in `src/`, then removed with its three panel-only test files. + `ChatVMCombatTests` was KEPT — three of its four tests are real `ChatVM` + coverage; only the one `ChatPanel` render test went. +- **CT-D2** ~~Reconcile the chat digest and `docs/ISSUES.md`~~ **DONE.** + `docs/ISSUES.md` turned out to be ACCURATE already — #358 and #363 are + recorded CLOSED there. Only the chat digest's "Open" section was stale, and + it is corrected: genuinely open are #359, #360, #361, #366. The digest also + gained a Campaign CT section and three new DO-NOT-RETRY rows. ## Research still owed before the affected slices diff --git a/src/AcDream.Core/Chat/ChatLog.cs b/src/AcDream.Core/Chat/ChatLog.cs index 397377d9..56d86d8f 100644 --- a/src/AcDream.Core/Chat/ChatLog.cs +++ b/src/AcDream.Core/Chat/ChatLog.cs @@ -507,8 +507,9 @@ public readonly record struct ChatEntry( /// /// Phase I.7: severity bucket for - /// entries. Null for every other kind. Drives the - /// 's TextColored color choice. + /// entries. Null for every other kind. Carried through to the transcript + /// on FormattedLine; the per-message colour itself now comes from + /// the retail colour table keyed by LogTextType. /// public Combat.CombatLineKind? CombatKind { get; init; } diff --git a/src/AcDream.Platform/packages.win-x64.lock.json b/src/AcDream.Platform/packages.win-x64.lock.json index c0ad5490..0f43b377 100644 --- a/src/AcDream.Platform/packages.win-x64.lock.json +++ b/src/AcDream.Platform/packages.win-x64.lock.json @@ -1,14 +1,7 @@ { "version": 2, "dependencies": { - "net10.0": { - "Microsoft.NET.ILLink.Tasks": { - "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "dVbSXGIFNR5nZcv2tOLoWI+a9T4jtFd77IYjuND+QVe360qWgAF7H0WtoopYhRw/+SgpGUTyrkrh+65+ClNnfw==" - } - }, + "net10.0": {}, "net10.0/win-x64": {} } } \ No newline at end of file diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs deleted file mode 100644 index 9e6bf097..00000000 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs +++ /dev/null @@ -1,225 +0,0 @@ -using System.Linq; -using System.Numerics; -using AcDream.Core.Chat; -using AcDream.Core.Combat; - -namespace AcDream.UI.Abstractions.Panels.Chat; - -/// -/// The chat panel. Shows the tail of + an input -/// field at the bottom that submits on Enter. -/// -/// -/// Phase I.4 added the input field and slash-command parsing. Supported -/// prefixes (alias-matched against the verb token, not by string-prefix -/// — so /general is NOT /g): -/// -/// /say <msg> or no prefix → Say (default) -/// /t / /tell <name> <msg> → whisper -/// /r / /reply <msg> → reply to most recent -/// INCOMING Tell (uses ; -/// drops the message if no Tell has arrived yet) -/// /g, /f, /a, /m, /p, /v, /cv, /lfg, /trade, /role, /society, -/// /olthoi <msg> → corresponding channel -/// unknown /xyz hello → Say with the literal text intact -/// (matches holtburger fall-through) -/// -/// -/// -/// -/// Empty / whitespace-only / target-but-no-message inputs are silently -/// dropped — the input field clears and no command goes out. -/// -/// -public sealed class ChatPanel : IPanel -{ - private const int InputBufferMaxLen = 512; - - private readonly ChatVM _vm; - private string _input = string.Empty; - - // Phase J Tier 3: tracks the chat-tail size between frames so we - // can auto-scroll the scrollable child to the bottom on new - // entries without yanking the user's manual scroll. - private int _lastRenderedCount; - - // Phase K.2: one-shot focus request for the chat input. Set by - // FocusInput() (driven by Tab → ToggleChatEntry); the next Render - // call emits SetKeyboardFocusHere immediately before the input - // field and clears the flag. Without the one-shot semantics, the - // panel would steal focus on every frame and the user could never - // click into another widget. - private bool _focusRequested; - - // L.0 follow-up: "Copy mode" — when true, render the chat tail as - // a read-only multi-line text widget the user can click+drag to - // select + Ctrl+C to copy. Trades per-line color for selectability; - // user toggles when they want to grab specific text out of the - // log (item names, coordinates, NPC dialogue, etc). - private bool _copyMode; - - public ChatPanel(ChatVM vm) - { - _vm = vm ?? throw new ArgumentNullException(nameof(vm)); - } - - /// - public string Id => "acdream.chat"; - - /// - public string Title => "Chat"; - - /// - public bool IsVisible { get; set; } = true; - - /// - /// Phase K.2: request keyboard focus for the chat input on the - /// NEXT . One-shot — fires once and resets, - /// so callers (e.g. GameWindow's Tab handler subscribing to - /// ToggleChatEntry) can drive it on a single key press - /// without trapping the user permanently in the input field. - /// - public void FocusInput() => _focusRequested = true; - - /// - public void Render(PanelContext ctx, IPanelRenderer renderer) - { - if (!renderer.Begin(Title)) - { - renderer.End(); - return; - } - - // L.0 follow-up: wrap the entire chat panel body in a single - // outer BeginChild so empty-space clicks anywhere in the body - // (Checkbox row, between Separator and input, etc.) are - // absorbed by BeginChild's drag-trap (an InvisibleButton the - // ImGui renderer adds inside every BeginChild). Without this - // wrapper the chat panel was draggable from any empty body - // pixel — only the inner ##chattail area was protected. - if (!renderer.BeginChild("##chatbody", new System.Numerics.Vector2(0f, 0f))) - { - renderer.EndChild(); - renderer.End(); - return; - } - - // L.0 follow-up: top-of-panel "Copy mode" toggle. When on, the - // chat tail rendering swaps to TextMultilineReadOnly so the - // user can mark + Ctrl+C any text. Off (default) preserves the - // colored per-line render with combat highlights. The checkbox - // sits ABOVE the chat tail (not in the footer) so it's always - // visible regardless of scroll position. - bool copyMode = _copyMode; - if (renderer.Checkbox("Copy mode (select text to Ctrl+C)", ref copyMode)) - _copyMode = copyMode; - renderer.Separator(); - - // Phase J Tier 3: keep the input field at the bottom of the - // window across resizes by reserving footer space and putting - // the chat tail in a scrollable child that fills the rest. - // The reserved footer holds: one Separator + one InputText. - // FrameHeightWithSpacing covers the input; we add a small fudge - // (~6px) for the separator above it. - float footerHeight = renderer.FrameHeightWithSpacing() + 6f; - - // Phase I.7: pull the typed-line view so combat entries can - // route through TextColored. Non-combat entries still take - // the plain Text path (visually identical to the I.4 panel). - var lines = _vm.RecentLinesDetailed(); - - if (_copyMode) - { - // Copy mode: one big read-only multiline text widget - // holding every visible line, joined with newlines. Loses - // per-line color but lets the user click+drag to select - // arbitrary spans of text + Ctrl+C to copy. Sized to fill - // the available space minus the footer. - string joined = lines.Count == 0 - ? "(no messages yet)" - : string.Join("\n", lines.Select(l => l.Text)); - renderer.TextMultilineReadOnly( - "##chattailcopy", joined, - new System.Numerics.Vector2(0f, -footerHeight)); - } - else if (renderer.BeginChild("##chattail", new System.Numerics.Vector2(0, -footerHeight))) - { - if (lines.Count == 0) - { - renderer.Text("(no messages yet)"); - } - else - { - for (int i = 0; i < lines.Count; i++) - { - var line = lines[i]; - if (line.Kind == ChatKind.Combat) - { - // Campaign CH slice CH1: color combat lines from the - // retail LogTextType table (Combat_Self/Combat_Enemy/ - // Default per CombatChatTranslator's ACE-cited - // mapping) instead of the CombatLineKind info/ - // warning/error severity bucket. Every LogTextType - // CombatChatTranslator emits is in-range (<0x22), so - // the fallback below is defensive only. - Vector4 color = RetailChatColorTable.TryGetColor(line.LogTextType, out var resolved) - ? resolved - : ColorForCombat(line.CombatKind ?? CombatLineKind.Info); - renderer.TextColored(color, line.Text); - } - else - { - renderer.Text(line.Text); - } - } - } - - // Auto-scroll to bottom only when a new line was appended - // since the last render. Manual user scroll-up isn't fought - // against; new messages will jump the view back down once - // they arrive. - if (lines.Count > _lastRenderedCount) - { - renderer.SetScrollHereY(1.0f); - } - _lastRenderedCount = lines.Count; - } - if (!_copyMode) renderer.EndChild(); - - // Phase I.4: input field. Backend implementation clears _input - // on submit per the IPanelRenderer contract. - renderer.Separator(); - // Phase K.2: honor a pending FocusInput() request — emit - // SetKeyboardFocusHere immediately before the input widget so - // ImGui (or the future custom backend) applies it to that - // field. One-shot: clear the flag after firing. - if (_focusRequested) - { - renderer.SetKeyboardFocusHere(); - _focusRequested = false; - } - if (renderer.InputTextSubmit("##chatinput", ref _input, InputBufferMaxLen, out var submitted) - && submitted is not null) - { - ChatCommandRouter.Submit(submitted, _vm, ctx.Commands, ChatChannelKind.Say); - _input = string.Empty; - } - - renderer.EndChild(); // outer ##chatbody - renderer.End(); - } - - /// - /// Phase I.7: per-severity color for combat-feedback chat lines. - /// Maps onto holtburger's color_for_tags at chat.rs:330-333 - /// (info → yellowish, warning → red incoming, error → deep red). - /// - public static Vector4 ColorForCombat(CombatLineKind kind) => kind switch - { - CombatLineKind.Info => new Vector4(1.0f, 1.0f, 0.6f, 1.0f), - CombatLineKind.Warning => new Vector4(1.0f, 0.5f, 0.5f, 1.0f), - CombatLineKind.Error => new Vector4(1.0f, 0.3f, 0.3f, 1.0f), - _ => new Vector4(1f, 1f, 1f, 1f), - }; - -} diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs index 30081845..ee476db5 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs @@ -393,10 +393,9 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback : entry.ChannelName; /// - /// Phase I.7: snapshot of the chat tail with kind metadata so - /// can pick the right rendering primitive - /// per entry (plain Text for most kinds; TextColored - /// for combat lines, with the rgba chosen from + /// Phase I.7: snapshot of the chat tail with kind metadata so the + /// transcript can pick the right rendering per entry (most kinds take one + /// colour; combat lines take the rgba chosen from /// ). Campaign CH slice CH1 also /// carries through — the retail /// color key, keyed independently of . @@ -458,10 +457,8 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback } /// -/// Phase I.7: formatted chat line with kind metadata. The -/// switches on + -/// to pick a rendering primitive -/// (Text vs TextColored(rgba)). +/// Phase I.7: formatted chat line with kind metadata. The transcript switches +/// on + to pick a colour. /// /// /// Campaign CH slice CH1: the retail wire LogTextType that keys diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelFocusTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelFocusTests.cs deleted file mode 100644 index 95f7df1a..00000000 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelFocusTests.cs +++ /dev/null @@ -1,71 +0,0 @@ -using AcDream.Core.Chat; -using AcDream.UI.Abstractions.Panels.Chat; - -namespace AcDream.UI.Abstractions.Tests.Panels.Chat; - -/// -/// Phase K.2 — Tab fires , -/// which calls . The chat panel honors -/// the request on the very next by emitting -/// a SetKeyboardFocusHere immediately before the input field. After -/// it fires once, subsequent renders without another FocusInput -/// 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. -/// -public sealed class ChatPanelFocusTests -{ - private sealed class NullBus : AcDream.Runtime.Chat.ICommandBus - { - public void Publish(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"); - } -} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs deleted file mode 100644 index 77aac3f7..00000000 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs +++ /dev/null @@ -1,358 +0,0 @@ -using AcDream.Core.Chat; -using AcDream.UI.Abstractions.Panels.Chat; - -namespace AcDream.UI.Abstractions.Tests.Panels.Chat; - -/// -/// 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 -/// pre-loading a "submitted" string and a recording bus capturing the -/// resulting command. -/// -public sealed class ChatPanelInputTests -{ - private sealed class RecordingBus : ICommandBus - { - public List Published { get; } = new(); - public void Publish(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(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(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(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(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(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(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(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(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(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"); - } -} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs deleted file mode 100644 index 36904453..00000000 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs +++ /dev/null @@ -1,129 +0,0 @@ -using AcDream.Core.Chat; -using AcDream.UI.Abstractions.Panels.Chat; - -namespace AcDream.UI.Abstractions.Tests.Panels.Chat; - -/// -/// Phase J Tier 3: 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 -/// (0, -footerHeight), then the separator + input below it. -/// -public sealed class ChatPanelLayoutTests -{ - private sealed class NoBus : ICommandBus - { - public void Publish(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"); - } -} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMCombatTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMCombatTests.cs index 42bd5df4..6e544dd5 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMCombatTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMCombatTests.cs @@ -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 command) where T : notnull { /* no-op */ }