Files
readest/apps/readest-app/src/__tests__/services/command-registry-extended.test.ts
T
Huang Xin 772bb73b46 ui/ux: codify design system and migrate settings to shared primitives (#4116)
* ui/ux: codify design system and migrate settings to shared primitives

Document Readest's design language in DESIGN.md (Adwaita-aligned, e-ink-first,
RTL-correct) and migrate every settings panel onto a small set of primitives
(BoxedList, SettingsRow, SettingsSwitchRow, SettingsSelect, SettingsInput,
NavigationRow, Tips, SubPageHeader). AGENTS.md links to DESIGN.md so contributors
land there before inventing new chassis classes.

Replace the standalone KOReader/Readwise/Hardcover Config dialogs with a single
Integrations panel (Reading Sync + Content Sources sub-pages). The reader's
BookMenu now hides each provider until it's configured, and Hardcover's per-book
"Enable for This Book" toggle is dropped — there's no auto-sync to gate, so the
flag was just extra clicks.

Refresh highlight colors (two-trigger swatch + label, translatable default
names), background texture / theme color selectors (border-current keeps
selection legible on any backdrop), CustomFonts/CustomDictionaries (quiet
list-extension style + shared Tips primitive), the OPDS catalog manager
(debounced auto-download, right-aligned Browse), Set PIN, and the KOSync
conflict resolver. Translate the ~30 new strings across all 33 locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ui/ux: responsive typography, OPDS card polish, deep-link return paths

Restore the .settings-content responsive cascade (14px desktop / 16px
mobile) the legacy panels relied on by dropping hardcoded `text-sm`/`text-xs`
from the new primitives. Secondary text moves to em-relative `text-[0.85em]`
so it scales with the parent. Form controls (`<input>`, `<select>`) re-apply
the cascade explicitly via the `settings-content` class since browsers don't
inherit font-size onto form elements.

Extract `<SectionTitle>` primitive (caseless-language aware via
`isCaselessUILang`/`isCaselessLang`) and route every uppercase tag-style
header through it: BoxedList groups, Reading Sync, Content Sources, Theme
Color, Background Image, integration form labels, KOSyncResolver device
labels, and the OPDS My Catalogs / Popular Catalogs sections. CJK / Arabic
/ Hebrew / Indic / Thai / Tibetan locales bump to `1em` since `uppercase`
is a no-op on those scripts.

Redesign the OPDS My Catalogs cards: whole card becomes the browse trigger
(role='button'), edit/delete collapse into a 3-dot dropdown menu, and the
sync-status moves to a sub-line under Auto-download so the card height stays
constant whether the toggle is on/off or sync data has arrived.

Plumb a `from=settings-integrations` URL marker through the OPDS browser so
both manual close and auto-close-on-failure (preserved as `router.back()`
for transient failures, paired with a new `stashOPDSReturnTarget` helper)
return the user to Settings -> Integrations -> OPDS Catalogs sub-page
rather than the dialog's top level. Backed by new `requestedSubPage`
deep-link store field.

Skip the OPDS catalog passphrase prompt when credentials sync is disabled
-- `replicaPublish` already drops encrypted fields at the wire, so prompting
was both pointless and confusing.

Fix `SettingsDialog` calling `setRequestedPanel(null)` inside a `useState`
lazy initializer (zustand setter during render -> React warning); move the
clear into a one-shot `useEffect`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ui/ux: opt Settings into OverlayScrollbars + caseless typography polish

Add an opt-in `useOverlayScroll` prop to `<Dialog>` that swaps the body's
native `overflow-y-auto` for `<OverlayScrollbarsComponent>` (autohide,
click-scroll, no native overlaid bars). SettingsDialog flips it on so the
long Layout / Color panels keep a visible, theme-aware scroll track on
Android / iOS webviews where native scrollbars auto-hide entirely. Other
short-modal callers stay on the native scrollbar.

Drop the `uppercase tracking-wider` SectionTitle styling for caseless
scripts and pair it with body-weight `font-medium` instead — those
typographic effects are no-ops on Han / Hangul / Devanagari / Thai etc.,
so a plain medium-weight body-size title reads more correctly than a
shrunken pseudo-uppercase one. SettingsRow / NavigationRow primary labels
follow the same rule (drop `font-medium` in caseless locales since the
inherited body weight already carries; CJK fonts bold poorly at body
size).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ui/ux: SettingLabel primitive + KOSyncForm select polish + Tips alignment

Add `<SettingLabel>` primitive — caseless-aware row/field label that pairs
with `<SectionTitle>` (groups) for per-item labels. Cased scripts get
`font-medium`; caseless scripts (CJK / Arabic / Hebrew / Indic / Thai /
Tibetan) drop the weight since Han / Hangul / Devanagari etc. bold poorly
at body size. No font-size class so it inherits the `.settings-content`
14/16 cascade. Routed through `SettingsRow`, `NavigationRow`, and the
~12 ad-hoc inline `text-sm font-medium` callsites in AIPanel / FontPanel
/ ColorPanel / IntegrationsPanel / KOSync / Readwise / Hardcover forms.

Refactor KOSyncForm's Sync Strategy + Checksum Method rows onto the
shared `<SettingsSelect>` primitive — the inline 17-line div/select/
MdArrowDropDown chassis becomes a single SettingsSelect call with an
options array. Drops the unused MdArrowDropDown import and ~25 lines.

Fix Tips list-item alignment: callers traditionally pass `<li>` elements
(semantic) but the primitive was double-wrapping into `<li><span><li>...</li></span></li>` — invalid HTML, and the inner `<li>`'s
`display: list-item` broke line-wrap alignment on multi-line items.
Unwrap caller `<li>` to its content; add `flex-1` on the text span so
wrapped lines align under the first line instead of falling back to the
bullet column. Bullet container switches to `h-[1.4em]` so it tracks the
text line-height and pins to the first line's optical center via
`items-center` regardless of how much the content wraps.

DESIGN.md §5 typography updated to point primary-label callers at
`<SettingLabel>`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:46:25 +02:00

484 lines
16 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock react-icons before importing the module under test
vi.mock('react-icons/ri', () => ({
RiFontSize: () => null,
RiDashboardLine: () => null,
RiTranslate: () => null,
RiPlugLine: () => null,
RiShareLine: () => null,
}));
vi.mock('react-icons/vsc', () => ({
VscSymbolColor: () => null,
}));
vi.mock('react-icons/lia', () => ({
LiaHandPointerSolid: () => null,
}));
vi.mock('react-icons/io5', () => ({
IoAccessibilityOutline: () => null,
}));
vi.mock('react-icons/pi', () => ({
PiRobot: () => null,
PiSpeakerHigh: () => null,
PiSun: () => null,
PiMoon: () => null,
}));
vi.mock('react-icons/tb', () => ({
TbSunMoon: () => null,
}));
vi.mock('react-icons/md', () => ({
MdRefresh: () => null,
}));
vi.mock('react-icons', () => ({
IconType: undefined,
}));
vi.mock('@/utils/misc', () => ({
stubTranslation: (key: string) => key,
}));
import {
buildCommandRegistry,
searchCommands,
groupResultsByCategory,
getCategoryLabel,
getRecentCommands,
trackCommandUsage,
CommandItem,
CommandRegistryOptions,
CommandCategory,
} from '@/services/commandRegistry';
function createMockOptions(
overrides: Partial<CommandRegistryOptions> = {},
): CommandRegistryOptions {
return {
_: (key: string) => key,
openSettingsPanel: vi.fn(),
toggleTheme: vi.fn(),
toggleFullscreen: vi.fn(),
toggleAlwaysOnTop: vi.fn(),
toggleScreenWakeLock: vi.fn(),
toggleAutoUpload: vi.fn(),
reloadPage: vi.fn(),
toggleOpenLastBooks: vi.fn(),
showAbout: vi.fn(),
toggleTelemetry: vi.fn(),
isDesktop: false,
...overrides,
};
}
describe('buildCommandRegistry', () => {
it('should return an array of command items', () => {
const items = buildCommandRegistry(createMockOptions());
expect(items.length).toBeGreaterThan(0);
});
it('should include settings items from all panels', () => {
const items = buildCommandRegistry(createMockOptions());
const settingsItems = items.filter((i) => i.category === 'settings');
expect(settingsItems.length).toBeGreaterThan(0);
// Check that multiple panels are represented
const panels = new Set(settingsItems.map((i) => i.panel));
expect(panels.has('Font')).toBe(true);
expect(panels.has('Layout')).toBe(true);
expect(panels.has('Color')).toBe(true);
expect(panels.has('Control')).toBe(true);
expect(panels.has('Language')).toBe(true);
expect(panels.has('Custom')).toBe(true);
});
it('should include action items', () => {
const items = buildCommandRegistry(createMockOptions());
const actionItems = items.filter((i) => i.category === 'actions');
expect(actionItems.length).toBeGreaterThan(0);
const actionIds = actionItems.map((i) => i.id);
expect(actionIds).toContain('action.toggleTheme');
expect(actionIds).toContain('action.fullscreen');
expect(actionIds).toContain('action.reload');
expect(actionIds).toContain('action.about');
expect(actionIds).toContain('action.telemetry');
});
it('should use the provided translation function for localized labels', () => {
const translate = (key: string) => `translated:${key}`;
const items = buildCommandRegistry(createMockOptions({ _: translate }));
const fontItem = items.find((i) => i.id === 'settings.font.defaultFontSize');
expect(fontItem).toBeDefined();
expect(fontItem!.localizedLabel).toBe('translated:Default Font Size');
});
it('should set panelLabel using translation function', () => {
const translate = (key: string) => `t:${key}`;
const items = buildCommandRegistry(createMockOptions({ _: translate }));
const fontItem = items.find((i) => i.id === 'settings.font.defaultFontSize');
expect(fontItem!.panelLabel).toBe('t:Font');
// Control panel items use panelLabel 'Behavior'
const controlItem = items.find((i) => i.id === 'settings.control.scrolledMode');
expect(controlItem!.panelLabel).toBe('t:Behavior');
});
it('should call openSettingsPanel when settings item action is invoked', () => {
const openSettingsPanel = vi.fn();
const items = buildCommandRegistry(createMockOptions({ openSettingsPanel }));
const fontItem = items.find((i) => i.id === 'settings.font.defaultFontSize')!;
fontItem.action();
expect(openSettingsPanel).toHaveBeenCalledWith('Font', 'settings.font.defaultFontSize');
});
it('should call correct action handler for action items', () => {
const toggleTheme = vi.fn();
const reloadPage = vi.fn();
const items = buildCommandRegistry(createMockOptions({ toggleTheme, reloadPage }));
items.find((i) => i.id === 'action.toggleTheme')!.action();
expect(toggleTheme).toHaveBeenCalled();
items.find((i) => i.id === 'action.reload')!.action();
expect(reloadPage).toHaveBeenCalled();
});
it('should set isAvailable for desktop-only actions', () => {
const items = buildCommandRegistry(createMockOptions({ isDesktop: false }));
const fullscreen = items.find((i) => i.id === 'action.fullscreen')!;
expect(fullscreen.isAvailable).toBeDefined();
expect(fullscreen.isAvailable!()).toBe(false);
const alwaysOnTop = items.find((i) => i.id === 'action.alwaysOnTop')!;
expect(alwaysOnTop.isAvailable!()).toBe(false);
const openLastBooks = items.find((i) => i.id === 'action.openLastBooks')!;
expect(openLastBooks.isAvailable!()).toBe(false);
});
it('should report desktop-only actions as available on desktop', () => {
const items = buildCommandRegistry(createMockOptions({ isDesktop: true }));
const fullscreen = items.find((i) => i.id === 'action.fullscreen')!;
expect(fullscreen.isAvailable!()).toBe(true);
});
it('should not set isAvailable for non-desktop-only actions', () => {
const items = buildCommandRegistry(createMockOptions());
const themeItem = items.find((i) => i.id === 'action.toggleTheme')!;
expect(themeItem.isAvailable).toBeUndefined();
});
it('should have unique ids for all items', () => {
const items = buildCommandRegistry(createMockOptions());
const ids = items.map((i) => i.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('should include AI panel items in non-production', () => {
const items = buildCommandRegistry(createMockOptions());
const aiItems = items.filter((i) => i.panel === 'AI');
// In test environment (not production), AI items should be included
expect(aiItems.length).toBeGreaterThan(0);
});
it('should give each settings item keywords and section', () => {
const items = buildCommandRegistry(createMockOptions());
const settingsItems = items.filter((i) => i.category === 'settings');
for (const item of settingsItems) {
expect(item.keywords.length).toBeGreaterThan(0);
}
});
});
describe('searchCommands', () => {
let items: CommandItem[];
beforeEach(() => {
items = buildCommandRegistry(createMockOptions());
});
it('should return empty array for empty query', () => {
expect(searchCommands('', items)).toEqual([]);
});
it('should return empty array for whitespace-only query', () => {
expect(searchCommands(' ', items)).toEqual([]);
});
it('should find items matching the query', () => {
const results = searchCommands('font', items);
expect(results.length).toBeGreaterThan(0);
// At least some results should be font-related
const hasFontResult = results.some(
(r) => r.item.id.includes('font') || r.item.labelKey.toLowerCase().includes('font'),
);
expect(hasFontResult).toBe(true);
});
it('should filter out unavailable items', () => {
const itemsNonDesktop = buildCommandRegistry(createMockOptions({ isDesktop: false }));
const results = searchCommands('fullscreen', itemsNonDesktop);
// Fullscreen is desktop-only; should not appear when isDesktop is false
const hasFullscreen = results.some((r) => r.item.id === 'action.fullscreen');
expect(hasFullscreen).toBe(false);
});
it('should include available items', () => {
const desktopItems = buildCommandRegistry(createMockOptions({ isDesktop: true }));
const results = searchCommands('fullscreen', desktopItems);
const hasFullscreen = results.some((r) => r.item.id === 'action.fullscreen');
expect(hasFullscreen).toBe(true);
});
it('should return results with score and positions', () => {
const results = searchCommands('reload', items);
expect(results.length).toBeGreaterThan(0);
const first = results[0]!;
expect(typeof first.score).toBe('number');
expect(first.positions).toBeDefined();
expect(first.highlightIndices).toBeDefined();
});
it('should search across keywords', () => {
const results = searchCommands('hyphen', items);
expect(results.length).toBeGreaterThan(0);
const hasHyphenation = results.some((r) => r.item.id === 'settings.layout.hyphenation');
expect(hasHyphenation).toBe(true);
});
it('should search across panel label', () => {
const results = searchCommands('Behavior', items);
// Control panel items have panelLabel 'Behavior'
expect(results.length).toBeGreaterThan(0);
});
});
describe('groupResultsByCategory', () => {
let items: CommandItem[];
beforeEach(() => {
items = buildCommandRegistry(createMockOptions({ isDesktop: true }));
});
it('should group results by category', () => {
const results = searchCommands('theme', items);
const grouped = groupResultsByCategory(results);
expect(grouped).toHaveProperty('settings');
expect(grouped).toHaveProperty('actions');
expect(grouped).toHaveProperty('navigation');
// 'theme' should match both settings (Theme Mode under Color) and action (toggle theme)
expect(grouped.settings.length).toBeGreaterThan(0);
expect(grouped.actions.length).toBeGreaterThan(0);
});
it('should return empty arrays when no results', () => {
const grouped = groupResultsByCategory([]);
expect(grouped.settings).toEqual([]);
expect(grouped.actions).toEqual([]);
expect(grouped.navigation).toEqual([]);
});
it('should place all results into their correct category', () => {
const results = searchCommands('font', items);
const grouped = groupResultsByCategory(results);
let totalGrouped = 0;
totalGrouped += grouped.settings.length;
totalGrouped += grouped.actions.length;
totalGrouped += grouped.navigation.length;
expect(totalGrouped).toBe(results.length);
});
});
describe('getCategoryLabel', () => {
const translate = (key: string) => `t:${key}`;
it('should return translated label for settings', () => {
expect(getCategoryLabel(translate, 'settings')).toBe('t:Settings');
});
it('should return translated label for actions', () => {
expect(getCategoryLabel(translate, 'actions')).toBe('t:Actions');
});
it('should return translated label for navigation', () => {
expect(getCategoryLabel(translate, 'navigation')).toBe('t:Navigation');
});
it('should return the category string for unknown category', () => {
// Force an unknown category via type assertion
const unknown = 'unknown' as CommandCategory;
expect(getCategoryLabel(translate, unknown)).toBe('unknown');
});
});
describe('getRecentCommands', () => {
const originalLocalStorage = globalThis.localStorage;
beforeEach(() => {
// Set up a fresh localStorage mock
const store: Record<string, string> = {};
const mockLocalStorage = {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value;
}),
removeItem: vi.fn((key: string) => {
delete store[key];
}),
clear: vi.fn(() => {
for (const key of Object.keys(store)) {
delete store[key];
}
}),
get length() {
return Object.keys(store).length;
},
key: vi.fn((_index: number) => null),
};
Object.defineProperty(globalThis, 'localStorage', {
value: mockLocalStorage,
writable: true,
configurable: true,
});
});
afterEach(() => {
Object.defineProperty(globalThis, 'localStorage', {
value: originalLocalStorage,
writable: true,
configurable: true,
});
});
it('should return empty array when no recent commands stored', () => {
const items = buildCommandRegistry(createMockOptions());
expect(getRecentCommands(items)).toEqual([]);
});
it('should return matching recent commands', () => {
const items = buildCommandRegistry(createMockOptions());
localStorage.setItem('recentCommands', JSON.stringify(['action.reload', 'action.about']));
const recent = getRecentCommands(items);
expect(recent).toHaveLength(2);
expect(recent[0]!.id).toBe('action.reload');
expect(recent[1]!.id).toBe('action.about');
});
it('should skip ids that do not exist in items', () => {
const items = buildCommandRegistry(createMockOptions());
localStorage.setItem('recentCommands', JSON.stringify(['nonexistent.item', 'action.reload']));
const recent = getRecentCommands(items);
expect(recent).toHaveLength(1);
expect(recent[0]!.id).toBe('action.reload');
});
it('should respect the limit parameter', () => {
const items = buildCommandRegistry(createMockOptions());
localStorage.setItem(
'recentCommands',
JSON.stringify(['action.reload', 'action.about', 'action.toggleTheme']),
);
const recent = getRecentCommands(items, 2);
expect(recent).toHaveLength(2);
});
it('should use default limit of 5', () => {
const items = buildCommandRegistry(createMockOptions());
const ids = items.slice(0, 8).map((i) => i.id);
localStorage.setItem('recentCommands', JSON.stringify(ids));
const recent = getRecentCommands(items);
expect(recent).toHaveLength(5);
});
it('should return empty array on invalid JSON', () => {
const items = buildCommandRegistry(createMockOptions());
localStorage.setItem('recentCommands', 'not-json');
const recent = getRecentCommands(items);
expect(recent).toEqual([]);
});
});
describe('trackCommandUsage', () => {
const originalLocalStorage = globalThis.localStorage;
beforeEach(() => {
const store: Record<string, string> = {};
const mockLocalStorage = {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value;
}),
removeItem: vi.fn((key: string) => {
delete store[key];
}),
clear: vi.fn(() => {
for (const key of Object.keys(store)) {
delete store[key];
}
}),
get length() {
return Object.keys(store).length;
},
key: vi.fn((_index: number) => null),
};
Object.defineProperty(globalThis, 'localStorage', {
value: mockLocalStorage,
writable: true,
configurable: true,
});
});
afterEach(() => {
Object.defineProperty(globalThis, 'localStorage', {
value: originalLocalStorage,
writable: true,
configurable: true,
});
});
it('should store a new command id', () => {
trackCommandUsage('action.reload');
const stored = JSON.parse(localStorage.getItem('recentCommands') ?? '[]') as string[];
expect(stored).toContain('action.reload');
});
it('should move existing command to front', () => {
localStorage.setItem('recentCommands', JSON.stringify(['action.about', 'action.reload']));
trackCommandUsage('action.reload');
const stored = JSON.parse(localStorage.getItem('recentCommands') ?? '[]') as string[];
expect(stored[0]).toBe('action.reload');
expect(stored[1]).toBe('action.about');
// No duplicates
expect(stored.filter((id) => id === 'action.reload')).toHaveLength(1);
});
it('should limit stored commands to 10', () => {
const existingIds = Array.from({ length: 10 }, (_, i) => `cmd-${i}`);
localStorage.setItem('recentCommands', JSON.stringify(existingIds));
trackCommandUsage('new-cmd');
const stored = JSON.parse(localStorage.getItem('recentCommands') ?? '[]') as string[];
expect(stored).toHaveLength(10);
expect(stored[0]).toBe('new-cmd');
// Last item from the original list should have been dropped
expect(stored).not.toContain('cmd-9');
});
it('should handle empty localStorage gracefully', () => {
trackCommandUsage('action.reload');
const stored = JSON.parse(localStorage.getItem('recentCommands') ?? '[]') as string[];
expect(stored).toEqual(['action.reload']);
});
});