import { describe, it, expect, beforeEach } from 'vitest'
import { nextTick } from 'vue'
import { setActivePinia, createPinia } from 'pinia'
import { mount } from '@vue/test-utils'
import MessageList from '@/components/chat/MessageList.vue'
import SynapseEventNotice from '@/components/messages/SynapseEventNotice.vue'
import { useChatStore } from '@/stores/chat.js'
// jsdom has no layout engine, so the scroller's metrics are stubbed: 800px of
// content in a 400px viewport. scrollTop is a plain writable property there.
function openSession(chat) {
chat.messages = [{ id: 'u1', role: 'user', text: 'привет' }]
chat.loading = false
chat.currentId = 's1'
}
function mountList(chat) {
openSession(chat)
const wrapper = mount(MessageList)
const el = wrapper.find('.message-scroller').element
Object.defineProperty(el, 'scrollHeight', { value: 800, configurable: true })
Object.defineProperty(el, 'clientHeight', { value: 400, configurable: true })
return { wrapper, el }
}
// One streamed chunk, the way the store delivers it: onStreamStart puts the
// assistant message in the array and streamingMsg becomes that reactive proxy,
// so mutating the text wakes the component's deep watcher.
async function streamDelta(chat, text = ' world') {
chat.onStreamDelta(text)
await nextTick()
await nextTick()
}
// The pin stands down for 250ms after real input, so a test that means to check
// the direction flag (not that grace) has to let the window close first.
async function waitPastInputGrace() {
await new Promise(r => setTimeout(r, 300))
}
describe('MessageList auto-scroll', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('re-pins to the bottom when the stream finishes', async () => {
const chat = useChatStore()
const { el } = mountList(chat)
chat.streaming = true
await nextTick()
// The last per-delta pin ran before the footer/copy buttons landed, so the
// view sits short of the bottom when the stream closes.
el.scrollTop = 0
chat.streaming = false
await nextTick()
expect(el.scrollTop).toBe(800)
})
it('leaves the position alone when the user scrolled up', async () => {
const chat = useChatStore()
const { wrapper, el } = mountList(chat)
await wrapper.find('.message-scroller').trigger('scroll') // 800-0-400 > 100
chat.streaming = true
await nextTick()
el.scrollTop = 0
chat.streaming = false
await nextTick()
expect(el.scrollTop).toBe(0)
})
})
// While the agent streams, every delta re-pinned the list unless the user was
// more than 100px from the bottom — so a wheel tick or two was silently undone
// and only a large fling let go. The flag now comes from the direction of real
// input, and releasing it takes being back at the bottom.
describe('MessageList scroll during streaming', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
async function streamingList() {
const chat = useChatStore()
const { wrapper, el } = mountList(chat)
const scroller = wrapper.find('.message-scroller')
chat.onStreamStart()
await nextTick()
return { chat, scroller, el }
}
it('a small wheel-up survives the deltas that follow', async () => {
const { chat, scroller, el } = await streamingList()
el.scrollTop = 370 // 30px from the bottom — well inside the old rule
await scroller.trigger('wheel', { deltaY: -100 })
await scroller.trigger('scroll')
await waitPastInputGrace()
await streamDelta(chat)
expect(el.scrollTop).toBe(370)
})
it('the pin re-arms once the user is back at the bottom', async () => {
const { chat, scroller, el } = await streamingList()
el.scrollTop = 0
await scroller.trigger('scroll') // fallback rule: 800-0-400 > 100, flag on
el.scrollTop = 795 // 5px from the bottom
await scroller.trigger('scroll')
await streamDelta(chat)
expect(el.scrollTop).toBe(800)
})
it('a fling that leaves the bottom holds, delta after delta', async () => {
const { chat, scroller, el } = await streamingList()
el.scrollTop = 0
await scroller.trigger('scroll') // 800-0-400 > 100
await streamDelta(chat)
await streamDelta(chat)
expect(el.scrollTop).toBe(0)
})
it('a finger pulling the list up stops the pin', async () => {
const { chat, scroller, el } = await streamingList()
await scroller.trigger('touchstart', { touches: [{ clientY: 300 }] })
await scroller.trigger('touchmove', { touches: [{ clientY: 340 }] })
el.scrollTop = 370
await scroller.trigger('scroll')
await waitPastInputGrace()
await streamDelta(chat)
expect(el.scrollTop).toBe(370)
})
it('an upward wheel on a list that fits is not a request to leave the bottom', async () => {
const { chat, scroller, el } = await streamingList()
Object.defineProperty(el, 'scrollHeight', { value: 300, configurable: true })
el.scrollTop = 0
await scroller.trigger('wheel', { deltaY: -100 })
await waitPastInputGrace()
await streamDelta(chat)
// Nothing could scroll, so no scroll event would ever clear the flag again —
// the stream must keep following.
expect(el.scrollTop).toBe(300)
})
})
describe('MessageList row components', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('renders a Synapse event record with its own component', async () => {
const chat = useChatStore()
chat.loading = false
chat.currentId = 's1'
chat.messages = [
{
id: 'h_0',
role: 'system',
type: 'synapse_event',
text: 'The user got a message',
eventType: 'tg.message',
rawIndices: [0],
},
]
const wrapper = mount(MessageList, { global: { stubs: { GnIconButton: true } } })
await nextTick()
expect(wrapper.findComponent(SynapseEventNotice).exists()).toBe(true)
expect(wrapper.find('.synapse-event-label').exists()).toBe(true)
})
})