Implement file heatmap visualization that tracks which files are modified most frequently and by which workers. Helps identify hotspots and potential collision areas. Features: - Track file modifications across all workers - Heat levels (cold/warm/hot/critical) based on modification frequency - Worker contribution percentages per file - Collision risk detection for files with multiple workers - Sortable by modifications, recent activity, workers, or collisions - Filter by directory or collision-only files - Statistics overview with heat distribution Integration: - Press 'H' in TUI to toggle heatmap view - Press 's' to cycle sort modes - Press 'c' to toggle collision-only filter - Press 'Esc' to return to default view Also fixed pre-existing DependencyDag component build issues: - Created missing dagUtils.ts utility module - Fixed import paths and type annotations Tests: 20 new tests for file heatmap, all 154 tests passing Co-Authored-By: Claude Worker <noreply@anthropic.com>
262 lines
10 KiB
TypeScript
262 lines
10 KiB
TypeScript
/**
|
|
* Tests for File Heatmap functionality
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { InMemoryEventStore } from './store.js';
|
|
import { LogEvent } from './types.js';
|
|
|
|
describe('File Heatmap', () => {
|
|
let store: InMemoryEventStore;
|
|
|
|
beforeEach(() => {
|
|
store = new InMemoryEventStore();
|
|
});
|
|
|
|
const createFileEvent = (
|
|
path: string,
|
|
worker: string,
|
|
tool: string = 'Edit',
|
|
ts: number = Date.now()
|
|
): LogEvent => ({
|
|
ts,
|
|
worker,
|
|
level: 'info',
|
|
msg: `Modifying ${path}`,
|
|
path,
|
|
tool,
|
|
});
|
|
|
|
describe('getFileHeatmap', () => {
|
|
it('should return empty array when no file modifications', () => {
|
|
const heatmap = store.getFileHeatmap();
|
|
expect(heatmap).toHaveLength(0);
|
|
});
|
|
|
|
it('should track single file modification', () => {
|
|
store.add(createFileEvent('/src/index.ts', 'w-abc123'));
|
|
|
|
const heatmap = store.getFileHeatmap();
|
|
expect(heatmap).toHaveLength(1);
|
|
expect(heatmap[0].path).toBe('/src/index.ts');
|
|
expect(heatmap[0].modifications).toBe(1);
|
|
expect(heatmap[0].heatLevel).toBe('cold');
|
|
});
|
|
|
|
it('should track multiple modifications to same file', () => {
|
|
const now = Date.now();
|
|
store.add(createFileEvent('/src/index.ts', 'w-abc123', 'Edit', now));
|
|
store.add(createFileEvent('/src/index.ts', 'w-abc123', 'Edit', now + 1000));
|
|
store.add(createFileEvent('/src/index.ts', 'w-abc123', 'Edit', now + 2000));
|
|
|
|
const heatmap = store.getFileHeatmap();
|
|
expect(heatmap).toHaveLength(1);
|
|
expect(heatmap[0].modifications).toBe(3);
|
|
expect(heatmap[0].heatLevel).toBe('warm');
|
|
});
|
|
|
|
it('should track modifications by multiple workers', () => {
|
|
const now = Date.now();
|
|
store.add(createFileEvent('/src/index.ts', 'w-abc123', 'Edit', now));
|
|
store.add(createFileEvent('/src/index.ts', 'w-def456', 'Edit', now + 1000));
|
|
store.add(createFileEvent('/src/index.ts', 'w-ghi789', 'Edit', now + 2000));
|
|
|
|
const heatmap = store.getFileHeatmap();
|
|
expect(heatmap).toHaveLength(1);
|
|
expect(heatmap[0].workers).toHaveLength(3);
|
|
expect(heatmap[0].workers.map(w => w.workerId)).toContain('w-abc123');
|
|
expect(heatmap[0].workers.map(w => w.workerId)).toContain('w-def456');
|
|
expect(heatmap[0].workers.map(w => w.workerId)).toContain('w-ghi789');
|
|
});
|
|
|
|
it('should ignore non-modification tools', () => {
|
|
store.add(createFileEvent('/src/index.ts', 'w-abc123', 'Read'));
|
|
store.add(createFileEvent('/src/index.ts', 'w-abc123', 'Bash'));
|
|
|
|
const heatmap = store.getFileHeatmap();
|
|
expect(heatmap).toHaveLength(0);
|
|
});
|
|
|
|
it('should track Edit, Write, and NotebookEdit tools', () => {
|
|
store.add(createFileEvent('/src/a.ts', 'w-abc123', 'Edit'));
|
|
store.add(createFileEvent('/src/b.ts', 'w-abc123', 'Write'));
|
|
store.add(createFileEvent('/src/c.ipynb', 'w-abc123', 'NotebookEdit'));
|
|
|
|
const heatmap = store.getFileHeatmap();
|
|
expect(heatmap).toHaveLength(3);
|
|
});
|
|
|
|
it('should calculate correct heat levels', () => {
|
|
const now = Date.now();
|
|
for (let i = 0; i < 15; i++) {
|
|
store.add(createFileEvent('/src/hot.ts', 'w-abc123', 'Edit', now + i * 1000));
|
|
}
|
|
for (let i = 0; i < 5; i++) {
|
|
store.add(createFileEvent('/src/warm.ts', 'w-abc123', 'Edit', now + i * 1000));
|
|
}
|
|
|
|
const heatmap = store.getFileHeatmap();
|
|
const hotFile = heatmap.find(e => e.path === '/src/hot.ts');
|
|
const warmFile = heatmap.find(e => e.path === '/src/warm.ts');
|
|
|
|
expect(hotFile?.heatLevel).toBe('critical');
|
|
expect(warmFile?.heatLevel).toBe('warm');
|
|
});
|
|
});
|
|
|
|
describe('getFileHeatmap options', () => {
|
|
beforeEach(() => {
|
|
const now = Date.now();
|
|
// Create files in different directories
|
|
store.add(createFileEvent('/src/index.ts', 'w-abc123', 'Edit', now));
|
|
store.add(createFileEvent('/src/utils.ts', 'w-abc123', 'Edit', now + 1000));
|
|
store.add(createFileEvent('/test/test.ts', 'w-abc123', 'Edit', now + 2000));
|
|
store.add(createFileEvent('/lib/main.ts', 'w-abc123', 'Edit', now + 3000));
|
|
});
|
|
|
|
it('should filter by directory', () => {
|
|
const heatmap = store.getFileHeatmap({ directoryFilter: '/src' });
|
|
expect(heatmap).toHaveLength(2);
|
|
expect(heatmap.every(e => e.path.startsWith('/src'))).toBe(true);
|
|
});
|
|
|
|
it('should respect minModifications filter', () => {
|
|
// Add more modifications to one file
|
|
const now = Date.now();
|
|
store.add(createFileEvent('/src/index.ts', 'w-abc123', 'Edit', now + 4000));
|
|
store.add(createFileEvent('/src/index.ts', 'w-abc123', 'Edit', now + 5000));
|
|
|
|
const heatmap = store.getFileHeatmap({ minModifications: 2 });
|
|
expect(heatmap).toHaveLength(1);
|
|
expect(heatmap[0].path).toBe('/src/index.ts');
|
|
});
|
|
|
|
it('should respect maxEntries limit', () => {
|
|
const heatmap = store.getFileHeatmap({ maxEntries: 2 });
|
|
expect(heatmap).toHaveLength(2);
|
|
});
|
|
|
|
it('should sort by modifications (default)', () => {
|
|
// Add more modifications to index.ts
|
|
const now = Date.now();
|
|
store.add(createFileEvent('/src/index.ts', 'w-abc123', 'Edit', now + 4000));
|
|
store.add(createFileEvent('/src/index.ts', 'w-abc123', 'Edit', now + 5000));
|
|
|
|
const heatmap = store.getFileHeatmap({ sortBy: 'modifications' });
|
|
expect(heatmap[0].path).toBe('/src/index.ts');
|
|
});
|
|
|
|
it('should sort by recent', () => {
|
|
const heatmap = store.getFileHeatmap({ sortBy: 'recent' });
|
|
expect(heatmap[0].path).toBe('/lib/main.ts'); // Last modified
|
|
});
|
|
});
|
|
|
|
describe('getFileHeatmapStats', () => {
|
|
it('should return empty stats when no modifications', () => {
|
|
const stats = store.getFileHeatmapStats();
|
|
expect(stats.totalFiles).toBe(0);
|
|
expect(stats.totalModifications).toBe(0);
|
|
});
|
|
|
|
it('should calculate correct statistics', () => {
|
|
const now = Date.now();
|
|
store.add(createFileEvent('/src/a.ts', 'w-abc123', 'Edit', now));
|
|
store.add(createFileEvent('/src/a.ts', 'w-def456', 'Edit', now + 1000));
|
|
store.add(createFileEvent('/src/b.ts', 'w-abc123', 'Edit', now + 2000));
|
|
|
|
const stats = store.getFileHeatmapStats();
|
|
expect(stats.totalFiles).toBe(2);
|
|
expect(stats.totalModifications).toBe(3);
|
|
expect(stats.avgModificationsPerFile).toBe(1.5);
|
|
});
|
|
|
|
it('should calculate heat distribution', () => {
|
|
const now = Date.now();
|
|
// Create 1 cold file (1 mod)
|
|
store.add(createFileEvent('/src/cold.ts', 'w-abc123', 'Edit', now));
|
|
// Create 1 warm file (3 mods)
|
|
for (let i = 0; i < 3; i++) {
|
|
store.add(createFileEvent('/src/warm.ts', 'w-abc123', 'Edit', now + i * 1000));
|
|
}
|
|
// Create 1 hot file (8 mods)
|
|
for (let i = 0; i < 8; i++) {
|
|
store.add(createFileEvent('/src/hot.ts', 'w-abc123', 'Edit', now + i * 1000));
|
|
}
|
|
// Create 1 critical file (15 mods)
|
|
for (let i = 0; i < 15; i++) {
|
|
store.add(createFileEvent('/src/critical.ts', 'w-abc123', 'Edit', now + i * 1000));
|
|
}
|
|
|
|
const stats = store.getFileHeatmapStats();
|
|
expect(stats.heatDistribution.cold).toBe(1);
|
|
expect(stats.heatDistribution.warm).toBe(1);
|
|
expect(stats.heatDistribution.hot).toBe(1);
|
|
expect(stats.heatDistribution.critical).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('getWorkerFiles', () => {
|
|
it('should return files modified by specific worker', () => {
|
|
const now = Date.now();
|
|
store.add(createFileEvent('/src/a.ts', 'w-abc123', 'Edit', now));
|
|
store.add(createFileEvent('/src/b.ts', 'w-abc123', 'Edit', now + 1000));
|
|
store.add(createFileEvent('/src/c.ts', 'w-def456', 'Edit', now + 2000));
|
|
|
|
const workerFiles = store.getWorkerFiles('w-abc123');
|
|
expect(workerFiles).toHaveLength(2);
|
|
expect(workerFiles.map(f => f.path)).toContain('/src/a.ts');
|
|
expect(workerFiles.map(f => f.path)).toContain('/src/b.ts');
|
|
});
|
|
|
|
it('should return empty array for unknown worker', () => {
|
|
store.add(createFileEvent('/src/a.ts', 'w-abc123', 'Edit'));
|
|
const workerFiles = store.getWorkerFiles('w-unknown');
|
|
expect(workerFiles).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('getCollisionRiskFiles', () => {
|
|
it('should identify high-risk files with multiple workers', () => {
|
|
const now = Date.now();
|
|
// Create a high-risk file with 4 workers
|
|
store.add(createFileEvent('/src/hot.ts', 'w-abc123', 'Edit', now));
|
|
store.add(createFileEvent('/src/hot.ts', 'w-def456', 'Edit', now + 1000));
|
|
store.add(createFileEvent('/src/hot.ts', 'w-ghi789', 'Edit', now + 2000));
|
|
store.add(createFileEvent('/src/hot.ts', 'w-jkl012', 'Edit', now + 3000));
|
|
|
|
// Create a lower-risk file with 2 workers
|
|
store.add(createFileEvent('/src/warm.ts', 'w-abc123', 'Edit', now));
|
|
store.add(createFileEvent('/src/warm.ts', 'w-def456', 'Edit', now + 1000));
|
|
|
|
const riskFiles = store.getCollisionRiskFiles(3);
|
|
expect(riskFiles).toHaveLength(1);
|
|
expect(riskFiles[0].path).toBe('/src/hot.ts');
|
|
});
|
|
|
|
it('should return empty array when no high-risk files', () => {
|
|
store.add(createFileEvent('/src/a.ts', 'w-abc123', 'Edit'));
|
|
store.add(createFileEvent('/src/b.ts', 'w-abc123', 'Edit'));
|
|
|
|
const riskFiles = store.getCollisionRiskFiles(3);
|
|
expect(riskFiles).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('worker contribution percentages', () => {
|
|
it('should calculate correct percentages', () => {
|
|
const now = Date.now();
|
|
store.add(createFileEvent('/src/index.ts', 'w-abc123', 'Edit', now));
|
|
store.add(createFileEvent('/src/index.ts', 'w-abc123', 'Edit', now + 1000));
|
|
store.add(createFileEvent('/src/index.ts', 'w-abc123', 'Edit', now + 2000));
|
|
store.add(createFileEvent('/src/index.ts', 'w-def456', 'Edit', now + 3000));
|
|
|
|
const heatmap = store.getFileHeatmap();
|
|
const abc123 = heatmap[0].workers.find(w => w.workerId === 'w-abc123');
|
|
const def456 = heatmap[0].workers.find(w => w.workerId === 'w-def456');
|
|
|
|
expect(abc123?.percentage).toBe(75);
|
|
expect(def456?.percentage).toBe(25);
|
|
});
|
|
});
|
|
});
|