import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest' import { renderHook, act } from '@testing-library/react' import { useEditorSave } from './useEditorSave' const mockInvokeFn = vi.fn<(cmd: string, args?: Record) => Promise>(() => Promise.resolve(null)) vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), })) vi.mock('../mock-tauri', () => ({ isTauri: () => false, mockInvoke: (cmd: string, args?: Record) => mockInvokeFn(cmd, args), updateMockContent: vi.fn(), })) describe('useEditorSave', () => { let updateVaultContent: Mock let setTabs: Mock let setToastMessage: Mock beforeEach(() => { updateVaultContent = vi.fn() setTabs = vi.fn() setToastMessage = vi.fn() mockInvokeFn.mockClear() }) function renderSaveHook() { return renderHook(() => useEditorSave({ updateVaultContent, setTabs, setToastMessage })) } it('handleSave shows "Nothing to save" when no pending content', async () => { const { result } = renderSaveHook() await act(async () => { await result.current.handleSave() }) expect(setToastMessage).toHaveBeenCalledWith('Nothing to save') expect(mockInvokeFn).not.toHaveBeenCalled() }) it('handleSave persists pending content and shows "Saved"', async () => { const { result } = renderSaveHook() // Buffer content via handleContentChange act(() => { result.current.handleContentChange('/test/note.md', '---\ntitle: Test\n---\n\n# Test\n\nEdited') }) // Save via Cmd+S await act(async () => { await result.current.handleSave() }) expect(mockInvokeFn).toHaveBeenCalledWith('save_note_content', { path: '/test/note.md', content: '---\ntitle: Test\n---\n\n# Test\n\nEdited', }) expect(setToastMessage).toHaveBeenCalledWith('Saved') // Second save should show "Nothing to save" (pending cleared) await act(async () => { await result.current.handleSave() }) expect(setToastMessage).toHaveBeenCalledWith('Nothing to save') }) it('handleSave shows error toast on failure', async () => { mockInvokeFn.mockRejectedValueOnce(new Error('Disk full')) const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const { result } = renderSaveHook() act(() => { result.current.handleContentChange('/test/note.md', 'content') }) await act(async () => { await result.current.handleSave() }) expect(setToastMessage).toHaveBeenCalledWith(expect.stringContaining('Save failed')) consoleSpy.mockRestore() }) it('savePendingForPath saves content only for the matching path', async () => { const { result } = renderSaveHook() act(() => { result.current.handleContentChange('/test/note-a.md', 'content A') }) // Try saving for a different path — should be a no-op await act(async () => { await result.current.savePendingForPath('/test/note-b.md') }) expect(mockInvokeFn).not.toHaveBeenCalled() // Save for the correct path await act(async () => { await result.current.savePendingForPath('/test/note-a.md') }) expect(mockInvokeFn).toHaveBeenCalledWith('save_note_content', { path: '/test/note-a.md', content: 'content A', }) }) it('calls onAfterSave callback after successful save', async () => { const cb = vi.fn() const { result } = renderHook(() => useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave: cb }) ) act(() => { result.current.handleContentChange('/test/note.md', 'new content') }) await act(async () => { await result.current.handleSave() }) expect(cb).toHaveBeenCalled() }) it('calls onAfterSave even when nothing is pending (e.g. after rename)', async () => { const onAfterSave = vi.fn() const { result } = renderHook(() => useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave }) ) // No content buffered — simulate Cmd+S after a rename that already flushed pending await act(async () => { await result.current.handleSave() }) expect(setToastMessage).toHaveBeenCalledWith('Nothing to save') expect(onAfterSave).toHaveBeenCalledOnce() }) it('does not call onAfterSave when save fails', async () => { mockInvokeFn.mockRejectedValueOnce(new Error('Disk full')) const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const cb = vi.fn() const { result } = renderHook(() => useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave: cb }) ) act(() => { result.current.handleContentChange('/test/note.md', 'content') }) await act(async () => { await result.current.handleSave() }) expect(cb).not.toHaveBeenCalled() consoleSpy.mockRestore() }) it('handleContentChange buffers the latest content', () => { const { result } = renderSaveHook() act(() => { result.current.handleContentChange('/test/note.md', 'v1') result.current.handleContentChange('/test/note.md', 'v2') }) // The ref should hold the latest value — verified via save // (We'll check via the next handleSave call) }) it('handleContentChange syncs content to tab state immediately', () => { const { result } = renderSaveHook() act(() => { result.current.handleContentChange('/test/note.md', '---\ntitle: T\n---\n\n# T\n\nLive edits') }) // setTabs must be called on every content change (not just on save) // so that consumers like the AI panel see current editor content expect(setTabs).toHaveBeenCalled() const updater = setTabs.mock.calls[0][0] const tabs = [{ entry: { path: '/test/note.md' }, content: 'stale' }] const updated = updater(tabs) expect(updated[0].content).toBe('---\ntitle: T\n---\n\n# T\n\nLive edits') }) it('save updates tab content with edited body, not original (regression)', async () => { const { result } = renderSaveHook() // Simulate: user opens note, edits body, presses Cmd+S const original = '---\ntitle: My Note\n---\n\n# My Note\n\nOriginal body' const edited = '---\ntitle: My Note\n---\n\n# My Note\n\nEdited body with changes' act(() => { result.current.handleContentChange('/vault/note.md', edited) }) await act(async () => { await result.current.handleSave() }) // The save must persist the EDITED content, not the original expect(mockInvokeFn).toHaveBeenCalledWith('save_note_content', { path: '/vault/note.md', content: edited, }) // Tab content must be updated with the saved (edited) content expect(setTabs).toHaveBeenCalled() const tabUpdater = setTabs.mock.calls[0][0] const fakeTabs = [{ entry: { path: '/vault/note.md' }, content: original }] const updatedTabs = tabUpdater(fakeTabs) expect(updatedTabs[0].content).toBe(edited) // Vault in-memory state must also reflect the edit expect(updateVaultContent).toHaveBeenCalledWith('/vault/note.md', edited) }) it('calls onNotePersisted with path and content after saving pending content', async () => { const onNotePersisted = vi.fn() const { result } = renderHook(() => useEditorSave({ updateVaultContent, setTabs, setToastMessage, onNotePersisted }) ) act(() => { result.current.handleContentChange('/vault/theme/default.md', '---\nbackground: "#FFD700"\n---\n') }) await act(async () => { await result.current.handleSave() }) expect(onNotePersisted).toHaveBeenCalledWith( '/vault/theme/default.md', '---\nbackground: "#FFD700"\n---\n', ) }) it('calls onNotePersisted for unsaved fallback when no pending content', async () => { const onNotePersisted = vi.fn() const { result } = renderHook(() => useEditorSave({ updateVaultContent, setTabs, setToastMessage, onNotePersisted }) ) // No handleContentChange — simulate Cmd+S on a newly created unsaved note await act(async () => { await result.current.handleSave({ path: '/vault/theme/default.md', content: '---\nbackground: "#FF0000"\n---\n' }) }) expect(onNotePersisted).toHaveBeenCalledWith( '/vault/theme/default.md', '---\nbackground: "#FF0000"\n---\n', ) }) describe('auto-save debounce', () => { beforeEach(() => { vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) it('auto-saves 500ms after last content change', async () => { const onNotePersisted = vi.fn() const { result } = renderHook(() => useEditorSave({ updateVaultContent, setTabs, setToastMessage, onNotePersisted }) ) act(() => { result.current.handleContentChange('/test/note.md', 'auto-saved content') }) // Not saved yet expect(mockInvokeFn).not.toHaveBeenCalled() // Advance 500ms await act(async () => { vi.advanceTimersByTime(500) }) expect(mockInvokeFn).toHaveBeenCalledWith('save_note_content', { path: '/test/note.md', content: 'auto-saved content', }) expect(onNotePersisted).toHaveBeenCalledWith('/test/note.md', 'auto-saved content') }) it('resets debounce timer on each content change', async () => { const { result } = renderHook(() => useEditorSave({ updateVaultContent, setTabs, setToastMessage }) ) act(() => { result.current.handleContentChange('/test/note.md', 'v1') }) // Advance 400ms (not yet 500ms) await act(async () => { vi.advanceTimersByTime(400) }) expect(mockInvokeFn).not.toHaveBeenCalled() // New edit resets timer act(() => { result.current.handleContentChange('/test/note.md', 'v2') }) // Another 400ms (800ms total, but only 400ms from last edit) await act(async () => { vi.advanceTimersByTime(400) }) expect(mockInvokeFn).not.toHaveBeenCalled() // 100ms more = 500ms from last edit await act(async () => { vi.advanceTimersByTime(100) }) expect(mockInvokeFn).toHaveBeenCalledWith('save_note_content', { path: '/test/note.md', content: 'v2', }) }) it('auto-save does not show toast', async () => { const { result } = renderHook(() => useEditorSave({ updateVaultContent, setTabs, setToastMessage }) ) act(() => { result.current.handleContentChange('/test/note.md', 'content') }) await act(async () => { vi.advanceTimersByTime(500) }) expect(setToastMessage).not.toHaveBeenCalled() }) it('Cmd+S cancels pending auto-save and saves immediately', async () => { const { result } = renderHook(() => useEditorSave({ updateVaultContent, setTabs, setToastMessage }) ) act(() => { result.current.handleContentChange('/test/note.md', 'content') }) // Cmd+S before debounce fires await act(async () => { await result.current.handleSave() }) expect(mockInvokeFn).toHaveBeenCalledTimes(1) expect(setToastMessage).toHaveBeenCalledWith('Saved') // Advancing timer should NOT cause a second save await act(async () => { vi.advanceTimersByTime(500) }) expect(mockInvokeFn).toHaveBeenCalledTimes(1) }) it('auto-save calls onAfterSave', async () => { const onAfterSave = vi.fn() const { result } = renderHook(() => useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave }) ) act(() => { result.current.handleContentChange('/test/note.md', 'content') }) await act(async () => { vi.advanceTimersByTime(500) }) expect(onAfterSave).toHaveBeenCalled() }) it('clears auto-save timer on unmount', async () => { const { result, unmount } = renderHook(() => useEditorSave({ updateVaultContent, setTabs, setToastMessage }) ) act(() => { result.current.handleContentChange('/test/note.md', 'content') }) unmount() await act(async () => { vi.advanceTimersByTime(500) }) // Should not save after unmount expect(mockInvokeFn).not.toHaveBeenCalled() }) }) it('successive edits and saves persist each version correctly', async () => { const { result } = renderSaveHook() // First edit + save act(() => { result.current.handleContentChange('/vault/note.md', 'version 1') }) await act(async () => { await result.current.handleSave() }) expect(mockInvokeFn).toHaveBeenLastCalledWith('save_note_content', { path: '/vault/note.md', content: 'version 1', }) // Second edit + save — must NOT revert to version 1 act(() => { result.current.handleContentChange('/vault/note.md', 'version 2') }) await act(async () => { await result.current.handleSave() }) expect(mockInvokeFn).toHaveBeenLastCalledWith('save_note_content', { path: '/vault/note.md', content: 'version 2', }) }) })