Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

language-stats.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

language-stats.test.tsBlame460 lines · 1 contributor
07efa08Claude1/**
2 * Block J30 — Repository language breakdown. Pure helper tests.
3 *
4 * Exercises:
5 * - detectLanguage (extension map, filename map, case-insensitivity, null paths)
6 * - isVendoredOrGenerated (prefixes, nested prefixes, lockfile basenames)
7 * - computeLanguageStats (vendoring, size caps, min-bytes fold, unclassified)
8 * - foldIntoOther (idempotency, threshold, sort stability)
9 * - formatBytes / formatPercent (boundaries + NaN / negative handling)
10 * - buildLanguageReport (one-shot equivalence)
11 * - Route smoke test
12 * - __internal parity
13 */
14
15import { describe, it, expect } from "bun:test";
16import {
17 EXTENSION_MAP,
18 FILENAME_MAP,
19 LANGUAGE_COLORS,
20 DEFAULT_LANGUAGE_COLOR,
21 VENDORED_PREFIXES,
22 GENERATED_SUFFIXES,
23 detectLanguage,
24 isVendoredOrGenerated,
25 computeLanguageStats,
26 foldIntoOther,
27 formatBytes,
28 formatPercent,
29 buildLanguageReport,
30 __internal,
31 type LanguageFileEntry,
32 type LanguageReport,
33} from "../lib/language-stats";
34
35describe("language-stats — detectLanguage", () => {
36 it("resolves common extensions", () => {
37 expect(detectLanguage("src/app.ts")).toBe("TypeScript");
38 expect(detectLanguage("src/app.tsx")).toBe("TypeScript");
39 expect(detectLanguage("lib/foo.js")).toBe("JavaScript");
40 expect(detectLanguage("lib/foo.mjs")).toBe("JavaScript");
41 expect(detectLanguage("lib/foo.cjs")).toBe("JavaScript");
42 expect(detectLanguage("scripts/build.py")).toBe("Python");
43 expect(detectLanguage("pkg/main.go")).toBe("Go");
44 expect(detectLanguage("src/lib.rs")).toBe("Rust");
45 expect(detectLanguage("App.java")).toBe("Java");
46 expect(detectLanguage("App.kt")).toBe("Kotlin");
47 expect(detectLanguage("README.md")).toBe("Markdown");
48 expect(detectLanguage("docs/index.mdx")).toBe("MDX");
49 expect(detectLanguage("config.yaml")).toBe("YAML");
50 expect(detectLanguage("config.yml")).toBe("YAML");
51 expect(detectLanguage("package.json")).toBe("JSON");
52 expect(detectLanguage("query.gql")).toBe("GraphQL");
53 expect(detectLanguage("schema.graphql")).toBe("GraphQL");
54 expect(detectLanguage("style.scss")).toBe("SCSS");
55 expect(detectLanguage("index.html")).toBe("HTML");
56 expect(detectLanguage("hello.zig")).toBe("Zig");
57 expect(detectLanguage("hello.dart")).toBe("Dart");
58 });
59
60 it("resolves filename-only languages", () => {
61 expect(detectLanguage("Dockerfile")).toBe("Dockerfile");
62 expect(detectLanguage("docker/Dockerfile")).toBe("Dockerfile");
63 expect(detectLanguage("Makefile")).toBe("Makefile");
64 expect(detectLanguage("GNUmakefile")).toBe("Makefile");
65 expect(detectLanguage("Rakefile")).toBe("Ruby");
66 expect(detectLanguage("Gemfile")).toBe("Ruby");
67 expect(detectLanguage("Jenkinsfile")).toBe("Groovy");
68 expect(detectLanguage("CMakeLists.txt")).toBe("CMake");
69 expect(detectLanguage("meson.build")).toBe("Meson");
70 });
71
72 it("is case-insensitive for both maps", () => {
73 expect(detectLanguage("src/FOO.TS")).toBe("TypeScript");
74 expect(detectLanguage("DOCKERFILE")).toBe("Dockerfile");
75 expect(detectLanguage("makefile")).toBe("Makefile");
76 });
77
78 it("returns null for unrecognised files", () => {
79 expect(detectLanguage("unknown.xyz")).toBeNull();
80 expect(detectLanguage("no-extension")).toBeNull();
81 expect(detectLanguage("")).toBeNull();
82 });
83
84 it("guards non-string input", () => {
85 // @ts-expect-error — intentionally wrong
86 expect(detectLanguage(null)).toBeNull();
87 // @ts-expect-error
88 expect(detectLanguage(undefined)).toBeNull();
89 // @ts-expect-error
90 expect(detectLanguage(42)).toBeNull();
91 });
92
93 it("treats dotfiles (leading-dot basenames) as extensionless when not in filename map", () => {
94 // `.env`, `.gitignore` etc. have no extension by our rule.
95 expect(detectLanguage(".gitignore")).toBeNull();
96 expect(detectLanguage(".env")).toBeNull();
97 });
98
99 it("picks the last extension only", () => {
100 expect(detectLanguage("archive.tar.gz")).toBeNull();
101 expect(detectLanguage("index.d.ts")).toBe("TypeScript");
102 expect(detectLanguage("component.test.tsx")).toBe("TypeScript");
103 });
104});
105
106describe("language-stats — isVendoredOrGenerated", () => {
107 it("flags top-level vendored prefixes", () => {
108 for (const pref of VENDORED_PREFIXES) {
109 expect(isVendoredOrGenerated(pref + "any/file.ts")).toBe(true);
110 }
111 });
112 it("flags nested vendored prefixes", () => {
113 expect(isVendoredOrGenerated("pkg/node_modules/foo/bar.js")).toBe(true);
114 expect(isVendoredOrGenerated("apps/web/dist/main.js")).toBe(true);
115 });
116 it("flags lockfile basenames anywhere", () => {
117 for (const sfx of GENERATED_SUFFIXES) {
118 expect(isVendoredOrGenerated(sfx)).toBe(true);
119 expect(isVendoredOrGenerated(`some/nested/${sfx}`)).toBe(true);
120 }
121 });
122 it("does not flag ordinary source files", () => {
123 expect(isVendoredOrGenerated("src/app.ts")).toBe(false);
124 expect(isVendoredOrGenerated("lib/package.json")).toBe(false);
125 expect(isVendoredOrGenerated("README.md")).toBe(false);
126 });
127 it("tolerates a leading slash", () => {
128 expect(isVendoredOrGenerated("/node_modules/foo.js")).toBe(true);
129 });
130 it("guards non-string input", () => {
131 // @ts-expect-error
132 expect(isVendoredOrGenerated(null)).toBe(false);
133 // @ts-expect-error
134 expect(isVendoredOrGenerated(undefined)).toBe(false);
135 });
136});
137
138describe("language-stats — computeLanguageStats (basic rollup)", () => {
139 const entries: LanguageFileEntry[] = [
140 { path: "src/a.ts", size: 100 },
141 { path: "src/b.ts", size: 300 },
142 { path: "src/c.py", size: 200 },
143 { path: "README.md", size: 50 },
144 { path: "unknown.xyz", size: 9999 }, // dropped
145 ];
146
147 it("aggregates bytes per language and computes percentages", () => {
148 const r = computeLanguageStats(entries);
149 expect(r.totalFiles).toBe(5);
150 expect(r.countedFiles).toBe(4); // unknown dropped
151 expect(r.totalBytes).toBe(100 + 300 + 200 + 50);
152
153 const ts = r.buckets.find((b) => b.language === "TypeScript")!;
154 const py = r.buckets.find((b) => b.language === "Python")!;
155 const md = r.buckets.find((b) => b.language === "Markdown")!;
156 expect(ts.bytes).toBe(400);
157 expect(ts.fileCount).toBe(2);
158 expect(py.bytes).toBe(200);
159 expect(md.bytes).toBe(50);
160
161 const totalPct = r.buckets.reduce((acc, b) => acc + b.percent, 0);
162 expect(Math.round(totalPct)).toBe(100);
163
164 // Sorted by bytes desc — TS comes first.
165 expect(r.buckets[0]!.language).toBe("TypeScript");
166 expect(r.primary?.language).toBe("TypeScript");
167 });
168
169 it("assigns colours from the map", () => {
170 const r = computeLanguageStats(entries);
171 const ts = r.buckets.find((b) => b.language === "TypeScript")!;
172 expect(ts.color).toBe(LANGUAGE_COLORS.get("TypeScript"));
173 });
174
175 it("falls back to the default colour for unmapped languages", () => {
176 const out = computeLanguageStats([
177 { path: "hi.pl", size: 100 }, // Perl — not in LANGUAGE_COLORS
178 ]);
179 const perl = out.buckets.find((b) => b.language === "Perl")!;
180 expect(perl.color).toBe(DEFAULT_LANGUAGE_COLOR);
181 });
182
183 it("ignores vendored paths by default", () => {
184 const r = computeLanguageStats([
185 { path: "src/a.ts", size: 100 },
186 { path: "node_modules/foo/bar.js", size: 10_000 },
187 { path: "dist/main.js", size: 20_000 },
188 { path: "package-lock.json", size: 5_000 },
189 ]);
190 expect(r.totalBytes).toBe(100);
191 expect(r.buckets).toHaveLength(1);
192 expect(r.buckets[0]!.language).toBe("TypeScript");
193 });
194
195 it("counts vendored paths when ignoreVendored=false", () => {
196 const r = computeLanguageStats(
197 [
198 { path: "src/a.ts", size: 100 },
199 { path: "node_modules/foo/bar.js", size: 10_000 },
200 ],
201 { ignoreVendored: false }
202 );
203 expect(r.totalBytes).toBe(10_100);
204 const js = r.buckets.find((b) => b.language === "JavaScript")!;
205 expect(js.bytes).toBe(10_000);
206 });
207
208 it("caps per-file size via maxFileSize", () => {
209 const r = computeLanguageStats(
210 [
211 { path: "src/small.ts", size: 100 },
212 { path: "src/huge.ts", size: 50_000 },
213 ],
214 { maxFileSize: 1_000 }
215 );
216 const ts = r.buckets.find((b) => b.language === "TypeScript")!;
217 // small=100 + huge capped at 1_000 = 1_100
218 expect(ts.bytes).toBe(1_100);
219 });
220
221 it("skips zero-size files and negative sizes", () => {
222 const r = computeLanguageStats([
223 { path: "src/a.ts", size: 0 },
224 { path: "src/b.ts", size: -5 },
225 { path: "src/c.ts", size: 10 },
226 ]);
227 expect(r.countedFiles).toBe(1);
228 expect(r.totalBytes).toBe(10);
229 });
230
231 it("skips entries with non-string path or bogus size", () => {
232 const r = computeLanguageStats([
233 // @ts-expect-error
234 { path: 42, size: 1 },
235 { path: "src/a.ts", size: Number.NaN },
236 { path: "src/b.ts", size: 10 },
237 ]);
238 expect(r.totalBytes).toBe(10);
239 });
240
241 it("folds languages below minLanguageBytes into Other", () => {
242 const r = computeLanguageStats(
243 [
244 { path: "src/big.ts", size: 10_000 },
245 { path: "tiny.py", size: 50 },
246 { path: "small.go", size: 100 },
247 ],
248 { minLanguageBytes: 200 }
249 );
250 const other = r.buckets.find((b) => b.language === "Other")!;
251 expect(other.bytes).toBe(150);
252 expect(other.fileCount).toBe(2);
253 expect(other.color).toBe(LANGUAGE_COLORS.get("Other"));
254 // Other must sort last.
255 expect(r.buckets.at(-1)!.language).toBe("Other");
256 // primary must skip Other.
257 expect(r.primary?.language).toBe("TypeScript");
258 });
259
260 it("returns empty report when nothing classifies", () => {
261 const r = computeLanguageStats([
262 { path: "unknown.xyz", size: 10 },
263 ]);
264 expect(r.totalBytes).toBe(0);
265 expect(r.buckets).toHaveLength(0);
266 expect(r.primary).toBeNull();
267 });
268
269 it("handles an empty input", () => {
270 const r = computeLanguageStats([]);
271 expect(r.totalFiles).toBe(0);
272 expect(r.countedFiles).toBe(0);
273 expect(r.totalBytes).toBe(0);
274 expect(r.buckets).toEqual([]);
275 expect(r.primary).toBeNull();
276 });
277
278 it("breaks bucket ties by language name", () => {
279 const r = computeLanguageStats([
280 { path: "a.py", size: 100 },
281 { path: "a.rb", size: 100 },
282 { path: "a.go", size: 100 },
283 ]);
284 // All equal — alphabetical.
285 expect(r.buckets.map((b) => b.language)).toEqual([
286 "Go",
287 "Python",
288 "Ruby",
289 ]);
290 });
291});
292
293describe("language-stats — foldIntoOther", () => {
294 function mkReport(pairs: Array<[string, number]>): LanguageReport {
295 const total = pairs.reduce((acc, [, n]) => acc + n, 0);
296 return {
297 totalBytes: total,
298 totalFiles: pairs.length,
299 countedFiles: pairs.length,
300 buckets: pairs.map(([language, bytes]) => ({
301 language,
302 bytes,
303 fileCount: 1,
304 percent: total === 0 ? 0 : (bytes / total) * 100,
305 color: LANGUAGE_COLORS.get(language) ?? DEFAULT_LANGUAGE_COLOR,
306 })),
307 primary: null,
308 };
309 }
310
311 it("folds buckets below the threshold into Other", () => {
312 const r = mkReport([
313 ["TypeScript", 900],
314 ["Python", 80],
315 ["Ruby", 20],
316 ]);
317 const out = foldIntoOther(r, 10); // 10%
318 // Only TS survives (Python=8%, Ruby=2%).
319 expect(out.buckets.map((b) => b.language)).toEqual(["TypeScript", "Other"]);
320 const other = out.buckets.find((b) => b.language === "Other")!;
321 expect(other.bytes).toBe(100);
322 expect(other.fileCount).toBe(2);
323 expect(out.primary?.language).toBe("TypeScript");
324 });
325
326 it("is idempotent when called twice", () => {
327 const r = mkReport([
328 ["TypeScript", 900],
329 ["Python", 80],
330 ["Ruby", 20],
331 ]);
332 const once = foldIntoOther(r, 10);
333 const twice = foldIntoOther(once, 10);
334 expect(twice.buckets.map((b) => `${b.language}:${b.bytes}`)).toEqual(
335 once.buckets.map((b) => `${b.language}:${b.bytes}`)
336 );
337 });
338
339 it("threshold <=0 is a no-op", () => {
340 const r = mkReport([
341 ["TypeScript", 900],
342 ["Python", 100],
343 ]);
344 expect(foldIntoOther(r, 0)).toBe(r);
345 expect(foldIntoOther(r, -5)).toBe(r);
346 });
347
348 it("Other always sorts last after folding", () => {
349 const r = mkReport([
350 ["TypeScript", 500],
351 ["Python", 500],
352 ["Ruby", 1],
353 ]);
354 const out = foldIntoOther(r, 1);
355 expect(out.buckets.at(-1)!.language).toBe("Other");
356 });
357});
358
359describe("language-stats — formatBytes", () => {
360 it("formats bytes, KB, MB, GB, TB", () => {
361 expect(formatBytes(0)).toBe("0 B");
362 expect(formatBytes(512)).toBe("512 B");
363 expect(formatBytes(1024)).toBe("1.0 KB");
364 expect(formatBytes(1536)).toBe("1.5 KB");
365 expect(formatBytes(1024 * 1024)).toBe("1.0 MB");
366 expect(formatBytes(1024 * 1024 * 1024)).toBe("1.0 GB");
367 expect(formatBytes(1024 ** 4)).toBe("1.0 TB");
368 });
369 it("drops decimals past 10 in a unit", () => {
370 expect(formatBytes(10 * 1024)).toBe("10 KB");
371 expect(formatBytes(1024 * 1024 * 15)).toBe("15 MB");
372 });
373 it("handles negative and non-finite input", () => {
374 expect(formatBytes(-1)).toBe("0 B");
375 expect(formatBytes(Number.NaN)).toBe("0 B");
376 expect(formatBytes(Number.POSITIVE_INFINITY)).toBe("0 B");
377 });
378});
379
380describe("language-stats — formatPercent", () => {
381 it("formats to given digits and clamps to [0,100]", () => {
382 expect(formatPercent(50)).toBe("50.0%");
383 expect(formatPercent(50, 0)).toBe("50%");
384 expect(formatPercent(50, 2)).toBe("50.00%");
385 expect(formatPercent(-5)).toBe("0.0%");
386 expect(formatPercent(200)).toBe("100.0%");
387 });
388 it("handles NaN", () => {
389 expect(formatPercent(Number.NaN)).toBe("0%");
390 });
391});
392
393describe("language-stats — buildLanguageReport", () => {
394 const entries: LanguageFileEntry[] = [
395 { path: "src/a.ts", size: 1_000 },
396 { path: "main.py", size: 100 },
397 { path: "tiny.rb", size: 5 },
398 ];
399
400 it("matches computeLanguageStats when foldUnderPercent is 0", () => {
401 const a = computeLanguageStats(entries);
402 const b = buildLanguageReport({ entries });
403 expect(a.buckets.map((x) => x.language)).toEqual(
404 b.buckets.map((x) => x.language)
405 );
406 expect(a.totalBytes).toBe(b.totalBytes);
407 });
408
409 it("applies foldUnderPercent after rollup", () => {
410 const r = buildLanguageReport({ entries, foldUnderPercent: 2 });
411 // Ruby=5B out of 1105 ≈ 0.45% < 2% → folds into Other
412 expect(r.buckets.some((b) => b.language === "Ruby")).toBe(false);
413 expect(r.buckets.some((b) => b.language === "Other")).toBe(true);
414 });
415
416 it("respects ignoreVendored=false", () => {
417 const r = buildLanguageReport({
418 entries: [
419 { path: "src/a.ts", size: 100 },
420 { path: "node_modules/foo/bar.js", size: 10_000 },
421 ],
422 ignoreVendored: false,
423 });
424 expect(r.buckets.some((b) => b.language === "JavaScript")).toBe(true);
425 });
426});
427
428describe("language-stats — routes", () => {
429 it("GET /:o/:r/languages returns 200 or 404 (never 500)", async () => {
430 const { default: app } = await import("../app");
431 const res = await app.request("/alice/repo/languages");
432 expect([200, 404]).toContain(res.status);
433 });
434 it("ignores bogus query params", async () => {
435 const { default: app } = await import("../app");
436 const res = await app.request(
437 "/alice/repo/languages?include_vendored=yes&fold=abc"
438 );
439 expect([200, 404]).toContain(res.status);
440 });
441});
442
443describe("language-stats — __internal parity", () => {
444 it("re-exports every helper", () => {
445 expect(__internal.EXTENSION_MAP).toBe(EXTENSION_MAP);
446 expect(__internal.FILENAME_MAP).toBe(FILENAME_MAP);
447 expect(__internal.LANGUAGE_COLORS).toBe(LANGUAGE_COLORS);
448 expect(__internal.DEFAULT_LANGUAGE_COLOR).toBe(DEFAULT_LANGUAGE_COLOR);
449 expect(__internal.VENDORED_PREFIXES).toBe(VENDORED_PREFIXES);
450 expect(__internal.GENERATED_SUFFIXES).toBe(GENERATED_SUFFIXES);
451 expect(__internal.detectLanguage).toBe(detectLanguage);
452 expect(__internal.isVendoredOrGenerated).toBe(isVendoredOrGenerated);
453 expect(__internal.computeLanguageStats).toBe(computeLanguageStats);
454 expect(__internal.foldIntoOther).toBe(foldIntoOther);
455 expect(__internal.formatBytes).toBe(formatBytes);
456 expect(__internal.formatPercent).toBe(formatPercent);
457 expect(__internal.buildLanguageReport).toBe(buildLanguageReport);
458 expect(typeof __internal.colorFor).toBe("function");
459 });
460});