composer.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  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. describe('composer context gauge', () => {
  5. it('shows unknown measurements instead of inventing a 256k window or remaining budget', async () => {
  6. const wrapper = mount(ComposerShell, { props: { showContext: true } })
  7. expect(wrapper.get('.composer-context').text()).toBe('等待本轮实际用量 · 窗口未确认')
  8. expect(wrapper.find('.composer-context i').exists()).toBe(false)
  9. await wrapper.setProps({ contextLimit: 262_144 })
  10. expect(wrapper.get('.composer-context').text()).toBe('等待本轮实际用量 · 窗口 256.0k')
  11. expect(wrapper.text()).not.toContain('剩余')
  12. wrapper.unmount()
  13. })
  14. it('uses confirmed context and server percentage, and clears the gauge after reset', async () => {
  15. const wrapper = mount(ComposerShell, { props: { showContext: true, contextUsed: 2000, contextLimit: 8000, contextPercent: 45.5 } })
  16. expect(wrapper.get('.composer-context').text()).toBe('2.0k / 7.8k · 剩余 5.9k')
  17. expect(wrapper.get('.composer-context b').attributes('style')).toContain('45.5%')
  18. expect(wrapper.get('.composer-context').attributes('title')).toContain('最近一次模型请求')
  19. await wrapper.setProps({ contextUsed: 0, contextPercent: 0 })
  20. expect(wrapper.get('.composer-context').text()).toBe('等待本轮实际用量 · 窗口 7.8k')
  21. expect(wrapper.find('.composer-context i').exists()).toBe(false)
  22. wrapper.unmount()
  23. })
  24. it('shows confirmed occupancy without pretending the model window is known', () => {
  25. const wrapper = mount(ComposerShell, { props: { showContext: true, contextUsed: 12_500 } })
  26. expect(wrapper.get('.composer-context').text()).toBe('当前上下文 12.2k · 窗口未确认')
  27. expect(wrapper.find('.composer-context i').exists()).toBe(false)
  28. wrapper.unmount()
  29. })
  30. })
  31. beforeEach(() => {
  32. const values = new Map<string, string>()
  33. vi.stubGlobal('localStorage', {
  34. getItem: (key: string) => values.get(key) ?? null,
  35. setItem: (key: string, value: string) => void values.set(key, value),
  36. removeItem: (key: string) => void values.delete(key),
  37. })
  38. })
  39. afterEach(() => vi.unstubAllGlobals())
  40. describe('composer availability', () => {
  41. it('opens delivery mode from the plus menu and closes the chip without losing the draft', async () => {
  42. const wrapper = mount(ComposerShell, { props: { mode: 'group', deliveryAvailable: true } })
  43. const input = wrapper.get('textarea')
  44. await input.setValue('保留这份交付草稿')
  45. expect(wrapper.find('.composer-delivery-chip').exists()).toBe(false)
  46. expect(wrapper.find('[role="menu"]').exists()).toBe(false)
  47. await wrapper.get('[aria-label="添加"]').trigger('click')
  48. const goal = wrapper.findAll('[role="menuitem"]').find(item => item.text().includes('交付目标'))!
  49. await goal.trigger('click')
  50. expect(wrapper.emitted('update:deliveryMode')?.[0]).toEqual([true])
  51. expect(wrapper.find('[role="menu"]').exists()).toBe(false)
  52. await wrapper.setProps({ deliveryMode: true })
  53. expect(input.attributes('placeholder')).toContain('描述希望团队交付的结果')
  54. expect(wrapper.get('.composer-delivery-chip').text()).toBe('交付目标')
  55. await wrapper.get('.composer-delivery-chip').trigger('click')
  56. expect(wrapper.emitted('update:deliveryMode')?.[1]).toEqual([false])
  57. await wrapper.setProps({ deliveryMode: false })
  58. expect(wrapper.find('.composer-delivery-chip').exists()).toBe(false)
  59. expect(input.element.value).toBe('保留这份交付草稿')
  60. await wrapper.get('[aria-label="发送消息"]').trigger('click')
  61. expect(wrapper.emitted('send')?.[0]).toEqual([{ text: '保留这份交付草稿', files: [], mentionIds: [] }])
  62. wrapper.unmount()
  63. })
  64. it('keeps unavailable goal actions disabled with an explanation', async () => {
  65. const wrapper = mount(ComposerShell, { props: { mode: 'group', deliveryAvailable: true, deliveryDisabled: true, deliveryDisabledReason: '负责人不可用' } })
  66. await wrapper.get('[aria-label="添加"]').trigger('click')
  67. const goal = wrapper.findAll('[role="menuitem"]').find(item => item.text().includes('交付目标'))!
  68. expect(goal.text()).toContain('负责人不可用')
  69. expect(goal.attributes('disabled')).toBeDefined()
  70. await goal.trigger('click')
  71. expect(wrapper.emitted('update:deliveryMode')).toBeUndefined()
  72. wrapper.unmount()
  73. })
  74. it('opens mentions from the plus menu and inserts the selected member', async () => {
  75. const wrapper = mount(ComposerShell, { props: { mode: 'group', mentionOptions: [{ id: 'bot', label: '小夭' }] } })
  76. await wrapper.get('[aria-label="添加"]').trigger('click')
  77. await wrapper.findAll('[role="menuitem"]').find(item => item.text() === '提及成员')!.trigger('click')
  78. expect(wrapper.get('textarea').element.value).toBe('@')
  79. await wrapper.get('[role="option"]').trigger('mousedown')
  80. expect(wrapper.get('textarea').element.value).toBe('@小夭 ')
  81. expect(wrapper.find('[role="menu"]').exists()).toBe(false)
  82. wrapper.unmount()
  83. })
  84. it('supports menu keyboard navigation, Escape focus return, and outside dismissal', async () => {
  85. const wrapper = mount(ComposerShell, { attachTo: document.body, props: { mode: 'group', deliveryAvailable: true } })
  86. const trigger = wrapper.get('[aria-label="添加"]')
  87. await trigger.trigger('click')
  88. const items = wrapper.findAll('[role="menuitem"]')
  89. expect(document.activeElement).toBe(items[0]!.element)
  90. await items[0]!.trigger('keydown', { key: 'ArrowDown' })
  91. expect(document.activeElement).toBe(items[1]!.element)
  92. await items[1]!.trigger('keydown', { key: 'Escape' })
  93. expect(wrapper.find('[role="menu"]').exists()).toBe(false)
  94. expect(document.activeElement).toBe(trigger.element)
  95. await trigger.trigger('click')
  96. document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }))
  97. await wrapper.vm.$nextTick()
  98. expect(wrapper.find('[role="menu"]').exists()).toBe(false)
  99. wrapper.unmount()
  100. })
  101. it('retains attachments and hides goal controls in ordinary chat', async () => {
  102. const wrapper = mount(ComposerShell, { props: { mode: 'chat' } })
  103. const pick = vi.spyOn(wrapper.get<HTMLInputElement>('input[type="file"]').element, 'click')
  104. await wrapper.get('[aria-label="添加附件"]').trigger('click')
  105. expect(pick).toHaveBeenCalledOnce()
  106. expect(wrapper.find('[aria-label="添加"]').exists()).toBe(false)
  107. expect(wrapper.find('.composer-delivery-chip').exists()).toBe(false)
  108. wrapper.unmount()
  109. })
  110. it('keeps the group-chat send action available while an Agent is running', () => {
  111. const wrapper = mount(ComposerShell, { props: { mode: 'group', streaming: true } })
  112. expect(wrapper.find('[aria-label="发送消息"]').exists()).toBe(true)
  113. expect(wrapper.find('[aria-label="停止生成"]').exists()).toBe(false)
  114. })
  115. it('keeps the ordinary-chat interrupt action while its server run is active', () => {
  116. const wrapper = mount(ComposerShell, { props: { mode: 'chat', streaming: true } })
  117. expect(wrapper.find('[aria-label="停止生成"]').exists()).toBe(true)
  118. expect(wrapper.find('[aria-label="发送消息"]').exists()).toBe(false)
  119. })
  120. it('keeps an interrupt action available while a running session has a draft', async () => {
  121. const wrapper = mount(ComposerShell, { props: { mode: 'group', streaming: true, stopWhileRunning: true } })
  122. await wrapper.get('textarea').setValue('等待环境时补充的内容')
  123. expect(wrapper.get('[aria-label="发送消息"]').attributes('disabled')).toBeUndefined()
  124. const stop = wrapper.get('[aria-label="停止生成"]')
  125. expect(stop.attributes('disabled')).toBeUndefined()
  126. await stop.trigger('click')
  127. expect(wrapper.emitted('stop')).toHaveLength(1)
  128. expect(wrapper.emitted('send')).toBeUndefined()
  129. })
  130. it('places an accessible blue fast-mode toggle beside the model control', () => {
  131. const wrapper = mount(ComposerShell, { props: { mode: 'chat', modelLabel: 'gpt-5.6-terra', fastMode: true } })
  132. const model = wrapper.get('.composer-tool--model')
  133. const fast = wrapper.get('.composer-fast-mode')
  134. expect(model.element.nextElementSibling).toBe(fast.element)
  135. expect(fast.attributes('aria-pressed')).toBe('true')
  136. expect(fast.classes()).toContain('active')
  137. fast.trigger('click')
  138. expect(wrapper.emitted('fastModeToggle')?.[0]).toEqual([false])
  139. })
  140. it('keeps named group activity directly above the input after a reference', () => {
  141. const wrapper = mount(ComposerShell, {
  142. props: {
  143. mode: 'group',
  144. activityText: '夭夭正在输入…',
  145. reference: { id: 'message-1', author: '夭夭', content: '上一条消息' },
  146. },
  147. })
  148. const reference = wrapper.get('.composer-reference')
  149. const activity = wrapper.get('.composer-activity-slot')
  150. const shell = wrapper.get('.composer-shell')
  151. expect(reference.element.nextElementSibling).toBe(activity.element)
  152. expect(activity.element.nextElementSibling).toBe(shell.element)
  153. expect(activity.get('[role="status"]').text()).toBe('夭夭正在输入…')
  154. })
  155. it('adds attachments when randomUUID is unavailable in an HTTP browser', async () => {
  156. const descriptor = Object.getOwnPropertyDescriptor(globalThis.crypto, 'randomUUID')
  157. Object.defineProperty(globalThis.crypto, 'randomUUID', { configurable: true, value: undefined })
  158. try {
  159. const wrapper = mount(ComposerShell)
  160. const input = wrapper.get<HTMLInputElement>('.composer-file-input')
  161. const file = new File(['兼容内容'], '兼容.txt', { type: 'text/plain', lastModified: 123 })
  162. Object.defineProperty(input.element, 'files', { configurable: true, value: [file] })
  163. await input.trigger('change')
  164. expect(wrapper.get('.composer-attachment strong').text()).toBe('兼容.txt')
  165. expect(wrapper.find('[role="alert"]').exists()).toBe(false)
  166. wrapper.unmount()
  167. } finally {
  168. if (descriptor) Object.defineProperty(globalThis.crypto, 'randomUUID', descriptor)
  169. else Reflect.deleteProperty(globalThis.crypto, 'randomUUID')
  170. }
  171. })
  172. })
  173. it('keeps a newer draft even when its text is edited back to the submitted value', async () => {
  174. const wrapper = mount(ComposerShell, { props: { mode: 'group', draftKey: 'receipt-draft' } })
  175. const input = wrapper.get('textarea')
  176. await input.setValue('原消息')
  177. const api = wrapper.vm as unknown as { submissionToken(): { key: string; revision: number }; clearAfterSend(token?: { key: string; revision: number }): boolean }
  178. const token = api.submissionToken()
  179. await input.setValue('新草稿'); await input.setValue('原消息')
  180. expect(api.clearAfterSend(token)).toBe(false)
  181. expect((input.element as HTMLTextAreaElement).value).toBe('原消息')
  182. api.clearAfterSend()
  183. await wrapper.vm.$nextTick()
  184. expect((input.element as HTMLTextAreaElement).value).toBe('')
  185. wrapper.unmount()
  186. })
  187. it('clears only the submitted stored draft after switching conversations', async () => {
  188. const wrapper = mount(ComposerShell, { props: { mode: 'group', draftKey: 'receipt-a' } })
  189. await wrapper.get('textarea').setValue('已发送 A')
  190. const api = wrapper.vm as unknown as { submissionToken(): { key: string; revision: number }; clearAfterSend(token: { key: string; revision: number }): boolean }
  191. const token = api.submissionToken()
  192. await wrapper.setProps({ draftKey: 'receipt-b' }); await wrapper.get('textarea').setValue('保留 B')
  193. expect(api.clearAfterSend(token)).toBe(false)
  194. expect((wrapper.get('textarea').element as HTMLTextAreaElement).value).toBe('保留 B')
  195. expect(localStorage.getItem(token.key)).toBeNull()
  196. wrapper.unmount()
  197. })