settings-root.spec.tsx 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. // @vitest-environment jsdom
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import { useEffect, useState } from 'react'
  4. import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
  5. import type { SettingsRootComponentProps } from '../src/client/contract/slots.ts'
  6. import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
  7. afterEach(cleanup)
  8. type Row = { id: string; order: number; label: string }
  9. type Step = { id: string; order: number }
  10. /** Slot-content stand-ins: the shell renders whatever the seats contribute. */
  11. const SEAT_CONTENT: Record<string, string> = {
  12. 'settings.trigger': 'Settings',
  13. 'settings.header': 'Settings Title',
  14. 'settings.action': 'Open configuration file',
  15. 'settings.close': 'Close',
  16. }
  17. function mount({
  18. wide = true,
  19. onboardingActive = true,
  20. rows = [
  21. { id: 'general', order: 0, label: 'General' },
  22. { id: 'models', order: 10, label: 'Models' },
  23. { id: 'agent-presets', order: 20, label: 'Agent presets' },
  24. ],
  25. steps = [
  26. { id: 'welcome', order: -100 },
  27. { id: 'credential', order: 0 },
  28. ],
  29. }: { wide?: boolean; onboardingActive?: boolean; rows?: Row[]; steps?: Step[] } = {}) {
  30. // Mutable row source standing in for the bound useSections hook; bump()
  31. // plays a ledger change through the same observable contract.
  32. let current = rows
  33. const listeners = new Set<() => void>()
  34. const renderSlot = vi.fn(
  35. ((key: string, _owner: unknown, opts?: { only?: string }) => {
  36. if (key === 'settings.section') return <div data-testid={`section-${opts?.only ?? 'all'}`} />
  37. return SEAT_CONTENT[key]
  38. }) as SettingsRootComponentProps['renderSlot'],
  39. )
  40. const useSessions = ((select: (state: unknown) => unknown) => select(onboardingActive
  41. ? { phase: 'ready', current: undefined, byId: {} }
  42. : {
  43. phase: 'ready',
  44. current: 'active-session',
  45. byId: { 'active-session': { blank: false } },
  46. })) as never
  47. const unusedHook = (() => { throw new Error('unused by SettingsRoot') }) as never
  48. const props: SettingsRootComponentProps = {
  49. useSessions,
  50. useWorkspaces: unusedHook,
  51. wide,
  52. useOnboardingSteps: select => select(steps),
  53. useSections: (select) => {
  54. const [, force] = useState(0)
  55. useEffect(() => {
  56. const listener = () => { force(n => n + 1) }
  57. listeners.add(listener)
  58. return () => { listeners.delete(listener) }
  59. }, [])
  60. return select(current)
  61. },
  62. renderSlot,
  63. }
  64. const view = render(<SettingsRoot {...props} />)
  65. const bump = (next: Row[]) => {
  66. act(() => {
  67. current = next
  68. for (const fn of [...listeners]) fn()
  69. })
  70. }
  71. return { view, renderSlot, bump, listeners }
  72. }
  73. function openPanel() {
  74. fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
  75. }
  76. describe('SettingsRoot trigger', () => {
  77. it('renders the trigger seat content as the accessible name (no aria-label of its own)', () => {
  78. const { renderSlot } = mount()
  79. const trigger = screen.getByRole('button', { name: 'Settings' })
  80. expect(trigger.hasAttribute('aria-label')).toBe(false)
  81. expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide: true })
  82. expect(trigger.getAttribute('aria-expanded')).toBe('false')
  83. fireEvent.click(trigger)
  84. expect(screen.getByRole('dialog')).toBeTruthy()
  85. expect(screen.getByRole('button', { name: 'Settings', expanded: true })).toBeTruthy()
  86. })
  87. it('hands the rail state to the trigger seat', () => {
  88. const { renderSlot } = mount({ wide: false })
  89. expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide: false })
  90. })
  91. })
  92. describe('SettingsPanel chrome seats', () => {
  93. it('names the dialog via aria-labelledby pointing at the header seat node', () => {
  94. mount()
  95. openPanel()
  96. const dialog = screen.getByRole('dialog')
  97. const titleId = dialog.getAttribute('aria-labelledby')!
  98. expect(titleId).toBeTruthy()
  99. const title = document.getElementById(titleId)!
  100. expect(title.textContent).toBe('Settings Title')
  101. expect(screen.getByRole('dialog', { name: 'Settings Title' })).toBeTruthy()
  102. })
  103. it('names the close button through the visually-hidden close seat text', () => {
  104. mount()
  105. openPanel()
  106. const close = screen.getByRole('button', { name: 'Close' })
  107. expect(close.hasAttribute('aria-label')).toBe(false)
  108. expect(close.textContent).toContain('Close')
  109. })
  110. it('renders header actions before the shell-owned close control', () => {
  111. const { renderSlot } = mount()
  112. openPanel()
  113. expect(screen.getByText('Open configuration file')).toBeTruthy()
  114. expect(renderSlot).toHaveBeenCalledWith('settings.action', {})
  115. })
  116. })
  117. describe('SettingsPanel close paths', () => {
  118. it('closes via the header button', () => {
  119. mount()
  120. openPanel()
  121. fireEvent.click(screen.getByRole('button', { name: 'Close' }))
  122. expect(screen.queryByRole('dialog')).toBeNull()
  123. })
  124. it('closes via a mask click', () => {
  125. mount()
  126. openPanel()
  127. const dialog = screen.getByRole('dialog')
  128. fireEvent.click(dialog.parentElement!.firstElementChild!)
  129. expect(screen.queryByRole('dialog')).toBeNull()
  130. })
  131. it('closes via document-level Escape and unhooks the listener with the panel', () => {
  132. mount()
  133. openPanel()
  134. fireEvent.keyDown(document, { key: 'Escape' })
  135. expect(screen.queryByRole('dialog')).toBeNull()
  136. // Ignored while closed (listener removed with the panel) and non-Escape
  137. // keys are ignored while open.
  138. fireEvent.keyDown(document, { key: 'Escape' })
  139. openPanel()
  140. fireEvent.keyDown(document, { key: 'Enter' })
  141. expect(screen.getByRole('dialog')).toBeTruthy()
  142. })
  143. it('lands focus on the close button when the dialog opens', () => {
  144. mount()
  145. openPanel()
  146. expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Close' }))
  147. })
  148. })
  149. describe('SettingsPanel navigation', () => {
  150. it('projects rows, marks the first active, and renders only that section', () => {
  151. mount()
  152. openPanel()
  153. expect(screen.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true')
  154. expect(screen.getByRole('button', { name: 'Models' }).getAttribute('aria-current')).toBeNull()
  155. expect(screen.getByTestId('section-general')).toBeTruthy()
  156. })
  157. it('switches the rendered section on nav click', () => {
  158. mount()
  159. openPanel()
  160. fireEvent.click(screen.getByRole('button', { name: 'Models' }))
  161. expect(screen.getByRole('button', { name: 'Models' }).getAttribute('aria-current')).toBe('true')
  162. expect(screen.getByTestId('section-models')).toBeTruthy()
  163. expect(screen.queryByTestId('section-general')).toBeNull()
  164. })
  165. it('mounts onboarding steps in order and transfers ownership only on completion', () => {
  166. const { renderSlot } = mount()
  167. const first = renderSlot.mock.calls.find(call => call[0] === 'settings.onboarding')
  168. expect(first?.[1]).toMatchObject({ stepId: 'welcome' })
  169. expect(first?.[2]).toEqual({ only: 'welcome' })
  170. act(() => {
  171. (first?.[1] as { complete: () => void }).complete()
  172. ;(first?.[1] as { complete: () => void }).complete()
  173. })
  174. const onboardingCalls = renderSlot.mock.calls.filter(call => call[0] === 'settings.onboarding')
  175. const second = onboardingCalls.at(-1)
  176. expect(second?.[1]).toMatchObject({ stepId: 'credential' })
  177. expect(second?.[2]).toEqual({ only: 'credential' })
  178. act(() => {
  179. (second?.[1] as { openSection: (id: string) => void }).openSection('models')
  180. })
  181. expect(screen.getByRole('dialog')).toBeTruthy()
  182. expect(screen.getByTestId('section-models')).toBeTruthy()
  183. cleanup()
  184. const inactive = mount({ onboardingActive: false }).renderSlot.mock.calls
  185. .filter(call => call[0] === 'settings.onboarding')
  186. expect(inactive).toHaveLength(0)
  187. })
  188. it('makes the underlying application inert while onboarding owns the viewport', () => {
  189. const appRoot = document.createElement('div')
  190. appRoot.id = 'root'
  191. document.body.append(appRoot)
  192. const { view } = mount()
  193. expect(appRoot.inert).toBe(true)
  194. view.unmount()
  195. expect(appRoot.inert).toBe(false)
  196. appRoot.remove()
  197. })
  198. it('falls back to the first row when the active entry unregisters', () => {
  199. const { bump } = mount()
  200. openPanel()
  201. fireEvent.click(screen.getByRole('button', { name: 'Models' }))
  202. bump([{ id: 'general', order: 0, label: 'General' }])
  203. expect(screen.queryByRole('button', { name: 'Models' })).toBeNull()
  204. expect(screen.getByTestId('section-general')).toBeTruthy()
  205. })
  206. it('renders an empty content column when the ledger is empty', () => {
  207. const { renderSlot } = mount({ rows: [] })
  208. openPanel()
  209. expect(screen.getByRole('dialog')).toBeTruthy()
  210. const sectionCalls = renderSlot.mock.calls.filter(c => c[0] === 'settings.section')
  211. expect(sectionCalls).toHaveLength(0)
  212. })
  213. it('drops the ledger subscription on unmount', () => {
  214. const { view, listeners } = mount()
  215. expect(listeners.size).toBe(1)
  216. view.unmount()
  217. expect(listeners.size).toBe(0)
  218. })
  219. })