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

pr-size.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.

pr-size.test.tsBlame452 lines · 1 contributor
28bc555Claude1/**
2 * Block J32 — PR size distribution metric. Pure rollup tests.
3 */
4
5import { describe, it, expect } from "bun:test";
6import {
7 PR_SIZE_CLASSES,
8 DEFAULT_TOP_N,
9 DEFAULT_WINDOW_DAYS,
10 VALID_WINDOWS,
11 parseWindow,
12 classifyPrSize,
13 computePrSizeStats,
14 summarisePrSizes,
15 bucketPrSizes,
16 topLargestPrs,
17 buildPrSizeReport,
18 __internal,
19 type PrSizeInput,
20 type PrSizeStat,
21} from "../lib/pr-size";
22
23const DAY = 24 * 60 * 60 * 1000;
24
25describe("pr-size — re-exports from J25", () => {
26 it("parseWindow accepts canonical windows", () => {
27 expect(parseWindow("7")).toBe(7);
28 expect(parseWindow(undefined)).toBe(DEFAULT_WINDOW_DAYS);
29 });
30 it("VALID_WINDOWS includes the default", () => {
31 expect(VALID_WINDOWS).toContain(DEFAULT_WINDOW_DAYS);
32 });
33});
34
35describe("pr-size — classifyPrSize", () => {
36 it("maps to the five classes by boundary", () => {
37 expect(classifyPrSize(0)).toBe("xs");
38 expect(classifyPrSize(10)).toBe("xs"); // inclusive
39 expect(classifyPrSize(11)).toBe("s");
40 expect(classifyPrSize(50)).toBe("s"); // inclusive
41 expect(classifyPrSize(51)).toBe("m");
42 expect(classifyPrSize(250)).toBe("m");
43 expect(classifyPrSize(251)).toBe("l");
44 expect(classifyPrSize(1000)).toBe("l");
45 expect(classifyPrSize(1001)).toBe("xl");
46 expect(classifyPrSize(99_999)).toBe("xl");
47 });
48 it("defaults bogus values to xs", () => {
49 expect(classifyPrSize(Number.NaN)).toBe("xs");
50 expect(classifyPrSize(-5)).toBe("xs");
51 });
52 it("has exactly five ordered classes", () => {
53 expect(PR_SIZE_CLASSES).toHaveLength(5);
54 expect(PR_SIZE_CLASSES.map((c) => c.key)).toEqual([
55 "xs",
56 "s",
57 "m",
58 "l",
59 "xl",
60 ]);
61 });
62});
63
64describe("pr-size — computePrSizeStats + window", () => {
65 const now = new Date("2025-04-01T00:00:00Z").getTime();
66 const prs: PrSizeInput[] = [
67 {
68 id: "m",
69 number: 1,
70 title: "merged recent",
71 state: "merged",
72 createdAt: new Date(now - 60 * DAY),
73 mergedAt: new Date(now - 5 * DAY), // anchors on mergedAt
74 additions: 30,
75 deletions: 10,
76 files: 3,
77 },
78 {
79 id: "o",
80 number: 2,
81 title: "open recent",
82 state: "open",
83 createdAt: new Date(now - 3 * DAY),
84 additions: 300,
85 deletions: 50,
86 files: 10,
87 },
88 {
89 id: "old",
90 number: 3,
91 title: "old open",
92 state: "open",
93 createdAt: new Date(now - 90 * DAY),
94 additions: 5,
95 deletions: 5,
96 files: 1,
97 },
98 {
99 id: "bogus",
100 number: 4,
101 title: "bad date",
102 state: "open",
103 createdAt: "not-a-date",
104 additions: 10,
105 deletions: 10,
106 files: 1,
107 },
108 ];
109
110 it("filters to the window", () => {
111 const out = computePrSizeStats(prs, 30, now);
112 expect(out.map((s) => s.id).sort()).toEqual(["m", "o"]);
113 });
114
115 it("computes linesChanged + sizeClass", () => {
116 const out = computePrSizeStats(prs, 0, now);
117 const m = out.find((s) => s.id === "m")!;
118 const o = out.find((s) => s.id === "o")!;
119 expect(m.linesChanged).toBe(40);
120 expect(m.sizeClass).toBe("s");
121 expect(o.linesChanged).toBe(350);
122 expect(o.sizeClass).toBe("l");
123 });
124
125 it("window=0 keeps everything parseable", () => {
126 const out = computePrSizeStats(prs, 0, now);
127 expect(out.map((s) => s.id).sort()).toEqual(["m", "o", "old"]);
128 });
129
130 it("drops PRs with unparseable createdAt", () => {
131 const out = computePrSizeStats(prs, 0, now);
132 expect(out.some((s) => s.id === "bogus")).toBe(false);
133 });
134
135 it("anchors merged PRs on mergedAt so recent merges with old createdAt land in window", () => {
136 const far: PrSizeInput = {
137 id: "z",
138 number: 99,
139 title: "ancient PR, recent merge",
140 state: "merged",
141 createdAt: new Date(now - 365 * DAY),
142 mergedAt: new Date(now - 2 * DAY),
143 additions: 100,
144 deletions: 0,
145 files: 1,
146 };
147 const out = computePrSizeStats([far], 7, now);
148 expect(out).toHaveLength(1);
149 });
150
151 it("treats negative / NaN line counts as zero", () => {
152 const pr: PrSizeInput = {
153 id: "neg",
154 number: 1,
155 title: "garbage numbers",
156 state: "open",
157 createdAt: new Date(now),
158 additions: -10,
159 deletions: Number.NaN,
160 files: 2,
161 };
162 const [stat] = computePrSizeStats([pr], 0, now);
163 expect(stat!.linesChanged).toBe(0);
164 expect(stat!.sizeClass).toBe("xs");
165 });
166});
167
168describe("pr-size — summarisePrSizes", () => {
169 it("zero stats", () => {
170 const s = summarisePrSizes([]);
171 expect(s.total).toBe(0);
172 expect(s.medianLines).toBe(0);
173 expect(s.p90Lines).toBe(0);
174 expect(s.smallPrRatio).toBe(0);
175 });
176
177 it("computes percentiles over a uniform series", () => {
178 const stats: PrSizeStat[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((n) => ({
179 id: String(n),
180 number: n,
181 title: "t",
182 state: "merged",
183 isDraft: false,
184 createdAt: new Date(),
185 additions: n,
186 deletions: 0,
187 files: 1,
188 linesChanged: n,
189 sizeClass: "xs",
190 }));
191 const s = summarisePrSizes(stats);
192 expect(s.total).toBe(10);
193 expect(s.medianLines).toBe(6); // round(5.5)
194 expect(s.meanLines).toBe(6); // round(5.5)
195 expect(s.p90Lines).toBe(9); // round(9.1)
196 expect(s.largestLines).toBe(10);
197 expect(s.smallestLines).toBe(1);
198 expect(s.smallPrRatio).toBe(100); // all xs
199 });
200
201 it("classifies merged / open / draft separately", () => {
202 const mk = (
203 over: Partial<PrSizeStat> & { id: string }
204 ): PrSizeStat => ({
205 id: over.id,
206 number: 1,
207 title: "t",
208 state: "open",
209 isDraft: false,
210 createdAt: new Date(),
211 additions: 5,
212 deletions: 5,
213 files: 1,
214 linesChanged: 10,
215 sizeClass: "xs",
216 ...over,
217 });
218 const stats = [
219 mk({ id: "merged", state: "merged" }),
220 mk({ id: "open", state: "open", isDraft: false }),
221 mk({ id: "draft", state: "open", isDraft: true }),
222 mk({ id: "closed", state: "closed" }),
223 ];
224 const s = summarisePrSizes(stats);
225 expect(s.merged).toBe(1);
226 expect(s.open).toBe(1); // draft excluded
227 });
228
229 it("smallPrRatio rounds to one decimal", () => {
230 // 1 small PR, 2 large PRs → ratio = 33.3%
231 const mk = (linesChanged: number, cls: any): PrSizeStat => ({
232 id: String(linesChanged),
233 number: linesChanged,
234 title: "t",
235 state: "merged",
236 isDraft: false,
237 createdAt: new Date(),
238 additions: linesChanged,
239 deletions: 0,
240 files: 1,
241 linesChanged,
242 sizeClass: cls,
243 });
244 const s = summarisePrSizes([
245 mk(5, "xs"),
246 mk(500, "l"),
247 mk(500, "l"),
248 ]);
249 expect(s.smallPrRatio).toBe(33.3);
250 });
251});
252
253describe("pr-size — bucketPrSizes", () => {
254 it("distributes into the five class buckets with zero-count defaults", () => {
255 const mk = (
256 id: string,
257 linesChanged: number,
258 sizeClass: any
259 ): PrSizeStat => ({
260 id,
261 number: 1,
262 title: "t",
263 state: "merged",
264 isDraft: false,
265 createdAt: new Date(),
266 additions: linesChanged,
267 deletions: 0,
268 files: 1,
269 linesChanged,
270 sizeClass,
271 });
272 const stats = [
273 mk("a", 5, "xs"),
274 mk("b", 20, "s"),
275 mk("c", 100, "m"),
276 mk("d", 500, "l"),
277 mk("e", 2000, "xl"),
278 mk("f", 2, "xs"),
279 ];
280 const b = bucketPrSizes(stats);
281 const by = Object.fromEntries(b.map((x) => [x.key, x]));
282 expect(by.xs!.count).toBe(2);
283 expect(by.s!.count).toBe(1);
284 expect(by.m!.count).toBe(1);
285 expect(by.l!.count).toBe(1);
286 expect(by.xl!.count).toBe(1);
287 expect(by.xs!.bytes).toBe(7);
288 });
289
290 it("returns all five buckets for empty input", () => {
291 const b = bucketPrSizes([]);
292 expect(b).toHaveLength(5);
293 expect(b.every((x) => x.count === 0 && x.bytes === 0)).toBe(true);
294 });
295});
296
297describe("pr-size — topLargestPrs", () => {
298 const mk = (
299 id: string,
300 linesChanged: number,
301 number: number
302 ): PrSizeStat => ({
303 id,
304 number,
305 title: "t",
306 state: "merged",
307 isDraft: false,
308 createdAt: new Date(),
309 additions: linesChanged,
310 deletions: 0,
311 files: 1,
312 linesChanged,
313 sizeClass: "xs",
314 });
315
316 it("returns sorted-desc by linesChanged", () => {
317 const out = topLargestPrs([
318 mk("a", 10, 1),
319 mk("b", 1000, 2),
320 mk("c", 50, 3),
321 ]);
322 expect(out.map((s) => s.id)).toEqual(["b", "c", "a"]);
323 });
324
325 it("tie-breaks by PR number descending", () => {
326 const out = topLargestPrs([
327 mk("older", 100, 1),
328 mk("newer", 100, 2),
329 ]);
330 expect(out[0]!.id).toBe("newer");
331 expect(out[1]!.id).toBe("older");
332 });
333
334 it("honours limit", () => {
335 const stats = Array.from({ length: 50 }, (_, i) => mk(String(i), i, i));
336 expect(topLargestPrs(stats, 5)).toHaveLength(5);
337 });
338
339 it("defaults invalid limits to DEFAULT_TOP_N", () => {
340 const stats = Array.from({ length: 50 }, (_, i) => mk(String(i), i, i));
341 expect(topLargestPrs(stats, 0)).toHaveLength(DEFAULT_TOP_N);
342 expect(topLargestPrs(stats, -5)).toHaveLength(DEFAULT_TOP_N);
343 });
344
345 it("never mutates input", () => {
346 const stats = [mk("a", 10, 1), mk("b", 1000, 2)];
347 const copy = [...stats];
348 topLargestPrs(stats);
349 expect(stats).toEqual(copy);
350 });
351});
352
353describe("pr-size — buildPrSizeReport", () => {
354 const now = new Date("2025-04-01T00:00:00Z").getTime();
355 const prs: PrSizeInput[] = [
356 {
357 id: "a",
358 number: 1,
359 title: "tiny",
360 state: "merged",
361 createdAt: new Date(now - DAY),
362 mergedAt: new Date(now - DAY),
363 additions: 2,
364 deletions: 1,
365 files: 1,
366 },
367 {
368 id: "b",
369 number: 2,
370 title: "huge",
371 state: "merged",
372 createdAt: new Date(now - 2 * DAY),
373 mergedAt: new Date(now - 2 * DAY),
374 additions: 800,
375 deletions: 400,
376 files: 50,
377 },
378 {
379 id: "c",
380 number: 3,
381 title: "open",
382 state: "open",
383 createdAt: new Date(now - 3 * DAY),
384 additions: 40,
385 deletions: 0,
386 files: 2,
387 },
388 ];
389
390 it("builds a full report", () => {
391 const r = buildPrSizeReport({ prs, windowDays: 30, now });
392 expect(r.windowDays).toBe(30);
393 expect(r.now).toBe(now);
394 expect(r.perPr).toHaveLength(3);
395 expect(r.summary.total).toBe(3);
396 expect(r.summary.merged).toBe(2);
397 expect(r.summary.open).toBe(1);
398 expect(r.buckets).toHaveLength(5);
399 expect(r.largest[0]!.id).toBe("b");
400 });
401
402 it("defaults now to Date.now when omitted", () => {
403 const before = Date.now();
404 const r = buildPrSizeReport({ prs: [], windowDays: 30 });
405 const after = Date.now();
406 expect(r.now).toBeGreaterThanOrEqual(before);
407 expect(r.now).toBeLessThanOrEqual(after);
408 });
409
410 it("defaults windowDays to DEFAULT_WINDOW_DAYS when omitted", () => {
411 const r = buildPrSizeReport({ prs: [] });
412 expect(r.windowDays).toBe(DEFAULT_WINDOW_DAYS);
413 });
414
415 it("honours topN", () => {
416 const r = buildPrSizeReport({ prs, windowDays: 30, now, topN: 1 });
417 expect(r.largest).toHaveLength(1);
418 expect(r.largest[0]!.id).toBe("b");
419 });
420});
421
422describe("pr-size — routes", () => {
423 it("GET /:o/:r/insights/pr-size returns 200 or 404 (never 500)", async () => {
424 const { default: app } = await import("../app");
425 const res = await app.request("/alice/repo/insights/pr-size");
426 expect([200, 404]).toContain(res.status);
427 });
428 it("ignores bogus window values", async () => {
429 const { default: app } = await import("../app");
430 const res = await app.request(
431 "/alice/repo/insights/pr-size?window=abc&top=xyz"
432 );
433 expect([200, 404]).toContain(res.status);
434 });
435});
436
437describe("pr-size — __internal parity", () => {
438 it("re-exports every helper", () => {
439 expect(__internal.PR_SIZE_CLASSES).toBe(PR_SIZE_CLASSES);
440 expect(__internal.DEFAULT_TOP_N).toBe(DEFAULT_TOP_N);
441 expect(__internal.classifyPrSize).toBe(classifyPrSize);
442 expect(__internal.computePrSizeStats).toBe(computePrSizeStats);
443 expect(__internal.summarisePrSizes).toBe(summarisePrSizes);
444 expect(__internal.bucketPrSizes).toBe(bucketPrSizes);
445 expect(__internal.topLargestPrs).toBe(topLargestPrs);
446 expect(__internal.buildPrSizeReport).toBe(buildPrSizeReport);
447 expect(typeof __internal.toTime).toBe("function");
448 expect(typeof __internal.anchorTime).toBe("function");
449 expect(typeof __internal.percentile).toBe("function");
450 expect(typeof __internal.safeLines).toBe("function");
451 });
452});