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);
+ });
+});