Files
tolaria/src/hooks/useCommandRegistry.test.ts
Luca Rossi cae821ef4c feat: vault-native theming system with live reload (#154)
* feat: add theming system design file with 3 frames

Design frames for Settings > Appearance panel, Command Palette
theme switching, and live theme editing flow visualization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add Rust backend for vault-native theming system

Theme files live in _themes/*.json with colors, typography, and spacing
sections. Vault-level settings (.laputa/settings.json) store the active
theme. Built-in themes (default, dark, minimal) are seeded on vault
creation. All 6 Tauri commands registered: list_themes, get_theme,
get_vault_settings, save_vault_settings, set_active_theme, create_theme.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add frontend theme engine, settings UI, and command palette integration

Wire up useThemeManager hook to load themes via Tauri IPC, apply CSS
custom properties to :root, and support switching/creating themes.
Add Appearance section to Settings panel with color swatches and theme
cards. Register theme switch and create commands in the command registry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use .to_string() instead of format! for string literal (clippy)

* style: cargo fmt on theme.rs

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:05:14 +01:00

424 lines
17 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,
outgoingLinks: [],
...overrides,
})
function makeConfig(overrides: Record<string, unknown> = {}) {
return {
activeTabPath: null as string | null,
entries: [] as VaultEntry[],
modifiedCount: 0,
onQuickOpen: vi.fn(),
onCreateNote: vi.fn(),
onCreateNoteOfType: vi.fn(),
onSave: vi.fn(),
onOpenSettings: vi.fn(),
onTrashNote: 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(),
...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('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%')
})
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')
})
})
})
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'))
})
})