Files
tolaria/src/hooks/useEditorSave.test.ts
lucaronin d41fe306fe fix: AI chat receives live editor content instead of stale disk content
handleContentChange now syncs content to tab state on every editor
change (not just on Cmd+S save). This ensures the AI panel's context
snapshot always contains the current editor body, fixing the bug where
the AI reported empty note bodies for unsaved edits.

Root cause: pendingContentRef buffered content for save but never
updated notes.tabs[].content, so activeTab?.content (used by the AI
context builder) always reflected the last-saved disk content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 15:07:45 +01:00

250 lines
7.8 KiB
TypeScript

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<string, unknown>) => Promise<null>>(() => Promise.resolve(null))
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: (cmd: string, args?: Record<string, unknown>) => 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('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',
})
})
})