772bb73b46
* 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>
484 lines
16 KiB
TypeScript
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']);
|
|
});
|
|
});
|