| 1 | import CoreGraphics |
| 2 | import 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. |
| 8 | final 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 | |