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.
| 28bc555 | 1 | /** |
| 2 | * Block J32 — PR size distribution metric. Pure rollup tests. | |
| 3 | */ | |
| 4 | ||
| 5 | import { describe, it, expect } from "bun:test"; | |
| 6 | import { | |
| 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 | ||
| 23 | const DAY = 24 * 60 * 60 * 1000; | |
| 24 | ||
| 25 | describe("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 | ||
| 35 | describe("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 | ||
| 64 | describe("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 | ||
| 168 | describe("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 | ||
| 253 | describe("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 | ||
| 297 | describe("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 | ||
| 353 | describe("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 | ||
| 422 | describe("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 | ||
| 437 | describe("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 | }); |