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 => ({ 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: '', relationships: {}, icon: null, color: null, order: null, ...overrides, }) const localStorageMock = (() => { let store: Record = {} 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('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() }) }) })