Files
readest/apps/readest-app/src/__tests__/services/command-registry-extended.test.ts
T
Huang Xin 0e516f6e56 chore(test): add unit tests and enforce dash-case naming for test files (#3715)
Add ~290 new unit tests covering utils (lru, diff, css, a11y, walk,
usage, txt-worker), services (EdgeTTSClient, transformers), and
suppress noisy console output across all test files. Rename 27
camelCase test files to dash-case for consistency.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 09:14:00 +02:00

482 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,
}));
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']);
});
});