| 1 | import XCTest |
| 2 | @testable import LChatCore |
| 3 | |
| 4 | // π€ Cross-platform contract tests for reply-thread grouping β the native side |
| 5 | // of test/event-vectors/chat.json's `reply_thread_grouping` + |
| 6 | // `reply_in_thread_targets_root` vectors (web twin: test/event-vectors.test.mjs |
| 7 | // against chat.js's viewItems/replyTarget). |
| 8 | // |
| 9 | // The contract: reply threads group on the resolved ROOT message. New replies |
| 10 | // point straight at the root; a legacy chain (a reply quoting another reply) |
| 11 | // resolves transitively through loaded messages. Replying to ANY message in a |
| 12 | // thread stages the root β never the tapped reply. |
| 13 | final class ReplyThreadVectorTests: XCTestCase { |
| 14 | typealias Message = ChatFeedModel.Message |
| 15 | |
| 16 | // The reply_thread_grouping vector's message_sequence, folded. |
| 17 | private func vectorMessages() -> [Message] { |
| 18 | let alice = MessagePayload(type: nil, uid: "user-alice-1", name: "Alice", |
| 19 | text: "Hello everyone", ts: 1_700_000_000_000, |
| 20 | action: nil, attachments: nil, replyTo: nil) |
| 21 | return [ |
| 22 | Message(id: 100, name: "Alice", text: "Hello everyone", mine: false, |
| 23 | at: 1_700_000_000, edited: false, raw: alice), |
| 24 | Message(id: 101, name: "Bob", text: "Hi Alice", mine: false, |
| 25 | at: 1_700_000_001, edited: false, |
| 26 | reply: ReplyRef(id: 100, name: "Alice", snippet: "Hello everyone")), |
| 27 | // Legacy chain: quotes reply 101, not the root β must still resolve to 100. |
| 28 | Message(id: 102, name: "Charlie", text: "Welcome!", mine: false, |
| 29 | at: 1_700_000_002, edited: false, |
| 30 | reply: ReplyRef(id: 101, name: "Bob", snippet: "Hi Alice")), |
| 31 | Message(id: 103, name: "Dana", text: "Good to be here", mine: false, |
| 32 | at: 1_700_000_003, edited: false, |
| 33 | reply: ReplyRef(id: 100, name: "Alice", snippet: "Hello everyone")), |
| 34 | ] |
| 35 | } |
| 36 | |
| 37 | func testThreadGrouping() { |
| 38 | var list = vectorMessages() |
| 39 | ChatFeedModel.refreshReplyCounts(&list) |
| 40 | |
| 41 | // expected_threads.roots: every reply resolves to root 100. |
| 42 | XCTAssertEqual(list.first { $0.id == 101 }?.threadRootID, 100) |
| 43 | XCTAssertEqual(list.first { $0.id == 102 }?.threadRootID, 100, "legacy chain resolves transitively") |
| 44 | XCTAssertEqual(list.first { $0.id == 103 }?.threadRootID, 100) |
| 45 | XCTAssertNil(list.first { $0.id == 100 }?.threadRootID, "the root is not itself a reply") |
| 46 | |
| 47 | // expected_threads.reply_counts + first_reply. |
| 48 | XCTAssertEqual(list.first { $0.id == 100 }?.replyCount, 3, "the root tallies its whole thread") |
| 49 | XCTAssertEqual(list.first { $0.id == 100 }?.firstReplyID, 101) |
| 50 | XCTAssertEqual(list.first { $0.id == 101 }?.replyCount, 0, "a mid-thread reply never tallies") |
| 51 | } |
| 52 | |
| 53 | func testReplyTargetStagesRoot() { |
| 54 | let list = vectorMessages() |
| 55 | |
| 56 | // expected_reply_target: replying to the legacy chain reply 102 stages the |
| 57 | // root's id with a fresh snapshot of the root's name/text. |
| 58 | let target = ChatFeedModel.replyTarget(in: list, for: list.first { $0.id == 102 }!) |
| 59 | XCTAssertEqual(target.id, 100) |
| 60 | XCTAssertEqual(target.name, "Alice") |
| 61 | XCTAssertEqual(target.snippet, "Hello everyone") |
| 62 | |
| 63 | // Replying to a direct reply also stages the root. |
| 64 | let fromDirect = ChatFeedModel.replyTarget(in: list, for: list.first { $0.id == 103 }!) |
| 65 | XCTAssertEqual(fromDirect.id, 100) |
| 66 | |
| 67 | // Replying to a non-reply targets that message itself (it becomes the root). |
| 68 | let fresh = ChatFeedModel.replyTarget(in: list, for: list.first { $0.id == 100 }!) |
| 69 | XCTAssertEqual(fresh.id, 100) |
| 70 | XCTAssertEqual(fresh.name, "Alice") |
| 71 | XCTAssertEqual(fresh.snippet, "Hello everyone") |
| 72 | } |
| 73 | |
| 74 | func testUnloadedRootFallsBackToQuotedRef() { |
| 75 | // The parent scrolled out of the loaded window: keep the deepest pointer we |
| 76 | // can see (the quoted ref's frozen snapshot) rather than inventing one. |
| 77 | let orphan = Message(id: 200, name: "Eve", text: "Late reply", mine: false, |
| 78 | at: 1_700_000_010, edited: false, |
| 79 | reply: ReplyRef(id: 42, name: "Old Root", snippet: "long ago")) |
| 80 | var list = [orphan] |
| 81 | ChatFeedModel.refreshReplyCounts(&list) |
| 82 | XCTAssertEqual(list[0].threadRootID, 42, "an unloaded parent is still the best-known root") |
| 83 | |
| 84 | let target = ChatFeedModel.replyTarget(in: list, for: list[0]) |
| 85 | XCTAssertEqual(target.id, 42) |
| 86 | XCTAssertEqual(target.name, "Old Root") |
| 87 | XCTAssertEqual(target.snippet, "long ago") |
| 88 | } |
| 89 | |
| 90 | // threadItems β the thread view's selector; web twin: test/apps.test.mjs |
| 91 | // "threadItems selects a root's whole thread β legacy chains resolve into it". |
| 92 | private func threadItemsFixture() -> [Message] { |
| 93 | [ |
| 94 | Message(id: 1, name: "Al", text: "root", mine: false, at: 1, edited: false), |
| 95 | Message(id: 2, name: "Bo", text: "aside", mine: false, at: 2, edited: false), |
| 96 | Message(id: 3, name: "Cy", text: "re", mine: false, at: 3, edited: false, |
| 97 | reply: ReplyRef(id: 1, name: "Al", snippet: "root")), |
| 98 | // A legacy chain: this reply quotes the REPLY (3), not the root β it |
| 99 | // must still resolve transitively into 1's thread. |
| 100 | Message(id: 4, name: "Al", text: "re-re", mine: false, at: 4, edited: false, |
| 101 | reply: ReplyRef(id: 3, name: "Cy", snippet: "re")), |
| 102 | Message(id: 5, name: "Bo", text: "other thread", mine: false, at: 5, edited: false, |
| 103 | reply: ReplyRef(id: 2, name: "Bo", snippet: "aside")), |
| 104 | ] |
| 105 | } |
| 106 | |
| 107 | func testThreadItemsSelectsWholeThread() { |
| 108 | let msgs = threadItemsFixture() |
| 109 | XCTAssertEqual(ChatFeedModel.threadItems(in: msgs, root: 1).map(\.id), [1, 3, 4], |
| 110 | "root first, then its thread in server order; the reply-to-a-reply resolves in; asides and other threads stay out") |
| 111 | XCTAssertEqual(ChatFeedModel.threadItems(in: msgs, root: 2).map(\.id), [2, 5], |
| 112 | "the other thread folds on its own root") |
| 113 | } |
| 114 | |
| 115 | func testThreadItemsWithUnloadedRoot() { |
| 116 | // Root scrolled out of the loaded window: the thread still folds from the |
| 117 | // replies we can see (the view renders the quoted snapshot as its header). |
| 118 | let msgs = Array(threadItemsFixture().dropFirst(2)) |
| 119 | XCTAssertEqual(ChatFeedModel.threadItems(in: msgs, root: 1).map(\.id), [3, 4], |
| 120 | "unloaded root β replies only, chain intact") |
| 121 | XCTAssertEqual(ChatFeedModel.threadItems(in: threadItemsFixture(), root: 99).map(\.id), [], |
| 122 | "an unknown root has no thread") |
| 123 | } |
| 124 | |
| 125 | func testThreadReplyRefSnapshotsLoadedRootAndFallsBack() { |
| 126 | let msgs = threadItemsFixture() |
| 127 | // Loaded root: a fresh snapshot of its name/text (what the composer stages |
| 128 | // while the thread view is open). |
| 129 | var raw = MessagePayload() |
| 130 | raw.name = "Al" |
| 131 | raw.text = "root" |
| 132 | var withRaw = msgs |
| 133 | withRaw[0].raw = raw |
| 134 | let staged = ChatFeedModel.threadReplyRef(in: withRaw, root: 1) |
| 135 | XCTAssertEqual(staged.id, 1) |
| 136 | XCTAssertEqual(staged.name, "Al") |
| 137 | XCTAssertEqual(staged.snippet, "root") |
| 138 | |
| 139 | // Unloaded root: the deepest quoted ref a loaded thread member carries |
| 140 | // stands in (the web's threadFallback via replyTarget). |
| 141 | let partial = Array(msgs.dropFirst(2)) |
| 142 | let fallback = ChatFeedModel.threadReplyRef(in: partial, root: 1) |
| 143 | XCTAssertEqual(fallback.id, 1) |
| 144 | XCTAssertEqual(fallback.name, "Al") |
| 145 | XCTAssertEqual(fallback.snippet, "root") |
| 146 | |
| 147 | // Nothing of the thread loaded at all: keep at least the root id, so the |
| 148 | // staged reply still continues the thread. |
| 149 | let bare = ChatFeedModel.threadReplyRef(in: [], root: 7) |
| 150 | XCTAssertEqual(bare.id, 7) |
| 151 | } |
| 152 | |
| 153 | func testReplyCycleIsHopCapped() { |
| 154 | // A hostile payload could quote in a cycle β the walk must terminate. |
| 155 | var list = [ |
| 156 | Message(id: 1, name: "A", text: "a", mine: false, at: 1, edited: false, |
| 157 | reply: ReplyRef(id: 2, name: "B", snippet: "b")), |
| 158 | Message(id: 2, name: "B", text: "b", mine: false, at: 2, edited: false, |
| 159 | reply: ReplyRef(id: 1, name: "A", snippet: "a")), |
| 160 | ] |
| 161 | ChatFeedModel.refreshReplyCounts(&list) // must not hang |
| 162 | XCTAssertNotNil(list[0].threadRootID) |
| 163 | } |
| 164 | } |
| 165 | |