import { describe, it, expect, vi, beforeEach, 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.each([ { name: 'onAfterSave', key: 'onAfterSave' as const }, { name: 'onNoteSaved', key: 'onNoteSaved' as const }, ])('calls $name callback after successful save', async ({ key }) => { const cb = vi.fn() const { result } = renderHook(() => useEditorSave({ updateVaultContent, setTabs, setToastMessage, [key]: 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.each([ { name: 'onAfterSave', key: 'onAfterSave' as const }, { name: 'onNoteSaved', key: 'onNoteSaved' as const }, ])('does not call $name when save fails', async ({ key }) => { mockInvokeFn.mockRejectedValueOnce(new Error('Disk full')) const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const cb = vi.fn() const { result } = renderHook(() => useEditorSave({ updateVaultContent, setTabs, setToastMessage, [key]: 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('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('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', }) }) })