import Combine import SwiftUI import XCTest @testable import LChat @testable import LChatCore // The row-content ladder (2026-07-21): the container is settled — the scroll // cost lives in rendering real MessageBubble rows. These tests bisect WHICH // ingredient of a row is expensive by rebuilding the row rung by rung, from a // plain-text row (the diagnostic renderer's shape) up to the production // FeedMessageRow + .id, and timing the exact recycle path a scroll drives: // rootView swap + layout on one reused hosting controller. // // Not assertion-bound benchmarks — the printed LADDER/SWEEP numbers are the // instrument. Grind an optimization, re-run, compare. @MainActor final class RowLadderTests: XCTestCase { private var window: UIWindow! override func tearDown() { window?.isHidden = true window = nil super.tearDown() } // ---- content mix ---------------------------------------------------- static let longReply = "I can help with a few quick commands — try /help. " + "(Conversational AI answers aren't enabled on this server.)" private func mixedMessages(_ n: Int, reactions: Bool = true, senders: [String] = ["Eleven"]) -> [ChatFeedModel.Message] { (0.. [ChatFeedModel.Message] { (0.. FeedHooks { FeedHooks(isGroup: isGroup, themeName: "classic", chatFontSize: 15, senderName: { $0.name }, foldsAsMuted: { _ in false }, revealMuted: { _ in }, actions: { _ in MessageActions(canEdit: false, onReply: {}, onCopy: {}, onReact: { _ in }) }, onJump: { _ in }, onOpenThread: { _ in }, blobSource: { _ in nil }, onImageTap: { _, _, _ in }, onFileTap: { _ in }, onVideoTap: { _, _ in }, unfurl: { _ in nil }, startReply: { _ in }) } // ---- the rungs ------------------------------------------------------ // Each rung builds ONE row view for a message — from the diagnostic // renderer's shape up to the production tree. The ladder's whole point: // the first rung that gets expensive names the ingredient. private enum Rung: String, CaseIterable { case plain // bare Text — the lab row's shape case chrome // + bubble shape/fill/alignment (still plain Text) case bubble // + real MessageBubble (markdown, links, reactions) case row // + full FeedMessageRow (swipe/times overlays, hooks) case rowID // + .id(m.id) — the production tree, identity per recycle // Sub-bisect of the bubble delta — chrome plus ONE suspect each: case chromeSel // + .textSelection(.enabled) case chromeMenu // + .contextMenu (attached even with nil actions today) case chromeMD // + markdown AttributedString Text (MDBlockListView) } private func build(_ rung: Rung, _ m: ChatFeedModel.Message, prev: ChatFeedModel.Message?, hooks: FeedHooks, interaction: FeedInteraction) -> AnyView { switch rung { case .plain: return AnyView( Text(m.text) .font(.system(size: 15)) .frame(maxWidth: .infinity, alignment: m.mine ? .trailing : .leading) .padding(EdgeInsets(top: 3, leading: 12, bottom: 3, trailing: 12))) case .chrome, .chromeSel, .chromeMenu, .chromeMD: let shape = UnevenRoundedRectangle( cornerRadii: .init(topLeading: 18, bottomLeading: m.mine ? 18 : 6, bottomTrailing: m.mine ? 6 : 18, topTrailing: 18), style: .continuous) let inner: AnyView switch rung { case .chromeSel: inner = AnyView(Text(m.text).font(.system(size: 15)) .textSelection(.enabled)) case .chromeMD: inner = AnyView(MessageBubble.MDBlockListView( blocks: MessageBubble.cachedTree(m.text), ctx: .init(fontSize: 15, ink: m.mine ? .white : .primary, mine: m.mine, mentions: nil))) default: inner = AnyView(Text(m.text).font(.system(size: 15))) } let bubble = inner .padding(EdgeInsets(top: 9, leading: 13, bottom: 9, trailing: 13)) .foregroundStyle(m.mine ? Color.white : Color.primary) .background(shape.fill(m.mine ? Color.blue : Color(.systemGray5))) let decorated: AnyView if case .chromeMenu = rung { // What production pays today with actions == nil: the shape + // the interaction machinery, an empty menu. decorated = AnyView(bubble .contentShape(.contextMenuPreview, RoundedRectangle(cornerRadius: 18, style: .continuous)) .contextMenu { EmptyView() }) } else { decorated = AnyView(bubble) } return AnyView( HStack(spacing: 0) { if m.mine { Spacer(minLength: 40) } decorated if !m.mine { Spacer(minLength: 40) } } .padding(EdgeInsets(top: 3, leading: 12, bottom: 3, trailing: 12))) case .bubble: return AnyView( HStack(spacing: 0) { if m.mine { Spacer(minLength: 40) } MessageBubble(message: m, showSender: false, theme: .classic) .environment(\.chatFontSize, 15) if !m.mine { Spacer(minLength: 40) } } .padding(EdgeInsets(top: 3, leading: 12, bottom: 3, trailing: 12))) case .row: return AnyView( FeedMessageRow(message: m, previous: prev, hooks: hooks, interaction: interaction)) case .rowID: return AnyView( FeedMessageRow(message: m, previous: prev, hooks: hooks, interaction: interaction) .id(m.id)) } } // ---- the recycle-path meter ---------------------------------------- // One reused hosting controller, rootView swapped per message + forced // layout + CA commit — the exact work a cell recycle does mid-scroll // (FeedHostedCell.set(root:parent:) then the cell's layout pass). private func sweep(_ rung: Rung, over messages: [ChatFeedModel.Message], hooks: FeedHooks, interaction: FeedInteraction, hc: UIHostingController) -> (totalMs: Double, maxMs: Double) { var total = 0.0, maxStep = 0.0 for (i, m) in messages.enumerated() { let prev = i > 0 ? messages[i - 1] : nil let root = build(rung, m, prev: prev, hooks: hooks, interaction: interaction) let t = CACurrentMediaTime() hc.rootView = root hc.view.setNeedsLayout() hc.view.layoutIfNeeded() CATransaction.flush() let ms = (CACurrentMediaTime() - t) * 1000 total += ms maxStep = max(maxStep, ms) } return (total, maxStep) } private func makeHost() -> UIHostingController { let scene = UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene }.first! window = UIWindow(windowScene: scene) let rootVC = UIViewController() window.rootViewController = rootVC window.makeKeyAndVisible() let hc = UIHostingController(rootView: AnyView(EmptyView())) hc.safeAreaRegions = [] hc.sizingOptions = [] rootVC.addChild(hc) hc.view.frame = CGRect(x: 0, y: 0, width: 402, height: 300) hc.view.autoresizingMask = [] rootVC.view.addSubview(hc.view) hc.didMove(toParent: rootVC) pump(0.2) return hc } private func pump(_ seconds: TimeInterval = 0.3) { let until = Date().addingTimeInterval(seconds) while Date() < until { RunLoop.main.run(mode: .default, before: Date().addingTimeInterval(0.02)) } } // ---- the ladder ----------------------------------------------------- // Per rung: a COLD pass (200 distinct messages — fresh mid-scroll rows, // markdown memos empty) and a WARM pass (same 200 again — memo hits, the // steady-state scroll-back cost). The rung whose numbers step up names // the expensive ingredient; cold-vs-warm splits parse cost from view // build/layout cost. func testRowLadderRecycleCost() { let interaction = FeedInteraction() let hooks = stubHooks() let messages = mixedMessages(200) let hc = makeHost() print("LADDER rung cold-total cold-avg cold-max warm-total warm-avg warm-max (ms)") for rung in Rung.allCases { // Settle + isolate: each rung starts from an empty tree. hc.rootView = AnyView(EmptyView()) hc.view.layoutIfNeeded() pump(0.1) let cold = sweep(rung, over: messages, hooks: hooks, interaction: interaction, hc: hc) let warm = sweep(rung, over: messages, hooks: hooks, interaction: interaction, hc: hc) print(String(format: "LADDER %-10s %8.1f %8.2f %8.2f %8.1f %8.2f %8.2f", (rung.rawValue as NSString).utf8String!, cold.totalMs, cold.totalMs / 200, cold.maxMs, warm.totalMs, warm.totalMs / 200, warm.maxMs)) } } // The coverage Paul flagged missing: group rows (sender names, run // starts) and media rows (image attachments) — both at the production // rung, against the DM baseline from the ladder above. func testGroupAndMediaVariants() { let interaction = FeedInteraction() let hc = makeHost() let dm = mixedMessages(200) let dmHooks = stubHooks() let group = mixedMessages(200, senders: ["Alice", "Bob", "Bill", "Gregorious"]) let groupHooks = stubHooks(isGroup: true) let media = mediaMessages(200) print("VARIANT kind cold-total cold-avg cold-max warm-total warm-avg warm-max (ms)") for (label, msgs, hooks) in [("dm", dm, dmHooks), ("group", group, groupHooks), ("media", media, dmHooks)] { hc.rootView = AnyView(EmptyView()) hc.view.layoutIfNeeded() pump(0.1) let cold = sweep(.rowID, over: msgs, hooks: hooks, interaction: interaction, hc: hc) let warm = sweep(.rowID, over: msgs, hooks: hooks, interaction: interaction, hc: hc) print(String(format: "VARIANT %-7s %8.1f %8.2f %8.2f %8.1f %8.2f %8.2f", (label as NSString).utf8String!, cold.totalMs, cold.totalMs / 200, cold.maxMs, warm.totalMs, warm.totalMs / 200, warm.maxMs)) } } // Ground truth: the REAL container (StockChatFeed mounted in a window) // driven through a full top-to-bottom scroll in ~100pt steps, timing each // synchronous materialisation batch — split into the UIKit layout half // (setContentOffset + layoutIfNeeded: cell recycle + rootView swap) and // the CA commit half (CATransaction.flush: layer layout + text raster — // the per-frame work the render loop pays). Steps over the 120Hz (8.3ms) // and 60Hz (16.7ms) budgets are the felt hitches. // // THE container ladder: identical machinery, row content swapped rung by // rung via the test-only override — the first rung whose numbers step up // names the jank ingredient. func testScrollSweepCost() { let interaction = FeedInteraction() let hooks = stubHooks() let rungs: [(String, Rung?)] = [("plain", .plain), ("chrome", .chrome), ("chrome+sel", .chromeSel), ("chrome+menu", .chromeMenu), ("chrome+md", .chromeMD), ("bubble", .bubble), ("row", .row), ("prod", nil)] for (label, rung) in rungs { if let rung { StockChatFeed.testRowOverride = { [self] m, prev in build(rung, m, prev: prev, hooks: hooks, interaction: interaction) } } else { StockChatFeed.testRowOverride = nil } sweepContainer(label: label, messages: mixedMessages(200), isGroup: false) window.isHidden = true } StockChatFeed.testRowOverride = nil sweepContainer(label: "group", messages: mixedMessages(200, senders: ["Alice", "Bob", "Bill", "Gregorious"]), isGroup: true) window.isHidden = true sweepContainer(label: "media", messages: mediaMessages(200), isGroup: false) } // A ~25s continuous production-rung sweep — not an assertion, a TARGET: // run this test, `sample` the sim app process from the Mac while it // loops, and the hot stacks name the real per-row cost with symbols // (the ladder attributes WHICH ingredient; this attributes WHY). func testProfilePadProdSweep() { StockChatFeed.testRowOverride = nil let msgs = mixedMessages(200) sweepContainer(label: "pad-warm", messages: msgs, isGroup: false) guard let cv = (window.rootViewController?.view).flatMap(findCV) else { return } let deadline = Date().addingTimeInterval(40) print("PROFILEPAD sweeping until \(deadline)") // Announce the live sweep to the host Mac (sim processes share the // host filesystem): the sampler triggers on this marker instead of // guessing the window — the first attempt sampled test teardown. let marker = "/tmp/lchat-profilepad-live" FileManager.default.createFile(atPath: marker, contents: Data("\(getpid())".utf8)) defer { try? FileManager.default.removeItem(atPath: marker) } while Date() < deadline { var y = cv.contentSize.height // jump to bottom, sweep to top let top = -cv.adjustedContentInset.top cv.setContentOffset(CGPoint(x: 0, y: max(top, y - cv.bounds.height)), animated: false) cv.layoutIfNeeded() while y > top, Date() < deadline { y = max(top, y - 100) cv.setContentOffset(CGPoint(x: 0, y: y), animated: false) cv.layoutIfNeeded() CATransaction.flush() } } print("PROFILEPAD done") } private func findCV(_ v: UIView) -> UICollectionView? { if let cv = v as? StockChatFeed.StockFeedCollectionView { return cv } for sub in v.subviews { if let hit = findCV(sub) { return hit } } return nil } private func sweepContainer(label: String, messages: [ChatFeedModel.Message], isGroup: Bool) { let inst = SavedInstance(origin: "https://example.invalid", deviceToken: "tok", screenName: "Paul", userPub: "", userPriv: "") let thread = ChatThread(id: "t1", kind: isGroup ? "group" : "dm", title: "Sweep", preview: "", lastAt: 0, unread: false, pinned: false, hidden: false, members: [], threadKeyB64: "") let model = ChatFeedModel(instance: inst, thread: thread, myUserId: "me") let controller = FeedScrollController() let feed = StockChatFeed(model: model, interaction: FeedInteraction(), controller: controller, hooks: stubHooks(isGroup: isGroup), topOverlay: 103, bottomOverlay: 90, composerFocused: false, unreadCutoff: nil, onFirstLayout: { _ in }) let vc = UIHostingController(rootView: AnyView(feed.ignoresSafeArea(.container, edges: .vertical))) let scene = UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene }.first! window = UIWindow(windowScene: scene) window.rootViewController = vc window.makeKeyAndVisible() pump(0.4) model._testInject(messages: messages) pump(0.5) func find(_ v: UIView) -> UICollectionView? { if let cv = v as? StockChatFeed.StockFeedCollectionView { return cv } for sub in v.subviews { if let hit = find(sub) { return hit } } return nil } guard let cv = find(window) else { XCTFail("no collection view"); return } cv.layoutIfNeeded() var layoutSteps: [Double] = [], flushSteps: [Double] = [] var y = cv.contentOffset.y let top = -cv.adjustedContentInset.top while y > top { y = max(top, y - 100) let t0 = CACurrentMediaTime() cv.setContentOffset(CGPoint(x: 0, y: y), animated: false) cv.layoutIfNeeded() let t1 = CACurrentMediaTime() CATransaction.flush() let t2 = CACurrentMediaTime() layoutSteps.append((t1 - t0) * 1000) flushSteps.append((t2 - t1) * 1000) } let combined = zip(layoutSteps, flushSteps).map(+) let over8 = combined.filter { $0 > 8.3 }.count let over16 = combined.filter { $0 > 16.7 }.count func stats(_ a: [Double]) -> String { String(format: "total=%.0f avg=%.2f max=%.2f", a.reduce(0, +), a.reduce(0, +) / Double(max(a.count, 1)), a.max() ?? 0) } print("SWEEP \(label) steps=\(combined.count) " + "layout[\(stats(layoutSteps))] flush[\(stats(flushSteps))] " + ">8.3ms=\(over8) >16.7ms=\(over16)") } }