eleven / ocho Public

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

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 EVERY sequence vector in test/event-vectors/chat.json that carries
6// `expected_threads` (today: `reply_thread_grouping` and
7// `inline_reply_chain_is_not_a_thread`), plus `thread_reply_root_unloaded`.
8// Web twin: test/event-vectors.test.mjs against chat.js's
9// viewItems/quoteTarget/threadTarget/threadItems.
10//
11// The vector list is DERIVED, never hardcoded: a new vector with thread
12// expectations must be checked on this side too, and a hardcoded list would
13// let it pass on web while silently skipping natively.
14//
15// The contract: inline replies and threads are SEPARATE. A thread is its root
16// plus the replies FLAGGED into it (replyTo.thread, pointing straight at the
17// root) β€” nothing else. Plain quote replies, including a chain of them, stay
18// inline in the feed, belong to no thread, and move no tally. A thread exists
19// only because someone chose "Reply in Thread".
20final class ReplyThreadVectorTests: XCTestCase {
21 typealias Message = ChatFeedModel.Message
22
23 // The reply_thread_grouping vector's message_sequence, folded. 101 and 103
24 // are ordinary quote replies to root 100; 102 quotes reply 101 (a chain of
25 // inline quotes); only 104 was made IN the thread.
26 private func vectorMessages() -> [Message] {
27 func raw(_ name: String, _ text: String) -> MessagePayload {
28 var p = MessagePayload()
29 p.name = name
30 p.text = text
31 return p
32 }
33 return [
34 Message(id: 100, name: "Alice", text: "Hello everyone", mine: false,
35 at: 1_700_000_000, edited: false, raw: raw("Alice", "Hello everyone")),
36 Message(id: 101, name: "Bob", text: "Hi Alice", mine: false,
37 at: 1_700_000_001, edited: false,
38 reply: ReplyRef(id: 100, name: "Alice", snippet: "Hello everyone"),
39 raw: raw("Bob", "Hi Alice")),
40 // A chain of inline quotes: this one quotes reply 101, not the root. It
41 // is NOT in 100's thread β€” it isn't in any thread.
42 Message(id: 102, name: "Charlie", text: "Welcome!", mine: false,
43 at: 1_700_000_002, edited: false,
44 reply: ReplyRef(id: 101, name: "Bob", snippet: "Hi Alice"),
45 raw: raw("Charlie", "Welcome!")),
46 Message(id: 103, name: "Dana", text: "Good to be here", mine: false,
47 at: 1_700_000_003, edited: false,
48 reply: ReplyRef(id: 100, name: "Alice", snippet: "Hello everyone"),
49 raw: raw("Dana", "Good to be here")),
50 // Sent IN the thread (replyTo.thread): the only member of 100's thread β€”
51 // it alone tallies, and the main feed leaves it out.
52 Message(id: 104, name: "Erin", text: "Replying from inside the thread", mine: false,
53 at: 1_700_000_004, edited: false,
54 reply: ReplyRef(id: 100, name: "Alice", snippet: "Hello everyone", thread: true),
55 raw: raw("Erin", "Replying from inside the thread")),
56 ]
57 }
58
59 func testThreadGrouping() {
60 var list = vectorMessages()
61 ChatFeedModel.refreshReplyCounts(&list)
62
63 // expected_threads.roots: only the flagged reply belongs to a thread.
64 XCTAssertNil(list.first { $0.id == 101 }?.threadRootID, "a quote reply is in no thread")
65 XCTAssertNil(list.first { $0.id == 102 }?.threadRootID, "a chain of quote replies is still no thread")
66 XCTAssertNil(list.first { $0.id == 103 }?.threadRootID)
67 XCTAssertEqual(list.first { $0.id == 104 }?.threadRootID, 100, "the flagged reply names its root directly")
68 XCTAssertNil(list.first { $0.id == 100 }?.threadRootID, "the root is not itself a reply")
69
70 // expected_threads.reply_counts + first_reply: the tally counts thread
71 // replies ONLY β€” the three inline replies add nothing.
72 XCTAssertEqual(list.first { $0.id == 100 }?.replyCount, 1, "the root tallies its one thread reply")
73 XCTAssertEqual(list.first { $0.id == 100 }?.firstReplyID, 104)
74 XCTAssertEqual(list.first { $0.id == 101 }?.replyCount, 0, "an inline reply quoted by another wears no tally")
75 }
76
77 // ---- the quote card's thread link (web twin: chat.js threadLinkCount) ----
78 // Only a THREAD reply's card ever links into a thread β€” and it only renders
79 // in the feed at all when its root is paged out (thread_reply_root_unloaded).
80 // A plain quote reply's card is a quote and a jump, nothing more.
81
82 func testOnlyThreadRepliesWearAThreadLink() {
83 var list = vectorMessages()
84 ChatFeedModel.refreshReplyCounts(&list)
85 let root = list.first { $0.id == 100 }!
86 for id: Int64 in [101, 102, 103] {
87 let m = list.first { $0.id == id }!
88 XCTAssertEqual(m.threadReplyCount, 0, "inline reply \(id) is in no thread, so there is no size to read")
89 XCTAssertEqual(ChatFeedModel.threadLinkCount(m), 0, "…and its card stays a plain quote")
90 }
91 let inThread = list.first { $0.id == 104 }!
92 XCTAssertEqual(inThread.threadReplyCount, root.replyCount, "a thread reply reads its root's tally")
93 XCTAssertEqual(ChatFeedModel.threadLinkCount(inThread), 0,
94 "a thread of one is not a conversation to go and see")
95 XCTAssertEqual(ChatFeedModel.threadLinkCount(root), 0,
96 "a root never wears one β€” it has the tally itself")
97 XCTAssertEqual(ChatFeedModel.threadLinkCount(Message(id: 1, name: "Al", text: "hi", mine: false,
98 at: 1, edited: false)), 0,
99 "and a plain message has no thread to link to")
100 }
101
102 func testAThreadReplyShownInlineKeepsItsWayBack() {
103 // The one case a thread link renders in the transcript: the root is paged
104 // out, so both replies degrade to inline bubbles and each card is the way
105 // back into the thread they belong to.
106 var list = [
107 Message(id: 51, name: "Cy", text: "in an old thread", mine: false, at: 101, edited: false,
108 reply: ReplyRef(id: 7, name: "Al", snippet: "old root", thread: true)),
109 Message(id: 52, name: "Al", text: "also in it", mine: false, at: 102, edited: false,
110 reply: ReplyRef(id: 7, name: "Al", snippet: "old root", thread: true)),
111 ]
112 ChatFeedModel.refreshReplyCounts(&list)
113 XCTAssertEqual(ChatFeedModel.mainFeedItems(list).map(\.id), [51, 52],
114 "no loaded root to carry the tally β€” both show inline")
115 XCTAssertEqual(list.map { ChatFeedModel.threadLinkCount($0) }, [2, 2],
116 "and each card reads the whole thread's size")
117 }
118
119 // The web twin is test/apps.test.mjs's "jumpReachNote" test, which pins the
120 // same two sentences β€” a jump that can't land must read identically on
121 // every platform, and must never be a silent no-op.
122 func testJumpReachNoteAnswersEveryOutcome() {
123 XCTAssertNil(ChatFeedModel.jumpReachNote(.found), "a landing has nothing to say")
124 XCTAssertEqual(ChatFeedModel.jumpReachNote(.gone), "That message isn't available any more.")
125 XCTAssertEqual(ChatFeedModel.jumpReachNote(.capped),
126 "That message is further back than this jump reaches β€” keep scrolling up for it.")
127 XCTAssertNotEqual(ChatFeedModel.jumpReachNote(.gone), ChatFeedModel.jumpReachNote(.capped),
128 "'gone' is final; 'capped' means keep scrolling β€” never the same sentence")
129 }
130
131 func testReplyCountLabelPluralisesAndLocalises() {
132 XCTAssertEqual(ChatFeedModel.replyCountLabel(1), "1 Reply", "never '1 Replies'")
133 XCTAssertEqual(ChatFeedModel.replyCountLabel(2), "2 Replies")
134 XCTAssertEqual(ChatFeedModel.replyCountLabel(1204), "\(1204.formatted(.number)) Replies",
135 "digits are locale-grouped")
136 }
137
138 // ---- thread-only replies vs the main feed (expected_main_feed) ------------
139 // Driven straight from test/event-vectors/chat.json so the Swift side runs
140 // the SAME sequences the web suite runs. Strict: expected_main_feed must be
141 // present and name every message in the sequence β€” the keys are the
142 // contract, a lagging JSON fails here rather than skipping.
143
144 /// Every sequence vector that carries thread expectations, checked against
145 /// the pure selectors: grouping, tallies, feed visibility, thread lists and
146 /// the two compose verbs. `inline_reply_chain_is_not_a_thread` is the one
147 /// that proves an inline chain produces NO thread and NO tally.
148 ///
149 /// The list comes from the JSON β€” every vector with `expected_threads` β€” so
150 /// a vector added for the web can't quietly skip this side.
151 func testSequenceVectorsPinTheThreadContract() throws {
152 let all = try ChatVectors.all().filter {
153 $0["expected_threads"] != nil && $0["message_sequence"] != nil
154 }
155 XCTAssertFalse(all.isEmpty, "chat.json must carry sequence vectors with expected_threads")
156 for v in all {
157 let name = (v["name"] as? String) ?? "?"
158 // Conversions fold from the same sequence: the claim map, then its
159 // projection onto the loaded window (applyConverts), then the tallies β€”
160 // the model's own rollup order.
161 let folded = try ChatVectors.sequence(v)
162 var list = folded.messages
163 ChatFeedModel.applyConverts(&list, folded.converts)
164 ChatFeedModel.refreshReplyCounts(&list)
165
166 // expected_threads: which messages are in a thread (0 = none), the
167 // tallies, and the first thread reply.
168 let threads = try XCTUnwrap(v["expected_threads"] as? [String: Any], "\(name) expected_threads")
169 let roots = try XCTUnwrap(threads["roots"] as? [String: NSNumber])
170 for (sid, root) in roots {
171 // The web reads replyRoot, where "no thread" is 0; native leaves the
172 // optional nil. Compare through the same zero so the JSON is one spec.
173 XCTAssertEqual(list.first { $0.id == Int64(sid) }?.threadRootID ?? 0, root.int64Value,
174 "\(name): thread root of \(sid)")
175 }
176 let counts = try XCTUnwrap(threads["reply_counts"] as? [String: NSNumber])
177 for (sid, n) in counts {
178 XCTAssertEqual(list.first { $0.id == Int64(sid) }?.replyCount, n.intValue,
179 "\(name): reply count of \(sid)")
180 }
181 let first = try XCTUnwrap(threads["first_reply"] as? [String: NSNumber])
182 for (sid, id) in first {
183 XCTAssertEqual(list.first { $0.id == Int64(sid) }?.firstReplyID, id.int64Value,
184 "\(name): first reply of \(sid)")
185 }
186
187 // expected_thread_items: the thread view's list, root first.
188 let items = try XCTUnwrap(v["expected_thread_items"] as? [String: Any], "\(name) expected_thread_items")
189 for (key, want) in items {
190 guard let root = Int64(key), let ids = want as? [NSNumber] else { continue } // "note" isn't a root
191 XCTAssertEqual(ChatFeedModel.threadItems(in: list, root: root).map(\.id),
192 ids.map(\.int64Value), "\(name): threadItems(\(key))")
193 }
194
195 // expected_main_feed: every message's main-feed visibility, through the
196 // pure filter β€” thread replies hidden, everything else (roots, inline
197 // replies, inline chains) shown, in order.
198 let feed = try XCTUnwrap(v["expected_main_feed"] as? [String: Bool],
199 "\(name) must carry expected_main_feed")
200 let visible = ChatFeedModel.mainFeedItems(list)
201 for m in list {
202 let want = try XCTUnwrap(feed[String(m.id)], "expected_main_feed must name message \(m.id)")
203 XCTAssertEqual(visible.contains { $0.id == m.id }, want, "\(name): main-feed visibility of \(m.id)")
204 }
205 XCTAssertEqual(visible.map(\.id), list.filter { feed[String($0.id)] == true }.map(\.id),
206 "\(name): the feed keeps server order")
207
208 // expected_reply_targets: the two compose verbs, against the same state.
209 let targets = try XCTUnwrap(v["expected_reply_targets"] as? [String: Any],
210 "\(name) must carry expected_reply_targets")
211 for (sid, want) in (targets["quote"] as? [String: [String: Any]]) ?? [:] {
212 let m = try XCTUnwrap(list.first { $0.id == Int64(sid) })
213 assertRef(ChatFeedModel.quoteTarget(for: m), want,
214 "\(name): quoteTarget(\(sid)) β€” plain Reply targets the exact message clicked")
215 }
216 for (sid, want) in (targets["thread"] as? [String: [String: Any]]) ?? [:] {
217 let m = try XCTUnwrap(list.first { $0.id == Int64(sid) })
218 assertRef(ChatFeedModel.threadTarget(in: list, for: m), want,
219 "\(name): threadTarget(\(sid)) β€” Reply in Thread targets the thread's root")
220 }
221
222 // expected_inline_chain: what "Convert to thread" would move from here β€”
223 // the same question the menus ask to decide whether to offer the verb at
224 // all. Empty means nothing hangs under that message (or it's already in
225 // a thread), which is also what a converted chain leaves behind.
226 for (key, want) in (v["expected_inline_chain"] as? [String: [NSNumber]]) ?? [:] {
227 guard let root = Int64(key) else { continue }
228 XCTAssertEqual(ChatFeedModel.inlineChainIDs(in: list, root: root),
229 want.map(\.int64Value), "\(name): inlineChainIDs(\(key))")
230 }
231 // The folded `hasInlineChain` flag is a per-row shortcut for exactly that
232 // walk (the containers build a menu for every row on screen and can't
233 // afford it): they must agree on EVERY message, or a menu offers a verb
234 // that does nothing β€” or hides one that would work.
235 for m in list {
236 XCTAssertEqual(m.hasInlineChain,
237 !ChatFeedModel.inlineChainIDs(in: list, root: m.id).isEmpty,
238 "\(name): hasInlineChain must equal the walk for \(m.id)")
239 }
240
241 // expected_main_feed_unconverted: the SAME sequence with the conversion
242 // dropped β€” precisely what a client too old to know the payload type
243 // does. It keeps showing the chain inline, the behaviour before this
244 // shipped, and nothing breaks.
245 if let want = v["expected_main_feed_unconverted"] as? [String: Bool] {
246 var plain = try ChatVectors.sequence(v, skipConverts: true).messages
247 ChatFeedModel.refreshReplyCounts(&plain)
248 let visible = Set(ChatFeedModel.mainFeedItems(plain).map(\.id))
249 for m in plain {
250 let shown = try XCTUnwrap(want[String(m.id)],
251 "\(name): expected_main_feed_unconverted must name \(m.id)")
252 XCTAssertEqual(visible.contains(m.id), shown,
253 "\(name): an old client's feed β€” visibility of \(m.id)")
254 }
255 }
256 }
257 }
258
259 // ---- "Convert to thread": the rules, hand-driven ------------------------
260 // Web twins in test/apps.test.mjs ("chat: inlineChainIds …" and the three
261 // that follow). The JSON-driven twin is the loop above.
262
263 private func chainFixture() -> [Message] {
264 func raw(_ text: String) -> MessagePayload {
265 var p = MessagePayload()
266 p.text = text
267 return p
268 }
269 return [
270 Message(id: 1, name: "Al", text: "root", mine: false, at: 1, edited: false, raw: raw("root")),
271 Message(id: 2, name: "Bo", text: "re", mine: false, at: 2, edited: false,
272 reply: ReplyRef(id: 1, name: "Al", snippet: "root"), raw: raw("re")),
273 Message(id: 3, name: "Cy", text: "re-re", mine: false, at: 3, edited: false,
274 reply: ReplyRef(id: 2, name: "Bo", snippet: "re"), raw: raw("re-re")),
275 Message(id: 4, name: "Di", text: "aside", mine: false, at: 4, edited: false, raw: raw("aside")),
276 ]
277 }
278
279 func testInlineChainIDsGathersTheChainAndOnlyThat() {
280 let msgs = chainFixture()
281 XCTAssertEqual(ChatFeedModel.inlineChainIDs(in: msgs, root: 1), [2, 3],
282 "the reply and the reply to it; the unrelated aside stays put")
283 XCTAssertEqual(ChatFeedModel.inlineChainIDs(in: msgs, root: 2), [3],
284 "from mid-chain, only what hangs below")
285 XCTAssertEqual(ChatFeedModel.inlineChainIDs(in: msgs, root: 3), [],
286 "nothing under the last reply β€” so the verb isn't offered")
287 XCTAssertEqual(ChatFeedModel.inlineChainIDs(in: msgs, root: 4), [],
288 "and none at all under a message nobody answered")
289 XCTAssertEqual(ChatFeedModel.inlineChainIDs(in: msgs, root: 99), [],
290 "an unloaded message has no chain we can see")
291 XCTAssertEqual(ChatFeedModel.inlineChainIDs(in: msgs, root: 0), [], "0 is never a root")
292 // A reply made IN a thread is not inline conversation β€” it's already
293 // somewhere β€” and a bubble inside a thread has no chain at all, which is
294 // what keeps the verb off the thread view's menus.
295 var withThread = msgs
296 withThread.append(Message(id: 5, name: "Al", text: "in thread", mine: false, at: 5, edited: false,
297 reply: ReplyRef(id: 4, name: "Di", snippet: "aside", thread: true)))
298 withThread.append(Message(id: 6, name: "Bo", text: "quotes it", mine: false, at: 6, edited: false,
299 reply: ReplyRef(id: 5, name: "Al", snippet: "in thread")))
300 XCTAssertEqual(ChatFeedModel.inlineChainIDs(in: withThread, root: 4), [],
301 "the thread reply under it is in a thread already")
302 XCTAssertEqual(ChatFeedModel.inlineChainIDs(in: withThread, root: 5), [],
303 "and a message inside a thread has no inline chain to convert")
304 // A pending optimistic copy can't be converted: the server has never heard
305 // of its id, so no other client could resolve it.
306 var pending = Message(id: 1 << 62, name: "Me", text: "sending", mine: true, at: 9, edited: false,
307 reply: ReplyRef(id: 3, name: "Cy", snippet: "re-re"))
308 pending.pending = true
309 XCTAssertEqual(ChatFeedModel.inlineChainIDs(in: msgs + [pending], root: 1), [2, 3],
310 "the in-flight reply isn't in the chain until it confirms")
311 // …and the folded flag agrees, including about the pending copy's target:
312 // offering a verb that would move nothing is the bug this pins.
313 var withPending = msgs + [pending]
314 ChatFeedModel.refreshReplyCounts(&withPending)
315 XCTAssertEqual(withPending.map(\.hasInlineChain), [true, true, false, false, false],
316 "only 1 and 2 have an inline chain under them")
317 }
318
319 func testClaimConvertsIsOrderIndependentAndIdempotent() {
320 var claims: [Int64: ChatFeedModel.ThreadConvert] = [:]
321 XCTAssertTrue(ChatFeedModel.claimConverts(&claims, root: 1, ids: [2, 3], at: 50))
322 XCTAssertEqual(claims[2], ChatFeedModel.ThreadConvert(root: 1, at: 50))
323 XCTAssertFalse(ChatFeedModel.claimConverts(&claims, root: 1, ids: [2, 3], at: 50),
324 "re-folding the same event changes nothing (history + live echo)")
325 XCTAssertFalse(ChatFeedModel.claimConverts(&claims, root: 9, ids: [2], at: 70),
326 "a LATER conversion can't steal a message from an earlier one")
327 XCTAssertTrue(ChatFeedModel.claimConverts(&claims, root: 9, ids: [2], at: 40),
328 "an EARLIER one wins, whichever order they arrive in")
329 XCTAssertEqual(claims[2], ChatFeedModel.ThreadConvert(root: 9, at: 40))
330 XCTAssertEqual(claims[3], ChatFeedModel.ThreadConvert(root: 1, at: 50),
331 "…and leaves its neighbours alone")
332 var fresh: [Int64: ChatFeedModel.ThreadConvert] = [:]
333 XCTAssertFalse(ChatFeedModel.claimConverts(&fresh, root: 1, ids: [1], at: 50),
334 "a root is never inside its own thread")
335 XCTAssertFalse(ChatFeedModel.claimConverts(&fresh, root: 1, ids: [], at: 50),
336 "an empty conversion is a no-op")
337 }
338
339 func testAConversionOnlyMovesMessagesThatHangOffItsRoot() {
340 // The sender's id list is a CLAIM, checked by the receiver against the
341 // messages themselves. Otherwise the event would be "move any N messages
342 // under any root" β€” and since a conversion renders no bubble, the oldest
343 // claim wins forever, and deleting the convert row is not an undo, one
344 // crafted payload could quietly hide unrelated messages under an old root.
345 // Web twin: "a conversion can only move messages that actually hang off its
346 // root" in apps.test.mjs; JSON twin: convert_unrelated_ids_are_ignored.
347 var list = chainFixture() // 1 root, 2β†’1, 3β†’2, 4 unrelated
348 list.append(Message(id: 5, name: "Al", text: "elsewhere", mine: false, at: 5, edited: false))
349 var claims: [Int64: ChatFeedModel.ThreadConvert] = [:]
350 ChatFeedModel.claimConverts(&claims, root: 1, ids: [2, 3, 4, 5], at: 50)
351 ChatFeedModel.applyConverts(&list, claims)
352 ChatFeedModel.refreshReplyCounts(&list)
353 XCTAssertEqual(ChatFeedModel.threadItems(in: list, root: 1).map(\.id), [1, 2, 3],
354 "only the real chain moved")
355 XCTAssertEqual(ChatFeedModel.mainFeedItems(list).map(\.id), [1, 4, 5],
356 "the unrelated messages never left the feed")
357 XCTAssertEqual(list.first { $0.id == 1 }?.replyCount, 2,
358 "and the tally counts only what moved")
359 // A claimed message that hasn't arrived yet has no quote to check, so it
360 // stays inline until it does β€” the same degradation as an unloaded root.
361 var partial = Array(chainFixture().prefix(2)) // 3 not loaded
362 ChatFeedModel.applyConverts(&partial, claims)
363 ChatFeedModel.refreshReplyCounts(&partial)
364 XCTAssertEqual(ChatFeedModel.threadItems(in: partial, root: 1).map(\.id), [1, 2],
365 "3 isn't here to be checked")
366 }
367
368 func testClaimConvertsCapsOneEventsIDs() {
369 // The server caps a blob at 64KB and can't read it, so a hostile client
370 // could name thousands of ids in one event β€” and the claim map is never
371 // pruned. Web twin: "a conversion's id list is capped…" in apps.test.mjs.
372 var claims: [Int64: ChatFeedModel.ThreadConvert] = [:]
373 ChatFeedModel.claimConverts(&claims, root: 1, ids: Array(1000..<6000), at: 9)
374 XCTAssertEqual(claims.count, ChatFeedModel.maxConvertIDs,
375 "capped, far above any real chain")
376 XCTAssertEqual(ChatFeedModel.maxConvertIDs, 2000, "and the same cap the web uses")
377 }
378
379 func testConvertedRootsFlattensNestingAndDropsCycles() {
380 // Threads don't nest: a claim whose root was itself converted resolves
381 // through to the outermost root.
382 let nested: [Int64: ChatFeedModel.ThreadConvert] = [
383 2: .init(root: 1, at: 10), 3: .init(root: 2, at: 20), 4: .init(root: 3, at: 30),
384 ]
385 XCTAssertEqual(ChatFeedModel.convertedRoots(nested), [2: 1, 3: 1, 4: 1])
386 // A cycle can only come from a made-up payload (a real reply's id is
387 // always higher than what it quotes). It terminates, and nothing moves.
388 XCTAssertEqual(ChatFeedModel.convertedRoots([1: .init(root: 2, at: 10),
389 2: .init(root: 1, at: 20)]), [:])
390 XCTAssertEqual(ChatFeedModel.convertedRoots([:]), [:])
391 }
392
393 func testApplyConvertsMovesTheChainIntoAThread() {
394 var list = chainFixture()
395 var claims: [Int64: ChatFeedModel.ThreadConvert] = [:]
396 ChatFeedModel.claimConverts(&claims, root: 1, ids: [2, 3], at: 50)
397 ChatFeedModel.applyConverts(&list, claims)
398 ChatFeedModel.refreshReplyCounts(&list)
399 XCTAssertEqual(ChatFeedModel.threadItems(in: list, root: 1).map(\.id), [1, 2, 3],
400 "the chain reads as a thread, root first, in server order")
401 XCTAssertEqual(ChatFeedModel.mainFeedItems(list).map(\.id), [1, 4],
402 "the replies leave the transcript; the root keeps its place")
403 XCTAssertEqual(list.first { $0.id == 1 }?.replyCount, 2,
404 "and wears the tally they moved into")
405 // Conversion never rewrites a message: the converted replies keep their own
406 // quote cards, so the chain's internal "who answered whom" stays legible.
407 XCTAssertEqual(list.first { $0.id == 3 }?.reply?.id, 2)
408 let moved = try? XCTUnwrap(list.first { $0.id == 3 })
409 XCTAssertEqual(ChatFeedModel.threadTarget(in: list, for: moved!).id, 1,
410 "Reply in Thread on a converted message continues the thread it moved into")
411 // Root paged out: they degrade back to inline bubbles, exactly as a
412 // flagged thread reply does β€” the tally lives on a loaded root.
413 var rootless = Array(list.dropFirst())
414 ChatFeedModel.refreshReplyCounts(&rootless)
415 XCTAssertEqual(ChatFeedModel.mainFeedItems(rootless).map(\.id), [2, 3, 4],
416 "with no root loaded to carry the tally, they show inline")
417 // …and the unloaded root is named by nothing rather than by the wrong
418 // message: a flagged reply's ref IS its root's snapshot, but a converted
419 // message's ref is whatever it quoted.
420 let stranded = ChatFeedModel.threadTarget(in: rootless, for: rootless[1])
421 XCTAssertEqual(stranded.id, 1)
422 XCTAssertNil(stranded.name, "a converted message carries the root's id alone")
423 XCTAssertNil(stranded.snippet)
424 let flagged = [Message(id: 9, name: "Cy", text: "in thread", mine: false, at: 9, edited: false,
425 reply: ReplyRef(id: 1, name: "Al", snippet: "root", thread: true))]
426 XCTAssertEqual(ChatFeedModel.threadTarget(in: flagged, for: flagged[0]).name, "Al",
427 "a flagged reply still stands in for its unloaded root")
428 }
429
430 func testConvertConfirmSaysWhatMovesAndThatItIsOneWay() {
431 XCTAssertEqual(ChatFeedModel.convertConfirm(1),
432 "Move this reply into a thread? Everyone will see the conversation there instead of in the chat, and this can't be undone.")
433 XCTAssertTrue(ChatFeedModel.convertConfirm(3).contains("these 3 replies"), "never 'these 3 reply'")
434 XCTAssertTrue(ChatFeedModel.convertConfirm(1204).contains("these \(1204.formatted(.number)) replies"),
435 "digits are locale-grouped, like every displayed count")
436 }
437
438 func testConvertLineNamesTheActorAndPluralises() {
439 // The chat-list line: web twin convertLine in test/format.test.mjs.
440 XCTAssertEqual(convertLine("James", 3), "James moved 3 replies into a thread")
441 XCTAssertEqual(convertLine("You", 1), "You moved a reply into a thread", "never '1 replies'")
442 XCTAssertEqual(convertLine("", 2), "Someone moved 2 replies into a thread")
443 }
444
445 // ---- the Threads list (expected_thread_list) -----------------------------
446
447 /// Every vector carrying `expected_thread_list`, against the pure selector:
448 /// one row per thread root, newest activity first, with the line each row is
449 /// headed by. Its own loop rather than a clause in the grouping test above,
450 /// because `thread_reply_root_unloaded` carries no `expected_threads` β€” and
451 /// that vector is the whole point of `root_loaded`.
452 ///
453 /// This is the twin of the web's "chat threads list: …" tests in
454 /// test/event-vectors.test.mjs: same file, same sequences, same expected
455 /// rows, so a Threads list that drifts on one platform fails on the other.
456 func testThreadListVectorsPinTheListAndItsOrder() throws {
457 let all = try ChatVectors.all().filter { $0["expected_thread_list"] != nil }
458 XCTAssertFalse(all.isEmpty, "chat.json must carry expected_thread_list vectors")
459 for v in all {
460 let name = (v["name"] as? String) ?? "?"
461 let folded = try ChatVectors.sequence(v)
462 var list = folded.messages
463 ChatFeedModel.applyConverts(&list, folded.converts)
464 ChatFeedModel.refreshReplyCounts(&list)
465
466 let spec = try XCTUnwrap(v["expected_thread_list"] as? [String: Any])
467 let want = try XCTUnwrap(spec["rows"] as? [[String: Any]], "\(name) expected_thread_list.rows")
468 let got = ChatFeedModel.threadListItems(list)
469 XCTAssertEqual(got.count, want.count, "\(name): the list's row count")
470 for (row, expected) in zip(got, want) {
471 let root = (expected["root"] as? NSNumber)?.int64Value
472 XCTAssertEqual(row.rootID, root, "\(name): row order / root")
473 XCTAssertEqual(row.rootLoaded, expected["root_loaded"] as? Bool, "\(name): row \(row.rootID) root_loaded")
474 XCTAssertEqual(row.name, expected["name"] as? String, "\(name): row \(row.rootID) name")
475 XCTAssertEqual(row.snippet, expected["snippet"] as? String, "\(name): row \(row.rootID) snippet")
476 XCTAssertEqual(row.count, (expected["count"] as? NSNumber)?.intValue, "\(name): row \(row.rootID) count")
477 }
478 // A row's tally IS its root's tally β€” the same number the bubble wears,
479 // so the list and the transcript can never disagree about a thread's size.
480 for row in got where row.rootLoaded {
481 XCTAssertEqual(list.first { $0.id == row.rootID }?.replyCount, row.count,
482 "\(name): row \(row.rootID) counts what its root tallies")
483 }
484 }
485 }
486
487 /// A still-pending optimistic reply is the newest thing in its thread, so the
488 /// thread you just replied in goes to the top β€” the same rule that keeps a
489 /// pending copy last inside a thread's own list. Hand-driven: the vectors
490 /// fold server frames and have no pending copies to carry. Web twin:
491 /// "chat: threadListItems floats a thread with a still-pending reply to the
492 /// top" in test/apps.test.mjs.
493 func testThreadListFloatsAPendingReplyToTheTop() {
494 func raw(_ text: String) -> MessagePayload {
495 var p = MessagePayload()
496 p.text = text
497 return p
498 }
499 var a = Message(id: 1, name: "Al", text: "first root", mine: false, at: 1, edited: false, raw: raw("first root"))
500 var b = Message(id: 2, name: "Bo", text: "second root", mine: false, at: 2, edited: false, raw: raw("second root"))
501 let older = Message(id: 3, name: "Cy", text: "in thread 1", mine: false, at: 3, edited: false,
502 reply: ReplyRef(id: 1, name: "Al", snippet: "first root", thread: true), raw: raw("in thread 1"))
503 let newer = Message(id: 4, name: "Cy", text: "in thread 2", mine: false, at: 4, edited: false,
504 reply: ReplyRef(id: 2, name: "Bo", snippet: "second root", thread: true), raw: raw("in thread 2"))
505 a.replyCount = 1
506 b.replyCount = 1
507 XCTAssertEqual(ChatFeedModel.threadListItems([a, b, older, newer]).map(\.rootID), [2, 1],
508 "newest reply first: thread 2 leads")
509
510 // Now answer the older thread, optimistically β€” no server id yet.
511 var pending = Message(id: 1 << 62, name: "Me", text: "just sent", mine: true, at: 5, edited: false,
512 reply: ReplyRef(id: 1, name: "Al", snippet: "first root", thread: true), raw: raw("just sent"))
513 pending.pending = true
514 let rows = ChatFeedModel.threadListItems([a, b, older, newer, pending])
515 XCTAssertEqual(rows.map(\.rootID), [1, 2], "the thread you just replied in is top of the list")
516 XCTAssertEqual(rows.first?.count, 2, "the pending copy counts β€” it is visibly in the thread")
517 }
518
519 /// The empty case the containers gate on: no thread, no rows, so the menu
520 /// offers no Threads entry at all.
521 func testThreadListIsEmptyWithoutThreads() {
522 XCTAssertTrue(ChatFeedModel.threadListItems([]).isEmpty, "an empty window has no threads")
523 XCTAssertTrue(ChatFeedModel.threadListItems(chainFixture()).isEmpty,
524 "a chat of nothing but inline quote replies has no threads at all")
525 }
526
527 private func assertRef(_ got: ReplyRef, _ want: [String: Any], _ label: String) {
528 XCTAssertEqual(got.id, (want["id"] as? NSNumber)?.int64Value, "\(label) β€” id")
529 XCTAssertEqual(got.name, want["name"] as? String, "\(label) β€” name")
530 XCTAssertEqual(got.snippet, want["snippet"] as? String, "\(label) β€” snippet")
531 }
532
533 func testThreadReplyWithUnloadedRootStaysInFeed() throws {
534 // thread_reply_root_unloaded: the hide is ROOT-AWARE. A thread reply
535 // whose root is older than the loaded page can't tally anywhere (the
536 // tally lives on a loaded root), so it degrades to an inline bubble
537 // rather than rendering nowhere β€” and collapses into the tally the
538 // moment its root pages in.
539 let v = try ChatVectors.vector("thread_reply_root_unloaded")
540 let sequence = try XCTUnwrap(v["message_sequence"] as? [[String: Any]])
541 var list = try sequence.map(ChatVectors.message)
542 ChatFeedModel.refreshReplyCounts(&list)
543 let feed = try XCTUnwrap(v["expected_main_feed"] as? [String: Bool],
544 "thread_reply_root_unloaded must carry expected_main_feed")
545 let reply = try XCTUnwrap(list.first, "the vector holds the flagged reply")
546 let rootID = try XCTUnwrap(reply.reply?.id)
547 XCTAssertTrue(ChatFeedModel.isThreadOnly(reply), "the vector's premise: a flagged reply…")
548 XCTAssertFalse(list.contains { $0.id == rootID }, "…whose root is NOT loaded")
549
550 let visible = ChatFeedModel.mainFeedItems(list)
551 for m in list {
552 let want = try XCTUnwrap(feed[String(m.id)], "expected_main_feed must name message \(m.id)")
553 XCTAssertEqual(visible.contains { $0.id == m.id }, want, "main-feed visibility of \(m.id)")
554 XCTAssertTrue(want, "the vector pins the degrade-to-inline case")
555 }
556
557 // The root paging in flips it: hidden from the feed, tallied on the root.
558 var withRoot = list
559 withRoot.insert(Message(id: rootID, name: "Alice", text: "Hello everyone", mine: false,
560 at: reply.at - 5, edited: false), at: 0)
561 ChatFeedModel.refreshReplyCounts(&withRoot)
562 XCTAssertEqual(ChatFeedModel.mainFeedItems(withRoot).map(\.id), [rootID],
563 "with the root loaded the reply collapses into the tally")
564 XCTAssertEqual(withRoot.first { $0.id == rootID }?.replyCount, 1)
565 XCTAssertEqual(ChatFeedModel.threadItems(in: withRoot, root: rootID).map(\.id), [rootID, reply.id])
566 }
567
568 // ---- the notification tap's thread landing (unreadThreadJump) ------------
569 // A push for a thread-only reply routes at thread granularity, so the
570 // container asks this: is EVERY unread hidden in a thread? Only then does
571 // it open one β€” a visible unread means the feed's own anchor (which lands
572 // on the OLDEST unread) is already right, and a thread opened over it
573 // would cover the messages the reader came for.
574
575 private func unreadFixture() -> (visible: Message, hiddenOld: Message, hiddenNew: Message, root: Message) {
576 let root = Message(id: 10, name: "Al", text: "root", mine: false, at: 100, edited: false)
577 let visible = Message(id: 11, name: "Bo", text: "inline unread", mine: false, at: 200, edited: false)
578 let hiddenOld = Message(id: 12, name: "Cy", text: "thread unread", mine: false, at: 210,
579 edited: false, reply: ReplyRef(id: 10, name: "Al", snippet: "root", thread: true))
580 let hiddenNew = Message(id: 13, name: "Di", text: "newer thread unread", mine: false, at: 220,
581 edited: false, reply: ReplyRef(id: 10, name: "Al", snippet: "root", thread: true))
582 return (visible, hiddenOld, hiddenNew, root)
583 }
584
585 func testUnreadThreadJumpOnlyWhenNothingVisibleIsUnread() {
586 let f = unreadFixture()
587 let cutoff: Int64 = 150 // the pre-tap watermark: the root is read, the rest aren't
588
589 // Every unread hidden in a thread β†’ the newest of them is the landing.
590 let allHidden = [f.root, f.hiddenOld, f.hiddenNew]
591 XCTAssertEqual(ChatFeedModel.unreadThreadJump(in: allHidden,
592 feed: ChatFeedModel.mainFeedItems(allHidden),
593 since: cutoff), 13,
594 "all unreads are in threads β€” open the newest one's")
595
596 // A visible unread among them β†’ hands off to the feed's own anchor, even
597 // though the NEWEST unread is a thread reply (the reported bug).
598 let mixed = [f.root, f.visible, f.hiddenOld, f.hiddenNew]
599 XCTAssertNil(ChatFeedModel.unreadThreadJump(in: mixed,
600 feed: ChatFeedModel.mainFeedItems(mixed),
601 since: cutoff),
602 "an unread in the transcript means the feed's anchor owns this open")
603
604 // Nothing unread at all β†’ nothing to do.
605 XCTAssertNil(ChatFeedModel.unreadThreadJump(in: allHidden,
606 feed: ChatFeedModel.mainFeedItems(allHidden),
607 since: 999),
608 "no unread above the cutoff")
609 }
610
611 func testUnreadThreadJumpIgnoresMineAndRootlessReplies() {
612 let f = unreadFixture()
613 // My own message above the cutoff is not an unread β€” it must neither
614 // trigger a landing nor block one.
615 var mine = f.visible
616 mine.mine = true
617 let withMine = [f.root, mine, f.hiddenNew]
618 XCTAssertEqual(ChatFeedModel.unreadThreadJump(in: withMine,
619 feed: ChatFeedModel.mainFeedItems(withMine),
620 since: 150), 13,
621 "my own send doesn't count as a visible unread")
622
623 // A thread reply whose root ISN'T loaded renders inline (mainFeedItems)
624 // β€” so it's a visible unread, and no thread opens over it.
625 let rootless = [f.hiddenNew]
626 XCTAssertNil(ChatFeedModel.unreadThreadJump(in: rootless,
627 feed: ChatFeedModel.mainFeedItems(rootless),
628 since: 150),
629 "root unloaded β€” the reply is an ordinary bubble in the feed")
630 }
631
632 @MainActor
633 func testMainFeedItemsKeepsNeighboursAdjacent() {
634 // A hidden reply between two feed messages: the visible list has them
635 // side by side, so sender-run grouping and day separators computed over
636 // it (the containers read `previous` off the visible list) see them as
637 // neighbours β€” a thread reply never splits a run in the transcript.
638 var raw = MessagePayload()
639 raw.uid = "user-al-1"
640 let a = Message(id: 1, name: "Al", text: "one", mine: false, at: 1, edited: false, raw: raw)
641 let inThread = Message(id: 2, name: "Bo", text: "in thread", mine: false, at: 2, edited: false,
642 reply: ReplyRef(id: 1, name: "Al", snippet: "one", thread: true))
643 let b = Message(id: 3, name: "Al", text: "two", mine: false, at: 3, edited: false, raw: raw)
644 let inline = Message(id: 4, name: "Cy", text: "quote reply", mine: false, at: 4, edited: false,
645 reply: ReplyRef(id: 1, name: "Al", snippet: "one"))
646 let feed = ChatFeedModel.mainFeedItems([a, inThread, b, inline])
647 XCTAssertEqual(feed.map(\.id), [1, 3, 4], "thread-only hidden; roots and inline replies stay")
648 XCTAssertTrue(ChatFeedModel.sameSender(feed[0], feed[1]),
649 "the two feed messages group as one run across the hidden reply")
650 XCTAssertEqual(ChatFeedModel.mainFeedItems([]).map(\.id), [], "empty in, empty out")
651 // Root-aware: the same thread reply with its root NOT loaded stays inline.
652 XCTAssertEqual(ChatFeedModel.mainFeedItems([inThread, b, inline]).map(\.id), [2, 3, 4],
653 "no loaded root to carry the tally β€” the reply shows inline")
654 // The root is the one the flag POINTS AT β€” no walking. A flagged reply
655 // quoting an inline reply is hidden by THAT message being loaded.
656 let chained = Message(id: 5, name: "Di", text: "chain", mine: false, at: 5, edited: false,
657 reply: ReplyRef(id: 4, name: "Cy", snippet: "quote reply", thread: true))
658 XCTAssertEqual(ChatFeedModel.mainFeedItems([a, inline, chained]).map(\.id), [1, 4],
659 "its root 4 is loaded β€” hidden, tallying on 4")
660 XCTAssertEqual(ChatFeedModel.mainFeedItems([a, chained]).map(\.id), [1, 5],
661 "root 4 not loaded β€” shown inline, whatever else is")
662 }
663
664 #if DEBUG
665 @MainActor
666 func testFeedMessagesMirrorsTheFold() {
667 // The model's published main-feed list tracks every publish of the fold
668 // (didSet), so the containers never filter for themselves.
669 let inst = SavedInstance(origin: "https://example.invalid", deviceToken: "tok",
670 screenName: "Paul", userPub: "", userPriv: "")
671 let thread = ChatThread(id: "t1", kind: "dm", title: "Eleven", preview: "",
672 lastAt: 0, unread: false, pinned: false, hidden: false,
673 members: [], threadKeyB64: "")
674 let model = ChatFeedModel(instance: inst, thread: thread, myUserId: "me")
675 var list = vectorMessages()
676 ChatFeedModel.refreshReplyCounts(&list)
677 model._testInject(messages: list)
678 XCTAssertEqual(model.messages.map(\.id), [100, 101, 102, 103, 104])
679 XCTAssertEqual(model.feedMessages.map(\.id), [100, 101, 102, 103],
680 "the feed list is the fold minus thread-only replies")
681 XCTAssertEqual(model.feedMessages.first { $0.id == 100 }?.replyCount, 1,
682 "the root's tally counts the hidden thread reply β€” and only it")
683 }
684 #endif
685
686 // ---- the two compose verbs, hand-built (the JSON twin is above) ----------
687
688 func testQuoteTargetIsAlwaysTheExactMessage() {
689 let list = vectorMessages()
690
691 // Plain Reply on a chain reply quotes THAT reply β€” no root walk. This is
692 // the change: it used to stage root 100 and silently join a thread.
693 let onChain = ChatFeedModel.quoteTarget(for: list.first { $0.id == 102 }!)
694 XCTAssertEqual(onChain.id, 102)
695 XCTAssertEqual(onChain.name, "Charlie")
696 XCTAssertEqual(onChain.snippet, "Welcome!")
697
698 // Even inside a thread the plain verb quotes the bubble it was tapped on.
699 XCTAssertEqual(ChatFeedModel.quoteTarget(for: list.first { $0.id == 104 }!).id, 104)
700
701 // A non-reply quotes itself, as it always did.
702 let fresh = ChatFeedModel.quoteTarget(for: list.first { $0.id == 100 }!)
703 XCTAssertEqual(fresh.id, 100)
704 XCTAssertEqual(fresh.name, "Alice")
705 XCTAssertEqual(fresh.snippet, "Hello everyone")
706 }
707
708 func testThreadTargetContinuesAThreadOrStartsOne() {
709 let list = vectorMessages()
710
711 // Already in a thread β†’ its ROOT, snapshotted fresh.
712 let inThread = ChatFeedModel.threadTarget(in: list, for: list.first { $0.id == 104 }!)
713 XCTAssertEqual(inThread.id, 100)
714 XCTAssertEqual(inThread.name, "Alice")
715 XCTAssertEqual(inThread.snippet, "Hello everyone")
716
717 // In no thread β†’ the message itself becomes the new thread's root, whether
718 // it's a plain message, a quote reply, or the deep end of a quote chain.
719 for id: Int64 in [100, 101, 102, 103] {
720 XCTAssertEqual(ChatFeedModel.threadTarget(in: list, for: list.first { $0.id == id }!).id, id,
721 "\(id) is in no thread, so Reply in Thread roots a new one on it")
722 }
723 }
724
725 func testUnloadedThreadRootFallsBackToQuotedRef() {
726 // The root scrolled out of the loaded window: keep the frozen snapshot the
727 // reply carries, so a reply staged from here still lands in the thread.
728 var list = [
729 Message(id: 200, name: "Eve", text: "Late reply", mine: false,
730 at: 1_700_000_010, edited: false,
731 reply: ReplyRef(id: 42, name: "Old Root", snippet: "long ago", thread: true)),
732 ]
733 ChatFeedModel.refreshReplyCounts(&list)
734 XCTAssertEqual(list[0].threadRootID, 42, "an unloaded root is still the thread it points at")
735
736 let target = ChatFeedModel.threadTarget(in: list, for: list[0])
737 XCTAssertEqual(target.id, 42)
738 XCTAssertEqual(target.name, "Old Root")
739 XCTAssertEqual(target.snippet, "long ago")
740
741 // The SAME message with no flag is in no thread at all: Reply in Thread
742 // roots one on it, and it never tallies on 42.
743 var inline = [list[0]]
744 inline[0].reply = ReplyRef(id: 42, name: "Old Root", snippet: "long ago")
745 ChatFeedModel.refreshReplyCounts(&inline)
746 XCTAssertNil(inline[0].threadRootID, "an inline reply belongs to no thread, loaded root or not")
747 XCTAssertEqual(ChatFeedModel.threadTarget(in: inline, for: inline[0]).id, 200)
748 }
749
750 // threadItems β€” the thread view's selector; web twin: test/apps.test.mjs
751 // "threadItems selects a root's thread β€” the flagged replies and nothing else".
752 private func threadItemsFixture() -> [Message] {
753 [
754 Message(id: 1, name: "Al", text: "root", mine: false, at: 1, edited: false),
755 Message(id: 2, name: "Bo", text: "aside", mine: false, at: 2, edited: false),
756 Message(id: 3, name: "Cy", text: "re", mine: false, at: 3, edited: false,
757 reply: ReplyRef(id: 1, name: "Al", snippet: "root", thread: true)),
758 Message(id: 4, name: "Al", text: "re-re", mine: false, at: 4, edited: false,
759 reply: ReplyRef(id: 1, name: "Al", snippet: "root", thread: true)),
760 Message(id: 5, name: "Bo", text: "other thread", mine: false, at: 5, edited: false,
761 reply: ReplyRef(id: 2, name: "Bo", snippet: "aside", thread: true)),
762 // A plain quote reply to the SAME root: it stacks inline in the feed and
763 // is not in the thread β€” nobody chose to put it there.
764 Message(id: 6, name: "Di", text: "inline quote", mine: false, at: 6, edited: false,
765 reply: ReplyRef(id: 1, name: "Al", snippet: "root")),
766 ]
767 }
768
769 func testThreadItemsSelectsTheFlaggedRepliesOnly() {
770 let msgs = threadItemsFixture()
771 XCTAssertEqual(ChatFeedModel.threadItems(in: msgs, root: 1).map(\.id), [1, 3, 4],
772 "root first, then its thread in server order; asides, other threads and the inline quote reply stay out")
773 XCTAssertEqual(ChatFeedModel.threadItems(in: msgs, root: 2).map(\.id), [2, 5],
774 "the other thread folds on its own root")
775 XCTAssertEqual(ChatFeedModel.threadItems(in: msgs, root: 0).map(\.id), [],
776 "id 0 is never a thread root")
777 }
778
779 func testAnInlineReplyChainIsNotAThread() {
780 // The regression the two-verb split exists to prevent: replying to a reply
781 // used to root-resolve and silently join (or invent) a thread.
782 var chain = [
783 Message(id: 1, name: "Al", text: "root", mine: false, at: 1, edited: false),
784 Message(id: 2, name: "Bo", text: "re", mine: false, at: 2, edited: false,
785 reply: ReplyRef(id: 1, name: "Al", snippet: "root")),
786 Message(id: 3, name: "Cy", text: "re-re", mine: false, at: 3, edited: false,
787 reply: ReplyRef(id: 2, name: "Bo", snippet: "re")),
788 ]
789 ChatFeedModel.refreshReplyCounts(&chain)
790 XCTAssertEqual(ChatFeedModel.threadItems(in: chain, root: 1).map(\.id), [1],
791 "the root's thread is just the root β€” the chain isn't in it")
792 XCTAssertEqual(ChatFeedModel.threadItems(in: chain, root: 2).map(\.id), [2],
793 "and the middle reply has no thread of its own either")
794 XCTAssertEqual(ChatFeedModel.mainFeedItems(chain).map(\.id), [1, 2, 3],
795 "all three stack inline in the feed")
796 XCTAssertEqual(chain.map(\.replyCount), [0, 0, 0], "and NOTHING wears a tally")
797 XCTAssertEqual(chain.map { ChatFeedModel.threadLinkCount($0) }, [0, 0, 0],
798 "no quote card doubles as a thread link")
799 }
800
801 func testThreadItemsSortPendingCopiesLast() {
802 // A just-sent reply's optimistic copy sits at the BOTTOM of the thread
803 // until its echo confirms it β€” never ahead of the root or a confirmed
804 // reply (the web's bySidPendingLast; its thread-view "blink" came from a
805 // pending copy sorting above the root). Holds whatever id the copy
806 // carries: the native synthetic id (1<<62) and a web-style 0 alike.
807 var msgs = threadItemsFixture()
808 var synthetic = Message(id: 1 << 62, name: "Me", text: "sending", mine: true, at: 9,
809 edited: false, reply: ReplyRef(id: 1, name: "Al", snippet: "root", thread: true))
810 synthetic.pending = true
811 synthetic.cid = "c1"
812 var zero = Message(id: 0, name: "Me", text: "also sending", mine: true, at: 9,
813 edited: false, reply: ReplyRef(id: 1, name: "Al", snippet: "root", thread: true))
814 zero.pending = true
815 zero.cid = "c2"
816 msgs.insert(zero, at: 0)
817 msgs.append(synthetic)
818 XCTAssertEqual(ChatFeedModel.threadItems(in: msgs, root: 1).map(\.id), [1, 3, 4, 0, 1 << 62],
819 "root, confirmed replies in server order, then the pending copies")
820 XCTAssertEqual(ChatFeedModel.threadItems(in: msgs, root: 1).first?.id, 1,
821 "the root is always first, whatever a pending copy's id")
822 }
823
824 func testThreadReplyCountCountsAnswersNotRows() {
825 // The thread view renders root and replies as ONE list, so rows β‰  replies:
826 // a thread nobody has answered is one row and must still read "0 Replies",
827 // never "1 Reply". Web twin: threadReplyCount in test/apps.test.mjs.
828 let msgs = threadItemsFixture()
829 let thread = ChatFeedModel.threadItems(in: msgs, root: 1) // [1, 3, 4]
830 XCTAssertEqual(ChatFeedModel.threadReplyCount(thread, root: 1), 2, "root + two answers")
831 let unanswered = Array(thread.prefix(1))
832 XCTAssertEqual(ChatFeedModel.threadReplyCount(unanswered, root: 1), 0,
833 "just the root β€” nobody has replied")
834 XCTAssertEqual(ChatFeedModel.replyCountLabel(ChatFeedModel.threadReplyCount(unanswered, root: 1)),
835 "0 Replies", "never '1 Reply' for an empty thread")
836 // Root paged out (threadItems drops it): every loaded row IS a reply.
837 XCTAssertEqual(ChatFeedModel.threadReplyCount(Array(thread.dropFirst()), root: 1), 2,
838 "unloaded root β€” the whole list is replies")
839 XCTAssertEqual(ChatFeedModel.threadReplyCount([], root: 1), 0, "nothing loaded at all")
840 }
841
842 func testThreadItemsWithUnloadedRoot() {
843 // Root scrolled out of the loaded window: the thread still folds from the
844 // replies we can see (the view's list starts at the oldest loaded reply,
845 // under its "the original message isn't available" note).
846 let msgs = Array(threadItemsFixture().dropFirst(2))
847 XCTAssertEqual(ChatFeedModel.threadItems(in: msgs, root: 1).map(\.id), [3, 4],
848 "unloaded root β€” replies only")
849 XCTAssertEqual(ChatFeedModel.threadItems(in: threadItemsFixture(), root: 99).map(\.id), [],
850 "an unknown root has no thread")
851 }
852
853 func testThreadReplyRefSnapshotsLoadedRootAndFallsBack() {
854 let msgs = threadItemsFixture()
855 // Loaded root: a fresh snapshot of its name/text (what the composer stages
856 // while the thread view is open).
857 var raw = MessagePayload()
858 raw.name = "Al"
859 raw.text = "root"
860 var withRaw = msgs
861 withRaw[0].raw = raw
862 let staged = ChatFeedModel.threadReplyRef(in: withRaw, root: 1)
863 XCTAssertEqual(staged.id, 1)
864 XCTAssertEqual(staged.name, "Al")
865 XCTAssertEqual(staged.snippet, "root")
866
867 // Unloaded root: the quoted snapshot a loaded thread member carries stands
868 // in (the web's threadFallback via threadTarget).
869 let partial = Array(msgs.dropFirst(2))
870 let fallback = ChatFeedModel.threadReplyRef(in: partial, root: 1)
871 XCTAssertEqual(fallback.id, 1)
872 XCTAssertEqual(fallback.name, "Al")
873 XCTAssertEqual(fallback.snippet, "root")
874
875 // Nothing of the thread loaded at all: keep at least the root id, so the
876 // staged reply still continues the thread.
877 let bare = ChatFeedModel.threadReplyRef(in: [], root: 7)
878 XCTAssertEqual(bare.id, 7)
879 }
880
881 func testHostileReplyPayloadsTerminate() {
882 // Two flagged replies quoting each other: with membership read straight
883 // off the flag there is no walk to cycle, but the rollup must still be
884 // sane β€” each is in the OTHER's "thread", neither is its own root.
885 var list = [
886 Message(id: 1, name: "A", text: "a", mine: false, at: 1, edited: false,
887 reply: ReplyRef(id: 2, name: "B", snippet: "b", thread: true)),
888 Message(id: 2, name: "B", text: "b", mine: false, at: 2, edited: false,
889 reply: ReplyRef(id: 1, name: "A", snippet: "a", thread: true)),
890 ]
891 ChatFeedModel.refreshReplyCounts(&list) // must not hang
892 XCTAssertEqual(list[0].threadRootID, 2)
893 XCTAssertEqual(list[1].threadRootID, 1)
894 XCTAssertEqual(list.map(\.replyCount), [1, 1])
895 }
896}
897