eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1import Combine
2import SwiftUI
3import XCTest
4
5@testable import LChat
6@testable import LChatCore
7
8// The row-content ladder (2026-07-21): the container is settled — the scroll
9// cost lives in rendering real MessageBubble rows. These tests bisect WHICH
10// ingredient of a row is expensive by rebuilding the row rung by rung, from a
11// plain-text row (the diagnostic renderer's shape) up to the production
12// FeedMessageRow + .id, and timing the exact recycle path a scroll drives:
13// rootView swap + layout on one reused hosting controller.
14//
15// Not assertion-bound benchmarks — the printed LADDER/SWEEP numbers are the
16// instrument. Grind an optimization, re-run, compare.
17@MainActor
18final class RowLadderTests: XCTestCase {
19
20 private var window: UIWindow!
21
22 override func tearDown() {
23 window?.isHidden = true
24 window = nil
25 super.tearDown()
26 }
27
28 // ---- content mix ----------------------------------------------------
29
30 static let longReply =
31 "I can help with a few quick commands — try /help. " +
32 "(Conversational AI answers aren't enabled on this server.)"
33
34 private func mixedMessages(_ n: Int, reactions: Bool = true,
35 senders: [String] = ["Eleven"]) -> [ChatFeedModel.Message] {
36 (0..<n).map { i in
37 let text: String
38 switch i % 10 {
39 case 0: text = Self.longReply
40 case 1: text = "**Bold plans** for _tonight_ — bring `snacks` and ~maybe~ drinks"
41 case 2: text = "😂🎉"
42 case 3: text = "On my way 👍"
43 case 4: text = "Did you see the thing about the thing? Unbelievable scenes."
44 default: text = "Message number \(i) with a bit of ordinary text in it"
45 }
46 var m = ChatFeedModel.Message(
47 id: 1000 + Int64(i), name: senders[i % senders.count], text: text,
48 mine: i % 3 == 0, at: 1_700_000_000 + Int64(i) * 60, edited: false)
49 if reactions, i % 7 == 0 {
50 m.reactions = [Reaction(emoji: "👍", count: 2, mine: true),
51 Reaction(emoji: "❤️", count: 1, mine: false)]
52 }
53 return m
54 }
55 }
56
57 // Image rows: w/h + BlurHash present (the send path always stamps them),
58 // blobSource nil in the harness — this times the LAYOUT cost of media
59 // rows; the decrypt/decode cost is device-side (BlobImageLoader) and
60 // measured by the hitch recorder, not here.
61 private func mediaMessages(_ n: Int) -> [ChatFeedModel.Message] {
62 (0..<n).map { i in
63 var m = ChatFeedModel.Message(
64 id: 3000 + Int64(i), name: "Eleven",
65 text: i % 4 == 0 ? "Look at this one" : "",
66 mine: i % 3 == 0, at: 1_700_000_000 + Int64(i) * 60, edited: false)
67 m.attachments = [Attachment(
68 id: "blob\(i)", name: "photo.jpg", type: "image/jpeg",
69 w: 1200 + (i % 3) * 200, h: 900 + (i % 5) * 100,
70 size: 250_000, bh: "LEHV6nWB2yk8pyo0adR*.7kCMdnj")]
71 return m
72 }
73 }
74
75 private func stubHooks(isGroup: Bool = false) -> FeedHooks {
76 FeedHooks(isGroup: isGroup, themeName: "classic", chatFontSize: 15,
77 senderName: { $0.name },
78 foldsAsMuted: { _ in false },
79 revealMuted: { _ in },
80 actions: { _ in MessageActions(canEdit: false,
81 onReply: {}, onCopy: {},
82 onReact: { _ in }) },
83 onJump: { _ in }, onOpenThread: { _ in },
84 blobSource: { _ in nil },
85 onImageTap: { _, _, _ in }, onFileTap: { _ in },
86 onVideoTap: { _, _ in },
87 unfurl: { _ in nil },
88 startReply: { _ in })
89 }
90
91 // ---- the rungs ------------------------------------------------------
92
93 // Each rung builds ONE row view for a message — from the diagnostic
94 // renderer's shape up to the production tree. The ladder's whole point:
95 // the first rung that gets expensive names the ingredient.
96 private enum Rung: String, CaseIterable {
97 case plain // bare Text — the lab row's shape
98 case chrome // + bubble shape/fill/alignment (still plain Text)
99 case bubble // + real MessageBubble (markdown, links, reactions)
100 case row // + full FeedMessageRow (swipe/times overlays, hooks)
101 case rowID // + .id(m.id) — the production tree, identity per recycle
102 // Sub-bisect of the bubble delta — chrome plus ONE suspect each:
103 case chromeSel // + .textSelection(.enabled)
104 case chromeMenu // + .contextMenu (attached even with nil actions today)
105 case chromeMD // + markdown AttributedString Text (MDBlockListView)
106 }
107
108 private func build(_ rung: Rung, _ m: ChatFeedModel.Message,
109 prev: ChatFeedModel.Message?,
110 hooks: FeedHooks, interaction: FeedInteraction) -> AnyView {
111 switch rung {
112 case .plain:
113 return AnyView(
114 Text(m.text)
115 .font(.system(size: 15))
116 .frame(maxWidth: .infinity, alignment: m.mine ? .trailing : .leading)
117 .padding(EdgeInsets(top: 3, leading: 12, bottom: 3, trailing: 12)))
118 case .chrome, .chromeSel, .chromeMenu, .chromeMD:
119 let shape = UnevenRoundedRectangle(
120 cornerRadii: .init(topLeading: 18,
121 bottomLeading: m.mine ? 18 : 6,
122 bottomTrailing: m.mine ? 6 : 18,
123 topTrailing: 18),
124 style: .continuous)
125 let inner: AnyView
126 switch rung {
127 case .chromeSel:
128 inner = AnyView(Text(m.text).font(.system(size: 15))
129 .textSelection(.enabled))
130 case .chromeMD:
131 inner = AnyView(MessageBubble.MDBlockListView(
132 blocks: MessageBubble.cachedTree(m.text),
133 ctx: .init(fontSize: 15,
134 ink: m.mine ? .white : .primary,
135 mine: m.mine, mentions: nil)))
136 default:
137 inner = AnyView(Text(m.text).font(.system(size: 15)))
138 }
139 let bubble = inner
140 .padding(EdgeInsets(top: 9, leading: 13, bottom: 9, trailing: 13))
141 .foregroundStyle(m.mine ? Color.white : Color.primary)
142 .background(shape.fill(m.mine ? Color.blue : Color(.systemGray5)))
143 let decorated: AnyView
144 if case .chromeMenu = rung {
145 // What production pays today with actions == nil: the shape +
146 // the interaction machinery, an empty menu.
147 decorated = AnyView(bubble
148 .contentShape(.contextMenuPreview,
149 RoundedRectangle(cornerRadius: 18, style: .continuous))
150 .contextMenu { EmptyView() })
151 } else {
152 decorated = AnyView(bubble)
153 }
154 return AnyView(
155 HStack(spacing: 0) {
156 if m.mine { Spacer(minLength: 40) }
157 decorated
158 if !m.mine { Spacer(minLength: 40) }
159 }
160 .padding(EdgeInsets(top: 3, leading: 12, bottom: 3, trailing: 12)))
161 case .bubble:
162 return AnyView(
163 HStack(spacing: 0) {
164 if m.mine { Spacer(minLength: 40) }
165 MessageBubble(message: m, showSender: false, theme: .classic)
166 .environment(\.chatFontSize, 15)
167 if !m.mine { Spacer(minLength: 40) }
168 }
169 .padding(EdgeInsets(top: 3, leading: 12, bottom: 3, trailing: 12)))
170 case .row:
171 return AnyView(
172 FeedMessageRow(message: m, previous: prev,
173 hooks: hooks, interaction: interaction))
174 case .rowID:
175 return AnyView(
176 FeedMessageRow(message: m, previous: prev,
177 hooks: hooks, interaction: interaction)
178 .id(m.id))
179 }
180 }
181
182 // ---- the recycle-path meter ----------------------------------------
183
184 // One reused hosting controller, rootView swapped per message + forced
185 // layout + CA commit — the exact work a cell recycle does mid-scroll
186 // (FeedHostedCell.set(root:parent:) then the cell's layout pass).
187 private func sweep(_ rung: Rung, over messages: [ChatFeedModel.Message],
188 hooks: FeedHooks, interaction: FeedInteraction,
189 hc: UIHostingController<AnyView>) -> (totalMs: Double, maxMs: Double) {
190 var total = 0.0, maxStep = 0.0
191 for (i, m) in messages.enumerated() {
192 let prev = i > 0 ? messages[i - 1] : nil
193 let root = build(rung, m, prev: prev, hooks: hooks, interaction: interaction)
194 let t = CACurrentMediaTime()
195 hc.rootView = root
196 hc.view.setNeedsLayout()
197 hc.view.layoutIfNeeded()
198 CATransaction.flush()
199 let ms = (CACurrentMediaTime() - t) * 1000
200 total += ms
201 maxStep = max(maxStep, ms)
202 }
203 return (total, maxStep)
204 }
205
206 private func makeHost() -> UIHostingController<AnyView> {
207 let scene = UIApplication.shared.connectedScenes
208 .compactMap { $0 as? UIWindowScene }.first!
209 window = UIWindow(windowScene: scene)
210 let rootVC = UIViewController()
211 window.rootViewController = rootVC
212 window.makeKeyAndVisible()
213 let hc = UIHostingController(rootView: AnyView(EmptyView()))
214 hc.safeAreaRegions = []
215 hc.sizingOptions = []
216 rootVC.addChild(hc)
217 hc.view.frame = CGRect(x: 0, y: 0, width: 402, height: 300)
218 hc.view.autoresizingMask = []
219 rootVC.view.addSubview(hc.view)
220 hc.didMove(toParent: rootVC)
221 pump(0.2)
222 return hc
223 }
224
225 private func pump(_ seconds: TimeInterval = 0.3) {
226 let until = Date().addingTimeInterval(seconds)
227 while Date() < until {
228 RunLoop.main.run(mode: .default, before: Date().addingTimeInterval(0.02))
229 }
230 }
231
232 // ---- the ladder -----------------------------------------------------
233
234 // Per rung: a COLD pass (200 distinct messages — fresh mid-scroll rows,
235 // markdown memos empty) and a WARM pass (same 200 again — memo hits, the
236 // steady-state scroll-back cost). The rung whose numbers step up names
237 // the expensive ingredient; cold-vs-warm splits parse cost from view
238 // build/layout cost.
239 func testRowLadderRecycleCost() {
240 let interaction = FeedInteraction()
241 let hooks = stubHooks()
242 let messages = mixedMessages(200)
243 let hc = makeHost()
244
245 print("LADDER rung cold-total cold-avg cold-max warm-total warm-avg warm-max (ms)")
246 for rung in Rung.allCases {
247 // Settle + isolate: each rung starts from an empty tree.
248 hc.rootView = AnyView(EmptyView())
249 hc.view.layoutIfNeeded()
250 pump(0.1)
251 let cold = sweep(rung, over: messages, hooks: hooks,
252 interaction: interaction, hc: hc)
253 let warm = sweep(rung, over: messages, hooks: hooks,
254 interaction: interaction, hc: hc)
255 print(String(format: "LADDER %-10s %8.1f %8.2f %8.2f %8.1f %8.2f %8.2f",
256 (rung.rawValue as NSString).utf8String!,
257 cold.totalMs, cold.totalMs / 200, cold.maxMs,
258 warm.totalMs, warm.totalMs / 200, warm.maxMs))
259 }
260 }
261
262 // The coverage Paul flagged missing: group rows (sender names, run
263 // starts) and media rows (image attachments) — both at the production
264 // rung, against the DM baseline from the ladder above.
265 func testGroupAndMediaVariants() {
266 let interaction = FeedInteraction()
267 let hc = makeHost()
268
269 let dm = mixedMessages(200)
270 let dmHooks = stubHooks()
271 let group = mixedMessages(200, senders: ["Alice", "Bob", "Bill", "Gregorious"])
272 let groupHooks = stubHooks(isGroup: true)
273 let media = mediaMessages(200)
274
275 print("VARIANT kind cold-total cold-avg cold-max warm-total warm-avg warm-max (ms)")
276 for (label, msgs, hooks) in [("dm", dm, dmHooks),
277 ("group", group, groupHooks),
278 ("media", media, dmHooks)] {
279 hc.rootView = AnyView(EmptyView())
280 hc.view.layoutIfNeeded()
281 pump(0.1)
282 let cold = sweep(.rowID, over: msgs, hooks: hooks,
283 interaction: interaction, hc: hc)
284 let warm = sweep(.rowID, over: msgs, hooks: hooks,
285 interaction: interaction, hc: hc)
286 print(String(format: "VARIANT %-7s %8.1f %8.2f %8.2f %8.1f %8.2f %8.2f",
287 (label as NSString).utf8String!,
288 cold.totalMs, cold.totalMs / 200, cold.maxMs,
289 warm.totalMs, warm.totalMs / 200, warm.maxMs))
290 }
291 }
292
293 // Ground truth: the REAL container (StockChatFeed mounted in a window)
294 // driven through a full top-to-bottom scroll in ~100pt steps, timing each
295 // synchronous materialisation batch — split into the UIKit layout half
296 // (setContentOffset + layoutIfNeeded: cell recycle + rootView swap) and
297 // the CA commit half (CATransaction.flush: layer layout + text raster —
298 // the per-frame work the render loop pays). Steps over the 120Hz (8.3ms)
299 // and 60Hz (16.7ms) budgets are the felt hitches.
300 //
301 // THE container ladder: identical machinery, row content swapped rung by
302 // rung via the test-only override — the first rung whose numbers step up
303 // names the jank ingredient.
304 func testScrollSweepCost() {
305 let interaction = FeedInteraction()
306 let hooks = stubHooks()
307 let rungs: [(String, Rung?)] = [("plain", .plain), ("chrome", .chrome),
308 ("chrome+sel", .chromeSel),
309 ("chrome+menu", .chromeMenu),
310 ("chrome+md", .chromeMD),
311 ("bubble", .bubble), ("row", .row),
312 ("prod", nil)]
313 for (label, rung) in rungs {
314 if let rung {
315 StockChatFeed.testRowOverride = { [self] m, prev in
316 build(rung, m, prev: prev, hooks: hooks, interaction: interaction)
317 }
318 } else {
319 StockChatFeed.testRowOverride = nil
320 }
321 sweepContainer(label: label, messages: mixedMessages(200), isGroup: false)
322 window.isHidden = true
323 }
324 StockChatFeed.testRowOverride = nil
325 sweepContainer(label: "group",
326 messages: mixedMessages(200,
327 senders: ["Alice", "Bob", "Bill", "Gregorious"]),
328 isGroup: true)
329 window.isHidden = true
330 sweepContainer(label: "media", messages: mediaMessages(200), isGroup: false)
331 }
332
333 // A ~25s continuous production-rung sweep — not an assertion, a TARGET:
334 // run this test, `sample` the sim app process from the Mac while it
335 // loops, and the hot stacks name the real per-row cost with symbols
336 // (the ladder attributes WHICH ingredient; this attributes WHY).
337 func testProfilePadProdSweep() {
338 StockChatFeed.testRowOverride = nil
339 let msgs = mixedMessages(200)
340 sweepContainer(label: "pad-warm", messages: msgs, isGroup: false)
341 guard let cv = (window.rootViewController?.view).flatMap(findCV) else { return }
342 let deadline = Date().addingTimeInterval(40)
343 print("PROFILEPAD sweeping until \(deadline)")
344 // Announce the live sweep to the host Mac (sim processes share the
345 // host filesystem): the sampler triggers on this marker instead of
346 // guessing the window — the first attempt sampled test teardown.
347 let marker = "/tmp/lchat-profilepad-live"
348 FileManager.default.createFile(atPath: marker, contents: Data("\(getpid())".utf8))
349 defer { try? FileManager.default.removeItem(atPath: marker) }
350 while Date() < deadline {
351 var y = cv.contentSize.height // jump to bottom, sweep to top
352 let top = -cv.adjustedContentInset.top
353 cv.setContentOffset(CGPoint(x: 0, y: max(top, y - cv.bounds.height)),
354 animated: false)
355 cv.layoutIfNeeded()
356 while y > top, Date() < deadline {
357 y = max(top, y - 100)
358 cv.setContentOffset(CGPoint(x: 0, y: y), animated: false)
359 cv.layoutIfNeeded()
360 CATransaction.flush()
361 }
362 }
363 print("PROFILEPAD done")
364 }
365
366 private func findCV(_ v: UIView) -> UICollectionView? {
367 if let cv = v as? StockChatFeed.StockFeedCollectionView { return cv }
368 for sub in v.subviews { if let hit = findCV(sub) { return hit } }
369 return nil
370 }
371
372 private func sweepContainer(label: String,
373 messages: [ChatFeedModel.Message], isGroup: Bool) {
374 let inst = SavedInstance(origin: "https://example.invalid",
375 deviceToken: "tok", screenName: "Paul",
376 userPub: "", userPriv: "")
377 let thread = ChatThread(id: "t1", kind: isGroup ? "group" : "dm",
378 title: "Sweep", preview: "", lastAt: 0,
379 unread: false, pinned: false, hidden: false,
380 members: [], threadKeyB64: "")
381 let model = ChatFeedModel(instance: inst, thread: thread, myUserId: "me")
382 let controller = FeedScrollController()
383 let feed = StockChatFeed(model: model,
384 interaction: FeedInteraction(),
385 controller: controller,
386 hooks: stubHooks(isGroup: isGroup),
387 topOverlay: 103, bottomOverlay: 90,
388 composerFocused: false, unreadCutoff: nil,
389 onFirstLayout: { _ in })
390 let vc = UIHostingController(rootView:
391 AnyView(feed.ignoresSafeArea(.container, edges: .vertical)))
392 let scene = UIApplication.shared.connectedScenes
393 .compactMap { $0 as? UIWindowScene }.first!
394 window = UIWindow(windowScene: scene)
395 window.rootViewController = vc
396 window.makeKeyAndVisible()
397 pump(0.4)
398 model._testInject(messages: messages)
399 pump(0.5)
400
401 func find(_ v: UIView) -> UICollectionView? {
402 if let cv = v as? StockChatFeed.StockFeedCollectionView { return cv }
403 for sub in v.subviews { if let hit = find(sub) { return hit } }
404 return nil
405 }
406 guard let cv = find(window) else { XCTFail("no collection view"); return }
407 cv.layoutIfNeeded()
408
409 var layoutSteps: [Double] = [], flushSteps: [Double] = []
410 var y = cv.contentOffset.y
411 let top = -cv.adjustedContentInset.top
412 while y > top {
413 y = max(top, y - 100)
414 let t0 = CACurrentMediaTime()
415 cv.setContentOffset(CGPoint(x: 0, y: y), animated: false)
416 cv.layoutIfNeeded()
417 let t1 = CACurrentMediaTime()
418 CATransaction.flush()
419 let t2 = CACurrentMediaTime()
420 layoutSteps.append((t1 - t0) * 1000)
421 flushSteps.append((t2 - t1) * 1000)
422 }
423 let combined = zip(layoutSteps, flushSteps).map(+)
424 let over8 = combined.filter { $0 > 8.3 }.count
425 let over16 = combined.filter { $0 > 16.7 }.count
426 func stats(_ a: [Double]) -> String {
427 String(format: "total=%.0f avg=%.2f max=%.2f",
428 a.reduce(0, +), a.reduce(0, +) / Double(max(a.count, 1)),
429 a.max() ?? 0)
430 }
431 print("SWEEP \(label) steps=\(combined.count) " +
432 "layout[\(stats(layoutSteps))] flush[\(stats(flushSteps))] " +
433 ">8.3ms=\(over8) >16.7ms=\(over16)")
434 }
435}
436