Newer
Older
navi-1 / webclient / tests / unit / components / chat / messageList.test.js
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)
  })
})