mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-10 19:42:21 +00:00
Constrain transcript rows to the viewport and replace competing scroll anchors with measured tail corrections. Preserve following through media resizing and reset scroll state when the transcript remounts. Gate history paging on reader gestures and cover tail-following behavior with seven regression tests.
112 lines
5.2 KiB
Swift
112 lines
5.2 KiB
Swift
import HapiClient
|
|
import Testing
|
|
|
|
@Suite("Chat tail-following intent")
|
|
struct ChatTailFollowStateTests {
|
|
@Test func initialLayoutRequestsTheTailWithoutAMessageChange() throws {
|
|
// A newly mounted transcript already contains a refetched window;
|
|
// no subsequent model.blocks onChange is needed to position it.
|
|
var state = ChatTailFollowState()
|
|
state.layoutChanged(contentHeight: 4_000, viewportHeight: 600, isAtBottom: false)
|
|
let initialRequest = try #require(state.scrollRequest)
|
|
state.layoutChanged(contentHeight: 4_000, viewportHeight: 600, isAtBottom: false)
|
|
#expect(state.scrollRequest == initialRequest)
|
|
#expect(state.isFollowingTail)
|
|
|
|
state.layoutChanged(contentHeight: 4_000, viewportHeight: 600, isAtBottom: true)
|
|
#expect(state.scrollRequest == nil)
|
|
}
|
|
|
|
@Test func delayedImageGrowthKeepsFollowingSubsequentMessages() throws {
|
|
var state = ChatTailFollowState()
|
|
// A transcript ending in a 160 pt image placeholder is at the tail.
|
|
state.layoutChanged(contentHeight: 2_160, viewportHeight: 600, isAtBottom: true)
|
|
#expect(state.scrollRequest == nil)
|
|
|
|
// Only the rendered image changes (to 360 pt); no message event.
|
|
state.layoutChanged(contentHeight: 2_360, viewportHeight: 600, isAtBottom: false)
|
|
let correction = try #require(state.scrollRequest)
|
|
#expect(state.isFollowingTail)
|
|
|
|
// Reobserving the pre-correction position cannot cancel or requeue it.
|
|
state.layoutChanged(contentHeight: 2_360, viewportHeight: 600, isAtBottom: false)
|
|
#expect(state.scrollRequest == correction)
|
|
state.layoutChanged(contentHeight: 2_360, viewportHeight: 600, isAtBottom: true)
|
|
#expect(state.scrollRequest == nil)
|
|
#expect(state.isFollowingTail)
|
|
|
|
state.layoutChanged(contentHeight: 2_460, viewportHeight: 600, isAtBottom: false)
|
|
#expect(state.scrollRequest != nil)
|
|
#expect(state.isFollowingTail)
|
|
}
|
|
|
|
@Test func mediaGrowthInHistoryDoesNotPullTheReaderToTheTail() {
|
|
var state = ChatTailFollowState()
|
|
state.layoutChanged(contentHeight: 2_160, viewportHeight: 600, isAtBottom: true)
|
|
state.beginDragging()
|
|
state.layoutChanged(contentHeight: 2_160, viewportHeight: 600, isAtBottom: false)
|
|
state.endDragging(isAtBottom: false)
|
|
|
|
state.layoutChanged(contentHeight: 2_360, viewportHeight: 600, isAtBottom: false)
|
|
state.layoutChanged(contentHeight: 2_460, viewportHeight: 600, isAtBottom: false)
|
|
#expect(!state.isFollowingTail)
|
|
#expect(state.scrollRequest == nil)
|
|
}
|
|
|
|
@Test func readerDragCancelsAPendingResizeCorrection() {
|
|
var state = ChatTailFollowState()
|
|
state.layoutChanged(contentHeight: 2_160, viewportHeight: 600, isAtBottom: true)
|
|
state.layoutChanged(contentHeight: 2_360, viewportHeight: 600, isAtBottom: false)
|
|
#expect(state.scrollRequest != nil)
|
|
|
|
state.beginDragging()
|
|
#expect(state.scrollRequest == nil)
|
|
// More content can arrive before the finger lifts.
|
|
state.layoutChanged(contentHeight: 2_460, viewportHeight: 600, isAtBottom: false)
|
|
state.endDragging(isAtBottom: false)
|
|
#expect(!state.isFollowingTail)
|
|
#expect(state.scrollRequest == nil)
|
|
}
|
|
|
|
@Test func scrollingWithoutADragCallbackStillLeavesAndRejoinsTheTail() {
|
|
var state = ChatTailFollowState()
|
|
state.layoutChanged(contentHeight: 2_160, viewportHeight: 600, isAtBottom: true)
|
|
// Accessibility scrolling changes the offset without resizing rows.
|
|
state.layoutChanged(contentHeight: 2_160, viewportHeight: 600, isAtBottom: false)
|
|
#expect(!state.isFollowingTail)
|
|
#expect(state.scrollRequest == nil)
|
|
|
|
state.layoutChanged(contentHeight: 2_160, viewportHeight: 600, isAtBottom: true)
|
|
#expect(state.isFollowingTail)
|
|
state.layoutChanged(contentHeight: 2_360, viewportHeight: 600, isAtBottom: false)
|
|
#expect(state.scrollRequest != nil)
|
|
}
|
|
|
|
@Test func viewportResizeUsesTheSameCorrectionWithoutIdleRepeats() {
|
|
var state = ChatTailFollowState()
|
|
state.layoutChanged(contentHeight: 2_160, viewportHeight: 600, isAtBottom: true)
|
|
state.layoutChanged(contentHeight: 2_160, viewportHeight: 350, isAtBottom: false)
|
|
#expect(state.isFollowingTail)
|
|
#expect(state.scrollRequest != nil)
|
|
|
|
state.layoutChanged(contentHeight: 2_160, viewportHeight: 350, isAtBottom: true)
|
|
for _ in 0..<10 {
|
|
state.layoutChanged(contentHeight: 2_160, viewportHeight: 350, isAtBottom: true)
|
|
#expect(state.scrollRequest == nil)
|
|
}
|
|
}
|
|
|
|
@Test func newMessagesButtonWaitsForBottomGeometryBeforeSettling() {
|
|
var state = ChatTailFollowState()
|
|
state.layoutChanged(contentHeight: 2_160, viewportHeight: 600, isAtBottom: true)
|
|
state.layoutChanged(contentHeight: 2_160, viewportHeight: 600, isAtBottom: false)
|
|
|
|
state.followTail()
|
|
state.layoutChanged(contentHeight: 2_160, viewportHeight: 600, isAtBottom: false)
|
|
#expect(state.isFollowingTail)
|
|
#expect(state.scrollRequest != nil)
|
|
state.layoutChanged(contentHeight: 2_160, viewportHeight: 600, isAtBottom: true)
|
|
#expect(state.scrollRequest == nil)
|
|
}
|
|
}
|