diff --git a/HellionChat/GameFunctions/ChatBox.cs b/HellionChat/GameFunctions/ChatBox.cs index 1730e56..f70536c 100644 --- a/HellionChat/GameFunctions/ChatBox.cs +++ b/HellionChat/GameFunctions/ChatBox.cs @@ -16,6 +16,18 @@ public unsafe class ChatBox } public static void SendMessage(string message) + { + var bytes = ValidateMessage(message); + SendMessageUnsafe(bytes); + } + + // Validation split out so the deterministic checks (UTF-8 length, sanitise + // round-trip) can run in xUnit without ClientStructs game memory. The + // sanitiser is injectable so tests can pin throw behaviour without invoking + // Utf8String->SanitizeString, which only resolves in-process. Returns the + // already-encoded bytes so SendMessage doesn't pay GetBytes twice. + // TEST-MIRROR: ../../../Hellion Build test/GameFunctions/ChatBoxTests.cs + internal static byte[] ValidateMessage(string message, Func? sanitiserOverride = null) { var bytes = Encoding.UTF8.GetBytes(message); if (bytes.Length == 0) @@ -24,10 +36,11 @@ public unsafe class ChatBox if (bytes.Length > 500) throw new ArgumentException(Language.ChatBox_Error_Too_Long, nameof(message)); - if (message.Length != SanitiseText(message).Length) + var sanitiser = sanitiserOverride ?? SanitiseText; + if (message.Length != sanitiser(message).Length) throw new ArgumentException(Language.ChatBox_Error_Invalid, nameof(message)); - SendMessageUnsafe(bytes); + return bytes; } private static string SanitiseText(string text) diff --git a/HellionChat/Ui/ChatInputBar.cs b/HellionChat/Ui/ChatInputBar.cs index f1280a9..02e660a 100644 --- a/HellionChat/Ui/ChatInputBar.cs +++ b/HellionChat/Ui/ChatInputBar.cs @@ -1,5 +1,6 @@ using System; using System.Numerics; +using HellionChat._Helpers; using HellionChat.Code; using HellionChat.Util; using Dalamud.Bindings.ImGui; @@ -89,60 +90,42 @@ public sealed class ChatInputBar } } - private void SubmitCompact(Tab tab) - { - if (string.IsNullOrWhiteSpace(_state.Buffer)) - return; + // TEST-MIRROR: ../_Helpers/CompactInputSubmitter.cs + private void SubmitCompact(Tab tab) => + CompactInputSubmitter.TrySubmit(_state, tab, _host.SendChatBoxFromExternal); - var text = _state.Buffer; - _state.Buffer = string.Empty; - _state.HistoryCursor = -1; - _host.SendChatBoxFromExternal(tab, text); - } - - // History-navigation callback for the compact input. Mirrors the main - // window's logic but operates on _state.HistoryCursor and the shared - // InputHistoryService. Index semantics match v0.5.x InputBacklog: - // 0 = oldest, Count-1 = newest. + // History-navigation callback for the compact input. Cursor math is + // delegated to CompactInputHistoryNavigator; only the ImGui buffer + // splice stays here because it needs the live callback data. + // TEST-MIRROR: ../_Helpers/CompactInputHistoryNavigator.cs private int CompactCallback(scoped ref ImGuiInputTextCallbackData data) { if (data.EventFlag != ImGuiInputTextFlags.CallbackHistory) return 0; - var prev = _state.HistoryCursor; - switch (data.EventKey) + var direction = data.EventKey switch { - case ImGuiKey.UpArrow: - switch (_state.HistoryCursor) - { - case -1: - var offset = 0; - if (!string.IsNullOrWhiteSpace(_state.Buffer)) - { - InputHistoryService.Push(_state.Buffer); - offset = 1; - } - _state.HistoryCursor = InputHistoryService.Count - 1 - offset; - break; - case > 0: - _state.HistoryCursor--; - break; - } - break; - case ImGuiKey.DownArrow: - if (_state.HistoryCursor != -1) - if (++_state.HistoryCursor >= InputHistoryService.Count) - _state.HistoryCursor = -1; - break; - } - - if (prev == _state.HistoryCursor) + ImGuiKey.UpArrow => CompactInputHistoryNavigator.Direction.Up, + ImGuiKey.DownArrow => CompactInputHistoryNavigator.Direction.Down, + _ => (CompactInputHistoryNavigator.Direction?)null, + }; + if (direction is null) return 0; - var historyStr = InputHistoryService.GetByCursor(_state.HistoryCursor) ?? string.Empty; - data.DeleteChars(0, data.BufTextLen); - data.InsertChars(0, historyStr); + var (cursor, replacement) = CompactInputHistoryNavigator.Navigate( + direction.Value, + _state.HistoryCursor, + _state.Buffer, + () => InputHistoryService.Count, + InputHistoryService.Push, + InputHistoryService.GetByCursor); + _state.HistoryCursor = cursor; + if (replacement is null) + return 0; + + data.DeleteChars(0, data.BufTextLen); + data.InsertChars(0, replacement); return 0; } diff --git a/HellionChat/_Helpers/CompactInputHistoryNavigator.cs b/HellionChat/_Helpers/CompactInputHistoryNavigator.cs new file mode 100644 index 0000000..5af3222 --- /dev/null +++ b/HellionChat/_Helpers/CompactInputHistoryNavigator.cs @@ -0,0 +1,75 @@ +using System; + +namespace HellionChat._Helpers; + +// Pure-helper mirror of the compact pop-out history-navigation cursor +// math. The original CompactCallback was tangled with ImGuiInputTextCallbackData +// (DeleteChars/InsertChars), which can't be exercised in xUnit. The +// ImGui buffer mutation stays at the call site; only the deterministic +// cursor-and-replacement decision lives here. +// +// Index semantics match InputHistoryService: +// index 0 = oldest entry +// index Count - 1 = newest entry +// cursor == -1 = "not browsing history" +// +// TEST-MIRROR: ../../../Hellion Build test/Ui/CompactInputHistoryNavigatorTests.cs +public static class CompactInputHistoryNavigator +{ + public enum Direction { Up, Down } + + // replacement == null means: caller must NOT touch the buffer. This + // distinguishes "cursor unchanged, leave the user's typing alone" + // from "cursor moved to an empty slot, clear the buffer". + public static (int cursor, string? replacement) Navigate( + Direction direction, + int currentCursor, + string currentBuffer, + Func getCount, + Action push, + Func getByCursor) + { + ArgumentNullException.ThrowIfNull(getCount); + ArgumentNullException.ThrowIfNull(push); + ArgumentNullException.ThrowIfNull(getByCursor); + + var prev = currentCursor; + var next = currentCursor; + + switch (direction) + { + case Direction.Up: + if (currentCursor == -1) + { + // First Up press from a fresh buffer: stash whatever + // the user typed so they can recover it after browsing. + var offset = 0; + if (!string.IsNullOrWhiteSpace(currentBuffer)) + { + push(currentBuffer); + offset = 1; + } + next = getCount() - 1 - offset; + } + else if (currentCursor > 0) + { + next--; + } + break; + case Direction.Down: + if (currentCursor != -1) + { + next++; + if (next >= getCount()) + next = -1; + } + break; + } + + if (prev == next) + return (next, null); + + var replacement = getByCursor(next) ?? string.Empty; + return (next, replacement); + } +} diff --git a/HellionChat/_Helpers/CompactInputSubmitter.cs b/HellionChat/_Helpers/CompactInputSubmitter.cs new file mode 100644 index 0000000..7f1202b --- /dev/null +++ b/HellionChat/_Helpers/CompactInputSubmitter.cs @@ -0,0 +1,29 @@ +using System; +using HellionChat.Ui; + +namespace HellionChat._Helpers; + +// Pure-helper mirror of the compact pop-out submit flow. ChatInputBar's +// SubmitCompact used to inline this against a sealed ChatLogWindow, which +// blocks Moq-based isolation. Lifting the deterministic part into a POCO +// keeps the production call site a one-liner while letting xUnit assert +// the buffer/cursor reset and the sender contract directly. +// TEST-MIRROR: ../../../Hellion Build test/Ui/CompactInputSubmitterTests.cs +public static class CompactInputSubmitter +{ + public static bool TrySubmit(InputState state, Tab tab, Action sender) + { + ArgumentNullException.ThrowIfNull(state); + ArgumentNullException.ThrowIfNull(tab); + ArgumentNullException.ThrowIfNull(sender); + + if (string.IsNullOrWhiteSpace(state.Buffer)) + return false; + + var text = state.Buffer; + state.Buffer = string.Empty; + state.HistoryCursor = -1; + sender(tab, text); + return true; + } +}