test: add unit tests for prefetch cache, optimistic rollback, rapid switching
- Prefetch: content served from cache, cache cleared on vault reload, deduplication of concurrent requests - Optimistic rollback: trash/archive/restore/unarchive roll back updateEntry on disk write failure with error toast - Optimistic ordering: updateEntry called before frontmatter writes - Rapid switching: sequence counter prevents stale active tab when notes are opened faster than IPC resolves Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -405,6 +405,97 @@ describe('useEntryActions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('optimistic rollback on disk write failure', () => {
|
||||
it('rolls back trashed state when frontmatter write fails', async () => {
|
||||
handleUpdateFrontmatter.mockRejectedValueOnce(new Error('disk full'))
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { result } = setup()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleTrashNote('/vault/note/test.md')
|
||||
})
|
||||
|
||||
// First call: optimistic update (trashed: true)
|
||||
// Second call: rollback (trashed: false)
|
||||
expect(updateEntry).toHaveBeenCalledTimes(2)
|
||||
expect(updateEntry).toHaveBeenNthCalledWith(1, '/vault/note/test.md', {
|
||||
trashed: true, trashedAt: expect.any(Number),
|
||||
})
|
||||
expect(updateEntry).toHaveBeenNthCalledWith(2, '/vault/note/test.md', {
|
||||
trashed: false, trashedAt: null,
|
||||
})
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Failed to trash note — rolled back')
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('rolls back archived state when frontmatter write fails', async () => {
|
||||
handleUpdateFrontmatter.mockRejectedValueOnce(new Error('disk full'))
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { result } = setup()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleArchiveNote('/vault/note/test.md')
|
||||
})
|
||||
|
||||
expect(updateEntry).toHaveBeenCalledTimes(2)
|
||||
expect(updateEntry).toHaveBeenNthCalledWith(1, '/vault/note/test.md', { archived: true })
|
||||
expect(updateEntry).toHaveBeenNthCalledWith(2, '/vault/note/test.md', { archived: false })
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Failed to archive note — rolled back')
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('rolls back restore state when frontmatter write fails', async () => {
|
||||
handleUpdateFrontmatter.mockRejectedValueOnce(new Error('disk full'))
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { result } = setup()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRestoreNote('/vault/note/test.md')
|
||||
})
|
||||
|
||||
expect(updateEntry).toHaveBeenCalledTimes(2)
|
||||
expect(updateEntry).toHaveBeenNthCalledWith(1, '/vault/note/test.md', { trashed: false, trashedAt: null })
|
||||
expect(updateEntry).toHaveBeenNthCalledWith(2, '/vault/note/test.md', {
|
||||
trashed: true, trashedAt: expect.any(Number),
|
||||
})
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Failed to restore note — rolled back')
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('rolls back unarchive state when frontmatter write fails', async () => {
|
||||
handleUpdateFrontmatter.mockRejectedValueOnce(new Error('disk full'))
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { result } = setup()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUnarchiveNote('/vault/note/test.md')
|
||||
})
|
||||
|
||||
expect(updateEntry).toHaveBeenCalledTimes(2)
|
||||
expect(updateEntry).toHaveBeenNthCalledWith(1, '/vault/note/test.md', { archived: false })
|
||||
expect(updateEntry).toHaveBeenNthCalledWith(2, '/vault/note/test.md', { archived: true })
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Failed to unarchive note — rolled back')
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('trash: updateEntry is called BEFORE frontmatter writes (optimistic)', async () => {
|
||||
const callOrder: string[] = []
|
||||
updateEntry.mockImplementation(() => { callOrder.push('updateEntry') })
|
||||
handleUpdateFrontmatter.mockImplementation(() => {
|
||||
callOrder.push('handleUpdateFrontmatter')
|
||||
return Promise.resolve()
|
||||
})
|
||||
const { result } = setup()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleTrashNote('/vault/note/test.md')
|
||||
})
|
||||
|
||||
expect(callOrder[0]).toBe('updateEntry')
|
||||
expect(callOrder[1]).toBe('handleUpdateFrontmatter')
|
||||
})
|
||||
})
|
||||
|
||||
describe('onBeforeAction callback', () => {
|
||||
function setupWithBeforeAction(onBeforeAction: ReturnType<typeof vi.fn>) {
|
||||
return renderHook(() =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { useTabManagement } from './useTabManagement'
|
||||
import { useTabManagement, prefetchNoteContent, clearPrefetchCache } from './useTabManagement'
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
@@ -384,4 +384,95 @@ describe('useTabManagement', () => {
|
||||
expect(result.current.activeTabPath).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('content prefetch cache', () => {
|
||||
it('prefetch serves content to loadNoteContent (no extra IPC)', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke).mockResolvedValue('# Prefetched content')
|
||||
|
||||
prefetchNoteContent('/vault/note/pre.md')
|
||||
// Allow the prefetch promise to resolve
|
||||
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
|
||||
|
||||
// Now open the note — should use prefetched content
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/note/pre.md', title: 'Pre' }))
|
||||
})
|
||||
|
||||
expect(result.current.tabs[0].content).toBe('# Prefetched content')
|
||||
// mockInvoke was called once for prefetch, not again for handleSelectNote
|
||||
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('clearPrefetchCache prevents stale content from being served', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke).mockResolvedValue('# Stale')
|
||||
|
||||
prefetchNoteContent('/vault/note/stale.md')
|
||||
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
|
||||
|
||||
clearPrefetchCache()
|
||||
|
||||
// Reset mock to return fresh content
|
||||
vi.mocked(mockInvoke).mockResolvedValue('# Fresh')
|
||||
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/note/stale.md', title: 'Stale' }))
|
||||
})
|
||||
|
||||
// Should have made a new IPC call since cache was cleared
|
||||
expect(result.current.tabs[0].content).toBe('# Fresh')
|
||||
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('deduplicates concurrent prefetch requests for same path', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke).mockResolvedValue('# Content')
|
||||
|
||||
prefetchNoteContent('/vault/note/dup.md')
|
||||
prefetchNoteContent('/vault/note/dup.md')
|
||||
prefetchNoteContent('/vault/note/dup.md')
|
||||
|
||||
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
})
|
||||
|
||||
describe('rapid switching safety', () => {
|
||||
it('only activates the last note when switching rapidly', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
|
||||
// Simulate slow IPC: first call resolves after second call
|
||||
let resolveA: (v: string) => void
|
||||
let resolveB: (v: string) => void
|
||||
vi.mocked(mockInvoke)
|
||||
.mockImplementationOnce(() => new Promise<string>((r) => { resolveA = r as (v: string) => void }))
|
||||
.mockImplementationOnce(() => new Promise<string>((r) => { resolveB = r as (v: string) => void }))
|
||||
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||
// Start loading A (don't await — simulates rapid click)
|
||||
let selectADone = false
|
||||
act(() => {
|
||||
result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' })).then(() => { selectADone = true })
|
||||
})
|
||||
|
||||
// Start loading B while A is still loading
|
||||
let selectBDone = false
|
||||
act(() => {
|
||||
result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' })).then(() => { selectBDone = true })
|
||||
})
|
||||
|
||||
// B resolves first
|
||||
await act(async () => { resolveB!('# B content') })
|
||||
// A resolves after
|
||||
await act(async () => { resolveA!('# A content') })
|
||||
|
||||
await vi.waitFor(() => expect(selectADone && selectBDone).toBe(true))
|
||||
|
||||
// Active tab should be B (the last click), not A
|
||||
expect(result.current.activeTabPath).toBe('/vault/b.md')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user