settings-root.spec.tsx 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. // @vitest-environment jsdom
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
  4. import type { SettingsRootComponentProps } from '../src/client/contract/slots.ts'
  5. import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
  6. afterEach(cleanup)
  7. type Row = { id: string; order: number; label: string }
  8. /** Slot-content stand-ins: the shell renders whatever the seats contribute. */
  9. const SEAT_CONTENT: Record<string, string> = {
  10. 'settings.trigger': 'Settings',
  11. 'settings.header': 'Settings Title',
  12. 'settings.close': 'Close',
  13. }
  14. function mount({
  15. wide = true,
  16. rows = [
  17. { id: 'general', order: 0, label: 'General' },
  18. { id: 'models', order: 10, label: 'Models' },
  19. ],
  20. }: { wide?: boolean; rows?: Row[] } = {}) {
  21. // Mutable row store standing in for the ledger; bump() plays a change.
  22. let current = rows
  23. let version = 0
  24. const listeners = new Set<() => void>()
  25. const renderSlot = vi.fn(
  26. ((key: string, _owner: unknown, opts?: { only?: string }) => {
  27. if (key === 'settings.section') return <div data-testid={`section-${opts?.only ?? 'all'}`} />
  28. return SEAT_CONTENT[key]
  29. }) as SettingsRootComponentProps['renderSlot'],
  30. )
  31. // Global standard kit stubs: the shell consumes neither hook.
  32. const unusedHook = (() => { throw new Error('unused by SettingsRoot') }) as never
  33. const props: SettingsRootComponentProps = {
  34. useSessions: unusedHook,
  35. useWorkspaces: unusedHook,
  36. wide,
  37. sectionsVersion: () => version,
  38. subscribeSections: (listener) => {
  39. listeners.add(listener)
  40. return () => { listeners.delete(listener) }
  41. },
  42. sections: () => current,
  43. renderSlot,
  44. }
  45. const view = render(<SettingsRoot {...props} />)
  46. const bump = (next: Row[]) => {
  47. act(() => {
  48. current = next
  49. version += 1
  50. for (const fn of [...listeners]) fn()
  51. })
  52. }
  53. return { view, renderSlot, bump, listeners }
  54. }
  55. function openPanel() {
  56. fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
  57. }
  58. describe('SettingsRoot trigger', () => {
  59. it('renders the trigger seat content as the accessible name (no aria-label of its own)', () => {
  60. const { renderSlot } = mount()
  61. const trigger = screen.getByRole('button', { name: 'Settings' })
  62. expect(trigger.hasAttribute('aria-label')).toBe(false)
  63. expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide: true })
  64. expect(trigger.getAttribute('aria-expanded')).toBe('false')
  65. fireEvent.click(trigger)
  66. expect(screen.getByRole('dialog')).toBeTruthy()
  67. expect(screen.getByRole('button', { name: 'Settings', expanded: true })).toBeTruthy()
  68. })
  69. it('hands the rail state to the trigger seat', () => {
  70. const { renderSlot } = mount({ wide: false })
  71. expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide: false })
  72. })
  73. })
  74. describe('SettingsPanel chrome seats', () => {
  75. it('names the dialog via aria-labelledby pointing at the header seat node', () => {
  76. mount()
  77. openPanel()
  78. const dialog = screen.getByRole('dialog')
  79. const titleId = dialog.getAttribute('aria-labelledby')!
  80. expect(titleId).toBeTruthy()
  81. const title = document.getElementById(titleId)!
  82. expect(title.textContent).toBe('Settings Title')
  83. expect(screen.getByRole('dialog', { name: 'Settings Title' })).toBeTruthy()
  84. })
  85. it('names the close button through the visually-hidden close seat text', () => {
  86. mount()
  87. openPanel()
  88. const close = screen.getByRole('button', { name: 'Close' })
  89. expect(close.hasAttribute('aria-label')).toBe(false)
  90. expect(close.textContent).toContain('Close')
  91. })
  92. })
  93. describe('SettingsPanel close paths', () => {
  94. it('closes via the header button', () => {
  95. mount()
  96. openPanel()
  97. fireEvent.click(screen.getByRole('button', { name: 'Close' }))
  98. expect(screen.queryByRole('dialog')).toBeNull()
  99. })
  100. it('closes via a mask click', () => {
  101. mount()
  102. openPanel()
  103. const dialog = screen.getByRole('dialog')
  104. fireEvent.click(dialog.parentElement!.firstElementChild!)
  105. expect(screen.queryByRole('dialog')).toBeNull()
  106. })
  107. it('closes via document-level Escape and unhooks the listener with the panel', () => {
  108. mount()
  109. openPanel()
  110. fireEvent.keyDown(document, { key: 'Escape' })
  111. expect(screen.queryByRole('dialog')).toBeNull()
  112. // Ignored while closed (listener removed with the panel) and non-Escape
  113. // keys are ignored while open.
  114. fireEvent.keyDown(document, { key: 'Escape' })
  115. openPanel()
  116. fireEvent.keyDown(document, { key: 'Enter' })
  117. expect(screen.getByRole('dialog')).toBeTruthy()
  118. })
  119. it('lands focus on the close button when the dialog opens', () => {
  120. mount()
  121. openPanel()
  122. expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Close' }))
  123. })
  124. })
  125. describe('SettingsPanel navigation', () => {
  126. it('projects rows, marks the first active, and renders only that section', () => {
  127. mount()
  128. openPanel()
  129. expect(screen.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true')
  130. expect(screen.getByRole('button', { name: 'Models' }).getAttribute('aria-current')).toBeNull()
  131. expect(screen.getByTestId('section-general')).toBeTruthy()
  132. })
  133. it('switches the rendered section on nav click', () => {
  134. mount()
  135. openPanel()
  136. fireEvent.click(screen.getByRole('button', { name: 'Models' }))
  137. expect(screen.getByRole('button', { name: 'Models' }).getAttribute('aria-current')).toBe('true')
  138. expect(screen.getByTestId('section-models')).toBeTruthy()
  139. expect(screen.queryByTestId('section-general')).toBeNull()
  140. })
  141. it('falls back to the first row when the active entry unregisters', () => {
  142. const { bump } = mount()
  143. openPanel()
  144. fireEvent.click(screen.getByRole('button', { name: 'Models' }))
  145. bump([{ id: 'general', order: 0, label: 'General' }])
  146. expect(screen.queryByRole('button', { name: 'Models' })).toBeNull()
  147. expect(screen.getByTestId('section-general')).toBeTruthy()
  148. })
  149. it('renders an empty content column when the ledger is empty', () => {
  150. const { renderSlot } = mount({ rows: [] })
  151. openPanel()
  152. expect(screen.getByRole('dialog')).toBeTruthy()
  153. const sectionCalls = renderSlot.mock.calls.filter(c => c[0] === 'settings.section')
  154. expect(sectionCalls).toHaveLength(0)
  155. })
  156. it('drops the ledger subscription on unmount', () => {
  157. const { view, listeners } = mount()
  158. expect(listeners.size).toBe(1)
  159. view.unmount()
  160. expect(listeners.size).toBe(0)
  161. })
  162. })