popup-view.spec.tsx 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  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, and the submitting/failed states render pending text and a
  8. * working retry button.
  9. */
  10. import { afterEach, describe, expect, it, vi } from 'vitest'
  11. import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
  12. import type { SelectOption } from '../src/client/contract.ts'
  13. import type { PopupSpec, TokenSegment } from '../src/client/popup.ts'
  14. import { PopupSelectController } from '../src/client/popup.ts'
  15. import { PopupSelectView } from '../src/client/PopupSelectView.tsx'
  16. afterEach(cleanup)
  17. const OPTIONS: SelectOption[] = [
  18. { id: 'dark', label: 'Dark' },
  19. { id: 'light', label: 'Light', active: true },
  20. { id: 'sepia', label: 'Sepia', detail: 'warm' },
  21. ]
  22. const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' }
  23. function spec(overrides: Partial<PopupSpec<string>> = {}): PopupSpec<string> {
  24. return {
  25. options: () => Promise.resolve(OPTIONS),
  26. onSelect: () => undefined,
  27. ...overrides,
  28. }
  29. }
  30. async function mountOpen(overrides: Partial<PopupSpec<string>> = {}, consumeResult = true) {
  31. const consume = vi.fn((_segment: TokenSegment) => consumeResult)
  32. const focusComposer = vi.fn()
  33. const popup = new PopupSelectController<string>({ consume, focusComposer })
  34. const view = render(<PopupSelectView popup={popup} />)
  35. await act(async () => {
  36. popup.open('theme', spec(overrides), 'ctx-A', SEGMENT)
  37. await Promise.resolve()
  38. })
  39. return { popup, view, consume, focusComposer, search: screen.getByRole('textbox', { name: 'Filter options' }) }
  40. }
  41. function rowLabels(): string[] {
  42. return screen.getAllByRole('option').map(o => o.querySelector('span')!.textContent)
  43. }
  44. describe('PopupSelectView', () => {
  45. it('renders null while closed, opens with focus in the search input', async () => {
  46. const popup = new PopupSelectController<string>({ consume: () => true, focusComposer: () => {} })
  47. const view = render(<PopupSelectView popup={popup} />)
  48. expect(view.container.childElementCount).toBe(0)
  49. await act(async () => {
  50. popup.open('theme', spec(), 'ctx-A', SEGMENT)
  51. await Promise.resolve()
  52. })
  53. const search = screen.getByRole('textbox', { name: 'Filter options' })
  54. expect(document.activeElement).toBe(search)
  55. expect(rowLabels()).toEqual(['Dark', 'Light', 'Sepia'])
  56. })
  57. it('typing filters rows locally and rebases the highlight', async () => {
  58. const options = vi.fn(() => Promise.resolve(OPTIONS))
  59. const { search } = await mountOpen({ options })
  60. act(() => { fireEvent.change(search, { target: { value: 'li' } }) })
  61. expect(rowLabels()).toEqual(['Light'])
  62. expect(screen.getByRole('option').getAttribute('aria-selected')).toBe('true')
  63. expect(options).toHaveBeenCalledTimes(1)
  64. act(() => { fireEvent.change(search, { target: { value: 'zzz' } }) })
  65. expect(screen.queryByRole('option')).toBeNull()
  66. expect(screen.queryByText('No options')).not.toBeNull()
  67. })
  68. it('ArrowUp/Down move the filtered highlight; ArrowLeft/Right are left to the native caret', async () => {
  69. const { search } = await mountOpen()
  70. act(() => { fireEvent.keyDown(search, { key: 'ArrowDown' }) })
  71. let options = screen.getAllByRole('option')
  72. expect(options[1]!.getAttribute('aria-selected')).toBe('true')
  73. act(() => { fireEvent.keyDown(search, { key: 'ArrowUp' }) })
  74. options = screen.getAllByRole('option')
  75. expect(options[0]!.getAttribute('aria-selected')).toBe('true')
  76. // fireEvent returns false when preventDefault was called: arrow left/right must NOT be intercepted.
  77. expect(fireEvent.keyDown(search, { key: 'ArrowLeft' })).toBe(true)
  78. expect(fireEvent.keyDown(search, { key: 'ArrowRight' })).toBe(true)
  79. })
  80. it('Enter selects the highlighted row: onSelect, consume, close, focusComposer', async () => {
  81. const seen: Array<{ option: SelectOption; context: string }> = []
  82. const { view, search, consume, focusComposer } = await mountOpen({
  83. onSelect: (option, context) => { seen.push({ option, context }) },
  84. })
  85. act(() => { fireEvent.keyDown(search, { key: 'ArrowDown' }) })
  86. await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) })
  87. expect(seen).toEqual([{ option: OPTIONS[1], context: 'ctx-A' }])
  88. expect(consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)
  89. expect(focusComposer).toHaveBeenCalledTimes(1)
  90. expect(view.container.childElementCount).toBe(0)
  91. })
  92. it('click selects a row; mouseenter moves the highlight', async () => {
  93. const seen: SelectOption[] = []
  94. const { view } = await mountOpen({ onSelect: (option) => { seen.push(option) } })
  95. const options = screen.getAllByRole('option')
  96. act(() => { fireEvent.mouseEnter(options[2]!) })
  97. expect(screen.getAllByRole('option')[2]!.getAttribute('aria-selected')).toBe('true')
  98. await act(async () => { fireEvent.click(options[2]!) })
  99. expect(seen).toEqual([OPTIONS[2]])
  100. expect(view.container.childElementCount).toBe(0)
  101. })
  102. it('submitting shows pending, locks the search input, and further Enter/click no-op', async () => {
  103. let release!: () => void
  104. const onSelect = vi.fn(() => new Promise<void>((resolve) => { release = resolve }))
  105. const { search, consume } = await mountOpen({ onSelect })
  106. await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) })
  107. expect(screen.queryByText('Applying…')).not.toBeNull()
  108. expect((search as HTMLInputElement).readOnly).toBe(true)
  109. await act(async () => {
  110. fireEvent.keyDown(search, { key: 'Enter' })
  111. fireEvent.click(screen.getAllByRole('option')[1]!)
  112. })
  113. expect(onSelect).toHaveBeenCalledTimes(1)
  114. await act(async () => {
  115. release()
  116. await Promise.resolve()
  117. })
  118. expect(consume).toHaveBeenCalledTimes(1)
  119. })
  120. it('a failed options load shows the error with a Retry button that reloads', async () => {
  121. let attempts = 0
  122. await mountOpen({
  123. options: () => {
  124. attempts += 1
  125. return attempts === 1 ? Promise.reject(new Error('directory down')) : Promise.resolve(OPTIONS)
  126. },
  127. })
  128. expect(screen.getByRole('alert').textContent).toContain('directory down')
  129. await act(async () => {
  130. fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
  131. await Promise.resolve()
  132. })
  133. expect(attempts).toBe(2)
  134. expect(rowLabels()).toEqual(['Dark', 'Light', 'Sepia'])
  135. })
  136. it('an onSelect failure keeps the shell open with the error strip and no retry button (re-select is the retry)', async () => {
  137. const { search, consume } = await mountOpen({ onSelect: () => Promise.reject(new Error('host rejected')) })
  138. await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) })
  139. expect(screen.getByRole('alert').textContent).toContain('host rejected')
  140. expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
  141. expect(consume).not.toHaveBeenCalled()
  142. expect(screen.getAllByRole('option').length).toBe(3)
  143. })
  144. it('Escape dismisses and restores composer focus', async () => {
  145. const { view, search, focusComposer } = await mountOpen()
  146. act(() => { fireEvent.keyDown(search, { key: 'Escape' }) })
  147. expect(view.container.childElementCount).toBe(0)
  148. expect(focusComposer).toHaveBeenCalledTimes(1)
  149. })
  150. it('an outside pointerdown dismisses without focusComposer; an inside one does not dismiss', async () => {
  151. const { view, focusComposer } = await mountOpen()
  152. act(() => { fireEvent.pointerDown(screen.getAllByRole('option')[0]!) })
  153. expect(view.container.childElementCount).not.toBe(0)
  154. act(() => { fireEvent.pointerDown(document.body) })
  155. expect(view.container.childElementCount).toBe(0)
  156. expect(focusComposer).not.toHaveBeenCalled()
  157. })
  158. })