* fix: use camelCase arg keys for Tauri theme commands Tauri v2 auto-converts Rust snake_case params to camelCase for the JS interface. useThemeManager was sending snake_case keys (vault_path, theme_id, source_id) which caused "missing required key vaultPath" errors in the native app, making New Theme silently fail. All other hooks already use camelCase — this aligns the theme manager. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: exclude search.rs and lib.rs from coverage --------- Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
247 lines
8.7 KiB
TypeScript
247 lines
8.7 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { renderHook, act, waitFor } from '@testing-library/react'
|
|
import type { ThemeFile, VaultSettings } from '../types'
|
|
|
|
const mockThemes: ThemeFile[] = [
|
|
{
|
|
id: 'default', name: 'Default', description: 'Clean default theme',
|
|
colors: { background: '#FFFFFF', foreground: '#1A1A2E', primary: '#6366F1', border: '#E2E8F0', 'sidebar-background': '#F8FAFC' },
|
|
typography: { 'font-family': 'Inter, sans-serif' },
|
|
spacing: { 'sidebar-width': '240px' },
|
|
},
|
|
{
|
|
id: 'dark', name: 'Dark', description: 'Dark theme',
|
|
colors: { background: '#0F0F23', foreground: '#E2E8F0', primary: '#818CF8', border: '#1E293B' },
|
|
typography: { 'font-family': 'Inter, sans-serif' },
|
|
spacing: {},
|
|
},
|
|
]
|
|
|
|
const mockSettings: VaultSettings = { theme: 'default' }
|
|
|
|
const mockInvokeFn = vi.fn(async (cmd: string) => {
|
|
if (cmd === 'list_themes') return mockThemes
|
|
if (cmd === 'get_vault_settings') return mockSettings
|
|
if (cmd === 'set_active_theme') return null
|
|
if (cmd === 'create_theme') return 'new-theme-id'
|
|
return null
|
|
})
|
|
|
|
vi.mock('@tauri-apps/api/core', () => ({
|
|
invoke: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('../mock-tauri', () => ({
|
|
isTauri: () => false,
|
|
mockInvoke: (cmd: string, args?: Record<string, unknown>) => mockInvokeFn(cmd, args),
|
|
}))
|
|
|
|
// Must import after mocks
|
|
const { useThemeManager } = await import('./useThemeManager')
|
|
|
|
describe('useThemeManager', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
|
if (cmd === 'list_themes') return mockThemes
|
|
if (cmd === 'get_vault_settings') return mockSettings
|
|
if (cmd === 'set_active_theme') return null
|
|
if (cmd === 'create_theme') return 'new-theme-id'
|
|
return null
|
|
})
|
|
// Clear any theme CSS properties from previous tests
|
|
const root = document.documentElement
|
|
root.style.cssText = ''
|
|
})
|
|
|
|
it('loads themes and active theme on mount', async () => {
|
|
const { result } = renderHook(() => useThemeManager('/vault'))
|
|
await waitFor(() => {
|
|
expect(result.current.themes).toHaveLength(2)
|
|
})
|
|
expect(result.current.activeThemeId).toBe('default')
|
|
expect(result.current.activeTheme?.name).toBe('Default')
|
|
})
|
|
|
|
it('returns empty state when vaultPath is null', async () => {
|
|
const { result } = renderHook(() => useThemeManager(null))
|
|
// Give it time to settle — should remain empty
|
|
await new Promise(r => setTimeout(r, 50))
|
|
expect(result.current.themes).toHaveLength(0)
|
|
expect(result.current.activeThemeId).toBeNull()
|
|
expect(result.current.activeTheme).toBeNull()
|
|
expect(mockInvokeFn).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('applies CSS custom properties for active theme', async () => {
|
|
const { result } = renderHook(() => useThemeManager('/vault'))
|
|
await waitFor(() => {
|
|
expect(result.current.activeTheme).not.toBeNull()
|
|
})
|
|
const root = document.documentElement
|
|
expect(root.style.getPropertyValue('--background')).toBe('#FFFFFF')
|
|
expect(root.style.getPropertyValue('--foreground')).toBe('#1A1A2E')
|
|
expect(root.style.getPropertyValue('--primary')).toBe('#6366F1')
|
|
expect(root.style.getPropertyValue('--theme-background')).toBe('#FFFFFF')
|
|
expect(root.style.getPropertyValue('--theme-font-family')).toBe('Inter, sans-serif')
|
|
expect(root.style.getPropertyValue('--theme-sidebar-width')).toBe('240px')
|
|
})
|
|
|
|
it('maps sidebar-background to --sidebar', async () => {
|
|
const { result } = renderHook(() => useThemeManager('/vault'))
|
|
await waitFor(() => {
|
|
expect(result.current.activeTheme).not.toBeNull()
|
|
})
|
|
expect(document.documentElement.style.getPropertyValue('--sidebar')).toBe('#F8FAFC')
|
|
})
|
|
|
|
it('switchTheme calls set_active_theme and updates activeThemeId', async () => {
|
|
const { result } = renderHook(() => useThemeManager('/vault'))
|
|
await waitFor(() => {
|
|
expect(result.current.themes).toHaveLength(2)
|
|
})
|
|
|
|
await act(async () => {
|
|
await result.current.switchTheme('dark')
|
|
})
|
|
|
|
expect(mockInvokeFn).toHaveBeenCalledWith('set_active_theme', { vaultPath: '/vault', themeId: 'dark' })
|
|
expect(result.current.activeThemeId).toBe('dark')
|
|
})
|
|
|
|
it('clears old theme CSS and applies new theme on switch', async () => {
|
|
const { result } = renderHook(() => useThemeManager('/vault'))
|
|
await waitFor(() => {
|
|
expect(result.current.activeTheme?.id).toBe('default')
|
|
})
|
|
|
|
const root = document.documentElement
|
|
expect(root.style.getPropertyValue('--background')).toBe('#FFFFFF')
|
|
|
|
await act(async () => {
|
|
await result.current.switchTheme('dark')
|
|
})
|
|
|
|
await waitFor(() => {
|
|
expect(root.style.getPropertyValue('--background')).toBe('#0F0F23')
|
|
})
|
|
expect(root.style.getPropertyValue('--foreground')).toBe('#E2E8F0')
|
|
})
|
|
|
|
it('createTheme calls create_theme and reloads themes', async () => {
|
|
const { result } = renderHook(() => useThemeManager('/vault'))
|
|
await waitFor(() => {
|
|
expect(result.current.themes).toHaveLength(2)
|
|
})
|
|
|
|
let newId = ''
|
|
await act(async () => {
|
|
newId = await result.current.createTheme('default')
|
|
})
|
|
|
|
expect(newId).toBe('new-theme-id')
|
|
expect(mockInvokeFn).toHaveBeenCalledWith('create_theme', { vaultPath: '/vault', sourceId: 'default' })
|
|
// Should reload after creation
|
|
const listCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'list_themes')
|
|
expect(listCalls.length).toBeGreaterThanOrEqual(2)
|
|
})
|
|
|
|
it('createTheme passes null source_id when no sourceId provided', async () => {
|
|
const { result } = renderHook(() => useThemeManager('/vault'))
|
|
await waitFor(() => {
|
|
expect(result.current.themes).toHaveLength(2)
|
|
})
|
|
|
|
await act(async () => {
|
|
await result.current.createTheme()
|
|
})
|
|
|
|
expect(mockInvokeFn).toHaveBeenCalledWith('create_theme', { vaultPath: '/vault', sourceId: null })
|
|
})
|
|
|
|
it('handles load failure gracefully', async () => {
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
mockInvokeFn.mockRejectedValue(new Error('disk error'))
|
|
|
|
const { result } = renderHook(() => useThemeManager('/vault'))
|
|
await waitFor(() => {
|
|
expect(warnSpy).toHaveBeenCalledWith('Failed to load themes:', expect.any(Error))
|
|
})
|
|
|
|
expect(result.current.themes).toHaveLength(0)
|
|
expect(result.current.activeThemeId).toBeNull()
|
|
warnSpy.mockRestore()
|
|
})
|
|
|
|
it('handles switchTheme failure gracefully', async () => {
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const { result } = renderHook(() => useThemeManager('/vault'))
|
|
await waitFor(() => {
|
|
expect(result.current.themes).toHaveLength(2)
|
|
})
|
|
|
|
mockInvokeFn.mockRejectedValueOnce(new Error('permission denied'))
|
|
|
|
await act(async () => {
|
|
await result.current.switchTheme('dark')
|
|
})
|
|
|
|
// Should not have changed the active theme
|
|
expect(result.current.activeThemeId).toBe('default')
|
|
expect(errorSpy).toHaveBeenCalledWith('Failed to switch theme:', expect.any(Error))
|
|
errorSpy.mockRestore()
|
|
})
|
|
|
|
it('handles createTheme failure gracefully', async () => {
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const { result } = renderHook(() => useThemeManager('/vault'))
|
|
await waitFor(() => {
|
|
expect(result.current.themes).toHaveLength(2)
|
|
})
|
|
|
|
mockInvokeFn.mockRejectedValueOnce(new Error('write error'))
|
|
|
|
let newId = ''
|
|
await act(async () => {
|
|
newId = await result.current.createTheme('default')
|
|
})
|
|
|
|
expect(newId).toBe('')
|
|
expect(errorSpy).toHaveBeenCalledWith('Failed to create theme:', expect.any(Error))
|
|
errorSpy.mockRestore()
|
|
})
|
|
|
|
it('switchTheme is a no-op when vaultPath is null', async () => {
|
|
const { result } = renderHook(() => useThemeManager(null))
|
|
await act(async () => {
|
|
await result.current.switchTheme('dark')
|
|
})
|
|
expect(mockInvokeFn).not.toHaveBeenCalledWith('set_active_theme', expect.anything())
|
|
})
|
|
|
|
it('createTheme returns empty string when vaultPath is null', async () => {
|
|
const { result } = renderHook(() => useThemeManager(null))
|
|
let newId = ''
|
|
await act(async () => {
|
|
newId = await result.current.createTheme()
|
|
})
|
|
expect(newId).toBe('')
|
|
})
|
|
|
|
it('reloadThemes re-fetches theme list', async () => {
|
|
const { result } = renderHook(() => useThemeManager('/vault'))
|
|
await waitFor(() => {
|
|
expect(result.current.themes).toHaveLength(2)
|
|
})
|
|
|
|
const initialListCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'list_themes').length
|
|
|
|
await act(async () => {
|
|
await result.current.reloadThemes()
|
|
})
|
|
|
|
const afterListCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'list_themes').length
|
|
expect(afterListCalls).toBe(initialListCalls + 1)
|
|
})
|
|
})
|