|
1 | 1 | import { describe, expect, it } from "vitest"; |
2 | 2 | import { |
3 | 3 | classifyOutcome, |
| 4 | + computeSubmissionCadence, |
4 | 5 | countOutcomes, |
5 | 6 | DEFAULT_REPUTATION_CONFIG, |
| 7 | + getSubmitterCadence, |
6 | 8 | getSubmitterReputation, |
| 9 | + isMachinePacedCadence, |
7 | 10 | recordSubmissionOutcome, |
8 | 11 | REPUTATION_WINDOW_DAYS, |
9 | 12 | type ReputationConfig, |
@@ -273,6 +276,104 @@ describe("recordSubmissionOutcome / getSubmitterReputation (D1, fail-safe)", () |
273 | 276 | }); |
274 | 277 | }); |
275 | 278 |
|
| 279 | +describe("computeSubmissionCadence (pure) (#4514)", () => { |
| 280 | + it("returns count and null medianGapMs for 0 or 1 samples (nothing to measure a gap between)", () => { |
| 281 | + expect(computeSubmissionCadence([])).toEqual({ count: 0, medianGapMs: null }); |
| 282 | + expect(computeSubmissionCadence(["2026-01-01T00:00:00.000Z"])).toEqual({ count: 1, medianGapMs: null }); |
| 283 | + }); |
| 284 | + |
| 285 | + it("computes the median gap between consecutive submissions, order-independent", () => { |
| 286 | + // Gaps: 10min, 20min, 30min -> sorted [10,20,30] -> median 20min. |
| 287 | + const t0 = new Date("2026-01-01T00:00:00.000Z").getTime(); |
| 288 | + const timestamps = [t0, t0 + 10 * 60_000, t0 + 30 * 60_000, t0 + 60 * 60_000].map((ms) => new Date(ms).toISOString()); |
| 289 | + // Shuffle the input order -- the function must sort internally, not assume caller ordering. |
| 290 | + const shuffled = [timestamps[2]!, timestamps[0]!, timestamps[3]!, timestamps[1]!]; |
| 291 | + expect(computeSubmissionCadence(shuffled)).toEqual({ count: 4, medianGapMs: 20 * 60_000 }); |
| 292 | + }); |
| 293 | + |
| 294 | + it("averages the two middle gaps for an even number of gaps", () => { |
| 295 | + // 3 timestamps -> 2 gaps: 10min, 30min -> even count -> average = 20min. |
| 296 | + const t0 = new Date("2026-01-01T00:00:00.000Z").getTime(); |
| 297 | + const timestamps = [t0, t0 + 10 * 60_000, t0 + 40 * 60_000].map((ms) => new Date(ms).toISOString()); |
| 298 | + expect(computeSubmissionCadence(timestamps)).toEqual({ count: 3, medianGapMs: 20 * 60_000 }); |
| 299 | + }); |
| 300 | +}); |
| 301 | + |
| 302 | +describe("isMachinePacedCadence (pure) (#4514)", () => { |
| 303 | + it("requires BOTH the minimum sample size AND a sub-threshold median gap", () => { |
| 304 | + // Below minSample (5) -- fast, but not enough samples to call it a pattern. |
| 305 | + expect(isMachinePacedCadence({ count: 4, medianGapMs: 60_000 })).toBe(false); |
| 306 | + // Enough samples, but the gap is comfortably human (well over 10min). |
| 307 | + expect(isMachinePacedCadence({ count: 10, medianGapMs: 60 * 60_000 })).toBe(false); |
| 308 | + // No gap at all to measure (count < 2 internally, or explicitly null). |
| 309 | + expect(isMachinePacedCadence({ count: 8, medianGapMs: null })).toBe(false); |
| 310 | + // Enough samples AND a tight gap -- machine-paced. |
| 311 | + expect(isMachinePacedCadence({ count: 5, medianGapMs: 5 * 60_000 })).toBe(true); |
| 312 | + expect(isMachinePacedCadence({ count: 20, medianGapMs: 60_000 })).toBe(true); |
| 313 | + }); |
| 314 | + |
| 315 | + it("is a boundary at exactly the configured thresholds", () => { |
| 316 | + // Exactly at minSample, exactly under the max gap -- still counts. |
| 317 | + expect(isMachinePacedCadence({ count: 5, medianGapMs: 10 * 60_000 - 1 })).toBe(true); |
| 318 | + // Exactly AT the max gap -- not strictly under, so not machine-paced. |
| 319 | + expect(isMachinePacedCadence({ count: 5, medianGapMs: 10 * 60_000 })).toBe(false); |
| 320 | + }); |
| 321 | +}); |
| 322 | + |
| 323 | +describe("getSubmitterCadence (D1, fail-safe) (#4514)", () => { |
| 324 | + function makeCadenceEnv(createdAts: string[]): Env { |
| 325 | + return { |
| 326 | + DB: { |
| 327 | + prepare: () => ({ |
| 328 | + bind: () => ({ |
| 329 | + all: async () => ({ results: createdAts.map((createdAt) => ({ createdAt })) }), |
| 330 | + }), |
| 331 | + }), |
| 332 | + }, |
| 333 | + } as unknown as Env; |
| 334 | + } |
| 335 | + |
| 336 | + it("returns count 0 / null with no submitter (early return, no DB touch)", async () => { |
| 337 | + expect(await getSubmitterCadence({} as Env, "p", undefined)).toEqual({ count: 0, medianGapMs: null }); |
| 338 | + }); |
| 339 | + |
| 340 | + it("derives cadence from the queried created_at timestamps", async () => { |
| 341 | + const t0 = new Date("2026-01-01T00:00:00.000Z").getTime(); |
| 342 | + const env = makeCadenceEnv([t0, t0 + 5 * 60_000, t0 + 10 * 60_000, t0 + 15 * 60_000, t0 + 20 * 60_000].map((ms) => new Date(ms).toISOString())); |
| 343 | + const cadence = await getSubmitterCadence(env, "p", "farmer99"); |
| 344 | + expect(cadence).toEqual({ count: 5, medianGapMs: 5 * 60_000 }); |
| 345 | + expect(isMachinePacedCadence(cadence)).toBe(true); |
| 346 | + }); |
| 347 | + |
| 348 | + it("fail-safe: degrades to count 0 / null when the query throws, never throws into the caller", async () => { |
| 349 | + const env = { |
| 350 | + DB: { |
| 351 | + prepare: () => ({ |
| 352 | + bind: () => ({ |
| 353 | + all: async () => { |
| 354 | + throw new Error("D1 boom"); |
| 355 | + }, |
| 356 | + }), |
| 357 | + }), |
| 358 | + }, |
| 359 | + } as unknown as Env; |
| 360 | + expect(await getSubmitterCadence(env, "p", "farmer99")).toEqual({ count: 0, medianGapMs: null }); |
| 361 | + }); |
| 362 | + |
| 363 | + it("fail-safe: degrades to count 0 / null when the query returns a malformed result (?? [] fallback)", async () => { |
| 364 | + const env = { |
| 365 | + DB: { |
| 366 | + prepare: () => ({ |
| 367 | + bind: () => ({ |
| 368 | + all: async () => undefined, |
| 369 | + }), |
| 370 | + }), |
| 371 | + }, |
| 372 | + } as unknown as Env; |
| 373 | + expect(await getSubmitterCadence(env, "p", "farmer99")).toEqual({ count: 0, medianGapMs: null }); |
| 374 | + }); |
| 375 | +}); |
| 376 | + |
276 | 377 | // A minimal D1 stub: the first query (.first) returns submitter_stats; the window query (.all) returns the |
277 | 378 | // review_targets rows. Both come off the same prepared-statement stub (the two call sites use .first vs .all). |
278 | 379 | function makeEnv(opts: { statRow: { submissions: number; merged: number; closed: number; manual: number } | null; windowRows: Row[] }): Env { |
|
0 commit comments