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 short-chat bunching repro (2026-07-21): chats with a handful of
9// messages measured every row at one-line height, rendered truncated
10// ("try…"), overlapped cells, and floated mid-screen. These tests mount the
11// REAL container — StockChatFeed via UIHostingController in a window — over
12// a fake model, so the geometry is checkable in-process with no server and
13// no device.
14@MainActor
15final class ShortChatFeedTests: XCTestCase {
16
17 // The exact text from the field report — wraps to 3 lines at 402pt.
18 static let elevenReply =
19 "I can help with a few quick commands — try /help. " +
20 "(Conversational AI answers aren't enabled on this server.)"
21
22 private var window: UIWindow!
23 private var cancellables = Set<AnyCancellable>()
24
25 override func tearDown() {
26 window?.isHidden = true
27 window = nil
28 cancellables.removeAll()
29 super.tearDown()
30 }
31
32 // ---- harness --------------------------------------------------------
33
34 private func makeModel(texts: [String]) -> ChatFeedModel {
35 let inst = SavedInstance(origin: "https://example.invalid",
36 deviceToken: "tok", screenName: "Paul",
37 userPub: "", userPriv: "")
38 let thread = ChatThread(id: "t1", kind: "dm", title: "Eleven",
39 preview: "", lastAt: 0, unread: false,
40 pinned: false, hidden: false,
41 members: [], threadKeyB64: "")
42 let model = ChatFeedModel(instance: inst, thread: thread, myUserId: "me")
43 model._testInject(messages: messages(texts))
44 return model
45 }
46
47 private func messages(_ texts: [String], startID: Int64 = 420)
48 -> [ChatFeedModel.Message] {
49 texts.enumerated().map { i, t in
50 ChatFeedModel.Message(id: startID + Int64(i), name: "Eleven", text: t,
51 mine: false, at: 1_700_000_000 + Int64(i) * 60,
52 edited: false)
53 }
54 }
55
56 private func stubHooks(isGroup: Bool = false) -> FeedHooks {
57 FeedHooks(isGroup: isGroup, themeName: "classic", chatFontSize: 15,
58 senderName: { $0.name },
59 foldsAsMuted: { _ in false },
60 revealMuted: { _ in },
61 actions: { _ in MessageActions(canEdit: false,
62 onReply: {}, onCopy: {},
63 onReact: { _ in }) },
64 onJump: { _ in }, onOpenThread: { _ in },
65 blobSource: { _ in nil },
66 onImageTap: { _, _, _ in }, onFileTap: { _ in },
67 onVideoTap: { _, _ in },
68 unfurl: { _ in nil },
69 startReply: { _ in })
70 }
71
72 @discardableResult
73 private func mount(model: ChatFeedModel, isGroup: Bool = false)
74 -> (UIHostingController<AnyView>, FeedScrollController) {
75 let controller = FeedScrollController()
76 let feed = StockChatFeed(model: model,
77 interaction: FeedInteraction(),
78 controller: controller,
79 hooks: stubHooks(isGroup: isGroup),
80 topOverlay: 103, bottomOverlay: 90,
81 composerFocused: false, unreadCutoff: nil,
82 onFirstLayout: { _ in })
83 let vc = UIHostingController(rootView:
84 AnyView(feed.ignoresSafeArea(.container, edges: .vertical)))
85 // Scene-based host app: a bare UIWindow(frame:) never attaches — it
86 // must join the test host's window scene to lay out at all.
87 let scene = UIApplication.shared.connectedScenes
88 .compactMap { $0 as? UIWindowScene }.first!
89 window = UIWindow(windowScene: scene)
90 window.rootViewController = vc
91 window.makeKeyAndVisible()
92 pump(0.8)
93 return (vc, controller)
94 }
95
96 private func pump(_ seconds: TimeInterval = 0.3) {
97 let until = Date().addingTimeInterval(seconds)
98 while Date() < until {
99 RunLoop.main.run(mode: .default, before: Date().addingTimeInterval(0.02))
100 }
101 }
102
103 private func feedCV() -> UICollectionView {
104 func find(_ v: UIView) -> UICollectionView? {
105 if let cv = v as? StockChatFeed.StockFeedCollectionView { return cv }
106 for sub in v.subviews { if let hit = find(sub) { return hit } }
107 return nil
108 }
109 guard let cv = find(window) else {
110 XCTFail("no StockFeedCollectionView in the window")
111 fatalError()
112 }
113 return cv
114 }
115
116 // ---- diagnostics ----------------------------------------------------
117
118 private func dumpState(_ cv: UICollectionView, label: String) {
119 print("FEEDTEST[\(label)] offset=\(cv.contentOffset.y.rounded()) " +
120 "contentH=\(cv.contentSize.height.rounded()) boundsH=\(cv.bounds.height) " +
121 "adjTop=\(cv.adjustedContentInset.top) adjBottom=\(cv.adjustedContentInset.bottom)")
122 let cells = cv.visibleCells
123 .compactMap { c in cv.indexPath(for: c).map { ($0.item, c) } }
124 .sorted { $0.0 < $1.0 }
125 for (item, cell) in cells {
126 let hosted = cell.contentView.subviews.first
127 let ideal = hosted?.sizeThatFits(
128 CGSize(width: cv.bounds.width,
129 height: .greatestFiniteMagnitude)).height ?? -1
130 print(" #\(item) y=\(cell.frame.minY.rounded()) h=\(cell.frame.height.rounded()) " +
131 "hostedH=\((hosted?.frame.height ?? -1).rounded()) idealH=\(ideal.rounded())")
132 }
133 }
134
135 private func assertNoOverlaps(_ cv: UICollectionView,
136 file: StaticString = #filePath, line: UInt = #line) {
137 let cells = cv.visibleCells
138 .compactMap { c in cv.indexPath(for: c).map { ($0.item, c.frame) } }
139 .sorted { $0.0 < $1.0 }
140 for (a, b) in zip(cells, cells.dropFirst()) {
141 XCTAssertLessThanOrEqual(a.1.maxY, b.1.minY + 1.0,
142 "cells #\(a.0)/#\(b.0) overlap: \(a.1) vs \(b.1)",
143 file: file, line: line)
144 }
145 }
146
147 // ---- the repro ------------------------------------------------------
148
149 // Five copies of the 3-line Eleven reply — the exact chat from the field
150 // report. Every row must measure its real (multi-line) height, nothing
151 // may overlap, and the rendered hosted view must FIT its cell.
152 func testShortChatRowsMeasureTheirRealHeight() {
153 let model = makeModel(texts: Array(repeating: Self.elevenReply, count: 5))
154 mount(model: model)
155 let cv = feedCV()
156 dumpState(cv, label: "open")
157
158 let cells = cv.visibleCells
159 .compactMap { c in cv.indexPath(for: c).map { ($0.item, c) } }
160 .sorted { $0.0 < $1.0 }
161 XCTAssertGreaterThanOrEqual(cells.count, 5, "all five rows should be visible")
162 for (item, cell) in cells where item < 5 {
163 guard let hosted = cell.contentView.subviews.first else {
164 XCTFail("cell #\(item) has no hosted content"); continue
165 }
166 let ideal = hosted.sizeThatFits(
167 CGSize(width: cv.bounds.width, height: .greatestFiniteMagnitude)).height
168 // 3 lines of 15pt text + bubble chrome is well past one-line
169 // height (~72): a one-line answer here is the measurement bug.
170 XCTAssertGreaterThan(ideal, 80,
171 "cell #\(item): ideal height \(ideal) looks like a one-line measure")
172 XCTAssertEqual(cell.frame.height, ideal, accuracy: 2.0,
173 "cell #\(item): laid-out height \(cell.frame.height) vs ideal \(ideal)")
174 }
175 assertNoOverlaps(cv)
176 }
177
178 // The Eleven-DM lifecycle: open short chat → typing appears → reply
179 // arrives → typing clears, several rounds. This is the churn that
180 // produced overlapped/stale cells and the mid-screen float in the field.
181 func testTypingChurnKeepsCellsDisjoint() {
182 let model = makeModel(texts: Array(repeating: Self.elevenReply, count: 3))
183 mount(model: model)
184 let cv = feedCV()
185
186 for round in 0..<3 {
187 model._testInject(typing: ["Eleven"])
188 pump(0.25)
189 var all = model.messages
190 all.append(contentsOf: messages([Self.elevenReply],
191 startID: 500 + Int64(round)))
192 model._testInject(messages: all, typing: [])
193 pump(0.25)
194 dumpState(cv, label: "round\(round)")
195 assertNoOverlaps(cv)
196 }
197
198 // Short content must rest against the TOP inset — not float
199 // mid-screen: offset clamps to -adjustedTop when content fits.
200 if cv.contentSize.height + cv.adjustedContentInset.bottom
201 + cv.adjustedContentInset.top <= cv.bounds.height {
202 XCTAssertEqual(cv.contentOffset.y, -cv.adjustedContentInset.top,
203 accuracy: 1.0, "short content should rest at the top")
204 }
205
206 // The typing row hides by COLLAPSING to zero height — and its hosted
207 // view must clip, or the dots paint straight through the collapsed
208 // cell (the 2026-07-21 stuck-dots sighting).
209 let typingIP = IndexPath(item: cv.numberOfItems(inSection: 0) - 1, section: 0)
210 let attrs = cv.layoutAttributesForItem(at: typingIP)
211 XCTAssertEqual(attrs?.frame.height ?? -1, 0, accuracy: 0.5,
212 "typing row should be zero-height when nobody types")
213 if let cell = cv.cellForItem(at: typingIP),
214 let hosted = cell.contentView.subviews.first {
215 XCTAssertTrue(hosted.clipsToBounds, "typing hosted view must clip")
216 }
217 }
218
219 // The discriminator: same content injected AFTER the mount settles —
220 // cells then configure from a plain run-loop turn instead of inside the
221 // mount's SwiftUI transaction. If these render full-height, the wedge is
222 // transaction-timing, not content.
223 func testLateInjectionRendersFullHeights() {
224 let model = makeModel(texts: [])
225 mount(model: model)
226 model._testInject(messages: messages(
227 Array(repeating: Self.elevenReply, count: 5)))
228 pump(0.6)
229 let cv = feedCV()
230 dumpState(cv, label: "late-inject")
231 for cell in cv.visibleCells {
232 guard let item = cv.indexPath(for: cell)?.item, item < 5,
233 let hosted = cell.contentView.subviews.first else { continue }
234 XCTAssertEqual(hosted.frame.height, cell.frame.height, accuracy: 2.0,
235 "cell #\(item) hosted \(hosted.frame.height) vs cell \(cell.frame.height)")
236 }
237 // Tree autopsy: wedged vs healthy internals, for the fix hunt.
238 for probe in [1, 3] {
239 if let cell = cv.cellForItem(at: IndexPath(item: probe, section: 0)) {
240 dumpTree(cell.contentView, indent: " T#\(probe) ")
241 }
242 }
243 }
244
245 private func dumpTree(_ v: UIView, indent: String, depth: Int = 0) {
246 guard depth < 6 else { return }
247 let label = (v as? UILabel)?.text ?? ""
248 print("\(indent)\(String(repeating: "· ", count: depth))\(type(of: v)) " +
249 "\(v.frame.integral) \(label.prefix(30))")
250 for sub in v.subviews { dumpTree(sub, indent: indent, depth: depth + 1) }
251 }
252
253 // An unfurl card with an image must MEASURE with its image slot even
254 // though the measuring pass never loads images — a load-dependent card
255 // height painted over the rows below it (App test group, 2026-07-21:
256 // cell 227 vs ideal 366, the delta exactly the image slot).
257 func testUnfurlCardMeasuresImageSlotBeforeLoad() {
258 let msg = ChatFeedModel.Message(
259 id: 700, name: "James", text: "testing https://link.example/x",
260 mine: false, at: 1_700_000_000, edited: false)
261 func rowHeight(_ preview: LinkPreview) -> CGFloat {
262 var h = stubHooks()
263 h.peekUnfurl = { _ in .some(preview) }
264 let row = FeedMessageRow(message: msg, previous: nil,
265 hooks: h, interaction: FeedInteraction())
266 let hc = UIHostingController(rootView: AnyView(row))
267 hc.safeAreaRegions = []
268 return ceil(hc.sizeThatFits(
269 in: CGSize(width: 402, height: CGFloat.greatestFiniteMagnitude)).height)
270 }
271 let url = URL(string: "https://link.example/x")!
272 let bare = rowHeight(LinkPreview(
273 url: url, title: "Title", description: "Desc", imageURL: nil))
274 let withImage = rowHeight(LinkPreview(
275 url: url, title: "Title", description: "Desc",
276 imageURL: URL(string: "https://unloadable.invalid/i.png")))
277 print("UNFURL bare=\(bare) withImage=\(withImage)")
278 XCTAssertEqual(withImage - bare, 150, accuracy: 4,
279 "image slot must reserve its space before the image loads")
280 }
281
282 // Mid-feed dated separators in a GROUP (sender labels + day gaps — the
283 // App-test-group screenshot, 2026-07-21): every rendered cell must be
284 // exactly its content's ideal height, or separators paint over the
285 // neighbouring bubbles.
286 func testGroupSeparatorRowsMeasureExactly() {
287 let inst = SavedInstance(origin: "https://example.invalid",
288 deviceToken: "tok", screenName: "Paul",
289 userPub: "", userPriv: "")
290 let thread = ChatThread(id: "t1", kind: "group", title: "Group",
291 preview: "", lastAt: 0, unread: false,
292 pinned: false, hidden: false,
293 members: [], threadKeyB64: "")
294 let model = ChatFeedModel(instance: inst, thread: thread, myUserId: "me")
295 var msgs: [ChatFeedModel.Message] = []
296 let names = ["James", "Alice", "James", "James", "Bill"]
297 for i in 0..<5 {
298 msgs.append(ChatFeedModel.Message(
299 id: 100 + Int64(i), name: names[i],
300 text: "Group message number \(i) with some words in it",
301 mine: false,
302 // Multi-day gaps → a DATED separator above every row.
303 at: 1_700_000_000 + Int64(i) * 200_000, edited: false))
304 }
305 model._testInject(messages: msgs)
306 mount(model: model, isGroup: true)
307 let cv = feedCV()
308 dumpState(cv, label: "group-seps")
309 for cell in cv.visibleCells {
310 guard let item = cv.indexPath(for: cell)?.item, item < 5,
311 let hosted = cell.contentView.subviews.first else { continue }
312 let ideal = hosted.sizeThatFits(
313 CGSize(width: cv.bounds.width, height: .greatestFiniteMagnitude)).height
314 XCTAssertEqual(cell.frame.height, ideal, accuracy: 2.0,
315 "cell #\(item): laid out \(cell.frame.height) vs ideal \(ideal)")
316 }
317 assertNoOverlaps(cv)
318 }
319
320 // A prepend that CHANGES the boundary row's separator/run state (older
321 // page lands above it) must re-measure that row — a kept stale height
322 // is content taller than its cell, painting over neighbours.
323 func testPrependReMeasuresBoundaryRow() {
324 let model = makeModel(texts: [])
325 mount(model: model)
326 let cv = feedCV()
327 // Newest page only: one message, no previous → separator ABOVE it.
328 let newest = ChatFeedModel.Message(
329 id: 500, name: "Eleven", text: Self.elevenReply, mine: false,
330 at: 1_700_400_000, edited: false)
331 model._testInject(messages: [newest])
332 pump(0.4)
333 // The older page lands: same-day predecessor minutes earlier — the
334 // boundary row LOSES its separator; an older-day head gains one.
335 var all: [ChatFeedModel.Message] = []
336 for i in 0..<3 {
337 all.append(ChatFeedModel.Message(
338 id: 400 + Int64(i), name: "Eleven",
339 text: "Older message \(i)", mine: false,
340 at: 1_700_399_700 + Int64(i) * 60, edited: false))
341 }
342 all.append(newest)
343 model._testInject(messages: all)
344 pump(0.4)
345 dumpState(cv, label: "post-prepend")
346 // The last item is the typing row — zero-height by design (its
347 // content's ideal is the dots bubble; clipping hides it).
348 let count = cv.numberOfItems(inSection: 0)
349 for cell in cv.visibleCells {
350 guard let hosted = cell.contentView.subviews.first,
351 let item = cv.indexPath(for: cell)?.item,
352 item < count - 1 else { continue }
353 let ideal = hosted.sizeThatFits(
354 CGSize(width: cv.bounds.width, height: .greatestFiniteMagnitude)).height
355 XCTAssertEqual(cell.frame.height, ideal, accuracy: 2.0,
356 "cell #\(item): laid out \(cell.frame.height) vs ideal \(ideal)")
357 }
358 assertNoOverlaps(cv)
359 }
360
361 // A typer's arriving message must retire their dots in the SAME apply —
362 // the coordinator clears the local typing state itself rather than
363 // waiting for the typing-off event, so the pane moves once (the
364 // 2026-07-21 handoff jump).
365 func testArrivalReplacesTypersDots() {
366 let model = makeModel(texts: [Self.elevenReply])
367 mount(model: model)
368 let cv = feedCV()
369 model._testInject(typing: ["Eleven"])
370 pump(0.3)
371 var typingIP = IndexPath(item: cv.numberOfItems(inSection: 0) - 1, section: 0)
372 XCTAssertGreaterThan(
373 cv.layoutAttributesForItem(at: typingIP)?.frame.height ?? 0, 10,
374 "typing row should have height while Eleven types")
375
376 var all = model.messages
377 all.append(contentsOf: messages(["Here you go"], startID: 900))
378 model._testInject(messages: all) // typing deliberately NOT cleared
379 pump(0.3)
380 typingIP = IndexPath(item: cv.numberOfItems(inSection: 0) - 1, section: 0)
381 XCTAssertEqual(
382 cv.layoutAttributesForItem(at: typingIP)?.frame.height ?? -1, 0,
383 accuracy: 0.5,
384 "the arrival should retire the typer's dots in the same apply")
385 }
386
387 // The owned long-press menu (FeedMenuOverlay): presenting for a wide and
388 // a narrow bubble must float a bubble-sized snapshot (never row-wide)
389 // with the palette and the labelled card; dismissing removes it.
390 func testLongPressMenuPresentsBubbleSized() {
391 let model = makeModel(texts: [Self.elevenReply, "Yas!", Self.elevenReply])
392 let (_, controller) = mount(model: model)
393 let cv = feedCV()
394 guard let coord = controller.target as? StockChatFeed.Coordinator else {
395 XCTFail("no coordinator"); return
396 }
397 let container = cv.superview!
398 for (item, id) in [(0, Int64(420)), (1, 421), (2, 422)] {
399 let rect = coord.bubbleRect(for: .message(id))
400 XCTAssertNotNil(rect, "row #\(item) has no bubble rect")
401 if let rect {
402 XCTAssertGreaterThan(rect.width, 10)
403 XCTAssertLessThan(rect.width, cv.bounds.width - 50,
404 "row #\(item) bubble rect is row-wide")
405 }
406 coord.presentMenu(for: id)
407 pump(0.3)
408 let overlay = container.subviews
409 .compactMap { $0 as? FeedMenuOverlay }.first
410 XCTAssertNotNil(overlay, "row #\(item): no menu overlay presented")
411 if let overlay {
412 let buttons = allButtons(in: overlay)
413 // 6 reactions + Reply + Copy from the stub actions.
414 print("MENU #\(item) buttons=\(buttons.count)")
415 XCTAssertGreaterThanOrEqual(buttons.count, 8)
416 let bubbles = overlay.subviews.compactMap { $0 as? UIImageView }
417 XCTAssertEqual(bubbles.count, 1)
418 if let b = bubbles.first, let rect {
419 XCTAssertEqual(b.frame.width, rect.width, accuracy: 1.0)
420 }
421 overlay.dismiss()
422 pump(0.3)
423 let remaining = container.subviews
424 .compactMap { $0 as? FeedMenuOverlay }
425 XCTAssertTrue(remaining.isEmpty, "overlay did not dismiss")
426 }
427 }
428 // The typing row and lab rows carry no bubble.
429 XCTAssertNil(coord.bubbleRect(for: .typing))
430 }
431
432 // The open-cost number (the field's 3.1s feed.mount for 201 messages,
433 // 2026-07-21): mount a 200-message chat and time to first settled
434 // layout. Not an assertion-bound benchmark — the printed number is the
435 // regression instrument.
436 func testTwoHundredMessageMountCost() {
437 // The test host boots LChatApp, which warms the persisted cache —
438 // start empty so the cold number stays a cold number.
439 FeedHeightCache.store.removeAll()
440 var texts: [String] = []
441 for i in 0..<200 {
442 texts.append(i % 3 == 0 ? Self.elevenReply : "Message number \(i) here")
443 }
444 // Empty mount first; then time the SYNCHRONOUS main-thread cost of
445 // the 200-message publish — the apply + full measure + layout runs
446 // inside the sink, and that block IS the felt freeze.
447 let model = makeModel(texts: [])
448 mount(model: model)
449 let cv = feedCV()
450 let t0 = Date()
451 model._testInject(messages: messages(texts))
452 let syncMs = Int(Date().timeIntervalSince(t0) * 1000)
453 pump(0.3)
454 let firstContentH = cv.contentSize.height
455 print("MOUNTCOST 200 rows: sync=\(syncMs)ms " +
456 "contentH=\(Int(firstContentH))")
457 XCTAssertGreaterThan(firstContentH, 5000)
458
459 // RE-open (the field's 3.1s mounts were re-opens): a fresh screen
460 // over the same content must ride the cross-open height cache.
461 window.isHidden = true
462 let model2 = makeModel(texts: [])
463 mount(model: model2)
464 let cv2 = feedCV()
465 let t1 = Date()
466 model2._testInject(messages: messages(texts))
467 let reopenMs = Int(Date().timeIntervalSince(t1) * 1000)
468 pump(0.3)
469 print("MOUNTCOST reopen: sync=\(reopenMs)ms " +
470 "contentH=\(Int(cv2.contentSize.height))")
471 XCTAssertEqual(cv2.contentSize.height, firstContentH, accuracy: 2,
472 "cached heights diverge from measured ones")
473 XCTAssertLessThan(reopenMs, syncMs / 2 + 30,
474 "re-open did not ride the height cache")
475 }
476
477 // A relaunch must not re-pay the measure pass: the persisted cache
478 // warms from disk and the first open rides it (the device ledger's
479 // 3.1s feed.mount for 201 messages, 2026-07-21).
480 func testHeightCacheSurvivesRelaunch() {
481 FeedHeightCache.store.removeAll()
482 var texts: [String] = []
483 for i in 0..<200 {
484 texts.append(i % 3 == 0 ? Self.elevenReply : "Relaunch row \(i) content")
485 }
486 let model = makeModel(texts: [])
487 mount(model: model)
488 model._testInject(messages: messages(texts))
489 pump(0.3)
490 let contentH = feedCV().contentSize.height
491 FeedHeightCache.flushNow()
492 pump(0.5) // the detached write must land before the "relaunch"
493
494 FeedHeightCache.store.removeAll() // simulate a fresh process
495 FeedHeightCache.warm()
496 let deadline = Date().addingTimeInterval(3)
497 while FeedHeightCache.store.isEmpty, Date() < deadline { pump(0.1) }
498 XCTAssertFalse(FeedHeightCache.store.isEmpty, "warm() merged nothing from disk")
499
500 window.isHidden = true
501 let model2 = makeModel(texts: [])
502 mount(model: model2)
503 let t = Date()
504 model2._testInject(messages: messages(texts))
505 let ms = Int(Date().timeIntervalSince(t) * 1000)
506 pump(0.3)
507 print("RELAUNCH open: sync=\(ms)ms contentH=\(Int(feedCV().contentSize.height))")
508 XCTAssertEqual(feedCV().contentSize.height, contentH, accuracy: 2,
509 "warmed heights diverge from measured ones")
510 XCTAssertLessThan(ms, 60, "relaunch first open re-measured (\(ms)ms)")
511 }
512
513 private func allButtons(in v: UIView) -> [UIButton] {
514 v.subviews.flatMap { sub -> [UIButton] in
515 var out = allButtons(in: sub)
516 if let b = sub as? UIButton { out.append(b) }
517 return out
518 }
519 }
520
521 // Keyboard lift + drop over a short chat — the inset churn path.
522 func testKeyboardCycleOverShortChat() {
523 let model = makeModel(texts: Array(repeating: Self.elevenReply, count: 4))
524 mount(model: model)
525 let cv = feedCV()
526
527 NotificationCenter.default.post(
528 name: UIResponder.keyboardWillShowNotification, object: nil)
529 pump(0.2)
530 NotificationCenter.default.post(
531 name: UIResponder.keyboardWillHideNotification, object: nil)
532 pump(0.2)
533 dumpState(cv, label: "after-keyboard")
534 assertNoOverlaps(cv)
535 XCTAssertEqual(cv.contentOffset.y, -cv.adjustedContentInset.top,
536 accuracy: 1.0, "short content should rest at the top")
537 }
538}
539