eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1import CoreGraphics
2import XCTest
3@testable import LChatCore
4
5// πŸ€– The Mac feed's scroll-position arithmetic (FeedGeometry): the reading the
6// per-thread scroll memory stores and the prepend re-anchor restores. Pinned
7// here because both callers must agree β€” a drifting reading is a jumping feed.
8final class FeedGeometryTests: XCTestCase {
9
10 // A stack of equal rows laid out from `top`, keyed 1…n.
11 private func rows(count: Int, height: CGFloat, top: CGFloat) -> [Int64: CGRect] {
12 var out: [Int64: CGRect] = [:]
13 for i in 0..<count {
14 out[Int64(i + 1)] = CGRect(x: 0, y: top + CGFloat(i) * height,
15 width: 400, height: height)
16 }
17 return out
18 }
19
20 func testPicksTheRowNearestTheViewportCentre() {
21 // 7 Γ— 100pt rows from y=50, viewport 800 β†’ centre 400. Row 4 spans
22 // 350…450, its midY exactly on the centre line: an unambiguous pick
23 // (a tie would only pin dictionary order, which is nothing to pin).
24 let r = rows(count: 7, height: 100, top: 50)
25 XCTAssertEqual(FeedGeometry.centreRow(rows: r, viewportHeight: 800)?.rowID, 4)
26 }
27
28 func testFractionRoundTripsThroughTheViewport() {
29 // One row at the very top of a viewport it doesn't fill β†’ fraction 0.
30 let top: [Int64: CGRect] = [1: CGRect(x: 0, y: 0, width: 400, height: 100)]
31 XCTAssertEqual(FeedGeometry.centreRow(rows: top, viewportHeight: 500)?.fraction, 0)
32
33 // The same row resting on the bottom edge β†’ fraction 1.
34 let bottom: [Int64: CGRect] = [1: CGRect(x: 0, y: 400, width: 400, height: 100)]
35 XCTAssertEqual(FeedGeometry.centreRow(rows: bottom, viewportHeight: 500)?.fraction, 1)
36
37 // Half way down β†’ 0.5 of the travel (viewport 500 βˆ’ row 100 = 400).
38 let mid: [Int64: CGRect] = [1: CGRect(x: 0, y: 200, width: 400, height: 100)]
39 XCTAssertEqual(FeedGeometry.centreRow(rows: mid, viewportHeight: 500)?.fraction ?? 0,
40 0.5, accuracy: 0.0001)
41 }
42
43 func testIgnoresRowsOnlyPartlyOnScreen() {
44 // Row 1 straddles the top edge and row 3 the bottom; only the
45 // fully-visible row 2 is a valid anchor.
46 let r: [Int64: CGRect] = [
47 1: CGRect(x: 0, y: -60, width: 400, height: 100),
48 2: CGRect(x: 0, y: 40, width: 400, height: 100),
49 3: CGRect(x: 0, y: 460, width: 400, height: 100),
50 ]
51 // Viewport 500: row 2 (40…140) is fully visible, row 3 (460…560) is not.
52 XCTAssertEqual(FeedGeometry.centreRow(rows: r, viewportHeight: 500)?.rowID, 2)
53 }
54
55 func testGiantRowFillingTheScreenAnchorsItsTop() {
56 // A code block taller than the viewport: no row is fully visible, so
57 // the reading is that row's top rather than nothing at all.
58 let r: [Int64: CGRect] = [9: CGRect(x: 0, y: -200, width: 400, height: 1200)]
59 let a = FeedGeometry.centreRow(rows: r, viewportHeight: 500)
60 XCTAssertEqual(a?.rowID, 9)
61 XCTAssertEqual(a?.fraction, 0)
62 }
63
64 func testNoReadingWithoutGeometry() {
65 XCTAssertNil(FeedGeometry.centreRow(rows: [:], viewportHeight: 500))
66 XCTAssertNil(FeedGeometry.centreRow(rows: rows(count: 3, height: 100, top: 0),
67 viewportHeight: 0))
68 // Every row above the viewport (the stranded-offset wedge) reads as
69 // nothing β€” the caller falls back to its own rescue.
70 let above: [Int64: CGRect] = [1: CGRect(x: 0, y: -900, width: 400, height: 100)]
71 XCTAssertNil(FeedGeometry.centreRow(rows: above, viewportHeight: 500))
72 }
73
74 // ---- lastRowIsFlush: the pin verify's states ------------------------
75
76 // A tall chat pinned: the newest row rests at the viewport's bottom. The
77 // viewport is already the composer-reduced height (safeAreaInset shrinks
78 // the scroll view's frame), so nothing is subtracted for the band.
79 func testPinnedTallChatIsFlush() {
80 let last = CGRect(x: 0, y: 300, width: 400, height: 79) // maxY 379 of 380
81 XCTAssertTrue(FeedGeometry.lastRowIsFlush(last: last, first: nil, viewportHeight: 380))
82 }
83
84 // The typing bubble sits between the newest row and the bottom β€” still
85 // flush, or the verify would never settle in a live conversation.
86 func testTypingBubbleGapIsStillFlush() {
87 let last = CGRect(x: 0, y: 240, width: 400, height: 90) // maxY 330 of 380
88 XCTAssertTrue(FeedGeometry.lastRowIsFlush(last: last, first: nil, viewportHeight: 380))
89 }
90
91 // Bug #900: the offset outlived the content and every row sits ABOVE the
92 // viewport with a negative maxY. Never flush β€” revealing it shows blank.
93 func testStrandedAboveIsNotFlush() {
94 let last = CGRect(x: 0, y: -800, width: 400, height: 100) // maxY -700
95 XCTAssertFalse(FeedGeometry.lastRowIsFlush(last: last, first: nil, viewportHeight: 380))
96 }
97
98 // Scrolled back: the newest row is below the fold.
99 func testScrolledBackIsNotFlush() {
100 let last = CGRect(x: 0, y: 900, width: 400, height: 100)
101 XCTAssertFalse(FeedGeometry.lastRowIsFlush(last: last, first: nil, viewportHeight: 380))
102 }
103
104 // A chat too short to fill the pane reads from the TOP: flush means the
105 // first row starts at the top edge and the last one is inside the viewport.
106 func testShortChatIsFlushWhenTopAligned() {
107 let first = CGRect(x: 0, y: 0, width: 400, height: 60)
108 let last = CGRect(x: 0, y: 70, width: 400, height: 60) // maxY 130
109 XCTAssertTrue(FeedGeometry.lastRowIsFlush(last: last, first: first, viewportHeight: 380))
110 // Dragged ABOVE the top edge β€” the offset outran the content, which is
111 // the short-chat form of the #900 wedge.
112 let stranded = CGRect(x: 0, y: -30, width: 400, height: 60)
113 XCTAssertFalse(FeedGeometry.lastRowIsFlush(
114 last: CGRect(x: 0, y: 40, width: 400, height: 60), first: stranded,
115 viewportHeight: 380))
116 }
117
118 func testNoGeometryIsNeverFlush() {
119 XCTAssertFalse(FeedGeometry.lastRowIsFlush(last: nil, first: nil, viewportHeight: 380))
120 XCTAssertFalse(FeedGeometry.lastRowIsFlush(
121 last: CGRect(x: 0, y: 0, width: 400, height: 60), first: nil, viewportHeight: 0))
122 }
123
124 func testNearBottomSlack() {
125 // The newest row resting just under the viewport bottom (behind the
126 // composer inset) still counts as at-the-bottom.
127 XCTAssertTrue(FeedGeometry.isNearBottom(
128 lastRow: CGRect(x: 0, y: 700, width: 400, height: 100), viewportHeight: 800))
129 XCTAssertTrue(FeedGeometry.isNearBottom(
130 lastRow: CGRect(x: 0, y: 840, width: 400, height: 100), viewportHeight: 800))
131 // A screenful further down is not.
132 XCTAssertFalse(FeedGeometry.isNearBottom(
133 lastRow: CGRect(x: 0, y: 1200, width: 400, height: 100), viewportHeight: 800))
134 XCTAssertFalse(FeedGeometry.isNearBottom(lastRow: nil, viewportHeight: 800))
135 }
136
137 // Why every near-bottom gate in NativeFeedView reads the row's BOTTOM and
138 // not its top. A newest message TALLER than the viewport (a long code
139 // block) is the case that separates them: the reader can be a screenful
140 // into scroll-back and still have that row's top above the fold, so a
141 // `minY < height + slack` test called them "at the bottom" and every
142 // trigger keyed on it β€” a composer resize, a link card landing, the typing
143 // bubble, a text-size change β€” yanked them down to the newest message.
144 func testTallNewestRowDoesNotReadAsAtTheBottom() {
145 let viewport: CGFloat = 800
146 // One 900pt message, pinned: its bottom rests at the viewport bottom,
147 // its top is far above the fold (minY = -100).
148 let pinned = CGRect(x: 0, y: -100, width: 400, height: 900)
149 XCTAssertTrue(FeedGeometry.isNearBottom(lastRow: pinned, viewportHeight: viewport))
150
151 // The same row after scrolling 300pt back INTO it: still overlapping the
152 // viewport, top now on screen β€” and emphatically not at the bottom.
153 let scrolledBack = CGRect(x: 0, y: 200, width: 400, height: 900)
154 XCTAssertFalse(FeedGeometry.isNearBottom(lastRow: scrolledBack, viewportHeight: viewport))
155 // The reading the gates used to use would have said yes to that one.
156 XCTAssertLessThan(scrolledBack.minY, viewport + 150)
157 }
158}
159