Files
hapi/web/src/components/AssistantChat/HappyThread.test.tsx
T
SSU-WEI HUANGandGitHub f213bc348c fix(web): require user intent to cancel initial scroll (#1291)
* fix(web): require user intent to cancel initial scroll

* fix(web): snapshot scroll intent before consumption
2026-08-02 09:27:37 +08:00

344 lines
12 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import type { ComponentProps } from 'react'
import { I18nProvider } from '@/lib/i18n-context'
import {
ConversationOutlinePanel,
captureScrollAnchor,
getHistoryCoverageRetryDelay,
getPullToLoadState,
getScrollIntent,
hasAppliedHistoryVersion,
locateOutlineTargetMessage,
prependMissingUserSnapshot,
restoreScrollAnchor,
shouldLoadOlderForViewport,
shouldCancelInitialScrollSettling,
} from '@/components/AssistantChat/HappyThread'
import type { ConversationOutlineItem } from '@/chat/outline'
const outlineItems: ConversationOutlineItem[] = [
{
id: 'outline:user-text:m1',
targetMessageId: 'user-text:m1',
kind: 'user',
label: 'Implement the panel',
createdAt: 1000
},
{
id: 'outline:user-text:m2',
targetMessageId: 'user-text:m2',
kind: 'user',
label: 'Second user prompt',
createdAt: 2000
}
]
function rect(values: Pick<DOMRect, 'top' | 'bottom'> & Partial<DOMRect>): DOMRect {
return {
left: 0,
right: 300,
width: 300,
height: values.bottom - values.top,
x: 0,
y: values.top,
toJSON: () => ({}),
...values
} as DOMRect
}
function renderPanel(props: Partial<ComponentProps<typeof ConversationOutlinePanel>> = {}) {
return render(
<I18nProvider>
<ConversationOutlinePanel
items={outlineItems}
hasMoreMessages={false}
isLoadingMoreMessages={false}
onLoadMore={vi.fn()}
onSelect={vi.fn()}
onClose={vi.fn()}
{...props}
/>
</I18nProvider>
)
}
describe('ConversationOutlinePanel', () => {
it('renders outline items and selects an item', () => {
const onSelect = vi.fn()
renderPanel({ onSelect })
fireEvent.click(screen.getByText('Implement the panel'))
expect(onSelect).toHaveBeenCalledWith(outlineItems[0])
})
it('shows each message time instead of a redundant user label', () => {
const { container } = renderPanel()
const timestamps = container.querySelectorAll('time')
expect(timestamps).toHaveLength(outlineItems.length)
expect(timestamps[0]).toHaveAttribute('dateTime', new Date(outlineItems[0].createdAt).toISOString())
expect(timestamps[0]).toHaveAttribute('title')
expect(screen.queryByText('User')).not.toBeInTheDocument()
})
it('shows load earlier when older messages exist', () => {
const onLoadMore = vi.fn()
renderPanel({ hasMoreMessages: true, onLoadMore })
fireEvent.click(screen.getByRole('button', { name: /Load earlier/ }))
expect(onLoadMore).toHaveBeenCalledTimes(1)
})
it('filters loaded outline items without hiding load earlier', () => {
const onLoadMore = vi.fn()
renderPanel({ hasMoreMessages: true, onLoadMore })
fireEvent.change(screen.getByRole('searchbox', { name: 'Search outline items' }), {
target: { value: 'SECOND' }
})
expect(screen.queryByText('Implement the panel')).not.toBeInTheDocument()
expect(screen.getByText('Second user prompt')).toBeInTheDocument()
expect(screen.getByText('1 of 2 items')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Load earlier/ }))
expect(onLoadMore).toHaveBeenCalledTimes(1)
})
it('shows a search-specific empty state', () => {
renderPanel()
fireEvent.change(screen.getByRole('searchbox', { name: 'Search outline items' }), {
target: { value: 'missing' }
})
expect(screen.getByText('No matching outline items')).toBeInTheDocument()
expect(screen.queryByText('No outline items in loaded messages')).not.toBeInTheDocument()
})
it('keeps an in-panel close action available', () => {
const onClose = vi.fn()
renderPanel({ onClose })
const closeButton = screen.getByRole('button', { name: 'Close' })
expect(closeButton).toHaveClass('border', 'rounded-md', 'h-9', 'w-9')
fireEvent.click(closeButton)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('renders an empty state', () => {
renderPanel({ items: [] })
expect(screen.getByText('No outline items in loaded messages')).toBeInTheDocument()
})
})
describe('scroll anchor helpers', () => {
it('captures the first visible message relative to the viewport', () => {
const viewport = document.createElement('div')
const first = document.createElement('div')
const second = document.createElement('div')
first.id = 'first-message'
second.id = 'second-message'
viewport.className = 'viewport'
const messages = document.createElement('div')
messages.className = 'happy-thread-messages'
messages.append(first, second)
viewport.append(messages)
document.body.append(viewport)
vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue(rect({ top: 100, bottom: 500 }))
vi.spyOn(first, 'getBoundingClientRect').mockReturnValue(rect({ top: 60, bottom: 90 }))
vi.spyOn(second, 'getBoundingClientRect').mockReturnValue(rect({ top: 120, bottom: 180 }))
expect(captureScrollAnchor(viewport)).toEqual({
id: 'second-message',
topOffset: 20
})
viewport.remove()
})
it('treats upward motion near the bottom as manual scroll intent', () => {
expect(getScrollIntent({
scrollTop: 690,
previousScrollTop: 702,
scrollHeight: 1232,
clientHeight: 530
})).toMatchObject({
distanceFromBottom: 12,
isNearBottom: true,
isScrollingUp: true
})
})
it('does not classify downward movement as upward manual scroll intent', () => {
expect(getScrollIntent({
scrollTop: 702,
previousScrollTop: 690,
scrollHeight: 1232,
clientHeight: 530
})).toMatchObject({
distanceFromBottom: 0,
isNearBottom: true,
isScrollingUp: false
})
})
it('cancels initial scroll settling when the user scrolls up away from the bottom', () => {
const intent = getScrollIntent({
scrollTop: 520,
previousScrollTop: 700,
scrollHeight: 1232,
clientHeight: 530
})
expect(intent).toMatchObject({
distanceFromBottom: 182,
isScrollingUp: true
})
expect(shouldCancelInitialScrollSettling(intent, true)).toBe(true)
})
it('keeps initial scroll settling for programmatic upward movement', () => {
const intent = getScrollIntent({
scrollTop: 0,
previousScrollTop: 700,
scrollHeight: 1232,
clientHeight: 530
})
expect(shouldCancelInitialScrollSettling(intent, false)).toBe(false)
})
it('keeps initial scroll settling for negligible movement at the bottom', () => {
const intent = getScrollIntent({
scrollTop: 702,
previousScrollTop: 702,
scrollHeight: 1232,
clientHeight: 530
})
expect(intent).toMatchObject({
distanceFromBottom: 0,
isScrollingUp: false
})
expect(shouldCancelInitialScrollSettling(intent, false)).toBe(false)
})
it('restores the captured message to the same viewport offset', () => {
const viewport = document.createElement('div')
const message = document.createElement('div')
message.id = 'anchored-message'
viewport.append(message)
document.body.append(viewport)
viewport.scrollTop = 200
vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue(rect({ top: 100, bottom: 500 }))
vi.spyOn(message, 'getBoundingClientRect').mockReturnValue(rect({ top: 180, bottom: 260 }))
expect(restoreScrollAnchor(viewport, { id: 'anchored-message', topOffset: 30 })).toBe(true)
expect(viewport.scrollTop).toBe(250)
viewport.remove()
})
it('waits until assistant-ui has applied the loaded history version', () => {
expect(hasAppliedHistoryVersion(4, 4)).toBe(false)
expect(hasAppliedHistoryVersion(4, 5)).toBe(true)
})
})
describe('top-triggered history loading', () => {
it('recognizes an underfilled viewport and a top sentinel inside the preload margin', () => {
expect(shouldLoadOlderForViewport({
scrollHeight: 300,
clientHeight: 500,
viewportTop: 100,
sentinelTop: 100,
sentinelBottom: 101
})).toBe(true)
expect(shouldLoadOlderForViewport({
scrollHeight: 1_000,
clientHeight: 500,
viewportTop: 100,
sentinelTop: -200,
sentinelBottom: -199
})).toBe(false)
})
it('defers an intersection signal until the initial scroll-settling deadline', () => {
expect(getHistoryCoverageRetryDelay(2_800, 1_000)).toBe(1_816)
expect(getHistoryCoverageRetryDelay(900, 1_000)).toBe(16)
})
it('shows pull feedback at 16px and arms release loading at 64px', () => {
expect(getPullToLoadState(15)).toBe('idle')
expect(getPullToLoadState(16)).toBe('pulling')
expect(getPullToLoadState(63)).toBe('pulling')
expect(getPullToLoadState(64)).toBe('ready')
})
})
describe('outline target loading', () => {
it('loads older messages through the scroll-preserving wrapper until the target appears', async () => {
const loadOlderPreservingScroll = vi.fn<() => Promise<boolean>>()
let loadCount = 0
loadOlderPreservingScroll.mockImplementation(async () => {
loadCount += 1
return true
})
const findTarget = vi.fn((anchorId: string) => {
if (anchorId !== 'hapi-message-user-text:target') {
return null
}
return loadCount >= 2 ? document.createElement('div') : null
})
const target = await locateOutlineTargetMessage({
targetMessageId: 'user-text:target',
findTarget,
hasMoreMessages: () => loadCount < 2,
loadOlderPreservingScroll
})
expect(target).toBeInstanceOf(HTMLElement)
expect(loadOlderPreservingScroll).toHaveBeenCalledTimes(2)
expect(findTarget).toHaveBeenCalledWith('hapi-message-user-text:target')
})
it('stops when history is exhausted before the target is loaded', async () => {
const loadOlderPreservingScroll = vi.fn(async () => false)
const target = await locateOutlineTargetMessage({
targetMessageId: 'user-text:missing',
findTarget: () => null,
hasMoreMessages: () => true,
loadOlderPreservingScroll
})
expect(target).toBeNull()
expect(loadOlderPreservingScroll).toHaveBeenCalledTimes(1)
})
})
describe('share turn snapshots', () => {
it('restores the preceding user prompt when a long rendered turn only contains assistant DOM', () => {
const assistant = { html: '<div data-hapi-message-role="assistant">answer</div>', text: 'answer', role: 'assistant' as const }
const user = { html: '', text: 'original long-conversation prompt', role: 'user' as const }
expect(prependMissingUserSnapshot([assistant], user)).toEqual([user, assistant])
})
it('does not duplicate a user prompt already captured from the DOM', () => {
const user = { html: '<div data-hapi-message-role="user">prompt</div>', text: 'prompt' }
const fallback = { html: '', text: 'prompt', role: 'user' as const }
expect(prependMissingUserSnapshot([user], fallback)).toEqual([user])
})
})