eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1import 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.
13final 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