Files
tolaria/src/hooks/useCommandRegistry.test.ts
Test 5b1804e5e1 feat: vault management — remove vault from list and restore Getting Started
Add ability to remove vaults from the app list without deleting files on disk,
and restore the bundled Getting Started demo vault when needed.

Changes:
- Rust: add hidden_defaults field to VaultList for tracking removed default vaults
- useVaultSwitcher: add removeVault() and restoreGettingStarted() with auto-switch
- useCommandRegistry: add 'Remove Vault from List' and 'Restore Getting Started Vault' commands
- StatusBar: add X button per vault item in vault menu dropdown
- 25 new tests covering removal, restore, edge cases, and command palette

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 02:36:33 +01:00

803 lines
32 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useCommandRegistry, groupSortKey, pluralizeType, extractVaultTypes } from './useCommandRegistry'
import type { VaultEntry } from '../types'
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
path: '/vault/note/test.md',
filename: 'test.md',
title: 'Test Note',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: 'Active',
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: 1700000000,
fileSize: 100,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
...overrides,
})
function makeConfig(overrides: Record<string, unknown> = {}) {
return {
activeTabPath: null as string | null,
entries: [] as VaultEntry[],
modifiedCount: 0,
activeNoteModified: false,
onQuickOpen: vi.fn(),
onCreateNote: vi.fn(),
onCreateNoteOfType: vi.fn(),
onSave: vi.fn(),
onOpenSettings: vi.fn(),
onTrashNote: vi.fn(),
onRestoreNote: vi.fn(),
onArchiveNote: vi.fn(),
onUnarchiveNote: vi.fn(),
onCommitPush: vi.fn(),
onSetViewMode: vi.fn(),
onToggleInspector: vi.fn(),
onZoomIn: vi.fn(),
onZoomOut: vi.fn(),
onZoomReset: vi.fn(),
zoomLevel: 100,
onSelect: vi.fn(),
onCloseTab: vi.fn(),
onOpenDailyNote: vi.fn(),
...overrides,
}
}
describe('useCommandRegistry', () => {
it('returns all command groups', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
const groups = new Set(result.current.map(c => c.group))
expect(groups).toContain('Navigation')
expect(groups).toContain('Note')
expect(groups).toContain('Git')
expect(groups).toContain('View')
expect(groups).toContain('Settings')
})
it('has search-notes command with shortcut', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
const cmd = result.current.find(c => c.id === 'search-notes')
expect(cmd).toBeDefined()
expect(cmd!.shortcut).toBe('⌘P')
expect(cmd!.enabled).toBe(true)
})
it('disables contextual actions when no note is open', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ activeTabPath: null })))
const trashCmd = result.current.find(c => c.id === 'trash-note')
expect(trashCmd!.enabled).toBe(false)
const saveCmd = result.current.find(c => c.id === 'save-note')
expect(saveCmd!.enabled).toBe(false)
const closeCmd = result.current.find(c => c.id === 'close-tab')
expect(closeCmd!.enabled).toBe(false)
})
it('enables contextual actions when a note is open', () => {
const entries = [makeEntry({ path: '/vault/note/test.md' })]
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries })),
)
expect(result.current.find(c => c.id === 'trash-note')!.enabled).toBe(true)
expect(result.current.find(c => c.id === 'save-note')!.enabled).toBe(true)
expect(result.current.find(c => c.id === 'close-tab')!.enabled).toBe(true)
})
it('shows "Unarchive Note" when active note is archived', () => {
const entries = [makeEntry({ path: '/vault/note/test.md', archived: true })]
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries })),
)
const archiveCmd = result.current.find(c => c.id === 'archive-note')
expect(archiveCmd!.label).toBe('Unarchive Note')
})
it('shows "Archive Note" when active note is not archived', () => {
const entries = [makeEntry({ path: '/vault/note/test.md', archived: false })]
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries })),
)
const archiveCmd = result.current.find(c => c.id === 'archive-note')
expect(archiveCmd!.label).toBe('Archive Note')
})
it('shows "Restore Note" when active note is trashed', () => {
const entries = [makeEntry({ path: '/vault/note/test.md', trashed: true })]
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries })),
)
const trashCmd = result.current.find(c => c.id === 'trash-note')
expect(trashCmd!.label).toBe('Restore Note')
})
it('shows "Trash Note" when active note is not trashed', () => {
const entries = [makeEntry({ path: '/vault/note/test.md', trashed: false })]
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries })),
)
const trashCmd = result.current.find(c => c.id === 'trash-note')
expect(trashCmd!.label).toBe('Trash Note')
})
it('calls onRestoreNote when trash command executes on trashed note', () => {
const onRestoreNote = vi.fn()
const entries = [makeEntry({ path: '/vault/note/test.md', trashed: true })]
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries, onRestoreNote })),
)
result.current.find(c => c.id === 'trash-note')!.execute()
expect(onRestoreNote).toHaveBeenCalledWith('/vault/note/test.md')
})
it('calls onTrashNote when trash command executes on non-trashed note', () => {
const onTrashNote = vi.fn()
const entries = [makeEntry({ path: '/vault/note/test.md', trashed: false })]
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries, onTrashNote })),
)
result.current.find(c => c.id === 'trash-note')!.execute()
expect(onTrashNote).toHaveBeenCalledWith('/vault/note/test.md')
})
it('has toggle-raw-editor command in View group', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
const cmd = result.current.find(c => c.id === 'toggle-raw-editor')
expect(cmd).toBeDefined()
expect(cmd!.group).toBe('View')
})
it('disables toggle-raw-editor when no note is open', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ activeTabPath: null })))
const cmd = result.current.find(c => c.id === 'toggle-raw-editor')
expect(cmd!.enabled).toBe(false)
})
it('enables toggle-raw-editor when a note is open', () => {
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md' })),
)
const cmd = result.current.find(c => c.id === 'toggle-raw-editor')
expect(cmd!.enabled).toBe(true)
})
it('calls onToggleRawEditor when toggle-raw-editor executes', () => {
const onToggleRawEditor = vi.fn()
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', onToggleRawEditor })),
)
result.current.find(c => c.id === 'toggle-raw-editor')!.execute()
expect(onToggleRawEditor).toHaveBeenCalledOnce()
})
it('disables commit when no modified files', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ modifiedCount: 0 })))
expect(result.current.find(c => c.id === 'commit-push')!.enabled).toBe(false)
})
it('enables commit when modified files exist', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ modifiedCount: 3 })))
expect(result.current.find(c => c.id === 'commit-push')!.enabled).toBe(true)
})
it('calls onQuickOpen when search-notes executes', () => {
const onQuickOpen = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onQuickOpen })))
result.current.find(c => c.id === 'search-notes')!.execute()
expect(onQuickOpen).toHaveBeenCalled()
})
it('calls onSelect with filter when navigation commands execute', () => {
const onSelect = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onSelect })))
result.current.find(c => c.id === 'go-all')!.execute()
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'all' })
})
it('calls onSetViewMode when view commands execute', () => {
const onSetViewMode = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onSetViewMode })))
result.current.find(c => c.id === 'view-editor')!.execute()
expect(onSetViewMode).toHaveBeenCalledWith('editor-only')
})
it('zoom-in is enabled when below max zoom', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ zoomLevel: 100 })))
expect(result.current.find(c => c.id === 'zoom-in')!.enabled).toBe(true)
})
it('zoom-in is disabled at max zoom', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ zoomLevel: 150 })))
expect(result.current.find(c => c.id === 'zoom-in')!.enabled).toBe(false)
})
it('zoom-out is disabled at min zoom', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ zoomLevel: 80 })))
expect(result.current.find(c => c.id === 'zoom-out')!.enabled).toBe(false)
})
it('zoom-reset is disabled at 100%', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ zoomLevel: 100 })))
expect(result.current.find(c => c.id === 'zoom-reset')!.enabled).toBe(false)
})
it('zoom-reset is enabled when not at 100%', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ zoomLevel: 120 })))
expect(result.current.find(c => c.id === 'zoom-reset')!.enabled).toBe(true)
})
it('zoom-in label shows current zoom level', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ zoomLevel: 120 })))
expect(result.current.find(c => c.id === 'zoom-in')!.label).toContain('120%')
})
it('has toggle-ai-chat command with shortcut', () => {
const onToggleAIChat = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onToggleAIChat })))
const cmd = result.current.find(c => c.id === 'toggle-ai-chat')
expect(cmd).toBeDefined()
expect(cmd!.shortcut).toBe('⌘I')
expect(cmd!.group).toBe('View')
expect(cmd!.enabled).toBe(true)
})
it('calls onToggleAIChat when toggle-ai-chat executes', () => {
const onToggleAIChat = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onToggleAIChat })))
result.current.find(c => c.id === 'toggle-ai-chat')!.execute()
expect(onToggleAIChat).toHaveBeenCalled()
})
it('has toggle-diff command in View group', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
const cmd = result.current.find(c => c.id === 'toggle-diff')
expect(cmd).toBeDefined()
expect(cmd!.group).toBe('View')
expect(cmd!.label).toBe('Toggle Diff Mode')
})
it('disables toggle-diff when no note is open', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ activeTabPath: null })))
const cmd = result.current.find(c => c.id === 'toggle-diff')
expect(cmd!.enabled).toBe(false)
})
it('disables toggle-diff when note has no changes', () => {
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', activeNoteModified: false })),
)
const cmd = result.current.find(c => c.id === 'toggle-diff')
expect(cmd!.enabled).toBe(false)
})
it('enables toggle-diff when note has uncommitted changes', () => {
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', activeNoteModified: true })),
)
const cmd = result.current.find(c => c.id === 'toggle-diff')
expect(cmd!.enabled).toBe(true)
})
it('calls onToggleDiff when toggle-diff executes', () => {
const onToggleDiff = vi.fn()
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', activeNoteModified: true, onToggleDiff })),
)
result.current.find(c => c.id === 'toggle-diff')!.execute()
expect(onToggleDiff).toHaveBeenCalledOnce()
})
it('has toggle-backlinks command in View group', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
const cmd = result.current.find(c => c.id === 'toggle-backlinks')
expect(cmd).toBeDefined()
expect(cmd!.group).toBe('View')
expect(cmd!.label).toBe('Toggle Backlinks')
})
it('disables toggle-backlinks when no note is open', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ activeTabPath: null })))
const cmd = result.current.find(c => c.id === 'toggle-backlinks')
expect(cmd!.enabled).toBe(false)
})
it('enables toggle-backlinks when a note is open', () => {
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md' })),
)
const cmd = result.current.find(c => c.id === 'toggle-backlinks')
expect(cmd!.enabled).toBe(true)
})
it('calls onToggleInspector when toggle-backlinks executes', () => {
const onToggleInspector = vi.fn()
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', onToggleInspector })),
)
result.current.find(c => c.id === 'toggle-backlinks')!.execute()
expect(onToggleInspector).toHaveBeenCalledOnce()
})
it('toggle-inspector label includes Properties', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
const cmd = result.current.find(c => c.id === 'toggle-inspector')
expect(cmd!.label).toBe('Toggle Properties Panel')
})
it('has open-daily-note command with shortcut', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
const cmd = result.current.find(c => c.id === 'open-daily-note')
expect(cmd).toBeDefined()
expect(cmd!.label).toBe("Open Today's Note")
expect(cmd!.shortcut).toBe('⌘J')
expect(cmd!.group).toBe('Note')
expect(cmd!.enabled).toBe(true)
})
it('calls onOpenDailyNote when open-daily-note executes', () => {
const onOpenDailyNote = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onOpenDailyNote })))
result.current.find(c => c.id === 'open-daily-note')!.execute()
expect(onOpenDailyNote).toHaveBeenCalled()
})
describe('check-updates command', () => {
it('has check-updates command in Settings group', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
const cmd = result.current.find(c => c.id === 'check-updates')
expect(cmd).toBeDefined()
expect(cmd!.label).toBe('Check for Updates')
expect(cmd!.group).toBe('Settings')
expect(cmd!.keywords).toContain('update')
expect(cmd!.keywords).toContain('version')
})
it('is enabled when not updating', () => {
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ isUpdating: false })),
)
expect(result.current.find(c => c.id === 'check-updates')!.enabled).toBe(true)
})
it('is disabled when updating', () => {
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ isUpdating: true })),
)
expect(result.current.find(c => c.id === 'check-updates')!.enabled).toBe(false)
})
it('calls onCheckForUpdates when executed', () => {
const onCheckForUpdates = vi.fn()
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ onCheckForUpdates })),
)
result.current.find(c => c.id === 'check-updates')!.execute()
expect(onCheckForUpdates).toHaveBeenCalled()
})
})
describe('create-type command', () => {
it('has create-type command in Note group when onCreateType is provided', () => {
const onCreateType = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onCreateType })))
const cmd = result.current.find(c => c.id === 'create-type')
expect(cmd).toBeDefined()
expect(cmd!.label).toBe('New Type')
expect(cmd!.group).toBe('Note')
expect(cmd!.enabled).toBe(true)
})
it('is disabled when onCreateType is not provided', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
const cmd = result.current.find(c => c.id === 'create-type')
expect(cmd).toBeDefined()
expect(cmd!.enabled).toBe(false)
})
it('calls onCreateType when executed', () => {
const onCreateType = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onCreateType })))
result.current.find(c => c.id === 'create-type')!.execute()
expect(onCreateType).toHaveBeenCalled()
})
it('has relevant keywords for discoverability', () => {
const onCreateType = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onCreateType })))
const cmd = result.current.find(c => c.id === 'create-type')
expect(cmd!.keywords).toContain('new')
expect(cmd!.keywords).toContain('create')
expect(cmd!.keywords).toContain('type')
})
})
describe('type-aware commands', () => {
it('generates "New [Type]" commands from vault entries', () => {
const entries = [
makeEntry({ path: '/vault/event/birthday.md', isA: 'Event' }),
makeEntry({ path: '/vault/person/alice.md', isA: 'Person' }),
]
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries })))
const newEvent = result.current.find(c => c.id === 'new-event')
const newPerson = result.current.find(c => c.id === 'new-person')
expect(newEvent).toBeDefined()
expect(newEvent!.label).toBe('New Event')
expect(newEvent!.group).toBe('Note')
expect(newPerson).toBeDefined()
expect(newPerson!.label).toBe('New Person')
})
it('generates "List [Type]" commands with pluralized names', () => {
const entries = [
makeEntry({ path: '/vault/event/birthday.md', isA: 'Event' }),
makeEntry({ path: '/vault/person/alice.md', isA: 'Person' }),
]
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries })))
const listEvents = result.current.find(c => c.id === 'list-event')
const listPeople = result.current.find(c => c.id === 'list-person')
expect(listEvents).toBeDefined()
expect(listEvents!.label).toBe('List Events')
expect(listEvents!.group).toBe('Navigation')
expect(listPeople).toBeDefined()
expect(listPeople!.label).toBe('List People')
})
it('calls onCreateNoteOfType when "New [Type]" executes', () => {
const onCreateNoteOfType = vi.fn()
const entries = [makeEntry({ path: '/vault/event/birthday.md', isA: 'Event' })]
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries, onCreateNoteOfType })))
result.current.find(c => c.id === 'new-event')!.execute()
expect(onCreateNoteOfType).toHaveBeenCalledWith('Event')
})
it('calls onSelect with sectionGroup when "List [Type]" executes', () => {
const onSelect = vi.fn()
const entries = [makeEntry({ path: '/vault/person/alice.md', isA: 'Person' })]
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries, onSelect })))
result.current.find(c => c.id === 'list-person')!.execute()
expect(onSelect).toHaveBeenCalledWith({ kind: 'sectionGroup', type: 'Person' })
})
it('uses default types when vault has no typed notes', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries: [] })))
expect(result.current.find(c => c.id === 'new-event')).toBeDefined()
expect(result.current.find(c => c.id === 'new-person')).toBeDefined()
expect(result.current.find(c => c.id === 'new-project')).toBeDefined()
expect(result.current.find(c => c.id === 'new-note')).toBeDefined()
})
it('excludes Type entries from vault type list', () => {
const entries = [
makeEntry({ path: '/vault/type/event.md', isA: 'Type', title: 'Event' }),
makeEntry({ path: '/vault/event/birthday.md', isA: 'Event' }),
]
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries })))
expect(result.current.find(c => c.id === 'new-type')).toBeUndefined()
})
it('excludes trashed entries from type extraction', () => {
const entries = [
makeEntry({ path: '/vault/event/old.md', isA: 'Event', trashed: true }),
]
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries })))
// Should fall back to defaults since the only Event entry is trashed
expect(result.current.find(c => c.id === 'new-event')).toBeDefined()
expect(result.current.find(c => c.id === 'new-project')).toBeDefined()
})
it('deduplicates types from multiple entries', () => {
const entries = [
makeEntry({ path: '/vault/event/a.md', isA: 'Event' }),
makeEntry({ path: '/vault/event/b.md', isA: 'Event' }),
makeEntry({ path: '/vault/event/c.md', isA: 'Event' }),
]
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries })))
const eventCmds = result.current.filter(c => c.id === 'new-event')
expect(eventCmds).toHaveLength(1)
})
})
describe('theme commands', () => {
const themeFixtures = [
{ id: 'default', name: 'Default', description: '', colors: {}, typography: {}, spacing: {} },
{ id: 'dark', name: 'Dark', description: '', colors: {}, typography: {}, spacing: {} },
]
it('generates switch-theme commands for each theme', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({
themes: themeFixtures, activeThemeId: 'default', onSwitchTheme: vi.fn(),
})))
const switchDefault = result.current.find(c => c.id === 'switch-theme-default')
const switchDark = result.current.find(c => c.id === 'switch-theme-dark')
expect(switchDefault).toBeDefined()
expect(switchDefault!.label).toBe('Switch to Default Theme')
expect(switchDefault!.group).toBe('Appearance')
expect(switchDark).toBeDefined()
expect(switchDark!.label).toBe('Switch to Dark Theme')
})
it('disables switch command for the currently active theme', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({
themes: themeFixtures, activeThemeId: 'default', onSwitchTheme: vi.fn(),
})))
expect(result.current.find(c => c.id === 'switch-theme-default')!.enabled).toBe(false)
expect(result.current.find(c => c.id === 'switch-theme-dark')!.enabled).toBe(true)
})
it('calls onSwitchTheme when switch command executes', () => {
const onSwitchTheme = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
themes: themeFixtures, activeThemeId: 'default', onSwitchTheme,
})))
result.current.find(c => c.id === 'switch-theme-dark')!.execute()
expect(onSwitchTheme).toHaveBeenCalledWith('dark')
})
it('includes new-theme command when onCreateTheme is provided', () => {
const onCreateTheme = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
themes: themeFixtures, activeThemeId: 'default', onCreateTheme,
})))
const newTheme = result.current.find(c => c.id === 'new-theme')
expect(newTheme).toBeDefined()
expect(newTheme!.group).toBe('Appearance')
expect(newTheme!.enabled).toBe(true)
})
it('omits new-theme command when onCreateTheme is not provided', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({
themes: themeFixtures, activeThemeId: 'default',
})))
expect(result.current.find(c => c.id === 'new-theme')).toBeUndefined()
})
it('calls onCreateTheme when new-theme executes', () => {
const onCreateTheme = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
themes: themeFixtures, activeThemeId: 'default', onCreateTheme,
})))
result.current.find(c => c.id === 'new-theme')!.execute()
expect(onCreateTheme).toHaveBeenCalled()
})
it('includes Appearance in command groups', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({
themes: themeFixtures, activeThemeId: 'default', onSwitchTheme: vi.fn(),
})))
const groups = new Set(result.current.map(c => c.group))
expect(groups).toContain('Appearance')
})
it('generates open-theme commands for each theme when onOpenTheme is provided', () => {
const onOpenTheme = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
themes: themeFixtures, activeThemeId: 'default', onOpenTheme,
})))
const openDefault = result.current.find(c => c.id === 'open-theme-default')
const openDark = result.current.find(c => c.id === 'open-theme-dark')
expect(openDefault).toBeDefined()
expect(openDefault!.label).toBe('Edit Default Theme')
expect(openDefault!.group).toBe('Appearance')
expect(openDefault!.enabled).toBe(true)
expect(openDark).toBeDefined()
expect(openDark!.label).toBe('Edit Dark Theme')
})
it('omits open-theme commands when onOpenTheme is not provided', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({
themes: themeFixtures, activeThemeId: 'default',
})))
expect(result.current.find(c => c.id === 'open-theme-default')).toBeUndefined()
expect(result.current.find(c => c.id === 'open-theme-dark')).toBeUndefined()
})
it('calls onOpenTheme with correct themeId when open-theme command executes', () => {
const onOpenTheme = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
themes: themeFixtures, activeThemeId: 'default', onOpenTheme,
})))
result.current.find(c => c.id === 'open-theme-dark')!.execute()
expect(onOpenTheme).toHaveBeenCalledWith('dark')
})
it('open-theme command is always enabled regardless of active theme', () => {
const onOpenTheme = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
themes: themeFixtures, activeThemeId: 'default', onOpenTheme,
})))
expect(result.current.find(c => c.id === 'open-theme-default')!.enabled).toBe(true)
})
})
describe('vault management commands', () => {
it('has remove-vault command in Settings group', () => {
const onRemoveActiveVault = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
onRemoveActiveVault, vaultCount: 2,
})))
const cmd = result.current.find(c => c.id === 'remove-vault')
expect(cmd).toBeDefined()
expect(cmd!.label).toBe('Remove Vault from List')
expect(cmd!.group).toBe('Settings')
expect(cmd!.enabled).toBe(true)
})
it('disables remove-vault when only one vault remains', () => {
const onRemoveActiveVault = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
onRemoveActiveVault, vaultCount: 1,
})))
expect(result.current.find(c => c.id === 'remove-vault')!.enabled).toBe(false)
})
it('disables remove-vault when onRemoveActiveVault is not provided', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ vaultCount: 3 })))
expect(result.current.find(c => c.id === 'remove-vault')!.enabled).toBe(false)
})
it('calls onRemoveActiveVault when remove-vault executes', () => {
const onRemoveActiveVault = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
onRemoveActiveVault, vaultCount: 2,
})))
result.current.find(c => c.id === 'remove-vault')!.execute()
expect(onRemoveActiveVault).toHaveBeenCalled()
})
it('has restore-getting-started command in Settings group', () => {
const onRestoreGettingStarted = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
onRestoreGettingStarted, isGettingStartedHidden: true,
})))
const cmd = result.current.find(c => c.id === 'restore-getting-started')
expect(cmd).toBeDefined()
expect(cmd!.label).toBe('Restore Getting Started Vault')
expect(cmd!.group).toBe('Settings')
expect(cmd!.enabled).toBe(true)
})
it('disables restore-getting-started when vault is not hidden', () => {
const onRestoreGettingStarted = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
onRestoreGettingStarted, isGettingStartedHidden: false,
})))
expect(result.current.find(c => c.id === 'restore-getting-started')!.enabled).toBe(false)
})
it('calls onRestoreGettingStarted when restore command executes', () => {
const onRestoreGettingStarted = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
onRestoreGettingStarted, isGettingStartedHidden: true,
})))
result.current.find(c => c.id === 'restore-getting-started')!.execute()
expect(onRestoreGettingStarted).toHaveBeenCalled()
})
it('remove-vault has relevant keywords for discoverability', () => {
const onRemoveActiveVault = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
onRemoveActiveVault, vaultCount: 2,
})))
const cmd = result.current.find(c => c.id === 'remove-vault')
expect(cmd!.keywords).toContain('vault')
expect(cmd!.keywords).toContain('remove')
expect(cmd!.keywords).toContain('disconnect')
})
it('restore-getting-started has relevant keywords for discoverability', () => {
const onRestoreGettingStarted = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
onRestoreGettingStarted, isGettingStartedHidden: true,
})))
const cmd = result.current.find(c => c.id === 'restore-getting-started')
expect(cmd!.keywords).toContain('vault')
expect(cmd!.keywords).toContain('restore')
expect(cmd!.keywords).toContain('demo')
})
})
})
describe('pluralizeType', () => {
it('handles regular plurals', () => {
expect(pluralizeType('Event')).toBe('Events')
expect(pluralizeType('Project')).toBe('Projects')
expect(pluralizeType('Note')).toBe('Notes')
expect(pluralizeType('Topic')).toBe('Topics')
})
it('handles special plurals', () => {
expect(pluralizeType('Person')).toBe('People')
expect(pluralizeType('Responsibility')).toBe('Responsibilities')
})
it('handles words ending in y', () => {
expect(pluralizeType('Category')).toBe('Categories')
})
it('handles words ending in s/x/ch/sh', () => {
expect(pluralizeType('Process')).toBe('Processes')
expect(pluralizeType('Box')).toBe('Boxes')
})
it('does not double-pluralize words ending in vowel+y', () => {
expect(pluralizeType('Key')).toBe('Keys')
expect(pluralizeType('Day')).toBe('Days')
})
})
describe('extractVaultTypes', () => {
it('extracts unique types from entries', () => {
const entries = [
makeEntry({ isA: 'Event' }),
makeEntry({ isA: 'Person' }),
makeEntry({ isA: 'Event' }),
]
const types = extractVaultTypes(entries)
expect(types).toEqual(['Event', 'Person'])
})
it('returns default types when no entries', () => {
const types = extractVaultTypes([])
expect(types).toContain('Event')
expect(types).toContain('Person')
expect(types).toContain('Project')
expect(types).toContain('Note')
expect(types).toHaveLength(4)
})
it('excludes Type entries', () => {
const entries = [
makeEntry({ isA: 'Type' }),
makeEntry({ isA: 'Event' }),
]
const types = extractVaultTypes(entries)
expect(types).toEqual(['Event'])
expect(types).not.toContain('Type')
})
it('excludes trashed entries', () => {
const entries = [makeEntry({ isA: 'Event', trashed: true })]
const types = extractVaultTypes(entries)
// Falls back to defaults since the only typed entry is trashed
expect(types).toContain('Event')
expect(types).toContain('Person')
expect(types).toHaveLength(4)
})
it('returns sorted types', () => {
const entries = [
makeEntry({ isA: 'Procedure' }),
makeEntry({ isA: 'Event' }),
makeEntry({ isA: 'Note' }),
]
expect(extractVaultTypes(entries)).toEqual(['Event', 'Note', 'Procedure'])
})
})
describe('groupSortKey', () => {
it('returns ordered keys for all groups', () => {
expect(groupSortKey('Navigation')).toBeLessThan(groupSortKey('Note'))
expect(groupSortKey('Note')).toBeLessThan(groupSortKey('Git'))
expect(groupSortKey('Git')).toBeLessThan(groupSortKey('View'))
expect(groupSortKey('View')).toBeLessThan(groupSortKey('Appearance'))
expect(groupSortKey('Appearance')).toBeLessThan(groupSortKey('Settings'))
})
})