import Combine import SwiftUI import XCTest @testable import LChat @testable import LChatCore // The short-chat bunching repro (2026-07-21): chats with a handful of // messages measured every row at one-line height, rendered truncated // ("try…"), overlapped cells, and floated mid-screen. These tests mount the // REAL container — StockChatFeed via UIHostingController in a window — over // a fake model, so the geometry is checkable in-process with no server and // no device. @MainActor final class ShortChatFeedTests: XCTestCase { // The exact text from the field report — wraps to 3 lines at 402pt. static let elevenReply = "I can help with a few quick commands — try /help. " + "(Conversational AI answers aren't enabled on this server.)" private var window: UIWindow! private var cancellables = Set() override func tearDown() { window?.isHidden = true window = nil cancellables.removeAll() super.tearDown() } // ---- harness -------------------------------------------------------- private func makeModel(texts: [String]) -> ChatFeedModel { let inst = SavedInstance(origin: "https://example.invalid", deviceToken: "tok", screenName: "Paul", userPub: "", userPriv: "") let thread = ChatThread(id: "t1", kind: "dm", title: "Eleven", preview: "", lastAt: 0, unread: false, pinned: false, hidden: false, members: [], threadKeyB64: "") let model = ChatFeedModel(instance: inst, thread: thread, myUserId: "me") model._testInject(messages: messages(texts)) return model } private func messages(_ texts: [String], startID: Int64 = 420) -> [ChatFeedModel.Message] { texts.enumerated().map { i, t in ChatFeedModel.Message(id: startID + Int64(i), name: "Eleven", text: t, mine: false, at: 1_700_000_000 + Int64(i) * 60, edited: false) } } private func stubHooks(isGroup: Bool = false) -> 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 }) } @discardableResult private func mount(model: ChatFeedModel, isGroup: Bool = false) -> (UIHostingController, FeedScrollController) { 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))) // Scene-based host app: a bare UIWindow(frame:) never attaches — it // must join the test host's window scene to lay out at all. let scene = UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene }.first! window = UIWindow(windowScene: scene) window.rootViewController = vc window.makeKeyAndVisible() pump(0.8) return (vc, controller) } 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)) } } private func feedCV() -> UICollectionView { 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 StockFeedCollectionView in the window") fatalError() } return cv } // ---- diagnostics ---------------------------------------------------- private func dumpState(_ cv: UICollectionView, label: String) { print("FEEDTEST[\(label)] offset=\(cv.contentOffset.y.rounded()) " + "contentH=\(cv.contentSize.height.rounded()) boundsH=\(cv.bounds.height) " + "adjTop=\(cv.adjustedContentInset.top) adjBottom=\(cv.adjustedContentInset.bottom)") let cells = cv.visibleCells .compactMap { c in cv.indexPath(for: c).map { ($0.item, c) } } .sorted { $0.0 < $1.0 } for (item, cell) in cells { let hosted = cell.contentView.subviews.first let ideal = hosted?.sizeThatFits( CGSize(width: cv.bounds.width, height: .greatestFiniteMagnitude)).height ?? -1 print(" #\(item) y=\(cell.frame.minY.rounded()) h=\(cell.frame.height.rounded()) " + "hostedH=\((hosted?.frame.height ?? -1).rounded()) idealH=\(ideal.rounded())") } } private func assertNoOverlaps(_ cv: UICollectionView, file: StaticString = #filePath, line: UInt = #line) { let cells = cv.visibleCells .compactMap { c in cv.indexPath(for: c).map { ($0.item, c.frame) } } .sorted { $0.0 < $1.0 } for (a, b) in zip(cells, cells.dropFirst()) { XCTAssertLessThanOrEqual(a.1.maxY, b.1.minY + 1.0, "cells #\(a.0)/#\(b.0) overlap: \(a.1) vs \(b.1)", file: file, line: line) } } // ---- the repro ------------------------------------------------------ // Five copies of the 3-line Eleven reply — the exact chat from the field // report. Every row must measure its real (multi-line) height, nothing // may overlap, and the rendered hosted view must FIT its cell. func testShortChatRowsMeasureTheirRealHeight() { let model = makeModel(texts: Array(repeating: Self.elevenReply, count: 5)) mount(model: model) let cv = feedCV() dumpState(cv, label: "open") let cells = cv.visibleCells .compactMap { c in cv.indexPath(for: c).map { ($0.item, c) } } .sorted { $0.0 < $1.0 } XCTAssertGreaterThanOrEqual(cells.count, 5, "all five rows should be visible") for (item, cell) in cells where item < 5 { guard let hosted = cell.contentView.subviews.first else { XCTFail("cell #\(item) has no hosted content"); continue } let ideal = hosted.sizeThatFits( CGSize(width: cv.bounds.width, height: .greatestFiniteMagnitude)).height // 3 lines of 15pt text + bubble chrome is well past one-line // height (~72): a one-line answer here is the measurement bug. XCTAssertGreaterThan(ideal, 80, "cell #\(item): ideal height \(ideal) looks like a one-line measure") XCTAssertEqual(cell.frame.height, ideal, accuracy: 2.0, "cell #\(item): laid-out height \(cell.frame.height) vs ideal \(ideal)") } assertNoOverlaps(cv) } // The Eleven-DM lifecycle: open short chat → typing appears → reply // arrives → typing clears, several rounds. This is the churn that // produced overlapped/stale cells and the mid-screen float in the field. func testTypingChurnKeepsCellsDisjoint() { let model = makeModel(texts: Array(repeating: Self.elevenReply, count: 3)) mount(model: model) let cv = feedCV() for round in 0..<3 { model._testInject(typing: ["Eleven"]) pump(0.25) var all = model.messages all.append(contentsOf: messages([Self.elevenReply], startID: 500 + Int64(round))) model._testInject(messages: all, typing: []) pump(0.25) dumpState(cv, label: "round\(round)") assertNoOverlaps(cv) } // Short content must rest against the TOP inset — not float // mid-screen: offset clamps to -adjustedTop when content fits. if cv.contentSize.height + cv.adjustedContentInset.bottom + cv.adjustedContentInset.top <= cv.bounds.height { XCTAssertEqual(cv.contentOffset.y, -cv.adjustedContentInset.top, accuracy: 1.0, "short content should rest at the top") } // The typing row hides by COLLAPSING to zero height — and its hosted // view must clip, or the dots paint straight through the collapsed // cell (the 2026-07-21 stuck-dots sighting). let typingIP = IndexPath(item: cv.numberOfItems(inSection: 0) - 1, section: 0) let attrs = cv.layoutAttributesForItem(at: typingIP) XCTAssertEqual(attrs?.frame.height ?? -1, 0, accuracy: 0.5, "typing row should be zero-height when nobody types") if let cell = cv.cellForItem(at: typingIP), let hosted = cell.contentView.subviews.first { XCTAssertTrue(hosted.clipsToBounds, "typing hosted view must clip") } } // The discriminator: same content injected AFTER the mount settles — // cells then configure from a plain run-loop turn instead of inside the // mount's SwiftUI transaction. If these render full-height, the wedge is // transaction-timing, not content. func testLateInjectionRendersFullHeights() { let model = makeModel(texts: []) mount(model: model) model._testInject(messages: messages( Array(repeating: Self.elevenReply, count: 5))) pump(0.6) let cv = feedCV() dumpState(cv, label: "late-inject") for cell in cv.visibleCells { guard let item = cv.indexPath(for: cell)?.item, item < 5, let hosted = cell.contentView.subviews.first else { continue } XCTAssertEqual(hosted.frame.height, cell.frame.height, accuracy: 2.0, "cell #\(item) hosted \(hosted.frame.height) vs cell \(cell.frame.height)") } // Tree autopsy: wedged vs healthy internals, for the fix hunt. for probe in [1, 3] { if let cell = cv.cellForItem(at: IndexPath(item: probe, section: 0)) { dumpTree(cell.contentView, indent: " T#\(probe) ") } } } private func dumpTree(_ v: UIView, indent: String, depth: Int = 0) { guard depth < 6 else { return } let label = (v as? UILabel)?.text ?? "" print("\(indent)\(String(repeating: "· ", count: depth))\(type(of: v)) " + "\(v.frame.integral) \(label.prefix(30))") for sub in v.subviews { dumpTree(sub, indent: indent, depth: depth + 1) } } // An unfurl card with an image must MEASURE with its image slot even // though the measuring pass never loads images — a load-dependent card // height painted over the rows below it (App test group, 2026-07-21: // cell 227 vs ideal 366, the delta exactly the image slot). func testUnfurlCardMeasuresImageSlotBeforeLoad() { let msg = ChatFeedModel.Message( id: 700, name: "James", text: "testing https://link.example/x", mine: false, at: 1_700_000_000, edited: false) func rowHeight(_ preview: LinkPreview) -> CGFloat { var h = stubHooks() h.peekUnfurl = { _ in .some(preview) } let row = FeedMessageRow(message: msg, previous: nil, hooks: h, interaction: FeedInteraction()) let hc = UIHostingController(rootView: AnyView(row)) hc.safeAreaRegions = [] return ceil(hc.sizeThatFits( in: CGSize(width: 402, height: CGFloat.greatestFiniteMagnitude)).height) } let url = URL(string: "https://link.example/x")! let bare = rowHeight(LinkPreview( url: url, title: "Title", description: "Desc", imageURL: nil)) let withImage = rowHeight(LinkPreview( url: url, title: "Title", description: "Desc", imageURL: URL(string: "https://unloadable.invalid/i.png"))) print("UNFURL bare=\(bare) withImage=\(withImage)") XCTAssertEqual(withImage - bare, 150, accuracy: 4, "image slot must reserve its space before the image loads") } // Mid-feed dated separators in a GROUP (sender labels + day gaps — the // App-test-group screenshot, 2026-07-21): every rendered cell must be // exactly its content's ideal height, or separators paint over the // neighbouring bubbles. func testGroupSeparatorRowsMeasureExactly() { let inst = SavedInstance(origin: "https://example.invalid", deviceToken: "tok", screenName: "Paul", userPub: "", userPriv: "") let thread = ChatThread(id: "t1", kind: "group", title: "Group", preview: "", lastAt: 0, unread: false, pinned: false, hidden: false, members: [], threadKeyB64: "") let model = ChatFeedModel(instance: inst, thread: thread, myUserId: "me") var msgs: [ChatFeedModel.Message] = [] let names = ["James", "Alice", "James", "James", "Bill"] for i in 0..<5 { msgs.append(ChatFeedModel.Message( id: 100 + Int64(i), name: names[i], text: "Group message number \(i) with some words in it", mine: false, // Multi-day gaps → a DATED separator above every row. at: 1_700_000_000 + Int64(i) * 200_000, edited: false)) } model._testInject(messages: msgs) mount(model: model, isGroup: true) let cv = feedCV() dumpState(cv, label: "group-seps") for cell in cv.visibleCells { guard let item = cv.indexPath(for: cell)?.item, item < 5, let hosted = cell.contentView.subviews.first else { continue } let ideal = hosted.sizeThatFits( CGSize(width: cv.bounds.width, height: .greatestFiniteMagnitude)).height XCTAssertEqual(cell.frame.height, ideal, accuracy: 2.0, "cell #\(item): laid out \(cell.frame.height) vs ideal \(ideal)") } assertNoOverlaps(cv) } // A prepend that CHANGES the boundary row's separator/run state (older // page lands above it) must re-measure that row — a kept stale height // is content taller than its cell, painting over neighbours. func testPrependReMeasuresBoundaryRow() { let model = makeModel(texts: []) mount(model: model) let cv = feedCV() // Newest page only: one message, no previous → separator ABOVE it. let newest = ChatFeedModel.Message( id: 500, name: "Eleven", text: Self.elevenReply, mine: false, at: 1_700_400_000, edited: false) model._testInject(messages: [newest]) pump(0.4) // The older page lands: same-day predecessor minutes earlier — the // boundary row LOSES its separator; an older-day head gains one. var all: [ChatFeedModel.Message] = [] for i in 0..<3 { all.append(ChatFeedModel.Message( id: 400 + Int64(i), name: "Eleven", text: "Older message \(i)", mine: false, at: 1_700_399_700 + Int64(i) * 60, edited: false)) } all.append(newest) model._testInject(messages: all) pump(0.4) dumpState(cv, label: "post-prepend") // The last item is the typing row — zero-height by design (its // content's ideal is the dots bubble; clipping hides it). let count = cv.numberOfItems(inSection: 0) for cell in cv.visibleCells { guard let hosted = cell.contentView.subviews.first, let item = cv.indexPath(for: cell)?.item, item < count - 1 else { continue } let ideal = hosted.sizeThatFits( CGSize(width: cv.bounds.width, height: .greatestFiniteMagnitude)).height XCTAssertEqual(cell.frame.height, ideal, accuracy: 2.0, "cell #\(item): laid out \(cell.frame.height) vs ideal \(ideal)") } assertNoOverlaps(cv) } // A typer's arriving message must retire their dots in the SAME apply — // the coordinator clears the local typing state itself rather than // waiting for the typing-off event, so the pane moves once (the // 2026-07-21 handoff jump). func testArrivalReplacesTypersDots() { let model = makeModel(texts: [Self.elevenReply]) mount(model: model) let cv = feedCV() model._testInject(typing: ["Eleven"]) pump(0.3) var typingIP = IndexPath(item: cv.numberOfItems(inSection: 0) - 1, section: 0) XCTAssertGreaterThan( cv.layoutAttributesForItem(at: typingIP)?.frame.height ?? 0, 10, "typing row should have height while Eleven types") var all = model.messages all.append(contentsOf: messages(["Here you go"], startID: 900)) model._testInject(messages: all) // typing deliberately NOT cleared pump(0.3) typingIP = IndexPath(item: cv.numberOfItems(inSection: 0) - 1, section: 0) XCTAssertEqual( cv.layoutAttributesForItem(at: typingIP)?.frame.height ?? -1, 0, accuracy: 0.5, "the arrival should retire the typer's dots in the same apply") } // The owned long-press menu (FeedMenuOverlay): presenting for a wide and // a narrow bubble must float a bubble-sized snapshot (never row-wide) // with the palette and the labelled card; dismissing removes it. func testLongPressMenuPresentsBubbleSized() { let model = makeModel(texts: [Self.elevenReply, "Yas!", Self.elevenReply]) let (_, controller) = mount(model: model) let cv = feedCV() guard let coord = controller.target as? StockChatFeed.Coordinator else { XCTFail("no coordinator"); return } let container = cv.superview! for (item, id) in [(0, Int64(420)), (1, 421), (2, 422)] { let rect = coord.bubbleRect(for: .message(id)) XCTAssertNotNil(rect, "row #\(item) has no bubble rect") if let rect { XCTAssertGreaterThan(rect.width, 10) XCTAssertLessThan(rect.width, cv.bounds.width - 50, "row #\(item) bubble rect is row-wide") } coord.presentMenu(for: id) pump(0.3) let overlay = container.subviews .compactMap { $0 as? FeedMenuOverlay }.first XCTAssertNotNil(overlay, "row #\(item): no menu overlay presented") if let overlay { let buttons = allButtons(in: overlay) // 6 reactions + Reply + Copy from the stub actions. print("MENU #\(item) buttons=\(buttons.count)") XCTAssertGreaterThanOrEqual(buttons.count, 8) let bubbles = overlay.subviews.compactMap { $0 as? UIImageView } XCTAssertEqual(bubbles.count, 1) if let b = bubbles.first, let rect { XCTAssertEqual(b.frame.width, rect.width, accuracy: 1.0) } overlay.dismiss() pump(0.3) let remaining = container.subviews .compactMap { $0 as? FeedMenuOverlay } XCTAssertTrue(remaining.isEmpty, "overlay did not dismiss") } } // The typing row and lab rows carry no bubble. XCTAssertNil(coord.bubbleRect(for: .typing)) } // The open-cost number (the field's 3.1s feed.mount for 201 messages, // 2026-07-21): mount a 200-message chat and time to first settled // layout. Not an assertion-bound benchmark — the printed number is the // regression instrument. func testTwoHundredMessageMountCost() { // The test host boots LChatApp, which warms the persisted cache — // start empty so the cold number stays a cold number. FeedHeightCache.store.removeAll() var texts: [String] = [] for i in 0..<200 { texts.append(i % 3 == 0 ? Self.elevenReply : "Message number \(i) here") } // Empty mount first; then time the SYNCHRONOUS main-thread cost of // the 200-message publish — the apply + full measure + layout runs // inside the sink, and that block IS the felt freeze. let model = makeModel(texts: []) mount(model: model) let cv = feedCV() let t0 = Date() model._testInject(messages: messages(texts)) let syncMs = Int(Date().timeIntervalSince(t0) * 1000) pump(0.3) let firstContentH = cv.contentSize.height print("MOUNTCOST 200 rows: sync=\(syncMs)ms " + "contentH=\(Int(firstContentH))") XCTAssertGreaterThan(firstContentH, 5000) // RE-open (the field's 3.1s mounts were re-opens): a fresh screen // over the same content must ride the cross-open height cache. window.isHidden = true let model2 = makeModel(texts: []) mount(model: model2) let cv2 = feedCV() let t1 = Date() model2._testInject(messages: messages(texts)) let reopenMs = Int(Date().timeIntervalSince(t1) * 1000) pump(0.3) print("MOUNTCOST reopen: sync=\(reopenMs)ms " + "contentH=\(Int(cv2.contentSize.height))") XCTAssertEqual(cv2.contentSize.height, firstContentH, accuracy: 2, "cached heights diverge from measured ones") XCTAssertLessThan(reopenMs, syncMs / 2 + 30, "re-open did not ride the height cache") } // A relaunch must not re-pay the measure pass: the persisted cache // warms from disk and the first open rides it (the device ledger's // 3.1s feed.mount for 201 messages, 2026-07-21). func testHeightCacheSurvivesRelaunch() { FeedHeightCache.store.removeAll() var texts: [String] = [] for i in 0..<200 { texts.append(i % 3 == 0 ? Self.elevenReply : "Relaunch row \(i) content") } let model = makeModel(texts: []) mount(model: model) model._testInject(messages: messages(texts)) pump(0.3) let contentH = feedCV().contentSize.height FeedHeightCache.flushNow() pump(0.5) // the detached write must land before the "relaunch" FeedHeightCache.store.removeAll() // simulate a fresh process FeedHeightCache.warm() let deadline = Date().addingTimeInterval(3) while FeedHeightCache.store.isEmpty, Date() < deadline { pump(0.1) } XCTAssertFalse(FeedHeightCache.store.isEmpty, "warm() merged nothing from disk") window.isHidden = true let model2 = makeModel(texts: []) mount(model: model2) let t = Date() model2._testInject(messages: messages(texts)) let ms = Int(Date().timeIntervalSince(t) * 1000) pump(0.3) print("RELAUNCH open: sync=\(ms)ms contentH=\(Int(feedCV().contentSize.height))") XCTAssertEqual(feedCV().contentSize.height, contentH, accuracy: 2, "warmed heights diverge from measured ones") XCTAssertLessThan(ms, 60, "relaunch first open re-measured (\(ms)ms)") } private func allButtons(in v: UIView) -> [UIButton] { v.subviews.flatMap { sub -> [UIButton] in var out = allButtons(in: sub) if let b = sub as? UIButton { out.append(b) } return out } } // Keyboard lift + drop over a short chat — the inset churn path. func testKeyboardCycleOverShortChat() { let model = makeModel(texts: Array(repeating: Self.elevenReply, count: 4)) mount(model: model) let cv = feedCV() NotificationCenter.default.post( name: UIResponder.keyboardWillShowNotification, object: nil) pump(0.2) NotificationCenter.default.post( name: UIResponder.keyboardWillHideNotification, object: nil) pump(0.2) dumpState(cv, label: "after-keyboard") assertNoOverlaps(cv) XCTAssertEqual(cv.contentOffset.y, -cv.adjustedContentInset.top, accuracy: 1.0, "short content should rest at the top") } }