menu-view.client.spec.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  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 skeleton
  6. * placeholders, pointer picks route (source, index) back without stealing
  7. * focus, the highlight is exposed through aria-activedescendant +
  8. * aria-selected, and 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-store'
  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 {
  17. InputTriggerCrumb, MenuState, TriggerHit,
  18. } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
  19. import { MenuView } from '../src/client/MenuView.tsx'
  20. const hit: TriggerHit = {
  21. trigger: '/',
  22. query: 'g',
  23. quoted: false,
  24. position: 'leading',
  25. span: { start: 0, end: 2, draftRev: 1 },
  26. }
  27. const CLOSED: MenuState = { open: false, hit: null, generation: 0, groups: [], highlight: null }
  28. function openState(partial?: Partial<MenuState>): MenuState {
  29. return {
  30. open: true,
  31. hit,
  32. generation: 1,
  33. groups: [
  34. { source: 'command', status: 'ready', items: [{ name: 'goal', description: 'Set up a goal', icon: 'file' }, { name: 'plan' }] },
  35. { source: 'skill', status: 'pending', items: [] },
  36. ],
  37. highlight: { source: 'command', index: 0 },
  38. ...partial,
  39. }
  40. }
  41. // jsdom has no scrollIntoView; the view calls it on the highlighted option.
  42. const scrollIntoView = vi.fn()
  43. beforeEach(() => {
  44. Element.prototype.scrollIntoView = scrollIntoView
  45. scrollIntoView.mockClear()
  46. })
  47. afterEach(() => {
  48. cleanup()
  49. vi.restoreAllMocks()
  50. })
  51. // The framework-injected t seat, stubbed over the zh dictionaries (the
  52. // default locale); the stub mirrors the LocaleRuntime key fallback, so an
  53. // unknown source comes back verbatim (its raw name).
  54. const t = makeTranslate(zh, commonZh)
  55. function mount(state: MenuState, crumbs: ReadonlyMap<string, readonly InputTriggerCrumb[]> = new Map()) {
  56. const menu = createSnapshotStore<MenuState>(state)
  57. const headers = createSnapshotStore<ReadonlyMap<string, readonly InputTriggerCrumb[]>>(crumbs)
  58. const onPick = vi.fn()
  59. const onCrumb = vi.fn()
  60. const onHover = vi.fn()
  61. const onDismiss = vi.fn()
  62. const view = render(
  63. <MenuView
  64. menu={menu}
  65. headers={headers}
  66. onPick={onPick}
  67. onCrumb={onCrumb}
  68. onHover={onHover}
  69. onDismiss={onDismiss}
  70. t={t}
  71. />,
  72. )
  73. return { menu, headers, onPick, onCrumb, onHover, onDismiss, view }
  74. }
  75. /** The bounded menu shell: it owns the height clamp, the listbox scrolls inside it. */
  76. function menuShell(): HTMLElement {
  77. const shell = document.querySelector('[data-trigger-menu]')
  78. if (!(shell instanceof HTMLElement)) throw new Error('menu shell is not rendered')
  79. return shell
  80. }
  81. /** The non-interactive group title rows (role=presentation), in document order. */
  82. function titles(container: HTMLElement): string[] {
  83. return [...container.querySelectorAll('div[role="presentation"][data-source]')]
  84. .map(el => el.textContent ?? '')
  85. }
  86. describe('MenuView', () => {
  87. it('renders null while closed and appears when the store opens', () => {
  88. const { menu, view } = mount(CLOSED)
  89. expect(view.container.childElementCount).toBe(0)
  90. act(() => { menu.set(openState()) })
  91. expect(screen.queryByRole('listbox')).not.toBeNull()
  92. act(() => { menu.set(CLOSED) })
  93. expect(view.container.childElementCount).toBe(0)
  94. })
  95. it('renders ready groups as option rows and pending groups as two skeleton rows', () => {
  96. mount(openState())
  97. const options = screen.getAllByRole('option')
  98. expect(options.map(o => o.textContent)).toEqual(['goalSet up a goal', 'plan'])
  99. // The icon token renders as an SVG glyph, not text.
  100. expect(options[0]?.querySelector('svg')).not.toBeNull()
  101. expect(options[1]?.querySelector('svg')).toBeNull()
  102. const status = screen.getByRole('status', { name: '正在加载…' })
  103. expect(status.children).toHaveLength(2)
  104. })
  105. it('keeps an opted-out source title hidden while its candidates are pending', () => {
  106. mount(openState({
  107. groups: [{ source: 'reference', showGroupTitle: false, status: 'pending', items: [] }],
  108. highlight: null,
  109. }))
  110. expect(screen.queryByText('reference')).toBeNull()
  111. expect(screen.getByRole('status', { name: '正在加载…' })).toBeTruthy()
  112. })
  113. it('renders retained items instead of skeletons while a refinement is pending', () => {
  114. mount(openState({
  115. groups: [{ source: 'command', status: 'pending', items: [{ name: 'goal' }] }],
  116. highlight: null,
  117. }))
  118. expect(screen.getAllByRole('option').map(o => o.textContent)).toEqual(['goal'])
  119. expect(screen.queryByRole('status')).toBeNull()
  120. })
  121. it('titles each group with the localized source name, raw name for unknown sources, none for empty ready groups', () => {
  122. const { view } = mount(openState({
  123. groups: [
  124. { source: 'command', status: 'ready', items: [{ name: 'goal' }] },
  125. { source: 'hollow', status: 'ready', items: [] },
  126. { source: 'mystery', status: 'ready', items: [{ name: 'x' }] },
  127. { source: 'skill', status: 'pending', items: [] },
  128. ],
  129. }))
  130. expect(titles(view.container)).toEqual(['指令', 'mystery', '技能'])
  131. })
  132. it('renders contiguous candidate sections once without changing option indexes', () => {
  133. const { onPick } = mount(openState({
  134. groups: [{
  135. source: 'reference',
  136. status: 'ready',
  137. items: [
  138. { name: 'Folder · src/', section: '文件与文件夹' },
  139. { name: 'File · README.md', section: '文件与文件夹' },
  140. { name: 'Session · Research', section: '对话' },
  141. ],
  142. }],
  143. highlight: { source: 'reference', index: 0 },
  144. }))
  145. expect(screen.queryByText('reference')).toBeNull()
  146. expect(screen.getAllByText('文件与文件夹')).toHaveLength(1)
  147. expect(screen.getAllByText('对话')).toHaveLength(1)
  148. const options = screen.getAllByRole('option')
  149. expect(options.map(option => option.textContent)).toEqual([
  150. 'Folder · src/',
  151. 'File · README.md',
  152. 'Session · Research',
  153. ])
  154. fireEvent.mouseDown(options[2]!)
  155. expect(onPick).toHaveBeenCalledWith('reference', 2)
  156. })
  157. it('renders the drill chevron only on drillable rows and routes its own action', () => {
  158. const { onPick } = mount(openState({
  159. groups: [{
  160. source: 'reference',
  161. status: 'ready',
  162. items: [
  163. { name: 'Folder · src/', drill: true },
  164. { name: 'File · README.md' },
  165. ],
  166. }],
  167. highlight: { source: 'reference', index: 0 },
  168. }))
  169. const chevrons = screen.getAllByRole('button', { name: '进入目录' })
  170. expect(chevrons).toHaveLength(1)
  171. // The chevron drills; the row body still settles the pick untouched.
  172. fireEvent.mouseDown(chevrons[0]!)
  173. expect(onPick).toHaveBeenCalledWith('reference', 0, 'drill')
  174. fireEvent.mouseDown(screen.getAllByRole('option')[0]!)
  175. expect(onPick).toHaveBeenCalledWith('reference', 0)
  176. })
  177. it('exposes the highlight via aria-activedescendant and aria-selected', () => {
  178. mount(openState({ highlight: { source: 'command', index: 1 } }))
  179. const listbox = screen.getByRole('listbox')
  180. const options = screen.getAllByRole('option')
  181. expect(options[1]!.id).toBeTruthy()
  182. expect(listbox.getAttribute('aria-activedescendant')).toBe(options[1]!.id)
  183. expect(options[1]!.getAttribute('aria-selected')).toBe('true')
  184. expect(options[0]!.getAttribute('aria-selected')).toBe('false')
  185. })
  186. it('omits aria-activedescendant without a highlight', () => {
  187. mount(openState({ highlight: null }))
  188. expect(screen.getByRole('listbox').getAttribute('aria-activedescendant')).toBeNull()
  189. })
  190. it('scrolls the highlighted option into view when the highlight moves', () => {
  191. const { menu } = mount(openState())
  192. scrollIntoView.mockClear()
  193. act(() => { menu.set(openState({ highlight: { source: 'command', index: 1 } })) })
  194. const options = screen.getAllByRole('option')
  195. expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
  196. expect(scrollIntoView.mock.instances.at(-1)).toBe(options[1])
  197. })
  198. it('caps the list height at the design maximum when the composer sits low enough', () => {
  199. vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 800 } as DOMRect)
  200. mount(openState())
  201. expect(menuShell().style.maxHeight).toBe('320px')
  202. })
  203. it('clamps the list height to the space above the composer minus the safe margin', () => {
  204. vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 200 } as DOMRect)
  205. mount(openState())
  206. expect(menuShell().style.maxHeight).toBe('188px')
  207. })
  208. it('re-fits the height when the window resizes', () => {
  209. const rect = vi.spyOn(Element.prototype, 'getBoundingClientRect')
  210. rect.mockReturnValue({ bottom: 800 } as DOMRect)
  211. mount(openState())
  212. expect(menuShell().style.maxHeight).toBe('320px')
  213. rect.mockReturnValue({ bottom: 100 } as DOMRect)
  214. act(() => { window.dispatchEvent(new Event('resize')) })
  215. expect(menuShell().style.maxHeight).toBe('88px')
  216. })
  217. it('pointerdown outside the menu (no composer card ancestor) dismisses', () => {
  218. const { onDismiss } = mount(openState())
  219. fireEvent.pointerDown(document.body)
  220. expect(onDismiss).toHaveBeenCalledTimes(1)
  221. })
  222. it('pointerdown inside the list does not dismiss', () => {
  223. const { onDismiss } = mount(openState())
  224. fireEvent.pointerDown(screen.getAllByRole('option')[0]!)
  225. expect(onDismiss).not.toHaveBeenCalled()
  226. })
  227. it('pointerdown inside the surrounding composer card does not dismiss; outside it does', () => {
  228. const menu = createSnapshotStore<MenuState>(openState())
  229. const onDismiss = vi.fn()
  230. render(
  231. <div data-composer-card="">
  232. <MenuView
  233. menu={menu}
  234. headers={createSnapshotStore<ReadonlyMap<string, readonly InputTriggerCrumb[]>>(new Map())}
  235. onPick={vi.fn()}
  236. onCrumb={vi.fn()}
  237. onHover={vi.fn()}
  238. onDismiss={onDismiss}
  239. t={t}
  240. />
  241. <button type="button" data-testid="composer-button" />
  242. </div>,
  243. )
  244. fireEvent.pointerDown(screen.getByTestId('composer-button'))
  245. expect(onDismiss).not.toHaveBeenCalled()
  246. fireEvent.pointerDown(document.body)
  247. expect(onDismiss).toHaveBeenCalledTimes(1)
  248. })
  249. it('ignores a pointerdown whose target is not a DOM node', () => {
  250. const { onDismiss } = mount(openState())
  251. const ev = new Event('pointerdown', { bubbles: true })
  252. Object.defineProperty(ev, 'target', { value: {} })
  253. document.dispatchEvent(ev)
  254. expect(onDismiss).not.toHaveBeenCalled()
  255. })
  256. it('closing the menu removes the dismiss listener', () => {
  257. const { menu, onDismiss } = mount(openState())
  258. act(() => { menu.set(CLOSED) })
  259. fireEvent.pointerDown(document.body)
  260. expect(onDismiss).not.toHaveBeenCalled()
  261. })
  262. it('mousedown on a row picks (source, index) and prevents the focus steal', () => {
  263. const { onPick } = mount(openState())
  264. const options = screen.getAllByRole('option')
  265. const notPrevented = fireEvent.mouseDown(options[1]!)
  266. // fireEvent returns false when preventDefault was called.
  267. expect(notPrevented).toBe(false)
  268. expect(onPick).toHaveBeenCalledWith('command', 1)
  269. })
  270. it('pointer motion over a row routes hover; the highlighted row stays silent', () => {
  271. const { onHover } = mount(openState())
  272. const options = screen.getAllByRole('option')
  273. fireEvent.mouseMove(options[1]!)
  274. expect(onHover).toHaveBeenCalledWith('command', 1)
  275. onHover.mockClear()
  276. // Index 0 already holds the highlight: no hover round-trip.
  277. fireEvent.mouseMove(options[0]!)
  278. expect(onHover).not.toHaveBeenCalled()
  279. })
  280. it('renders a source header as a breadcrumb above the list, current step last', () => {
  281. mount(openState(), new Map([['command', [
  282. { label: 'Workspace', value: 'root' },
  283. { label: 'src', value: 'src' },
  284. { label: 'module1', value: 'module1', current: true },
  285. ]]]))
  286. const nav = screen.getByRole('navigation', { name: '目录导航' })
  287. expect([...nav.querySelectorAll('button')].map(button => button.textContent))
  288. .toEqual(['Workspace', 'src', 'module1'])
  289. // The listbox holds options alone; the header is its sibling, not a row.
  290. expect(screen.getByRole('listbox').contains(nav)).toBe(false)
  291. })
  292. it('mousedown on a crumb routes (source, index) without stealing focus; the current step is inert', () => {
  293. const { onCrumb } = mount(openState(), new Map([['command', [
  294. { label: 'Workspace', value: 'root' },
  295. { label: 'src', value: 'src', current: true },
  296. ]]]))
  297. const crumbs = screen.getByRole('navigation', { name: '目录导航' }).querySelectorAll('button')
  298. expect(fireEvent.mouseDown(crumbs[0]!)).toBe(false)
  299. expect(onCrumb).toHaveBeenCalledWith('command', 0)
  300. onCrumb.mockClear()
  301. expect(crumbs[1]!.disabled).toBe(true)
  302. fireEvent.mouseDown(crumbs[1]!)
  303. expect(onCrumb).not.toHaveBeenCalled()
  304. })
  305. it('renders no header for a source that published no crumbs', () => {
  306. mount(openState())
  307. expect(screen.queryByRole('navigation')).toBeNull()
  308. })
  309. })