import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { renderHook, act } from '@testing-library/react' import type { SetStateAction } from 'react' import { useAppSave } from './useAppSave' import type { VaultEntry } from '../types' import { isTauri } from '../mock-tauri' import { invoke } from '@tauri-apps/api/core' const { startTransitionMock } = vi.hoisted(() => ({ startTransitionMock: vi.fn((callback: () => void) => callback()), })) vi.mock('react', async (importOriginal) => { const actual = await importOriginal() return { ...actual, startTransition: startTransitionMock, } }) vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn().mockResolvedValue(undefined), })) vi.mock('../mock-tauri', () => ({ isTauri: vi.fn(() => false), mockInvoke: vi.fn().mockResolvedValue(undefined), updateMockContent: vi.fn(), })) function makeEntry(path: string, title = 'Test', filename = 'test.md'): VaultEntry { return { path, title, filename, content: '', outgoingLinks: [], snippet: '', wordCount: 0, isA: 'Note', status: null, createdAt: null, modifiedAt: null, icon: null, tags: [] } as unknown as VaultEntry } describe('useAppSave', () => { const deps = { updateEntry: vi.fn(), setTabs: vi.fn(), handleSwitchTab: vi.fn(), setToastMessage: vi.fn(), loadModifiedFiles: vi.fn(), clearUnsaved: vi.fn(), unsavedPaths: new Set(), tabs: [] as Array<{ entry: VaultEntry; content: string }>, activeTabPath: null as string | null, handleRenameNote: vi.fn().mockResolvedValue(undefined), handleRenameFilename: vi.fn().mockResolvedValue(undefined), replaceEntry: vi.fn(), resolvedPath: '/vault', initialH1AutoRenameEnabled: true, } beforeEach(() => { vi.clearAllMocks() vi.useRealTimers() vi.mocked(isTauri).mockReturnValue(false) deps.unsavedPaths = new Set() deps.tabs = [] deps.activeTabPath = null deps.handleRenameNote.mockResolvedValue(undefined) deps.handleRenameFilename.mockResolvedValue(undefined) deps.initialH1AutoRenameEnabled = true }) afterEach(() => { vi.useRealTimers() }) function renderSave(overrides = {}) { return renderHook(() => useAppSave({ ...deps, ...overrides })) } function createDeferred() { let resolve!: (value: T) => void const promise = new Promise((res) => { resolve = res }) return { promise, resolve } } function setupUntitledRenameHarness(options?: { initialContent?: string diskContent?: string autoRenameResult?: Promise<{ new_path: string; updated_files: number } | null> | { new_path: string; updated_files: number } | null render?: boolean }) { vi.useFakeTimers() vi.mocked(isTauri).mockReturnValue(true) const oldPath = '/vault/untitled-note-123.md' const newPath = '/vault/fresh-title.md' const initialContent = options?.initialContent ?? '# Fresh Title\n\nBody' const diskContent = options?.diskContent ?? initialContent const entry = makeEntry(oldPath, 'Untitled Note 123', 'untitled-note-123.md') let tabsState = [{ entry, content: initialContent }] const setTabs = vi.fn((updater: SetStateAction) => { tabsState = typeof updater === 'function' ? updater(tabsState) : updater }) vi.mocked(invoke).mockImplementation(async (command: string, args?: Record) => { if (command === 'save_note_content') return undefined if (command === 'auto_rename_untitled') return options?.autoRenameResult ?? { new_path: newPath, updated_files: 0 } if (command === 'reload_vault_entry') return makeEntry(newPath, 'Fresh Title', 'fresh-title.md') if (command === 'get_note_content' && args?.path === newPath) return diskContent return undefined }) const rendered = options?.render === false ? {} : renderSave({ setTabs, tabs: tabsState, activeTabPath: oldPath, unsavedPaths: new Set([oldPath]), }) return { ...rendered, entry, oldPath, newPath, setTabs, getTabs: () => tabsState, setTabsState: (nextTabs: typeof tabsState) => { tabsState = nextTabs }, } } it('exposes contentChangeRef', () => { const { result } = renderSave() expect(result.current.contentChangeRef).toBeDefined() expect(typeof result.current.contentChangeRef.current).toBe('function') }) it('exposes handleSave', () => { const { result } = renderSave() expect(typeof result.current.handleSave).toBe('function') }) it('exposes handleTitleSync', () => { const { result } = renderSave() expect(typeof result.current.handleTitleSync).toBe('function') }) it('exposes flushBeforeAction', () => { const { result } = renderSave() expect(typeof result.current.flushBeforeAction).toBe('function') }) it('handleSave calls save with no fallback when no active tab', async () => { const { result } = renderSave() await act(async () => { await result.current.handleSave() }) // Should not throw — just a no-op save }) it('handleSave provides fallback for unsaved active tab', async () => { const entry = makeEntry('/vault/note.md', 'note', 'note.md') const unsavedPaths = new Set(['/vault/note.md']) const tabs = [{ entry, content: '# Hello' }] const { result } = renderSave({ tabs, activeTabPath: '/vault/note.md', unsavedPaths, }) await act(async () => { await result.current.handleSave() }) // Should complete without error }) it('handleContentChange is a function', () => { const { result } = renderSave() expect(typeof result.current.handleContentChange).toBe('function') }) it('debounces untitled H1 auto-rename until the user pauses typing', async () => { vi.useFakeTimers() vi.mocked(isTauri).mockReturnValue(true) vi.mocked(invoke).mockImplementation(async (command: string, args?: Record) => { if (command === 'save_note_content') return undefined if (command === 'auto_rename_untitled') return { new_path: '/vault/fresh-title.md', updated_files: 0 } if (command === 'reload_vault_entry') return makeEntry('/vault/fresh-title.md', 'Fresh Title', 'fresh-title.md') if (command === 'get_note_content' && args?.path === '/vault/fresh-title.md') return '# Fresh Title\n\nBody' return undefined }) const entry = makeEntry('/vault/untitled-note-123.md', 'Untitled Note 123', 'untitled-note-123.md') const tabs = [{ entry, content: '# Fresh Title\n\nBody' }] const { result } = renderSave({ tabs, activeTabPath: entry.path, unsavedPaths: new Set([entry.path]), }) await act(async () => { result.current.handleContentChange(entry.path, '# Fresh Title\n\nBody') await vi.advanceTimersByTimeAsync(500) }) expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('auto_rename_untitled', expect.anything()) await act(async () => { await vi.advanceTimersByTimeAsync(2_499) }) expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('auto_rename_untitled', expect.anything()) await act(async () => { await vi.advanceTimersByTimeAsync(1) }) expect(vi.mocked(invoke)).toHaveBeenCalledWith('auto_rename_untitled', { vaultPath: '/vault', notePath: entry.path, }) expect(deps.replaceEntry).toHaveBeenCalledWith( entry.path, expect.objectContaining({ path: '/vault/fresh-title.md', filename: 'fresh-title.md' }), '# Fresh Title\n\nBody', ) }) it('does not auto-rename untitled notes when the H1 auto-rename preference is disabled', async () => { vi.useFakeTimers() vi.mocked(isTauri).mockReturnValue(true) vi.mocked(invoke).mockImplementation(async (command: string) => { if (command === 'save_note_content') return undefined if (command === 'auto_rename_untitled') { throw new Error('auto_rename_untitled should not run when disabled') } return undefined }) const entry = makeEntry('/vault/untitled-note-123.md', 'Untitled Note 123', 'untitled-note-123.md') const tabs = [{ entry, content: '# Fresh Title\n\nBody' }] const { result } = renderSave({ tabs, activeTabPath: entry.path, unsavedPaths: new Set([entry.path]), initialH1AutoRenameEnabled: false, }) await act(async () => { result.current.handleContentChange(entry.path, '# Fresh Title\n\nBody') await vi.advanceTimersByTimeAsync(3_000) }) expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('auto_rename_untitled', expect.anything()) expect(deps.replaceEntry).not.toHaveBeenCalled() }) it('switches the active tab to the renamed path after untitled H1 auto-rename', async () => { const { result, newPath, getTabs } = setupUntitledRenameHarness() await act(async () => { result.current.handleContentChange('/vault/untitled-note-123.md', '# Fresh Title\n\nBody') await vi.advanceTimersByTimeAsync(3_000) }) expect(deps.handleSwitchTab).toHaveBeenCalledWith(newPath) expect(getTabs()[0].entry.path).toBe(newPath) expect(getTabs()[0].entry.filename).toBe('fresh-title.md') expect(getTabs()[0].content).toBe('# Fresh Title\n\nBody') }) it('reconciles untitled auto-rename state in a React transition', async () => { const { result } = setupUntitledRenameHarness() await act(async () => { result.current.handleContentChange('/vault/untitled-note-123.md', '# Fresh Title\n\nBody') await vi.advanceTimersByTimeAsync(3_000) }) expect(startTransitionMock).toHaveBeenCalled() }) it('cancels a pending untitled auto-rename when the user navigates away', async () => { vi.useFakeTimers() vi.mocked(isTauri).mockReturnValue(true) vi.mocked(invoke).mockImplementation(async (command: string) => { if (command === 'save_note_content') return undefined if (command === 'auto_rename_untitled') return { new_path: '/vault/fresh-title.md', updated_files: 0 } return undefined }) const entry = makeEntry('/vault/untitled-note-123.md', 'Untitled Note 123', 'untitled-note-123.md') const tabs = [{ entry, content: '# Fresh Title\n\nBody' }] const { result, rerender } = renderHook( ({ currentActiveTabPath }: { currentActiveTabPath: string | null }) => useAppSave({ ...deps, tabs, activeTabPath: currentActiveTabPath, unsavedPaths: new Set([entry.path]), }), { initialProps: { currentActiveTabPath: entry.path } }, ) await act(async () => { result.current.handleContentChange(entry.path, '# Fresh Title\n\nBody') await vi.advanceTimersByTimeAsync(500) }) rerender({ currentActiveTabPath: '/vault/other.md' }) await act(async () => { await vi.advanceTimersByTimeAsync(2_500) }) expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('auto_rename_untitled', expect.anything()) }) it('redirects stale editor saves to the latest renamed path', async () => { const { result, oldPath, newPath } = setupUntitledRenameHarness() await act(async () => { result.current.handleContentChange(oldPath, '# Fresh Title\n\nBody') await vi.advanceTimersByTimeAsync(3_000) }) await act(async () => { result.current.handleContentChange(oldPath, '# Fresh Title\n\nBody\n\nMore text') await vi.advanceTimersByTimeAsync(500) }) const saveCalls = vi.mocked(invoke).mock.calls.filter(([command]) => command === 'save_note_content') expect(saveCalls.at(-1)).toEqual([ 'save_note_content', { path: newPath, content: '# Fresh Title\n\nBody\n\nMore text' }, ]) expect(saveCalls).not.toContainEqual([ 'save_note_content', { path: oldPath, content: '# Fresh Title\n\nBody\n\nMore text' }, ]) }) it('tracks filename renames so follow-up saves do not recreate the old path', async () => { vi.useFakeTimers() vi.mocked(isTauri).mockReturnValue(true) const oldPath = '/vault/fresh-title.md' const newPath = '/vault/manual-name.md' const entry = makeEntry(oldPath, 'Fresh Title', 'fresh-title.md') vi.mocked(invoke).mockImplementation(async (command: string) => { if (command === 'save_note_content') return undefined return undefined }) deps.handleRenameFilename.mockImplementation(async (path, newFilenameStem, vaultPath, onEntryRenamed) => { expect(path).toBe(oldPath) expect(newFilenameStem).toBe('manual-name') expect(vaultPath).toBe('/vault') onEntryRenamed(path, { path: newPath, filename: 'manual-name.md', title: 'Fresh Title' }, '# Fresh Title\n\nBody') }) const { result } = renderSave({ tabs: [{ entry, content: '# Fresh Title\n\nBody' }], activeTabPath: oldPath, unsavedPaths: new Set([oldPath]), }) await act(async () => { await result.current.handleFilenameRename(oldPath, 'manual-name') }) await act(async () => { result.current.handleContentChange(oldPath, '# Fresh Title\n\nBody\n\nMore text') await vi.advanceTimersByTimeAsync(500) }) const saveCalls = vi.mocked(invoke).mock.calls.filter(([command]) => command === 'save_note_content') expect(saveCalls.at(-1)).toEqual([ 'save_note_content', { path: newPath, content: '# Fresh Title\n\nBody\n\nMore text' }, ]) expect(saveCalls).not.toContainEqual([ 'save_note_content', { path: oldPath, content: '# Fresh Title\n\nBody\n\nMore text' }, ]) expect(deps.replaceEntry).toHaveBeenCalledWith( oldPath, expect.objectContaining({ path: newPath, filename: 'manual-name.md' }), '# Fresh Title\n\nBody', ) }) it('uses the latest active tab content when untitled auto-rename resolves after continued typing', async () => { const { oldPath, newPath, entry, setTabs, getTabs, setTabsState, } = setupUntitledRenameHarness({ diskContent: '# Fresh Title\n\nBody from disk', render: false, }) let currentTabs = [{ entry, content: '# Fresh Title\n\nBody' }] const { result, rerender } = renderHook( ({ currentTabs, currentActiveTabPath }: { currentTabs: typeof currentTabs; currentActiveTabPath: string | null }) => useAppSave({ ...deps, setTabs, tabs: currentTabs, activeTabPath: currentActiveTabPath, unsavedPaths: new Set([oldPath]), }), { initialProps: { currentTabs, currentActiveTabPath: oldPath, }, }, ) await act(async () => { result.current.handleContentChange(oldPath, '# Fresh Title\n\nBody') await vi.advanceTimersByTimeAsync(500) }) currentTabs = [{ entry, content: '# Fresh Title\n\nBody that keeps changing while rename is pending' }] setTabsState(currentTabs) rerender({ currentTabs, currentActiveTabPath: oldPath, }) await act(async () => { await vi.advanceTimersByTimeAsync(2_500) }) expect(deps.replaceEntry).toHaveBeenCalledWith( oldPath, expect.objectContaining({ path: newPath, filename: 'fresh-title.md' }), '# Fresh Title\n\nBody that keeps changing while rename is pending', ) expect(getTabs()[0].entry.path).toBe(newPath) expect(getTabs()[0].content).toBe('# Fresh Title\n\nBody that keeps changing while rename is pending') }) it('remaps a buffered auto-save to the renamed path when untitled rename lands mid-idle window', async () => { const initialContent = '# Fresh Title\n\nInitial body' const bufferedContent = '# Fresh Title\n\nBody typed right before rename' const { result, oldPath, newPath } = setupUntitledRenameHarness({ initialContent, diskContent: initialContent, }) await act(async () => { result.current.handleContentChange(oldPath, initialContent) await vi.advanceTimersByTimeAsync(500) }) await act(async () => { await vi.advanceTimersByTimeAsync(2_300) result.current.handleContentChange(oldPath, bufferedContent) await vi.advanceTimersByTimeAsync(200) }) await act(async () => { await vi.advanceTimersByTimeAsync(300) }) const saveCalls = vi.mocked(invoke).mock.calls.filter(([command]) => command === 'save_note_content') expect(saveCalls.at(-1)).toEqual([ 'save_note_content', { path: newPath, content: bufferedContent }, ]) expect(saveCalls).not.toContainEqual([ 'save_note_content', { path: oldPath, content: bufferedContent }, ]) }) it('waits for an in-flight untitled rename before persisting body edits that arrive mid-rename', async () => { const renameDeferred = createDeferred<{ new_path: string; updated_files: number } | null>() const initialContent = '# Fresh Title\n\nInitial body' const bodyDuringRename = '# Fresh Title\n\nBody typed while rename is in flight' const { result, oldPath, newPath } = setupUntitledRenameHarness({ initialContent, diskContent: initialContent, autoRenameResult: renameDeferred.promise, }) await act(async () => { result.current.handleContentChange(oldPath, initialContent) await vi.advanceTimersByTimeAsync(3_000) }) const saveCallsBeforeRename = vi.mocked(invoke).mock.calls.filter(([command]) => command === 'save_note_content') expect(saveCallsBeforeRename).toHaveLength(1) expect(saveCallsBeforeRename[0]).toEqual([ 'save_note_content', { path: oldPath, content: initialContent }, ]) await act(async () => { result.current.handleContentChange(oldPath, bodyDuringRename) await vi.advanceTimersByTimeAsync(500) }) const saveCallsWhileRenamePending = vi.mocked(invoke).mock.calls.filter(([command]) => command === 'save_note_content') expect(saveCallsWhileRenamePending).toHaveLength(1) await act(async () => { renameDeferred.resolve({ new_path: newPath, updated_files: 0 }) await Promise.resolve() await Promise.resolve() }) const finalSaveCalls = vi.mocked(invoke).mock.calls.filter(([command]) => command === 'save_note_content') expect(finalSaveCalls.at(-1)).toEqual([ 'save_note_content', { path: newPath, content: bodyDuringRename }, ]) expect(finalSaveCalls).not.toContainEqual([ 'save_note_content', { path: oldPath, content: bodyDuringRename }, ]) }) })