popup-view.spec.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. // @vitest-environment jsdom
  2. /**
  3. * PopupSelectView interaction spec (design §10.2): the search input takes
  4. * focus on open and plain typing filters locally, ↑↓ move the filtered
  5. * highlight while ←→ stay native to the input, Enter selects single-flight,
  6. * Escape dismisses back through focusComposer, outside pointerdown dismisses
  7. * plainly, the submitting/failed states render pending text and a working
  8. * retry button, the highlighted row scrolls into view, and the card height
  9. * clamps to the space above the composer.
  10. */
  11. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  12. import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
  13. import type { SelectOption } from '../src/client/contract.ts'
  14. import type { PopupSpec, TokenSegment } from '../src/client/popup.ts'
  15. import { PopupSelectController } from '../src/client/popup.ts'
  16. import { PopupSelectView } from '../src/client/PopupSelectView.tsx'
  17. import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
  18. import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
  19. import { zh } from '../src/client/locales.ts'
  20. // The framework-injected t seat, stubbed over the zh dictionaries (the default locale).
  21. const t: Parameters<typeof PopupSelectView>[0]['t'] = makeTranslate(zh, commonZh)
  22. // jsdom has no scrollIntoView; the view calls it on the highlighted row.
  23. const scrollIntoView = vi.fn()
  24. beforeEach(() => {
  25. Element.prototype.scrollIntoView = scrollIntoView
  26. scrollIntoView.mockClear()
  27. })
  28. afterEach(() => {
  29. cleanup()
  30. vi.restoreAllMocks()
  31. })
  32. const OPTIONS: SelectOption[] = [
  33. { id: 'dark', label: 'Dark' },
  34. { id: 'light', label: 'Light', active: true },
  35. { id: 'sepia', label: 'Sepia', detail: 'warm' },
  36. ]
  37. const GATED: SelectOption = {
  38. id: 'full',
  39. label: 'Full access',
  40. confirmation: {
  41. title: 'Enable Full access?',
  42. description: 'Sensitive operations.',
  43. acknowledgeLabel: 'I understand the risks',
  44. cancelLabel: 'Cancel',
  45. confirmLabel: 'Enable Full access',
  46. },
  47. }
  48. const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' }
  49. function spec(overrides: Partial<PopupSpec<string>> = {}): PopupSpec<string> {
  50. return {
  51. options: () => Promise.resolve(OPTIONS),
  52. onSelect: () => undefined,
  53. ...overrides,
  54. }
  55. }
  56. async function mountOpen(overrides: Partial<PopupSpec<string>> = {}, consumeResult = true) {
  57. const consume = vi.fn((_segment: TokenSegment) => consumeResult)
  58. const focusComposer = vi.fn()
  59. const popup = new PopupSelectController<string>({ consume, focusComposer })
  60. const view = render(<PopupSelectView popup={popup} t={t} />)
  61. await act(async () => {
  62. popup.open('theme', spec(overrides), 'ctx-A', SEGMENT)
  63. await Promise.resolve()
  64. })
  65. return { popup, view, consume, focusComposer, search: screen.getByRole('textbox', { name: '筛选选项' }) }
  66. }
  67. function rowLabels(): string[] {
  68. return screen.getAllByRole('option').map(o => o.querySelector('span')!.textContent)
  69. }
  70. describe('PopupSelectView', () => {
  71. it('renders null while closed, opens with focus in the search input', async () => {
  72. const popup = new PopupSelectController<string>({ consume: () => true, focusComposer: () => {} })
  73. const view = render(<PopupSelectView popup={popup} t={t} />)
  74. expect(view.container.childElementCount).toBe(0)
  75. await act(async () => {
  76. popup.open('theme', spec(), 'ctx-A', SEGMENT)
  77. await Promise.resolve()
  78. })
  79. const search = screen.getByRole('textbox', { name: '筛选选项' })
  80. expect(document.activeElement).toBe(search)
  81. expect(rowLabels()).toEqual(['Dark', 'Light', 'Sepia'])
  82. })
  83. it('typing filters rows locally and rebases the highlight', async () => {
  84. const options = vi.fn(() => Promise.resolve(OPTIONS))
  85. const { search } = await mountOpen({ options })
  86. act(() => { fireEvent.change(search, { target: { value: 'li' } }) })
  87. expect(rowLabels()).toEqual(['Light'])
  88. expect(screen.getByRole('option').getAttribute('aria-selected')).toBe('true')
  89. expect(options).toHaveBeenCalledTimes(1)
  90. act(() => { fireEvent.change(search, { target: { value: 'zzz' } }) })
  91. expect(screen.queryByRole('option')).toBeNull()
  92. expect(screen.queryByText('无选项')).not.toBeNull()
  93. })
  94. it('ArrowUp/Down move the filtered highlight; ArrowLeft/Right are left to the native caret', async () => {
  95. const { search } = await mountOpen()
  96. act(() => { fireEvent.keyDown(search, { key: 'ArrowDown' }) })
  97. let options = screen.getAllByRole('option')
  98. expect(options[1]!.getAttribute('aria-selected')).toBe('true')
  99. act(() => { fireEvent.keyDown(search, { key: 'ArrowUp' }) })
  100. options = screen.getAllByRole('option')
  101. expect(options[0]!.getAttribute('aria-selected')).toBe('true')
  102. // fireEvent returns false when preventDefault was called: arrow left/right must NOT be intercepted.
  103. expect(fireEvent.keyDown(search, { key: 'ArrowLeft' })).toBe(true)
  104. expect(fireEvent.keyDown(search, { key: 'ArrowRight' })).toBe(true)
  105. })
  106. it('scrolls the highlighted row into view when the highlight moves', async () => {
  107. const { search } = await mountOpen()
  108. scrollIntoView.mockClear()
  109. act(() => { fireEvent.keyDown(search, { key: 'ArrowDown' }) })
  110. const options = screen.getAllByRole('option')
  111. expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
  112. expect(scrollIntoView.mock.instances.at(-1)).toBe(options[1])
  113. })
  114. it('caps the card height at the design maximum when the composer sits low enough', async () => {
  115. vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 800 } as DOMRect)
  116. await mountOpen()
  117. expect(screen.getByLabelText('/theme 选项').style.maxHeight).toBe('320px')
  118. })
  119. it('clamps the card height to the space above the composer minus the safe margin', async () => {
  120. vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 200 } as DOMRect)
  121. await mountOpen()
  122. expect(screen.getByLabelText('/theme 选项').style.maxHeight).toBe('188px')
  123. })
  124. it('Enter selects the highlighted row: onSelect, consume, close, focusComposer', async () => {
  125. const seen: Array<{ option: SelectOption; context: string }> = []
  126. const { view, search, consume, focusComposer } = await mountOpen({
  127. onSelect: (option, context) => { seen.push({ option, context }) },
  128. })
  129. act(() => { fireEvent.keyDown(search, { key: 'ArrowDown' }) })
  130. await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) })
  131. expect(seen).toEqual([{ option: OPTIONS[1], context: 'ctx-A' }])
  132. expect(consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)
  133. expect(focusComposer).toHaveBeenCalledTimes(1)
  134. expect(view.container.childElementCount).toBe(0)
  135. })
  136. it('click selects a row; mouseenter moves the highlight', async () => {
  137. const seen: SelectOption[] = []
  138. const { view } = await mountOpen({ onSelect: (option) => { seen.push(option) } })
  139. const options = screen.getAllByRole('option')
  140. act(() => { fireEvent.mouseEnter(options[2]!) })
  141. expect(screen.getAllByRole('option')[2]!.getAttribute('aria-selected')).toBe('true')
  142. await act(async () => { fireEvent.click(options[2]!) })
  143. expect(seen).toEqual([OPTIONS[2]])
  144. expect(view.container.childElementCount).toBe(0)
  145. })
  146. it('renders a gated option as an in-page modal and requires the checkbox before onSelect', async () => {
  147. const onSelect = vi.fn()
  148. const { popup, consume } = await mountOpen({
  149. options: () => Promise.resolve([GATED]),
  150. onSelect,
  151. })
  152. await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) })
  153. expect(screen.queryByLabelText('/theme 选项')).toBeNull()
  154. expect(screen.getByRole('dialog', { name: 'Enable Full access?' })).toBeTruthy()
  155. const enable = screen.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement
  156. expect(enable.disabled).toBe(true)
  157. expect(onSelect).not.toHaveBeenCalled()
  158. fireEvent.click(screen.getByRole('checkbox', { name: 'I understand the risks' }))
  159. expect(enable.disabled).toBe(false)
  160. await act(async () => { fireEvent.click(enable) })
  161. expect(onSelect).toHaveBeenCalledExactlyOnceWith(GATED, 'ctx-A')
  162. expect(consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)
  163. expect(popup.state.getSnapshot().open).toBe(false)
  164. })
  165. it('canceling a gated option returns to the picker with acknowledgement reset', async () => {
  166. await mountOpen({ options: () => Promise.resolve([GATED]) })
  167. await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) })
  168. fireEvent.click(screen.getByRole('checkbox'))
  169. fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
  170. expect(screen.getByLabelText('/theme 选项')).toBeTruthy()
  171. await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) })
  172. expect(screen.getByRole<HTMLInputElement>('checkbox').checked).toBe(false)
  173. })
  174. it('submitting shows pending, locks the search input, and further Enter/click no-op', async () => {
  175. let release!: () => void
  176. const onSelect = vi.fn(() => new Promise<void>((resolve) => { release = resolve }))
  177. const { search, consume } = await mountOpen({ onSelect })
  178. await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) })
  179. expect(screen.queryByText('正在应用…')).not.toBeNull()
  180. expect((search as HTMLInputElement).readOnly).toBe(true)
  181. await act(async () => {
  182. fireEvent.keyDown(search, { key: 'Enter' })
  183. fireEvent.click(screen.getAllByRole('option')[1]!)
  184. })
  185. expect(onSelect).toHaveBeenCalledTimes(1)
  186. await act(async () => {
  187. release()
  188. await Promise.resolve()
  189. })
  190. expect(consume).toHaveBeenCalledTimes(1)
  191. })
  192. it('a failed options load shows the error with a retry button that reloads', async () => {
  193. let attempts = 0
  194. await mountOpen({
  195. options: () => {
  196. attempts += 1
  197. return attempts === 1 ? Promise.reject(new Error('directory down')) : Promise.resolve(OPTIONS)
  198. },
  199. })
  200. expect(screen.getByRole('alert').textContent).toContain('directory down')
  201. await act(async () => {
  202. fireEvent.click(screen.getByRole('button', { name: '重试' }))
  203. await Promise.resolve()
  204. })
  205. expect(attempts).toBe(2)
  206. expect(rowLabels()).toEqual(['Dark', 'Light', 'Sepia'])
  207. })
  208. it('an onSelect failure keeps the shell open with the error strip and no retry button (re-select is the retry)', async () => {
  209. const { search, consume } = await mountOpen({ onSelect: () => Promise.reject(new Error('host rejected')) })
  210. await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) })
  211. expect(screen.getByRole('alert').textContent).toContain('host rejected')
  212. expect(screen.queryByRole('button', { name: '重试' })).toBeNull()
  213. expect(consume).not.toHaveBeenCalled()
  214. expect(screen.getAllByRole('option').length).toBe(3)
  215. })
  216. it('Escape dismisses and restores composer focus', async () => {
  217. const { view, search, focusComposer } = await mountOpen()
  218. act(() => { fireEvent.keyDown(search, { key: 'Escape' }) })
  219. expect(view.container.childElementCount).toBe(0)
  220. expect(focusComposer).toHaveBeenCalledTimes(1)
  221. })
  222. it('an outside pointerdown dismisses without focusComposer; an inside one does not dismiss', async () => {
  223. const { view, focusComposer } = await mountOpen()
  224. act(() => { fireEvent.pointerDown(screen.getAllByRole('option')[0]!) })
  225. expect(view.container.childElementCount).not.toBe(0)
  226. act(() => { fireEvent.pointerDown(document.body) })
  227. expect(view.container.childElementCount).toBe(0)
  228. expect(focusComposer).not.toHaveBeenCalled()
  229. })
  230. })