import { describe, it, expect } from 'vitest'; import { parseGitLog, computeOwnership, scoreFiles, repoStats } from './bus-factor.mjs'; // --- parseGitLog --- describe('parseGitLog', () => { it('parses a single commit with one file', () => { const raw = `commit abc123 Author: Alice Date: Mon Jan 1 00:00:00 2024 Initial commit 5\t2\tserver/src/index.js `; const result = parseGitLog(raw); expect(result['server/src/index.js']).toEqual({ Alice: 1 }); }); it('accumulates multiple commits by the same author', () => { const raw = `commit aaa Author: Alice Date: Mon Jan 1 00:00:00 2024 First 3\t0\tserver/src/app.js commit bbb Author: Alice Date: Tue Jan 2 00:00:00 2024 Second 1\t1\tserver/src/app.js `; const result = parseGitLog(raw); expect(result['server/src/app.js']['Alice']).toBe(2); }); it('tracks multiple authors for the same file', () => { const raw = `commit aaa Author: Alice Date: Mon Jan 1 00:00:00 2024 Alice commit 2\t0\tclient/src/App.jsx commit bbb Author: Bob Date: Tue Jan 2 00:00:00 2024 Bob commit 1\t0\tclient/src/App.jsx `; const result = parseGitLog(raw); expect(result['client/src/App.jsx']['Alice']).toBe(1); expect(result['client/src/App.jsx']['Bob']).toBe(1); }); it('handles multiple files per commit', () => { const raw = `commit aaa Author: Alice Date: Mon Jan 1 00:00:00 2024 Multi-file commit 2\t0\tserver/src/a.js 3\t1\tserver/src/b.js `; const result = parseGitLog(raw); expect(result['server/src/a.js']['Alice']).toBe(1); expect(result['server/src/b.js']['Alice']).toBe(1); }); it('handles rename syntax (old => new)', () => { const raw = `commit aaa Author: Alice Date: Mon Jan 1 00:00:00 2024 Rename 2\t0\told/path.js => new/path.js `; const result = parseGitLog(raw); expect(result['new/path.js']).toBeDefined(); expect(result['old/path.js']).toBeUndefined(); }); it('returns empty object for empty log', () => { expect(parseGitLog('')).toEqual({}); }); }); // --- computeOwnership --- describe('computeOwnership', () => { it('computes bus-factor of 1 for a solo author', () => { const result = computeOwnership({ Alice: 10 }); expect(result.busFactor).toBe(1); expect(result.totalCommits).toBe(10); expect(result.primaryOwner.name).toBe('Alice'); expect(result.primaryOwner.pct).toBe(1); }); it('computes bus-factor of 2 when two authors each own >= 10%', () => { const result = computeOwnership({ Alice: 8, Bob: 2 }); expect(result.busFactor).toBe(2); }); it('does not count authors below the threshold', () => { // Bob has 5% — below default 10% threshold const result = computeOwnership({ Alice: 19, Bob: 1 }); expect(result.busFactor).toBe(1); }); it('respects a custom ownership threshold', () => { // With 20% threshold, Bob (10%) doesn't count const result = computeOwnership({ Alice: 9, Bob: 1 }, 0.2); expect(result.busFactor).toBe(1); }); it('sorts authors by commit count descending', () => { const result = computeOwnership({ Alice: 3, Bob: 7, Carol: 5 }); expect(result.authors[0].name).toBe('Bob'); expect(result.authors[1].name).toBe('Carol'); expect(result.authors[2].name).toBe('Alice'); }); it('handles empty author counts gracefully', () => { const result = computeOwnership({}); expect(result.totalCommits).toBe(0); expect(result.busFactor).toBe(0); expect(result.primaryOwner).toBeNull(); }); }); // --- scoreFiles --- describe('scoreFiles', () => { const ownership = { 'server/src/risk.js': { Alice: 9, Bob: 1 }, // bus-factor 1 (Bob < 10%) 'server/src/shared.js': { Alice: 5, Bob: 5 }, // bus-factor 2 'server/src/tiny.js': { Alice: 1 }, // below minCommits=2, filtered }; it('filters files below minCommits', () => { const results = scoreFiles(ownership, { minCommits: 2 }); expect(results.find(f => f.file === 'server/src/tiny.js')).toBeUndefined(); }); it('includes files at or above minCommits', () => { const results = scoreFiles(ownership, { minCommits: 2 }); const files = results.map(f => f.file); expect(files).toContain('server/src/risk.js'); expect(files).toContain('server/src/shared.js'); }); it('sorts lowest bus-factor first', () => { const results = scoreFiles(ownership, { minCommits: 2 }); expect(results[0].file).toBe('server/src/risk.js'); expect(results[1].file).toBe('server/src/shared.js'); }); it('returns empty array for empty ownership', () => { expect(scoreFiles({}, {})).toEqual([]); }); }); // --- repoStats --- describe('repoStats', () => { it('returns zeros for empty input', () => { const stats = repoStats([]); expect(stats.avgBusFactor).toBe(0); expect(stats.highRiskCount).toBe(0); expect(stats.totalFiles).toBe(0); }); it('counts high-risk files (busFactor === 1)', () => { const files = [ { busFactor: 1, totalCommits: 10, authors: [] }, { busFactor: 2, totalCommits: 5, authors: [] }, { busFactor: 1, totalCommits: 3, authors: [] }, ]; const stats = repoStats(files); expect(stats.highRiskCount).toBe(2); expect(stats.totalFiles).toBe(3); }); it('computes weighted average bus-factor', () => { const files = [ { busFactor: 1, totalCommits: 10, authors: [] }, { busFactor: 3, totalCommits: 10, authors: [] }, ]; const stats = repoStats(files); // (1*10 + 3*10) / 20 = 2 expect(stats.avgBusFactor).toBe(2); }); });