Files
tolaria/src/hooks/useTabManagement.test.ts
Luca Rossi 986965a28f feat: note templates per type (💡 Note templates per tipo) (#170)
* feat: add template field to type entries and template-aware note creation

- Rust: add template field to Frontmatter, VaultEntry, SKIP_KEYS
- Rust: support YAML block scalar (|) for multi-line strings in frontmatter
- Rust: fix value continuation detection to handle block scalars properly
- TypeScript: add template to VaultEntry, buildNewEntry, frontmatterToEntryPatch
- Add DEFAULT_TEMPLATES for Project, Person, Responsibility, Experiment
- resolveNewNote/buildNoteContent accept optional template parameter
- handleCreateNote and handleCreateNoteImmediate look up type template
- Tests for all new behavior (Rust + TypeScript)

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

* feat: wire template UI through Sidebar and App, add TypeCustomizePopover template section

- Add template textarea with debounced save to TypeCustomizePopover
- Wire handleUpdateTypeTemplate through useEntryActions → Sidebar → App
- Add useDebouncedCallback hook for 500ms template save debounce
- Add tests for handleUpdateTypeTemplate and TypeCustomizePopover template UI
- Create design/note-templates.pen placeholder

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

* fix: useRef type arg + template field in bulk mock entries

* style: rustfmt

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-02 08:37:06 +01:00

345 lines
11 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import type { VaultEntry } from '../types'
import { useTabManagement } from './useTabManagement'
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: vi.fn().mockResolvedValue('# Mock content'),
}))
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,
})
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, val: string) => { store[key] = val }),
removeItem: vi.fn((key: string) => { delete store[key] }),
clear: vi.fn(() => { store = {} }),
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock })
describe('useTabManagement', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorageMock.clear()
})
it('starts with no tabs and no active tab', () => {
const { result } = renderHook(() => useTabManagement())
expect(result.current.tabs).toEqual([])
expect(result.current.activeTabPath).toBeNull()
})
describe('handleSelectNote', () => {
it('opens a new tab and sets it active', async () => {
const { result } = renderHook(() => useTabManagement())
const entry = makeEntry({ path: '/vault/note/a.md' })
await act(async () => {
await result.current.handleSelectNote(entry)
})
expect(result.current.tabs).toHaveLength(1)
expect(result.current.tabs[0].entry.path).toBe('/vault/note/a.md')
expect(result.current.activeTabPath).toBe('/vault/note/a.md')
})
it('switches to existing tab without duplicating', async () => {
const { result } = renderHook(() => useTabManagement())
const entry = makeEntry({ path: '/vault/note/a.md' })
await act(async () => {
await result.current.handleSelectNote(entry)
})
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/note/b.md', title: 'B' }))
})
// Select first entry again
await act(async () => {
await result.current.handleSelectNote(entry)
})
expect(result.current.tabs).toHaveLength(2)
expect(result.current.activeTabPath).toBe('/vault/note/a.md')
})
it('handles load content failure gracefully', async () => {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('fail'))
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { result } = renderHook(() => useTabManagement())
const entry = makeEntry()
await act(async () => {
await result.current.handleSelectNote(entry)
})
// Tab still opens with empty content on failure
expect(result.current.tabs).toHaveLength(1)
expect(result.current.tabs[0].content).toBe('')
warnSpy.mockRestore()
})
})
describe('handleCloseTab', () => {
it('removes the tab', async () => {
const { result } = renderHook(() => useTabManagement())
const entry = makeEntry({ path: '/vault/note/a.md' })
await act(async () => {
await result.current.handleSelectNote(entry)
})
act(() => {
result.current.handleCloseTab('/vault/note/a.md')
})
expect(result.current.tabs).toHaveLength(0)
})
it('selects next tab when active tab is closed', async () => {
const { result } = renderHook(() => useTabManagement())
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
})
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
})
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/c.md', title: 'C' }))
})
// Close middle tab B, should switch to C (same index)
act(() => {
result.current.handleSwitchTab('/vault/b.md')
})
act(() => {
result.current.handleCloseTab('/vault/b.md')
})
expect(result.current.tabs).toHaveLength(2)
expect(result.current.activeTabPath).toBe('/vault/c.md')
})
it('sets null active when last tab is closed', async () => {
const { result } = renderHook(() => useTabManagement())
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md' }))
})
act(() => {
result.current.handleCloseTab('/vault/a.md')
})
expect(result.current.activeTabPath).toBeNull()
})
})
describe('handleSwitchTab', () => {
it('changes the active tab path', async () => {
const { result } = renderHook(() => useTabManagement())
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
})
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
})
act(() => {
result.current.handleSwitchTab('/vault/a.md')
})
expect(result.current.activeTabPath).toBe('/vault/a.md')
})
})
describe('handleReorderTabs', () => {
it('moves a tab from one position to another', async () => {
const { result } = renderHook(() => useTabManagement())
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
})
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
})
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/c.md', title: 'C' }))
})
act(() => {
result.current.handleReorderTabs(2, 0)
})
expect(result.current.tabs.map(t => t.entry.title)).toEqual(['C', 'A', 'B'])
})
it('preserves active tab after reorder', async () => {
const { result } = renderHook(() => useTabManagement())
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
})
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
})
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/c.md', title: 'C' }))
})
// C is active (last opened). Move it to the front.
act(() => {
result.current.handleReorderTabs(2, 0)
})
expect(result.current.tabs.map(t => t.entry.title)).toEqual(['C', 'A', 'B'])
expect(result.current.activeTabPath).toBe('/vault/c.md')
})
it('persists tab order to localStorage', async () => {
const { result } = renderHook(() => useTabManagement())
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
})
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
})
act(() => {
result.current.handleReorderTabs(1, 0)
})
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'laputa-tab-order',
expect.any(String),
)
})
})
describe('handleReplaceActiveTab', () => {
it('replaces the active tab with a new entry', async () => {
const { result } = renderHook(() => useTabManagement())
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
})
const replacement = makeEntry({ path: '/vault/b.md', title: 'B' })
await act(async () => {
await result.current.handleReplaceActiveTab(replacement)
})
expect(result.current.tabs).toHaveLength(1)
expect(result.current.tabs[0].entry.path).toBe('/vault/b.md')
expect(result.current.activeTabPath).toBe('/vault/b.md')
})
it('does nothing when replacing with same entry', async () => {
const { result } = renderHook(() => useTabManagement())
const entry = makeEntry({ path: '/vault/a.md' })
await act(async () => {
await result.current.handleSelectNote(entry)
})
await act(async () => {
await result.current.handleReplaceActiveTab(entry)
})
expect(result.current.tabs).toHaveLength(1)
})
it('falls back to handleSelectNote when no active tab', async () => {
const { result } = renderHook(() => useTabManagement())
const entry = makeEntry({ path: '/vault/a.md' })
await act(async () => {
await result.current.handleReplaceActiveTab(entry)
})
expect(result.current.tabs).toHaveLength(1)
expect(result.current.activeTabPath).toBe('/vault/a.md')
})
it('switches to existing tab instead of replacing when note is already open', async () => {
const { result } = renderHook(() => useTabManagement())
// Open two tabs: A (active) and B
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
})
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
})
// Switch back to A
act(() => { result.current.handleSwitchTab('/vault/a.md') })
// Replace active tab with B — but B is already open, so it should just switch
await act(async () => {
await result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/b.md', title: 'B' }))
})
// Should still have 2 tabs (not replace A), and B should be active
expect(result.current.tabs).toHaveLength(2)
expect(result.current.activeTabPath).toBe('/vault/b.md')
expect(result.current.tabs.map(t => t.entry.title)).toEqual(['A', 'B'])
})
})
describe('closeAllTabs', () => {
it('clears all tabs and active path', async () => {
const { result } = renderHook(() => useTabManagement())
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md' }))
})
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
})
act(() => {
result.current.closeAllTabs()
})
expect(result.current.tabs).toHaveLength(0)
expect(result.current.activeTabPath).toBeNull()
})
})
})