diff --git a/tests/unit/RadioGroup.test.tsx b/tests/unit/RadioGroup.test.tsx new file mode 100644 index 000000000..27fd9c76f --- /dev/null +++ b/tests/unit/RadioGroup.test.tsx @@ -0,0 +1,66 @@ +/** + * Tests for components/ui/RadioGroup.tsx + * QNBS-v3: Accessible radiogroup atom — render, checked state, onChange, disabled, description, orientation. + */ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { RadioGroup } from '../../components/ui/RadioGroup'; + +const OPTIONS = [ + { value: 'a', label: 'Option A', description: 'First choice' }, + { value: 'b', label: 'Option B' }, + { value: 'c', label: 'Option C', disabled: true }, +]; + +describe('RadioGroup', () => { + it('renders a radiogroup with each option label and description', () => { + render(); + expect(screen.getByRole('radiogroup', { name: 'choices' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'Option A' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'Option B' })).toBeInTheDocument(); + expect(screen.getByText('First choice')).toBeInTheDocument(); + }); + + it('marks the option matching value as checked', () => { + render(); + expect(screen.getByRole('radio', { name: 'Option B' })).toBeChecked(); + expect(screen.getByRole('radio', { name: 'Option A' })).not.toBeChecked(); + }); + + it('calls onChange with the option value when an option is selected', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + await user.click(screen.getByRole('radio', { name: 'Option B' })); + expect(onChange).toHaveBeenCalledWith('b'); + }); + + it('renders disabled options as disabled and does not fire onChange', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + const disabled = screen.getByRole('radio', { name: 'Option C' }); + expect(disabled).toBeDisabled(); + await user.click(disabled); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('applies horizontal orientation layout', () => { + render( + , + ); + expect(screen.getByRole('radiogroup').className).toContain('flex-row'); + }); + + it('defaults to vertical orientation', () => { + render(); + expect(screen.getByRole('radiogroup').className).toContain('flex-col'); + }); +}); diff --git a/tests/unit/Tabs.test.tsx b/tests/unit/Tabs.test.tsx new file mode 100644 index 000000000..fa51f2191 --- /dev/null +++ b/tests/unit/Tabs.test.tsx @@ -0,0 +1,86 @@ +/** + * Tests for components/ui/Tabs.tsx (Tabs + TabPanel) + * QNBS-v3: WAI-ARIA tabs atom — tablist/tab roles, aria-selected, onChange, disabled, variants, panel visibility. + */ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { TabPanel, Tabs } from '../../components/ui/Tabs'; + +const TABS = [ + { id: 'one', label: 'One' }, + { id: 'two', label: 'Two' }, + { id: 'three', label: 'Three', disabled: true }, +]; + +describe('Tabs', () => { + it('renders a tablist with a tab per entry', () => { + render(); + expect(screen.getByRole('tablist', { name: 'Sections' })).toBeInTheDocument(); + expect(screen.getAllByRole('tab')).toHaveLength(3); + }); + + it('marks the active tab with aria-selected', () => { + render(); + expect(screen.getByRole('tab', { name: 'Two' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('tab', { name: 'One' })).toHaveAttribute('aria-selected', 'false'); + }); + + it('calls onChange with the tab id when a tab is clicked', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + await user.click(screen.getByRole('tab', { name: 'Two' })); + expect(onChange).toHaveBeenCalledWith('two'); + }); + + it('does not fire onChange for a disabled tab', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + const disabled = screen.getByRole('tab', { name: 'Three' }); + expect(disabled).toBeDisabled(); + await user.click(disabled); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('applies the pills variant class on tabs', () => { + render(); + expect(screen.getByRole('tab', { name: 'One' }).className).toContain('rounded-full'); + }); + + it('marks the active tab with data-state=active and inactive otherwise', () => { + render( + , + ); + expect(screen.getByRole('tab', { name: 'One' })).toHaveAttribute('data-state', 'active'); + expect(screen.getByRole('tab', { name: 'Two' })).toHaveAttribute('data-state', 'inactive'); + }); +}); + +describe('TabPanel', () => { + it('shows content and exposes the tabpanel role when active', () => { + render( + + Panel One + , + ); + const panel = screen.getByRole('tabpanel'); + expect(panel).not.toHaveAttribute('hidden'); + expect(panel).toHaveTextContent('Panel One'); + expect(panel).toHaveAttribute('aria-labelledby', 'grp-one'); + expect(panel).toHaveAttribute('id', 'grp-one-panel'); + }); + + it('hides the panel when not the active tab', () => { + render( + + Panel One + , + ); + // A hidden tabpanel is removed from the a11y tree, so query the section directly. + const section = screen.getByText('Panel One').closest('section'); + expect(section).toHaveAttribute('hidden'); + expect(section?.className).toContain('hidden'); + }); +}); diff --git a/tests/unit/factoryResetService.test.ts b/tests/unit/factoryResetService.test.ts new file mode 100644 index 000000000..9b669b44f --- /dev/null +++ b/tests/unit/factoryResetService.test.ts @@ -0,0 +1,103 @@ +/** + * Tests for services/factoryResetService.ts + * QNBS-v3: wipeAllAppData — clears IDB + web storage + SW caches, then reloads. Covers the + * native indexedDB.databases() path, the known-list fallback, and the Cache API branch. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { wipeAllAppData } from '../../services/factoryResetService'; +import { logger } from '../../services/logger'; + +vi.mock('../../services/logger', () => ({ + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, +})); + +function createDb(name: string): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(name, 1); + req.onupgradeneeded = () => req.result.createObjectStore('s'); + req.onsuccess = () => { + req.result.close(); + resolve(); + }; + req.onerror = () => reject(req.error); + }); +} + +// QNBS-v3: production has a real 300ms settle delay before reload — drive it with fake timers so +// the suite stays deterministic and fast (CodeAnt #132). runAllTimersAsync loops until every +// pending timer is drained, which also flushes the fake-indexeddb deletion scheduling. +async function runWipe(): Promise { + vi.useFakeTimers(); + try { + const done = wipeAllAppData(); + await vi.runAllTimersAsync(); + await done; + } finally { + vi.useRealTimers(); + } +} + +let reloadMock: ReturnType; +let originalLocation: Location; + +beforeEach(() => { + vi.clearAllMocks(); + reloadMock = vi.fn(); + originalLocation = window.location; + Object.defineProperty(window, 'location', { + configurable: true, + value: { ...originalLocation, reload: reloadMock }, + }); + localStorage.clear(); + sessionStorage.clear(); +}); + +afterEach(() => { + Object.defineProperty(window, 'location', { configurable: true, value: originalLocation }); + vi.unstubAllGlobals(); +}); + +describe('wipeAllAppData', () => { + it('clears web storage, deletes IDB databases, and reloads', async () => { + await createDb('storycraft-data-db'); + localStorage.setItem('foo', 'bar'); + sessionStorage.setItem('baz', 'qux'); + const delSpy = vi.spyOn(indexedDB, 'deleteDatabase'); + + await runWipe(); + + expect(localStorage.getItem('foo')).toBeNull(); + expect(sessionStorage.getItem('baz')).toBeNull(); + expect(delSpy).toHaveBeenCalledWith('storycraft-data-db'); + expect(reloadMock).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledTimes(1); + delSpy.mockRestore(); + }); + + it('falls back to the known database list when indexedDB.databases() fails', async () => { + const dbSpy = vi.spyOn(indexedDB, 'databases').mockRejectedValueOnce(new Error('not allowed')); + const delSpy = vi.spyOn(indexedDB, 'deleteDatabase'); + + await runWipe(); + + // Fallback deletes every name in the known list (e.g. the logs DB). + expect(delSpy).toHaveBeenCalledWith('storycraft-logs-db'); + expect(reloadMock).toHaveBeenCalledTimes(1); + dbSpy.mockRestore(); + delSpy.mockRestore(); + }); + + it('clears service-worker caches when the Cache API is available', async () => { + const del = vi.fn().mockResolvedValue(true); + vi.stubGlobal('caches', { + keys: vi.fn().mockResolvedValue(['static-v1', 'dynamic-v1']), + delete: del, + }); + + await runWipe(); + + expect(del).toHaveBeenCalledWith('static-v1'); + expect(del).toHaveBeenCalledWith('dynamic-v1'); + expect(reloadMock).toHaveBeenCalledTimes(1); + }); +});