menu-view.client.spec.tsx 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. // @vitest-environment jsdom
  2. /**
  3. * MenuView rendering spec, props-direct: closed store
  4. * renders null, groups render in roster order under localized title rows
  5. * (unknown sources fall back to the raw name) with pending rows as loading,
  6. * pointer picks route (source, index) back without stealing focus, the
  7. * highlight is exposed through aria-activedescendant + aria-selected, and
  8. * the list height clamps to the space above the composer.
  9. */
  10. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  11. import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
  12. import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
  13. import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
  14. import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
  15. import { zh } from '../src/client/locales.ts'
  16. import type { MenuState, TriggerHit } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
  17. import { MenuView } from '../src/client/MenuView.tsx'
  18. const hit: TriggerHit = {
  19. trigger: '/',
  20. query: 'g',
  21. position: 'leading',
  22. span: { start: 0, end: 2, draftRev: 1 },
  23. }
  24. const CLOSED: MenuState = { open: false, hit: null, generation: 0, groups: [], highlight: null }
  25. function openState(partial?: Partial<MenuState>): MenuState {
  26. return {
  27. open: true,
  28. hit,
  29. generation: 1,
  30. groups: [
  31. { source: 'command', status: 'ready', items: [{ name: 'goal', description: 'Set up a goal', icon: '⚑' }, { name: 'plan' }] },
  32. { source: 'skill', status: 'pending', items: [] },
  33. ],
  34. highlight: { source: 'command', index: 0 },
  35. ...partial,
  36. }
  37. }
  38. // jsdom has no scrollIntoView; the view calls it on the highlighted option.
  39. const scrollIntoView = vi.fn()
  40. beforeEach(() => {
  41. Element.prototype.scrollIntoView = scrollIntoView
  42. scrollIntoView.mockClear()
  43. })
  44. afterEach(() => {
  45. cleanup()
  46. vi.restoreAllMocks()
  47. })
  48. // The framework-injected t seat, stubbed over the zh dictionaries (the
  49. // default locale); the stub mirrors the LocaleRuntime key fallback, so an
  50. // unknown source comes back verbatim (its raw name).
  51. const t = makeTranslate(zh, commonZh)
  52. function mount(state: MenuState) {
  53. const menu = createSnapshotStore<MenuState>(state)
  54. const onPick = vi.fn()
  55. const onDismiss = vi.fn()
  56. const view = render(<MenuView menu={menu} onPick={onPick} onDismiss={onDismiss} t={t} />)
  57. return { menu, onPick, onDismiss, view }
  58. }
  59. /** The non-interactive group title rows (role=presentation), in document order. */
  60. function titles(container: HTMLElement): string[] {
  61. return [...container.querySelectorAll('div[role="presentation"][data-source]')]
  62. .map(el => el.textContent ?? '')
  63. }
  64. describe('MenuView', () => {
  65. it('renders null while closed and appears when the store opens', () => {
  66. const { menu, view } = mount(CLOSED)
  67. expect(view.container.childElementCount).toBe(0)
  68. act(() => { menu.set(openState()) })
  69. expect(screen.queryByRole('listbox')).not.toBeNull()
  70. act(() => { menu.set(CLOSED) })
  71. expect(view.container.childElementCount).toBe(0)
  72. })
  73. it('renders ready groups as option rows and pending groups as loading rows', () => {
  74. mount(openState())
  75. const options = screen.getAllByRole('option')
  76. expect(options.map(o => o.textContent)).toEqual(['⚑goalSet up a goal', 'plan'])
  77. expect(screen.queryByText('正在加载…')).not.toBeNull()
  78. })
  79. it('titles each group with the localized source name, raw name for unknown sources, none for empty ready groups', () => {
  80. const { view } = mount(openState({
  81. groups: [
  82. { source: 'command', status: 'ready', items: [{ name: 'goal' }] },
  83. { source: 'hollow', status: 'ready', items: [] },
  84. { source: 'mystery', status: 'ready', items: [{ name: 'x' }] },
  85. { source: 'skill', status: 'pending', items: [] },
  86. ],
  87. }))
  88. expect(titles(view.container)).toEqual(['命令', 'mystery', '技能'])
  89. })
  90. it('exposes the highlight via aria-activedescendant and aria-selected', () => {
  91. mount(openState({ highlight: { source: 'command', index: 1 } }))
  92. const listbox = screen.getByRole('listbox')
  93. const options = screen.getAllByRole('option')
  94. expect(options[1]!.id).toBeTruthy()
  95. expect(listbox.getAttribute('aria-activedescendant')).toBe(options[1]!.id)
  96. expect(options[1]!.getAttribute('aria-selected')).toBe('true')
  97. expect(options[0]!.getAttribute('aria-selected')).toBe('false')
  98. })
  99. it('omits aria-activedescendant without a highlight', () => {
  100. mount(openState({ highlight: null }))
  101. expect(screen.getByRole('listbox').getAttribute('aria-activedescendant')).toBeNull()
  102. })
  103. it('scrolls the highlighted option into view when the highlight moves', () => {
  104. const { menu } = mount(openState())
  105. scrollIntoView.mockClear()
  106. act(() => { menu.set(openState({ highlight: { source: 'command', index: 1 } })) })
  107. const options = screen.getAllByRole('option')
  108. expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
  109. expect(scrollIntoView.mock.instances.at(-1)).toBe(options[1])
  110. })
  111. it('caps the list height at the design maximum when the composer sits low enough', () => {
  112. vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 800 } as DOMRect)
  113. mount(openState())
  114. expect(screen.getByRole('listbox').style.maxHeight).toBe('320px')
  115. })
  116. it('clamps the list height to the space above the composer minus the safe margin', () => {
  117. vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 200 } as DOMRect)
  118. mount(openState())
  119. expect(screen.getByRole('listbox').style.maxHeight).toBe('188px')
  120. })
  121. it('re-fits the height when the window resizes', () => {
  122. const rect = vi.spyOn(Element.prototype, 'getBoundingClientRect')
  123. rect.mockReturnValue({ bottom: 800 } as DOMRect)
  124. mount(openState())
  125. expect(screen.getByRole('listbox').style.maxHeight).toBe('320px')
  126. rect.mockReturnValue({ bottom: 100 } as DOMRect)
  127. act(() => { window.dispatchEvent(new Event('resize')) })
  128. expect(screen.getByRole('listbox').style.maxHeight).toBe('88px')
  129. })
  130. it('pointerdown outside the menu (no composer card ancestor) dismisses', () => {
  131. const { onDismiss } = mount(openState())
  132. fireEvent.pointerDown(document.body)
  133. expect(onDismiss).toHaveBeenCalledTimes(1)
  134. })
  135. it('pointerdown inside the list does not dismiss', () => {
  136. const { onDismiss } = mount(openState())
  137. fireEvent.pointerDown(screen.getAllByRole('option')[0]!)
  138. expect(onDismiss).not.toHaveBeenCalled()
  139. })
  140. it('pointerdown inside the surrounding composer card does not dismiss; outside it does', () => {
  141. const menu = createSnapshotStore<MenuState>(openState())
  142. const onDismiss = vi.fn()
  143. render(
  144. <div data-composer-card="">
  145. <MenuView menu={menu} onPick={vi.fn()} onDismiss={onDismiss} t={t} />
  146. <button type="button" data-testid="composer-button" />
  147. </div>,
  148. )
  149. fireEvent.pointerDown(screen.getByTestId('composer-button'))
  150. expect(onDismiss).not.toHaveBeenCalled()
  151. fireEvent.pointerDown(document.body)
  152. expect(onDismiss).toHaveBeenCalledTimes(1)
  153. })
  154. it('ignores a pointerdown whose target is not a DOM node', () => {
  155. const { onDismiss } = mount(openState())
  156. const ev = new Event('pointerdown', { bubbles: true })
  157. Object.defineProperty(ev, 'target', { value: {} })
  158. document.dispatchEvent(ev)
  159. expect(onDismiss).not.toHaveBeenCalled()
  160. })
  161. it('closing the menu removes the dismiss listener', () => {
  162. const { menu, onDismiss } = mount(openState())
  163. act(() => { menu.set(CLOSED) })
  164. fireEvent.pointerDown(document.body)
  165. expect(onDismiss).not.toHaveBeenCalled()
  166. })
  167. it('mousedown on a row picks (source, index) and prevents the focus steal', () => {
  168. const { onPick } = mount(openState())
  169. const options = screen.getAllByRole('option')
  170. const notPrevented = fireEvent.mouseDown(options[1]!)
  171. // fireEvent returns false when preventDefault was called.
  172. expect(notPrevented).toBe(false)
  173. expect(onPick).toHaveBeenCalledWith('command', 1)
  174. })
  175. })