Files
hapi/ios/Packages/HapiKit/Tests/HapiClientTests/Chat/ChatTailFollowStateTests.swift
T
weishu 6a81d19509 fix(ios): stabilize session transcript scrolling
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.
2026-09-08 13:57:16 +08:00

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)
}
}