Files
developer d6c7464170 feat(app): add AppStateProvider for shared global settings
Central context for the settings a template app shares instead of prop
drilling: theme, viewMode, sidebarCollapsed and the search palette, with
the ⌘K/Ctrl+K shortcut moved out of App.

- settings persist on user action only (an effect-written theme would
  freeze the OS preference for users who never toggled)
- theme still boots from the <html data-theme> attribute the pre-paint
  script set; viewMode mirrors to data-view-mode as the audience-preset
  hook and deliberately gates nothing yet
- Topbar: viewMode segmented control, theme toggle and search now read
  the context; useAppState throws outside the provider
- tests/appState.test.tsx: defaults, persistence, rehydration, garbage
  storage values, ⌘K shortcut
2026-09-15 18:34:22 +00:00

114 lines
4.0 KiB
TypeScript

import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AppStateProvider, useAppState } from '../src/app'
function Probe() {
const { theme, viewMode, sidebarCollapsed, searchOpen, toggleTheme, setViewMode, toggleSidebar } = useAppState()
return (
<div>
<span data-testid="state">{`${theme}|${viewMode}|${sidebarCollapsed}|${searchOpen}`}</span>
<button onClick={toggleTheme}>theme</button>
<button onClick={() => setViewMode('compact')}>compact</button>
<button onClick={toggleSidebar}>sidebar</button>
</div>
)
}
function renderApp() {
return render(
<AppStateProvider>
<Probe />
</AppStateProvider>,
)
}
const state = () => screen.getByTestId('state').textContent
describe('AppStateProvider', () => {
beforeEach(() => {
localStorage.clear()
document.documentElement.classList.remove('dark', 'light')
document.documentElement.removeAttribute('data-theme')
document.documentElement.removeAttribute('data-view-mode')
})
afterEach(() => {
vi.restoreAllMocks()
})
it('throws when useAppState is called outside the provider', () => {
// React logs the render error too — keep the output readable.
vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => render(<Probe />)).toThrow(/AppStateProvider/)
})
it('starts from the defaults and mirrors them onto <html>', () => {
renderApp()
expect(state()).toBe('dark|default|false|false')
expect(document.documentElement.dataset.theme).toBe('dark')
expect(document.documentElement.dataset.viewMode).toBe('default')
})
it('persists settings on user action only, so prefers-color-scheme keeps working', () => {
renderApp()
// Nothing was stored by the initial mount — an effect-driven write would
// freeze the OS-derived theme for users who never toggled anything.
expect(localStorage.getItem('ns-theme')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'theme' }))
fireEvent.click(screen.getByRole('button', { name: 'compact' }))
fireEvent.click(screen.getByRole('button', { name: 'sidebar' }))
expect(localStorage.getItem('ns-theme')).toBe('light')
expect(localStorage.getItem('ns-view-mode')).toBe('compact')
expect(localStorage.getItem('ns-sidebar-collapsed')).toBe('true')
expect(document.documentElement.dataset.theme).toBe('light')
expect(document.documentElement.dataset.viewMode).toBe('compact')
expect(state()).toBe('light|compact|true|false')
})
it('rehydrates stored settings on the next mount', () => {
localStorage.setItem('ns-theme', 'light')
localStorage.setItem('ns-view-mode', 'compact')
localStorage.setItem('ns-sidebar-collapsed', 'true')
// Theme boots from the attribute index.html already painted (it resolved
// storage / prefers-color-scheme), so state can never contradict the DOM.
document.documentElement.setAttribute('data-theme', 'light')
renderApp()
expect(state()).toBe('light|compact|true|false')
})
it('ignores stored values outside the allowed set', () => {
localStorage.setItem('ns-view-mode', 'space')
localStorage.setItem('ns-sidebar-collapsed', 'yes')
renderApp()
expect(state()).toBe('dark|default|false|false')
})
it('toggles the search palette with ⌘K / Ctrl+K from anywhere', () => {
function SearchProbe() {
const { searchOpen } = useAppState()
return <span data-testid="search">{String(searchOpen)}</span>
}
render(
<AppStateProvider>
<SearchProbe />
</AppStateProvider>,
)
fireEvent.keyDown(window, { key: 'k', metaKey: true })
expect(screen.getByTestId('search')).toHaveTextContent('true')
fireEvent.keyDown(window, { key: 'k', ctrlKey: true })
expect(screen.getByTestId('search')).toHaveTextContent('false')
// A plain "k" must not hijack typing.
fireEvent.keyDown(window, { key: 'k' })
expect(screen.getByTestId('search')).toHaveTextContent('false')
})
})