| 1 | import Combine |
| 2 | import SwiftUI |
| 3 | import 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 |
| 18 | final 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 | |