Skip to main content
Glama
map.test.ts11.7 kB
/** * Tests for mapExpertise function * Phase 1: Core calculations and basic functionality */ import { beforeEach, describe, expect, test } from "bun:test"; import { ALICE, BOB, CHARLIE } from "../fixtures/authors"; import { REFERENCE_UNIX } from "../fixtures/timestamps"; // We need to test the internal logic by extracting pure functions // Since mocking simple-git is complex, let's test what we can directly describe("mapExpertise helper functions", () => { describe("buildLogOutput helper", () => { test("formats log entries correctly", () => { const entries = [ { ...ALICE, timestamp: REFERENCE_UNIX, files: ["src/index.ts"] }, ]; const output = buildLogOutput(entries); expect(output).toContain( `${ALICE.name}|${ALICE.email}|${REFERENCE_UNIX}`, ); expect(output).toContain("src/index.ts"); }); }); describe("repeat helper", () => { test("creates correct number of entries", () => { const entries = repeat( { ...ALICE, files: ["src/index.ts"] }, 5, REFERENCE_UNIX, ); expect(entries).toHaveLength(5); }); test("spaces entries by 1 hour", () => { const entries = repeat( { ...ALICE, files: ["src/index.ts"] }, 3, REFERENCE_UNIX, ); expect(entries[0]?.timestamp).toBe(REFERENCE_UNIX); expect(entries[1]?.timestamp).toBe(REFERENCE_UNIX - 3600); expect(entries[2]?.timestamp).toBe(REFERENCE_UNIX - 7200); }); }); describe("percentage calculations", () => { test("100% for single contributor", () => { const totalCommits = 50; const aliceCommits = 50; const percentage = Math.round((aliceCommits / totalCommits) * 100); expect(percentage).toBe(100); }); test("80/20 split calculation", () => { const totalCommits = 100; const aliceCommits = 80; const bobCommits = 20; const alicePct = Math.round((aliceCommits / totalCommits) * 100); const bobPct = Math.round((bobCommits / totalCommits) * 100); expect(alicePct).toBe(80); expect(bobPct).toBe(20); }); test("even 3-way split", () => { const totalCommits = 99; const commits = [33, 33, 33]; const percentages = commits.map((c) => Math.round((c / totalCommits) * 100), ); expect(percentages).toEqual([33, 33, 33]); }); }); describe("bus factor logic", () => { test("counts contributors with >= 5%", () => { const experts = [ { percentage: 40 }, { percentage: 35 }, { percentage: 25 }, ]; const significantContributors = experts.filter( (e) => e.percentage >= 5, ).length; const busFactor = Math.max(1, significantContributors); expect(busFactor).toBe(3); }); test("excludes contributors below 5%", () => { const experts = [{ percentage: 96 }, { percentage: 4 }]; const significantContributors = experts.filter( (e) => e.percentage >= 5, ).length; const busFactor = Math.max(1, significantContributors); expect(busFactor).toBe(1); }); test("minimum bus factor is 1", () => { const experts: { percentage: number }[] = []; const significantContributors = experts.filter( (e) => e.percentage >= 5, ).length; const busFactor = Math.max(1, significantContributors); expect(busFactor).toBe(1); }); }); describe("bus factor risk threshold", () => { test("exactly 80% triggers risk at default threshold", () => { const primaryPercentage = 80; const threshold = 80; const risk = primaryPercentage >= threshold; expect(risk).toBe(true); }); test("79% does not trigger risk at default threshold", () => { const primaryPercentage = 79; const threshold = 80; const risk = primaryPercentage >= threshold; expect(risk).toBe(false); }); test("55% triggers risk with 50% threshold", () => { const primaryPercentage = 55; const threshold = 50; const risk = primaryPercentage >= threshold; expect(risk).toBe(true); }); test("49% does not trigger risk with 50% threshold", () => { const primaryPercentage = 49; const threshold = 50; const risk = primaryPercentage >= threshold; expect(risk).toBe(false); }); }); describe("health score calculation", () => { // Replicate the calculateHealthScore function logic function calculateHealthScore( entriesLength: number, riskyCount: number, staleCount: number, avgBusFactor: number, ): number { if (entriesLength === 0) return 100; let score = 100; // Deduct for risky paths (up to 30 points) const riskyRatio = riskyCount / entriesLength; score -= Math.min(30, riskyRatio * 50); // Deduct for stale paths (up to 20 points) const staleRatio = staleCount / entriesLength; score -= Math.min(20, staleRatio * 30); // Deduct for low average bus factor (up to 30 points) if (avgBusFactor < 3) { score -= (3 - avgBusFactor) * 15; } // Bonus for high bus factor (up to 10 points) if (avgBusFactor >= 3) { score = Math.min(100, score + 10); } return Math.max(0, Math.round(score)); } test("empty repository returns 100", () => { expect(calculateHealthScore(0, 0, 0, 0)).toBe(100); }); test("perfect score with no issues and high bus factor", () => { // No risky, no stale, bus factor of 3 const score = calculateHealthScore(1, 0, 0, 3); expect(score).toBe(100); // 100 + 10 bonus capped at 100 }); test("deducts for risky paths", () => { // 1 entry, 1 risky (100% risky) // Deduction: min(30, 1.0 * 50) = 30 // Bus factor 1: (3-1)*15 = 30 deduction const score = calculateHealthScore(1, 1, 0, 1); expect(score).toBe(40); // 100 - 30 - 30 }); test("deducts for stale paths", () => { // 1 entry, 0 risky, 1 stale // Stale deduction: min(20, 1.0 * 30) = 20 // Bus factor 3: no deduction, +10 bonus const score = calculateHealthScore(1, 0, 1, 3); expect(score).toBe(90); // 100 - 20 + 10 capped }); test("deducts for low bus factor", () => { // Bus factor 1: (3-1)*15 = 30 deduction const score = calculateHealthScore(1, 0, 0, 1); expect(score).toBe(70); // 100 - 30 }); test("gives bonus for high bus factor", () => { // Bus factor 4: +10 bonus const score = calculateHealthScore(1, 0, 0, 4); expect(score).toBe(100); // 100 + 10 capped at 100 }); test("combined negative factors floor at 0", () => { // 1 entry, 1 risky, 1 stale, bus factor 1 // Risky: -30, Stale: -20, BusFactor: -30 = -80 const score = calculateHealthScore(1, 1, 1, 1); expect(score).toBe(20); // 100 - 80 = 20 }); }); describe("stale path detection", () => { const SIX_MONTHS_MS = 6 * 30 * 24 * 60 * 60 * 1000; test("path is stale if last activity > 6 months ago", () => { const now = Date.now(); const sevenMonthsAgo = now - 7 * 30 * 24 * 60 * 60 * 1000; const isStale = sevenMonthsAgo < now - SIX_MONTHS_MS; expect(isStale).toBe(true); }); test("path is not stale if last activity < 6 months ago", () => { const now = Date.now(); const fiveMonthsAgo = now - 5 * 30 * 24 * 60 * 60 * 1000; const isStale = fiveMonthsAgo < now - SIX_MONTHS_MS; expect(isStale).toBe(false); }); test("exactly 6 months is boundary (stale)", () => { const now = Date.now(); const exactlySixMonths = now - SIX_MONTHS_MS; // < means strictly before, so exactly at boundary is NOT stale const isStale = exactlySixMonths < now - SIX_MONTHS_MS; expect(isStale).toBe(false); }); }); describe("directory grouping", () => { function groupByDirectory( files: string[], depth: number, ): Record<string, string[]> { const groups: Record<string, string[]> = {}; for (const file of files) { const parts = file.split("/"); const dir = parts.slice(0, Math.min(depth, parts.length - 1)).join("/") || "."; if (!groups[dir]) groups[dir] = []; groups[dir].push(file); } return groups; } test("groups files at depth 1", () => { const files = ["src/index.ts", "src/utils.ts", "lib/helper.ts"]; const groups = groupByDirectory(files, 1); expect(Object.keys(groups)).toContain("src"); expect(Object.keys(groups)).toContain("lib"); expect(groups["src"]).toHaveLength(2); expect(groups["lib"]).toHaveLength(1); }); test("groups files at depth 2", () => { const files = [ "src/tools/git.ts", "src/tools/memory.ts", "src/utils/helper.ts", ]; const groups = groupByDirectory(files, 2); expect(Object.keys(groups)).toContain("src/tools"); expect(Object.keys(groups)).toContain("src/utils"); expect(groups["src/tools"]).toHaveLength(2); }); test("root level files grouped as .", () => { const files = ["README.md", "package.json"]; const groups = groupByDirectory(files, 1); expect(Object.keys(groups)).toContain("."); expect(groups["."]).toHaveLength(2); }); }); describe("ignored file detection", () => { function isIgnoredFile(file: string): boolean { const ignoredPatterns = [ /^\.git\//, /node_modules\//, /\.lock$/, /package-lock\.json$/, /yarn\.lock$/, /bun\.lock$/, /pnpm-lock\.yaml$/, /\.min\.(js|css)$/, /\.map$/, /dist\//, /build\//, /\.next\//, /coverage\//, ]; return ignoredPatterns.some((pattern) => pattern.test(file)); } test("ignores lock files", () => { expect(isIgnoredFile("package-lock.json")).toBe(true); expect(isIgnoredFile("yarn.lock")).toBe(true); expect(isIgnoredFile("bun.lock")).toBe(true); expect(isIgnoredFile("pnpm-lock.yaml")).toBe(true); }); test("ignores node_modules", () => { expect(isIgnoredFile("node_modules/lodash/index.js")).toBe(true); }); test("ignores dist/build folders", () => { expect(isIgnoredFile("dist/index.js")).toBe(true); expect(isIgnoredFile("build/app.js")).toBe(true); }); test("ignores minified files", () => { expect(isIgnoredFile("bundle.min.js")).toBe(true); expect(isIgnoredFile("styles.min.css")).toBe(true); }); test("ignores source maps", () => { expect(isIgnoredFile("bundle.js.map")).toBe(true); }); test("allows normal source files", () => { expect(isIgnoredFile("src/index.ts")).toBe(false); expect(isIgnoredFile("lib/utils.js")).toBe(false); expect(isIgnoredFile("README.md")).toBe(false); }); }); }); // Helper functions for test data interface LogEntry { name: string; email: string; timestamp: number; files: string[]; } function buildLogOutput(entries: LogEntry[]): string { const lines: string[] = []; for (const entry of entries) { lines.push(`${entry.name}|${entry.email}|${entry.timestamp}`); for (const file of entry.files) { lines.push(file); } lines.push(""); } return lines.join("\n"); } function repeat( author: { name: string; email: string; files: string[] }, count: number, startTimestamp: number, ): LogEntry[] { return Array.from({ length: count }, (_, i) => ({ ...author, timestamp: startTimestamp - i * 3600, })); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/docleaai/doclea-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server