Template
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
114 lines
4.0 KiB
TypeScript
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')
|
|
})
|
|
})
|