import { describe, it, expect, beforeEach } from 'vitest'; import { ConflictIndex } from '../conflict-index.js'; describe('ConflictIndex', () => { let idx: ConflictIndex; beforeEach(() => { idx = new ConflictIndex(); }); describe('registerChange', () => { it('adds an entry for a file path', () => { idx.registerChange('src/a.ts', 'wt-1', 'agent-a', { start: 1, end: 10 }); const entries = idx.getEntriesFor('src/a.ts'); expect(entries.size).toBe(1); const entry = [...entries][0]!; expect(entry.worktreeId).toBe('wt-1'); expect(entry.agent).toBe('agent-a'); expect(entry.lineRange).toEqual({ start: 1, end: 10 }); expect(entry.status).toBe('pending'); expect(entry.timestamp).toBeGreaterThan(0); }); it('supports multiple entries for the same file path', () => { idx.registerChange('src/a.ts', 'wt-1', 'agent-a', { start: 1, end: 10 }); idx.registerChange('src/a.ts', 'wt-2', 'agent-b', { start: 20, end: 30 }); expect(idx.getEntriesFor('src/a.ts').size).toBe(2); }); it('allows a worktree to have multiple entries (several edits to same file)', () => { idx.registerChange('src/a.ts', 'wt-1', 'agent-a', { start: 1, end: 10 }); idx.registerChange('src/a.ts', 'wt-1', 'agent-a', { start: 20, end: 30 }); // Duplicate entries with same fields — the Set dedupes by ref, // so a second identical call is still a distinct object (allowed). expect(idx.getEntriesFor('src/a.ts').size).toBe(2); }); it('separates files into distinct keys', () => { idx.registerChange('src/a.ts', 'wt-1', 'agent-a'); idx.registerChange('src/b.ts', 'wt-2', 'agent-b'); expect(idx.getEntriesFor('src/a.ts').size).toBe(1); expect(idx.getEntriesFor('src/b.ts').size).toBe(1); }); }); describe('removeWorktree', () => { it('removes all entries for a given worktree', () => { idx.registerChange('src/a.ts', 'wt-1', 'agent-a'); idx.registerChange('src/a.ts', 'wt-2', 'agent-b'); idx.registerChange('src/b.ts', 'wt-1', 'agent-a'); idx.removeWorktree('wt-1'); expect(idx.getEntriesFor('src/a.ts').size).toBe(1); expect([...idx.getEntriesFor('src/a.ts')][0]!.worktreeId).toBe('wt-2'); expect(idx.getEntriesFor('src/b.ts').size).toBe(0); }); it('is a no-op when worktree has no entries', () => { idx.registerChange('src/a.ts', 'wt-1', 'agent-a'); idx.removeWorktree('wt-ghost'); expect(idx.getEntriesFor('src/a.ts').size).toBe(1); }); it('cleans up file key when last entry is removed', () => { idx.registerChange('src/a.ts', 'wt-1', 'agent-a'); idx.removeWorktree('wt-1'); // After removal the key should be gone expect(idx.snapshot().has('src/a.ts')).toBe(false); }); }); describe('sweepStale', () => { it('removes entries older than maxAgeMs', async () => { idx.registerChange('src/a.ts', 'wt-1', 'agent-a'); idx.registerChange('src/b.ts', 'wt-2', 'agent-b'); // Wait a tick so timestamps diverge await new Promise((r) => setTimeout(r, 10)); idx.registerChange('src/c.ts', 'wt-3', 'agent-c'); const removed = idx.sweepStale(5); // 5ms cutoff — entries from before the await are stale expect(removed).toBeGreaterThanOrEqual(1); }); it('removes file key when all entries swept', async () => { idx.registerChange('src/a.ts', 'wt-1', 'agent-a'); // Wait so timestamp is definitely older than cutoff await new Promise((r) => setTimeout(r, 10)); const removed = idx.sweepStale(5); expect(removed).toBe(1); expect(idx.snapshot().has('src/a.ts')).toBe(false); }); it('returns 0 when no entries are stale', () => { idx.registerChange('src/a.ts', 'wt-1', 'agent-a'); const removed = idx.sweepStale(86_400_000); // 24h expect(removed).toBe(0); }); }); describe('getConflictsFor', () => { it('returns conflicts between worktrees', () => { idx.registerChange('src/a.ts', 'wt-1', 'agent-a', { start: 1, end: 10 }); idx.registerChange('src/a.ts', 'wt-2', 'agent-b', { start: 5, end: 15 }); const conflicts = idx.getConflictsFor('src/a.ts'); expect(conflicts).toHaveLength(1); expect(conflicts[0]!.filePath).toBe('src/a.ts'); // getConflictsFor doesn't know the caller's line range, // so severity defaults to 'different_area' expect(conflicts[0]!.severity).toBe('different_area'); }); it('returns empty for files with only one worktree', () => { idx.registerChange('src/a.ts', 'wt-1', 'agent-a'); expect(idx.getConflictsFor('src/a.ts')).toEqual([]); }); it('returns empty for files not in index', () => { expect(idx.getConflictsFor('src/never-touched.ts')).toEqual([]); }); }); describe('query', () => { it('delegates to findConflicts with proper data', () => { idx.registerChange('src/a.ts', 'wt-2', 'agent-b', { start: 5, end: 15 }); const ranges = new Map([['src/a.ts', { start: 10, end: 20 }]]); const result = idx.query(['src/a.ts'], 'wt-1', ranges); expect(result).toHaveLength(1); expect(result[0]!.severity).toBe('same_line'); }); it('returns empty when no conflicts', () => { idx.registerChange('src/a.ts', 'wt-1', 'agent-a', { start: 1, end: 10 }); const result = idx.query(['src/a.ts'], 'wt-1', new Map()); expect(result).toEqual([]); }); }); describe('snapshot', () => { it('returns a copy of the internal map', () => { idx.registerChange('src/a.ts', 'wt-1', 'agent-a'); const snap = idx.snapshot(); expect(snap.has('src/a.ts')).toBe(true); // Mutating the snapshot doesn't affect the original idx.removeWorktree('wt-1'); expect(snap.has('src/a.ts')).toBe(true); }); }); });