settings-root.spec.tsx 6.6 KB

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