composer.test.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. import { mount } from '@vue/test-utils'
  2. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  3. import ComposerShell from '@/components/composer/ComposerShell.vue'
  4. beforeEach(() => {
  5. const values = new Map<string, string>()
  6. vi.stubGlobal('localStorage', {
  7. getItem: (key: string) => values.get(key) ?? null,
  8. setItem: (key: string, value: string) => void values.set(key, value),
  9. removeItem: (key: string) => void values.delete(key),
  10. })
  11. })
  12. afterEach(() => vi.unstubAllGlobals())
  13. describe('composer availability', () => {
  14. it('keeps the group-chat send action available while an Agent is running', () => {
  15. const wrapper = mount(ComposerShell, { props: { mode: 'group', streaming: true } })
  16. expect(wrapper.find('[aria-label="发送消息"]').exists()).toBe(true)
  17. expect(wrapper.find('[aria-label="停止生成"]').exists()).toBe(false)
  18. })
  19. it('keeps the ordinary-chat interrupt action while its server run is active', () => {
  20. const wrapper = mount(ComposerShell, { props: { mode: 'chat', streaming: true } })
  21. expect(wrapper.find('[aria-label="停止生成"]').exists()).toBe(true)
  22. expect(wrapper.find('[aria-label="发送消息"]').exists()).toBe(false)
  23. })
  24. it('keeps an interrupt action available while a running session has a draft', async () => {
  25. const wrapper = mount(ComposerShell, { props: { mode: 'group', streaming: true, stopWhileRunning: true } })
  26. await wrapper.get('textarea').setValue('等待环境时补充的内容')
  27. expect(wrapper.get('[aria-label="发送消息"]').attributes('disabled')).toBeUndefined()
  28. const stop = wrapper.get('[aria-label="停止生成"]')
  29. expect(stop.attributes('disabled')).toBeUndefined()
  30. await stop.trigger('click')
  31. expect(wrapper.emitted('stop')).toHaveLength(1)
  32. expect(wrapper.emitted('send')).toBeUndefined()
  33. })
  34. it('places an accessible blue fast-mode toggle beside the model control', () => {
  35. const wrapper = mount(ComposerShell, { props: { mode: 'chat', modelLabel: 'gpt-5.6-terra', fastMode: true } })
  36. const model = wrapper.get('.composer-tool--model')
  37. const fast = wrapper.get('.composer-fast-mode')
  38. expect(model.element.nextElementSibling).toBe(fast.element)
  39. expect(fast.attributes('aria-pressed')).toBe('true')
  40. expect(fast.classes()).toContain('active')
  41. fast.trigger('click')
  42. expect(wrapper.emitted('fastModeToggle')?.[0]).toEqual([false])
  43. })
  44. it('keeps named group activity directly above the input after a reference', () => {
  45. const wrapper = mount(ComposerShell, {
  46. props: {
  47. mode: 'group',
  48. activityText: '夭夭正在输入…',
  49. reference: { id: 'message-1', author: '夭夭', content: '上一条消息' },
  50. },
  51. })
  52. const reference = wrapper.get('.composer-reference')
  53. const activity = wrapper.get('.composer-activity-slot')
  54. const shell = wrapper.get('.composer-shell')
  55. expect(reference.element.nextElementSibling).toBe(activity.element)
  56. expect(activity.element.nextElementSibling).toBe(shell.element)
  57. expect(activity.get('[role="status"]').text()).toBe('夭夭正在输入…')
  58. })
  59. it('adds attachments when randomUUID is unavailable in an HTTP browser', async () => {
  60. const descriptor = Object.getOwnPropertyDescriptor(globalThis.crypto, 'randomUUID')
  61. Object.defineProperty(globalThis.crypto, 'randomUUID', { configurable: true, value: undefined })
  62. try {
  63. const wrapper = mount(ComposerShell)
  64. const input = wrapper.get<HTMLInputElement>('.composer-file-input')
  65. const file = new File(['兼容内容'], '兼容.txt', { type: 'text/plain', lastModified: 123 })
  66. Object.defineProperty(input.element, 'files', { configurable: true, value: [file] })
  67. await input.trigger('change')
  68. expect(wrapper.get('.composer-attachment strong').text()).toBe('兼容.txt')
  69. expect(wrapper.find('[role="alert"]').exists()).toBe(false)
  70. wrapper.unmount()
  71. } finally {
  72. if (descriptor) Object.defineProperty(globalThis.crypto, 'randomUUID', descriptor)
  73. else Reflect.deleteProperty(globalThis.crypto, 'randomUUID')
  74. }
  75. })
  76. })
  77. it('keeps a newer draft even when its text is edited back to the submitted value', async () => {
  78. const wrapper = mount(ComposerShell, { props: { mode: 'group', draftKey: 'receipt-draft' } })
  79. const input = wrapper.get('textarea')
  80. await input.setValue('原消息')
  81. const api = wrapper.vm as unknown as { submissionToken(): { key: string; revision: number }; clearAfterSend(token?: { key: string; revision: number }): boolean }
  82. const token = api.submissionToken()
  83. await input.setValue('新草稿'); await input.setValue('原消息')
  84. expect(api.clearAfterSend(token)).toBe(false)
  85. expect((input.element as HTMLTextAreaElement).value).toBe('原消息')
  86. api.clearAfterSend()
  87. await wrapper.vm.$nextTick()
  88. expect((input.element as HTMLTextAreaElement).value).toBe('')
  89. wrapper.unmount()
  90. })
  91. it('clears only the submitted stored draft after switching conversations', async () => {
  92. const wrapper = mount(ComposerShell, { props: { mode: 'group', draftKey: 'receipt-a' } })
  93. await wrapper.get('textarea').setValue('已发送 A')
  94. const api = wrapper.vm as unknown as { submissionToken(): { key: string; revision: number }; clearAfterSend(token: { key: string; revision: number }): boolean }
  95. const token = api.submissionToken()
  96. await wrapper.setProps({ draftKey: 'receipt-b' }); await wrapper.get('textarea').setValue('保留 B')
  97. expect(api.clearAfterSend(token)).toBe(false)
  98. expect((wrapper.get('textarea').element as HTMLTextAreaElement).value).toBe('保留 B')
  99. expect(localStorage.getItem(token.key)).toBeNull()
  100. wrapper.unmount()
  101. })