| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180 |
- // @vitest-environment jsdom
- import { afterEach, describe, expect, it, vi } from 'vitest'
- import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
- import type { SettingsRootComponentProps } from '../src/client/contract/slots.ts'
- import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
- afterEach(cleanup)
- type Row = { id: string; order: number; label: string }
- /** Slot-content stand-ins: the shell renders whatever the seats contribute. */
- const SEAT_CONTENT: Record<string, string> = {
- 'settings.trigger': 'Settings',
- 'settings.header': 'Settings Title',
- 'settings.close': 'Close',
- }
- function mount({
- wide = true,
- rows = [
- { id: 'general', order: 0, label: 'General' },
- { id: 'models', order: 10, label: 'Models' },
- ],
- }: { wide?: boolean; rows?: Row[] } = {}) {
- // Mutable row store standing in for the ledger; bump() plays a change.
- let current = rows
- let version = 0
- const listeners = new Set<() => void>()
- const renderSlot = vi.fn(
- ((key: string, _owner: unknown, opts?: { only?: string }) => {
- if (key === 'settings.section') return <div data-testid={`section-${opts?.only ?? 'all'}`} />
- return SEAT_CONTENT[key]
- }) as SettingsRootComponentProps['renderSlot'],
- )
- // Global standard kit stubs: the shell consumes neither hook.
- const unusedHook = (() => { throw new Error('unused by SettingsRoot') }) as never
- const props: SettingsRootComponentProps = {
- useSessions: unusedHook,
- useWorkspaces: unusedHook,
- wide,
- sectionsVersion: () => version,
- subscribeSections: (listener) => {
- listeners.add(listener)
- return () => { listeners.delete(listener) }
- },
- sections: () => current,
- renderSlot,
- }
- const view = render(<SettingsRoot {...props} />)
- const bump = (next: Row[]) => {
- act(() => {
- current = next
- version += 1
- for (const fn of [...listeners]) fn()
- })
- }
- return { view, renderSlot, bump, listeners }
- }
- function openPanel() {
- fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
- }
- describe('SettingsRoot trigger', () => {
- it('renders the trigger seat content as the accessible name (no aria-label of its own)', () => {
- const { renderSlot } = mount()
- const trigger = screen.getByRole('button', { name: 'Settings' })
- expect(trigger.hasAttribute('aria-label')).toBe(false)
- expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide: true })
- expect(trigger.getAttribute('aria-expanded')).toBe('false')
- fireEvent.click(trigger)
- expect(screen.getByRole('dialog')).toBeTruthy()
- expect(screen.getByRole('button', { name: 'Settings', expanded: true })).toBeTruthy()
- })
- it('hands the rail state to the trigger seat', () => {
- const { renderSlot } = mount({ wide: false })
- expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide: false })
- })
- })
- describe('SettingsPanel chrome seats', () => {
- it('names the dialog via aria-labelledby pointing at the header seat node', () => {
- mount()
- openPanel()
- const dialog = screen.getByRole('dialog')
- const titleId = dialog.getAttribute('aria-labelledby')!
- expect(titleId).toBeTruthy()
- const title = document.getElementById(titleId)!
- expect(title.textContent).toBe('Settings Title')
- expect(screen.getByRole('dialog', { name: 'Settings Title' })).toBeTruthy()
- })
- it('names the close button through the visually-hidden close seat text', () => {
- mount()
- openPanel()
- const close = screen.getByRole('button', { name: 'Close' })
- expect(close.hasAttribute('aria-label')).toBe(false)
- expect(close.textContent).toContain('Close')
- })
- })
- describe('SettingsPanel close paths', () => {
- it('closes via the header button', () => {
- mount()
- openPanel()
- fireEvent.click(screen.getByRole('button', { name: 'Close' }))
- expect(screen.queryByRole('dialog')).toBeNull()
- })
- it('closes via a mask click', () => {
- mount()
- openPanel()
- const dialog = screen.getByRole('dialog')
- fireEvent.click(dialog.parentElement!.firstElementChild!)
- expect(screen.queryByRole('dialog')).toBeNull()
- })
- it('closes via document-level Escape and unhooks the listener with the panel', () => {
- mount()
- openPanel()
- fireEvent.keyDown(document, { key: 'Escape' })
- expect(screen.queryByRole('dialog')).toBeNull()
- // Ignored while closed (listener removed with the panel) and non-Escape
- // keys are ignored while open.
- fireEvent.keyDown(document, { key: 'Escape' })
- openPanel()
- fireEvent.keyDown(document, { key: 'Enter' })
- expect(screen.getByRole('dialog')).toBeTruthy()
- })
- it('lands focus on the close button when the dialog opens', () => {
- mount()
- openPanel()
- expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Close' }))
- })
- })
- describe('SettingsPanel navigation', () => {
- it('projects rows, marks the first active, and renders only that section', () => {
- mount()
- openPanel()
- expect(screen.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true')
- expect(screen.getByRole('button', { name: 'Models' }).getAttribute('aria-current')).toBeNull()
- expect(screen.getByTestId('section-general')).toBeTruthy()
- })
- it('switches the rendered section on nav click', () => {
- mount()
- openPanel()
- fireEvent.click(screen.getByRole('button', { name: 'Models' }))
- expect(screen.getByRole('button', { name: 'Models' }).getAttribute('aria-current')).toBe('true')
- expect(screen.getByTestId('section-models')).toBeTruthy()
- expect(screen.queryByTestId('section-general')).toBeNull()
- })
- it('falls back to the first row when the active entry unregisters', () => {
- const { bump } = mount()
- openPanel()
- fireEvent.click(screen.getByRole('button', { name: 'Models' }))
- bump([{ id: 'general', order: 0, label: 'General' }])
- expect(screen.queryByRole('button', { name: 'Models' })).toBeNull()
- expect(screen.getByTestId('section-general')).toBeTruthy()
- })
- it('renders an empty content column when the ledger is empty', () => {
- const { renderSlot } = mount({ rows: [] })
- openPanel()
- expect(screen.getByRole('dialog')).toBeTruthy()
- const sectionCalls = renderSlot.mock.calls.filter(c => c[0] === 'settings.section')
- expect(sectionCalls).toHaveLength(0)
- })
- it('drops the ledger subscription on unmount', () => {
- const { view, listeners } = mount()
- expect(listeners.size).toBe(1)
- view.unmount()
- expect(listeners.size).toBe(0)
- })
- })
|