diff --git a/ChatTwo.Tests/AutoTellTabsHistoryTest.cs b/ChatTwo.Tests/AutoTellTabsHistoryTest.cs new file mode 100644 index 0000000..0c47836 --- /dev/null +++ b/ChatTwo.Tests/AutoTellTabsHistoryTest.cs @@ -0,0 +1,167 @@ +using System; +using System.IO; +using System.Linq; +using ChatTwo.Code; +using ChatTwo.Util; +using Dalamud.Game.Text; +using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Game.Text.SeStringHandling.Payloads; +using JetBrains.Annotations; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ChatTwo.Tests; + +// Hellion Chat — Auto-Tell-Tabs history-preload coverage. +// +// These tests exercise MessageStore.GetTellHistoryWithSender, the query the +// AutoTellTabsService uses to populate a freshly spawned temp tab with the +// last conversations with that player. +// +// NOTE: like the rest of ChatTwo.Tests today, these will fail at runtime +// until the project's Dalamud.dll runtime dependency is sorted out (see +// Phase-2 backlog item "Test-Projekt fixen"). Compile-time the suite builds +// fine via DALAMUD_HOME, so the tests guard against API drift even before +// they can be executed locally. +[TestClass] +[TestSubject(typeof(MessageStore))] +public class AutoTellTabsHistoryTest +{ + public TestContext TestContext { get; set; } + + [TestMethod] + [Timeout(5000)] + public void GetTellHistoryWithSender_FiltersByNameAndWorld() + { + var tempDir = Directory.CreateTempSubdirectory("ChatTwo_test_"); + var dbPath = Path.Join(tempDir.FullName, "test.db"); + TestContext.WriteLine("Using database path: " + dbPath); + using var store = new MessageStore(dbPath); + + const ulong receiver = 99001; + var now = DateTimeOffset.UtcNow; + + // Two tells with the target sender, one with a different sender on + // the same world, one with the same name on a different world. Only + // the first two should make it into the result. + var asukaLichIn = TellMessage("Asuka", 76, receiver, now.AddMinutes(-30), ChatType.TellIncoming); + var asukaLichOut = TellMessage("Asuka", 76, receiver, now.AddMinutes(-20), ChatType.TellOutgoing); + var broboLich = TellMessage("Brobo", 76, receiver, now.AddMinutes(-10), ChatType.TellIncoming); + var asukaOmega = TellMessage("Asuka", 90, receiver, now.AddMinutes(-5), ChatType.TellIncoming); + + store.UpsertMessage(asukaLichIn); + store.UpsertMessage(asukaLichOut); + store.UpsertMessage(broboLich); + store.UpsertMessage(asukaOmega); + + var result = store.GetTellHistoryWithSender(receiver, "Asuka", 76, limit: 50); + + Assert.AreEqual(2, result.Count); + // Result is oldest-first so a tab can append messages chronologically. + Assert.AreEqual(asukaLichIn.Id, result[0].Id); + Assert.AreEqual(asukaLichOut.Id, result[1].Id); + } + + [TestMethod] + [Timeout(5000)] + public void GetTellHistoryWithSender_RespectsLimit() + { + var tempDir = Directory.CreateTempSubdirectory("ChatTwo_test_"); + var dbPath = Path.Join(tempDir.FullName, "test.db"); + TestContext.WriteLine("Using database path: " + dbPath); + using var store = new MessageStore(dbPath); + + const ulong receiver = 99002; + var now = DateTimeOffset.UtcNow; + + for (var i = 0; i < 30; i++) + { + var msg = TellMessage("Asuka", 76, receiver, now.AddMinutes(-i - 1), ChatType.TellIncoming); + store.UpsertMessage(msg); + } + + var result = store.GetTellHistoryWithSender(receiver, "Asuka", 76, limit: 5); + + Assert.AreEqual(5, result.Count); + } + + [TestMethod] + [Timeout(5000)] + public void GetTellHistoryWithSender_ZeroLimitReturnsEmpty() + { + var tempDir = Directory.CreateTempSubdirectory("ChatTwo_test_"); + var dbPath = Path.Join(tempDir.FullName, "test.db"); + TestContext.WriteLine("Using database path: " + dbPath); + using var store = new MessageStore(dbPath); + + const ulong receiver = 99003; + + var msg = TellMessage("Asuka", 76, receiver, DateTimeOffset.UtcNow, ChatType.TellIncoming); + store.UpsertMessage(msg); + + var result = store.GetTellHistoryWithSender(receiver, "Asuka", 76, limit: 0); + + Assert.AreEqual(0, result.Count); + } + + [TestMethod] + [Timeout(5000)] + public void GetTellHistoryWithSender_IgnoresOtherReceivers() + { + var tempDir = Directory.CreateTempSubdirectory("ChatTwo_test_"); + var dbPath = Path.Join(tempDir.FullName, "test.db"); + TestContext.WriteLine("Using database path: " + dbPath); + using var store = new MessageStore(dbPath); + + const ulong ourReceiver = 99004; + const ulong otherReceiver = 99005; + var now = DateTimeOffset.UtcNow; + + // Tell on the local player's account. + var ours = TellMessage("Asuka", 76, ourReceiver, now.AddMinutes(-1), ChatType.TellIncoming); + // Same sender, but logged against a different local character — + // common when the user has alts. Must not surface. + var foreign = TellMessage("Asuka", 76, otherReceiver, now, ChatType.TellIncoming); + + store.UpsertMessage(ours); + store.UpsertMessage(foreign); + + var result = store.GetTellHistoryWithSender(ourReceiver, "Asuka", 76, limit: 50); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual(ours.Id, result[0].Id); + } + + private static Message TellMessage( + string senderName, + uint senderWorld, + ulong receiver, + DateTimeOffset dateTime, + ChatType chatType) + { + var senderSeString = new SeStringBuilder() + .Add(new PlayerPayload(senderName, senderWorld)) + .AddText(senderName) + .Add(RawPayload.LinkTerminator) + .Build(); + + var contentSeString = new SeStringBuilder() + .AddText("test message") + .Build(); + + var senderChunks = ChunkUtil.ToChunks(senderSeString, ChunkSource.Sender, chatType).ToList(); + var contentChunks = ChunkUtil.ToChunks(contentSeString, ChunkSource.Content, chatType).ToList(); + + var chatCode = new ChatCode((XivChatType)chatType, XivChatRelationKind.LocalPlayer, XivChatRelationKind.LocalPlayer); + return new Message( + Guid.NewGuid(), + receiver, + 0, + dateTime, + chatCode, + senderChunks, + contentChunks, + senderSeString, + contentSeString, + Guid.Empty); + } +} diff --git a/ChatTwo/Configuration.cs b/ChatTwo/Configuration.cs index f10c3f0..e783164 100755 --- a/ChatTwo/Configuration.cs +++ b/ChatTwo/Configuration.cs @@ -361,41 +361,12 @@ public class Tab // here, otherwise all temp tabs would mirror "Tell Exclusive". if (IsTempTab && TellTarget?.IsSet() == true) { - return MatchesTempTabSender(message); + return ChunkUtil.MatchesSender(message, TellTarget.Name, TellTarget.World); } return true; } - private bool MatchesTempTabSender(Message message) - { - var senderPayload = ExtractPlayerPayload(message.Sender); - if (senderPayload == null) - { - senderPayload = ExtractPlayerPayload(message.Content); - } - if (senderPayload == null) - { - return false; - } - - var nameMatches = string.Equals(senderPayload.PlayerName, TellTarget.Name, StringComparison.OrdinalIgnoreCase); - var worldMatches = senderPayload.World.RowId == TellTarget.World; - return nameMatches && worldMatches; - } - - private static PlayerPayload? ExtractPlayerPayload(IReadOnlyList chunks) - { - foreach (var chunk in chunks) - { - if (chunk.Link is PlayerPayload pp) - { - return pp; - } - } - return null; - } - public void AddMessage(Message message, bool unread = true) { Messages.AddPrune(message, MessageManager.MessageDisplayLimit); diff --git a/ChatTwo/MessageStore.cs b/ChatTwo/MessageStore.cs index 3dee96c..5f6b565 100644 --- a/ChatTwo/MessageStore.cs +++ b/ChatTwo/MessageStore.cs @@ -602,6 +602,84 @@ internal class MessageStore : IDisposable return new MessageEnumerator(cmd.ExecuteReader()); } + /// + /// Hellion Chat — Auto-Tell-Tabs history preload. + /// + /// Returns up to tells exchanged with the named + /// player, oldest-first, ready to be added to a freshly spawned auto + /// tell tab. The Sender column is a serialized chunk blob, so SQL on its + /// own cannot filter by player identity; we narrow with SQL on Receiver + /// + ChatType (cheap, indexed) and let the client do the final + /// PlayerPayload comparison on the result set. + /// + /// caps how many recent tells we scan + /// before giving up. 500 covers around 10 days for an active greeter + /// and stays well under the 20 ms budget required to keep the spawn on + /// the message-processing worker thread. + /// + internal IReadOnlyList GetTellHistoryWithSender( + ulong receiver, + string senderName, + uint senderWorld, + int limit, + int sqlScanLimit = 500) + { + if (limit <= 0) + { + return []; + } + + using var cmd = Connection.CreateCommand(); + cmd.CommandText = @" + SELECT + Id, + Receiver, + ContentId, + Date, + ChatType, + SourceKind, + TargetKind, + Sender, + Content, + SenderSource, + ContentSource, + ExtraChatChannel + FROM messages + WHERE deleted = false + AND Receiver = $Receiver + AND ChatType IN ($TellIncoming, $TellOutgoing) + ORDER BY Date DESC + LIMIT $ScanLimit; + "; + cmd.CommandTimeout = 60; + cmd.Parameters.AddWithValue("$Receiver", receiver); + cmd.Parameters.AddWithValue("$TellIncoming", (int)ChatType.TellIncoming); + cmd.Parameters.AddWithValue("$TellOutgoing", (int)ChatType.TellOutgoing); + cmd.Parameters.AddWithValue("$ScanLimit", sqlScanLimit); + + var collected = new List(); + using var enumerator = new MessageEnumerator(cmd.ExecuteReader()); + foreach (var message in enumerator) + { + if (!ChunkUtil.MatchesSender(message, senderName, senderWorld)) + { + continue; + } + + collected.Add(message); + if (collected.Count >= limit) + { + break; + } + } + + // SQL was DESC (newest-first) so we hit the limit on the most + // recent matching tells. Reverse to oldest-first for chronological + // display in the tab. + collected.Reverse(); + return collected; + } + /// /// Marks a message as deleted so it won't get returned in queries. /// diff --git a/ChatTwo/Util/ChunkUtil.cs b/ChatTwo/Util/ChunkUtil.cs index 6f4728c..48702e7 100755 --- a/ChatTwo/Util/ChunkUtil.cs +++ b/ChatTwo/Util/ChunkUtil.cs @@ -398,6 +398,40 @@ internal static class ChunkUtil return builder.ToString(); } + // Hellion Chat — shared helper for Auto-Tell-Tabs and the MessageStore + // history-preload query. Walks the chunk list once and returns the + // first PlayerPayload it finds, or null when the message has no + // resolved player link (e.g. system messages, GM tells we already + // skipped earlier in the pipeline). + internal static PlayerPayload? TryGetPlayerPayload(IReadOnlyList chunks) + { + foreach (var chunk in chunks) + { + if (chunk.Link is PlayerPayload pp) + { + return pp; + } + } + return null; + } + + // True when the message's sender (or, as a fallback, content) carries a + // PlayerPayload that matches the given identity. Used by both the + // Tab.Matches sender filter and the MessageStore tell-history scan. + internal static bool MatchesSender(Message message, string senderName, uint senderWorld) + { + var payload = TryGetPlayerPayload(message.Sender) ?? TryGetPlayerPayload(message.Content); + if (payload == null) + { + return false; + } + if (!string.Equals(payload.PlayerName, senderName, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + return payload.World.RowId == senderWorld; + } + internal static readonly RawPayload PeriodicRecruitmentLink = new([0x02, 0x27, 0x07, 0x08, 0x01, 0x01, 0x01, 0xFF, 0x01, 0x03]); private static uint GetInteger(BinaryReader input)