- Add git_resolve_conflict and git_commit_conflict_resolution Rust commands - Create useConflictResolver hook for per-file resolution state management - Create ConflictResolverModal with keyboard shortcuts (K/T/O/Enter/Esc) - Make StatusBar conflict chip clickable to open resolver modal - Add 'Resolve Conflicts' command to command palette (conditional) - Pause auto-pull while conflict resolver modal is open - Tests: 5 new Rust tests, 10 new hook tests, 1 new auto-sync test Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
174 lines
5.4 KiB
TypeScript
174 lines
5.4 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { renderHook, act } from '@testing-library/react'
|
|
import { useConflictResolver } from './useConflictResolver'
|
|
|
|
const mockInvokeFn = vi.fn()
|
|
vi.mock('@tauri-apps/api/core', () => ({
|
|
invoke: (...args: unknown[]) => mockInvokeFn(...args),
|
|
}))
|
|
vi.mock('../mock-tauri', () => ({
|
|
isTauri: () => false,
|
|
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
|
|
}))
|
|
|
|
describe('useConflictResolver', () => {
|
|
const onResolved = vi.fn()
|
|
const onToast = vi.fn()
|
|
const onOpenFile = vi.fn()
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockInvokeFn.mockResolvedValue(undefined)
|
|
})
|
|
|
|
function renderResolver(files: string[] = ['note.md', 'plan.md']) {
|
|
const hook = renderHook(() =>
|
|
useConflictResolver({
|
|
vaultPath: '/vault',
|
|
onResolved,
|
|
onToast,
|
|
onOpenFile,
|
|
}),
|
|
)
|
|
// Initialize files
|
|
act(() => { hook.result.current.initFiles(files) })
|
|
return hook
|
|
}
|
|
|
|
it('initializes file states from conflict files', () => {
|
|
const { result } = renderResolver()
|
|
expect(result.current.fileStates).toHaveLength(2)
|
|
expect(result.current.fileStates[0]).toEqual({ file: 'note.md', resolution: null, resolving: false })
|
|
expect(result.current.fileStates[1]).toEqual({ file: 'plan.md', resolution: null, resolving: false })
|
|
expect(result.current.allResolved).toBe(false)
|
|
})
|
|
|
|
it('resolves a file with ours strategy', async () => {
|
|
const { result } = renderResolver()
|
|
|
|
await act(async () => {
|
|
await result.current.resolveFile('note.md', 'ours')
|
|
})
|
|
|
|
expect(mockInvokeFn).toHaveBeenCalledWith('git_resolve_conflict', {
|
|
vaultPath: '/vault', file: 'note.md', strategy: 'ours',
|
|
})
|
|
expect(result.current.fileStates[0].resolution).toBe('ours')
|
|
expect(result.current.allResolved).toBe(false) // plan.md still unresolved
|
|
})
|
|
|
|
it('resolves a file with theirs strategy', async () => {
|
|
const { result } = renderResolver()
|
|
|
|
await act(async () => {
|
|
await result.current.resolveFile('plan.md', 'theirs')
|
|
})
|
|
|
|
expect(mockInvokeFn).toHaveBeenCalledWith('git_resolve_conflict', {
|
|
vaultPath: '/vault', file: 'plan.md', strategy: 'theirs',
|
|
})
|
|
expect(result.current.fileStates[1].resolution).toBe('theirs')
|
|
})
|
|
|
|
it('marks allResolved when all files are resolved', async () => {
|
|
const { result } = renderResolver()
|
|
|
|
await act(async () => {
|
|
await result.current.resolveFile('note.md', 'ours')
|
|
})
|
|
expect(result.current.allResolved).toBe(false)
|
|
|
|
await act(async () => {
|
|
await result.current.resolveFile('plan.md', 'theirs')
|
|
})
|
|
expect(result.current.allResolved).toBe(true)
|
|
})
|
|
|
|
it('sets manual resolution when opening in editor', () => {
|
|
const { result } = renderResolver()
|
|
|
|
act(() => { result.current.openInEditor('note.md') })
|
|
|
|
expect(onOpenFile).toHaveBeenCalledWith('note.md')
|
|
expect(result.current.fileStates[0].resolution).toBe('manual')
|
|
})
|
|
|
|
it('commits resolution and calls onResolved', async () => {
|
|
mockInvokeFn.mockImplementation((cmd: string) => {
|
|
if (cmd === 'git_commit_conflict_resolution') return Promise.resolve('Committed')
|
|
return Promise.resolve(undefined)
|
|
})
|
|
|
|
const { result } = renderResolver(['note.md'])
|
|
|
|
await act(async () => {
|
|
await result.current.resolveFile('note.md', 'ours')
|
|
})
|
|
|
|
await act(async () => {
|
|
await result.current.commitResolution()
|
|
})
|
|
|
|
expect(mockInvokeFn).toHaveBeenCalledWith('git_commit_conflict_resolution', { vaultPath: '/vault' })
|
|
expect(onResolved).toHaveBeenCalled()
|
|
expect(onToast).toHaveBeenCalledWith('Conflicts resolved — sync resumed')
|
|
})
|
|
|
|
it('does not commit when not all files are resolved', async () => {
|
|
const { result } = renderResolver()
|
|
|
|
await act(async () => {
|
|
await result.current.commitResolution()
|
|
})
|
|
|
|
expect(mockInvokeFn).not.toHaveBeenCalledWith('git_commit_conflict_resolution', expect.anything())
|
|
})
|
|
|
|
it('shows error when resolve fails', async () => {
|
|
mockInvokeFn.mockRejectedValueOnce(new Error('git checkout failed'))
|
|
|
|
const { result } = renderResolver()
|
|
|
|
await act(async () => {
|
|
await result.current.resolveFile('note.md', 'ours')
|
|
})
|
|
|
|
expect(result.current.error).toContain('Failed to resolve note.md')
|
|
expect(result.current.fileStates[0].resolution).toBeNull()
|
|
})
|
|
|
|
it('shows error when commit fails', async () => {
|
|
mockInvokeFn.mockImplementation((cmd: string) => {
|
|
if (cmd === 'git_commit_conflict_resolution') return Promise.reject(new Error('user.email not set'))
|
|
return Promise.resolve(undefined)
|
|
})
|
|
|
|
const { result } = renderResolver(['note.md'])
|
|
|
|
await act(async () => {
|
|
await result.current.resolveFile('note.md', 'ours')
|
|
})
|
|
await act(async () => {
|
|
await result.current.commitResolution()
|
|
})
|
|
|
|
expect(result.current.error).toContain('Commit failed')
|
|
expect(onResolved).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('resets state on initFiles', async () => {
|
|
const { result } = renderResolver(['note.md'])
|
|
|
|
await act(async () => {
|
|
await result.current.resolveFile('note.md', 'ours')
|
|
})
|
|
expect(result.current.fileStates[0].resolution).toBe('ours')
|
|
|
|
act(() => { result.current.initFiles(['other.md']) })
|
|
|
|
expect(result.current.fileStates).toHaveLength(1)
|
|
expect(result.current.fileStates[0]).toEqual({ file: 'other.md', resolution: null, resolving: false })
|
|
expect(result.current.error).toBeNull()
|
|
})
|
|
})
|