diff --git a/frontend/__tests__/test/events/stats.spec.ts b/frontend/__tests__/test/events/stats.spec.ts index 4f584e34e305..a789dc635957 100644 --- a/frontend/__tests__/test/events/stats.spec.ts +++ b/frontend/__tests__/test/events/stats.spec.ts @@ -31,8 +31,9 @@ vi.mock("../../../src/ts/test/test-words", () => { }; }); +const customTextLimit = { mode: "words" as "words" | "time", value: 0 }; vi.mock("../../../src/ts/test/custom-text", () => ({ - getLimit: () => ({ mode: "words", value: 0 }), + getLimit: () => customTextLimit, })); import { @@ -161,14 +162,14 @@ describe("stats.ts", () => { ]); }); - it("includes end as boundary when far enough from last step", () => { + it("includes end as boundary when endMs % 1000 >= 500ms", () => { logTestEvent("timer", 1000, timer("start", 0)); logTestEvent("timer", 2000, timer("step", 1)); - logTestEvent("timer", 3000, timer("end", 2)); + logTestEvent("timer", 2500, timer("end", 1)); const events = getAllTestEvents(); - // end at testMs 2000, last step at testMs 1000 — gap is 1000 >= 500 - expect(statsTesting.getTimerBoundaries(events)).toEqual([1000, 2000]); + // endMs=1500 → 1500%1000=500ms → roundTo2(0.5)=0.5 → boundary added + expect(statsTesting.getTimerBoundaries(events)).toEqual([1000, 1500]); }); it("skips end when too close to last step", () => { @@ -181,28 +182,50 @@ describe("stats.ts", () => { expect(statsTesting.getTimerBoundaries(events)).toEqual([1000]); }); - it("includes end boundary when gap rounds to 0.5s via roundTo2", () => { - // 496ms gap: roundTo2(0.496) = 0.5 so this should be treated as a 0.5s remainder + it("includes end boundary when endMs % 1000 rounds to 0.5s", () => { + // endMs=1496 → 1496%1000=496ms → roundTo2(0.496)=0.50 → boundary added logTestEvent("timer", 1000, timer("start", 0)); logTestEvent("timer", 2000, timer("step", 1)); logTestEvent("timer", 2496, timer("end", 1)); const events = getAllTestEvents(); - // end testMs=1496, last step testMs=1000 — gap 496ms rounds to 0.50s expect(statsTesting.getTimerBoundaries(events)).toEqual([1000, 1496]); }); - it("skips end boundary when gap rounds below 0.5s via roundTo2", () => { - // 494ms gap: roundTo2(0.494) = 0.49 so this should not be an extra boundary + it("skips end boundary when endMs % 1000 rounds below 0.5s", () => { + // endMs=1494 → 1494%1000=494ms → roundTo2(0.494)=0.49 → no boundary logTestEvent("timer", 1000, timer("start", 0)); logTestEvent("timer", 2000, timer("step", 1)); logTestEvent("timer", 2494, timer("end", 1)); const events = getAllTestEvents(); - // end testMs=1494, last step testMs=1000 — gap 494ms rounds to 0.49s expect(statsTesting.getTimerBoundaries(events)).toEqual([1000]); }); + it("skips end boundary for .49 test even when step fires slightly early (drift)", () => { + // Step fires 5ms early (at 995ms instead of 1000ms). + // Gap = 1490-995 = 495ms — the old gap-based check would have added a boundary + // (roundTo2(0.495)=0.5), but endMs%1000=490ms → roundTo2(0.49)<0.5 → no boundary. + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("timer", 1995, timer("step", 1)); + logTestEvent("timer", 2490, timer("end", 1)); + + const events = getAllTestEvents(); + expect(statsTesting.getTimerBoundaries(events)).toEqual([995]); + }); + + it("includes end boundary for .99 test even when step fires late (drift)", () => { + // Step fires 510ms late (at 1510ms instead of 1000ms). + // Gap = 1990-1510 = 480ms — gap-based check would miss the boundary, + // but endMs%1000=990ms → roundTo2(0.99)>=0.5 → boundary added. + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("timer", 2510, timer("step", 1)); + logTestEvent("timer", 2990, timer("end", 1)); + + const events = getAllTestEvents(); + expect(statsTesting.getTimerBoundaries(events)).toEqual([1510, 1990]); + }); + it("excludes short trailing interval (<500ms) for non-round test duration", () => { // 1.35s test: step at 1s, end at 1.35s — remainder 350ms < 500 logTestEvent("timer", 1000, timer("start", 0)); @@ -246,6 +269,86 @@ describe("stats.ts", () => { // adjusted end = 4000 - 3500 = 500, steps at 1000 and 2000 are past it expect(boundaries).toEqual([500]); }); + + it("skips end boundary when endMs rounds up to whole second", () => { + // endMs=19997: roundTo2(19.997)=20.00 → fractional 0 → no extra bucket + // Legacy CE1 doesn't push extra because stats.time=20.00 has no fractional. + // Naive (endMs % 1000)/1000 = 0.997 → roundTo2 = 1.0 would wrongly add. + logTestEvent("timer", 0, timer("start", 0)); + for (let i = 1; i <= 20; i++) { + logTestEvent("timer", i * 1000 - 10, timer("step", i)); + } + logTestEvent("timer", 19997, timer("end", 20)); + + const events = getAllTestEvents(); + // 20 step boundaries, no end boundary (testSeconds rounds to 20.00) + expect(statsTesting.getTimerBoundaries(events)).toHaveLength(20); + }); + + it("skips end boundary in time mode even when endMs %1000 >= 500ms", () => { + // 120s time test where timer fires step 120 slightly early at ~119.99s. + // Legacy time mode never pushes an extra bucket — CE2 must match by + // skipping the end boundary entirely. + (Config as { mode: string }).mode = "time"; + logTestEvent("timer", 0, timer("start", 0)); + for (let i = 1; i <= 120; i++) { + logTestEvent("timer", i * 1000 - 8, timer("step", i)); + } + logTestEvent("timer", 119994, timer("end", 120)); + + const events = getAllTestEvents(); + const boundaries = statsTesting.getTimerBoundaries(events); + // 120 step boundaries, no end boundary + expect(boundaries).toHaveLength(120); + }); + + it("skips end boundary in custom time mode", () => { + (Config as { mode: string }).mode = "custom"; + customTextLimit.mode = "time"; + try { + logTestEvent("timer", 0, timer("start", 0)); + for (let i = 1; i <= 30; i++) { + logTestEvent("timer", i * 1000, timer("step", i)); + } + logTestEvent("timer", 29994, timer("end", 30)); + + const events = getAllTestEvents(); + expect(statsTesting.getTimerBoundaries(events)).toHaveLength(30); + } finally { + customTextLimit.mode = "words"; + } + }); + + describe("invariant: boundaries.length === Math.round(endMs / 1000)", () => { + // Sanity invariant: for non-timed modes, the number of timer boundaries + // must equal Math.round(testDurationSeconds) — matches the legacy + // keypressCountHistory length contract. Catches drift-induced extra steps + // or off-by-one boundaries. + const cases: { name: string; endMs: number }[] = [ + { name: ".00 (exactly 5s)", endMs: 5000 }, + { name: ".49 (5.49s)", endMs: 5490 }, + { name: ".50 (5.50s)", endMs: 5500 }, + { name: ".99 (5.99s)", endMs: 5990 }, + { name: "sub-1s .49", endMs: 490 }, + { name: "sub-1s .99", endMs: 990 }, + ]; + + for (const { name, endMs } of cases) { + it(`holds for ${name}`, () => { + logTestEvent("timer", 0, timer("start", 0)); + const fullSeconds = Math.floor(endMs / 1000); + for (let i = 1; i <= fullSeconds; i++) { + logTestEvent("timer", i * 1000, timer("step", i)); + } + logTestEvent("timer", endMs, timer("end", fullSeconds)); + + const events = getAllTestEvents(); + const boundaries = statsTesting.getTimerBoundaries(events); + const roundedDuration = Math.round(endMs / 1000); + expect(boundaries).toHaveLength(roundedDuration); + }); + } + }); }); describe("getStartToFirstKeypressMs", () => { @@ -535,7 +638,7 @@ describe("stats.ts", () => { logTestEvent("input", 2400, input({ charIndex: 2 })); logTestEvent("timer", 2496, timer("end", 1)); - // gap = 496ms, roundTo2(0.496) = 0.5 → end boundary added → [1, 2] + // endMs=1496, 1496%1000=496ms → roundTo2(0.496)=0.5 → end boundary added → [1, 2] expect(getKeypressesPerSecond()).toEqual([1, 2]); }); }); diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index 8972823aadd0..d544c053d21c 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -47,12 +47,18 @@ function getTimerBoundaries(events: TestEvent[]): number[] { } if (endMs !== undefined) { - const last = boundaries[boundaries.length - 1]; - // Must match the legacy condition: Math.round(roundTo2(testSeconds) % 1) >= 0.5. - // A naive ">= 500ms" check disagrees when the gap is in [495ms, 500ms) — roundTo2 - // rounds that fraction up to 0.50s and the legacy system pushes an extra bucket, - // but a raw millisecond comparison would skip the boundary. - if (roundTo2((endMs - (last ?? 0)) / 1000) >= 0.5) { + // Timed tests never push an extra bucket (legacy skips setLastSecondNotRound + // for time mode). For other modes, mirror the legacy condition exactly: + // Math.round(roundTo2(testSeconds) % 1) >= 0.5. The rounding must happen at + // the SECONDS level — taking the fractional ms first and rounding can give + // a different answer when the rounded seconds carry into the next integer + // (e.g. endMs=19997: roundTo2(19.997)=20.00 → no bucket, but 997ms/1000 + // rounds to 0.5 → wrongly adds a bucket). + const isTimedTest = + Config.mode === "time" || + (Config.mode === "custom" && CustomText.getLimit().mode === "time"); + const testSeconds = roundTo2(endMs / 1000); + if (!isTimedTest && Math.round(testSeconds % 1) >= 0.5) { boundaries.push(endMs); } } diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 18d51ac36017..88e18ae8bc36 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -976,7 +976,13 @@ function compareCompletedEvents( if (diffs.length === 0) { console.debug(`Completed event match on key charStats:`, a); } else { - notMatching.push(`charStats (${diffs.join(", ")})`); + if (TestWords.words.list.length <= 25) { + notMatching.push( + `charStats (${diffs.join(", ")}) words '${TestWords.words.list.join("_")}' input '${TestInput.input.getHistory().join("_")}'`, + ); + } else { + notMatching.push(`charStats (${diffs.join(", ")})`); + } mismatchedKeys.push("charStats"); console.error(`Completed event mismatch on key charStats:`, a, b); } @@ -1111,17 +1117,32 @@ function compareCompletedEvents( { const a = TestInput.keypressCountHistory; const b = getKeypressesPerSecond(); - if (a.length === b.length && a.every((val, i) => val === b[i])) { + const aTotal = a.reduce((acc, val) => { + if (val === undefined) return acc; + return acc + val; + }, 0); + const bTotal = b.reduce((acc, val) => { + if (val === undefined) return acc; + return acc + val; + }, 0); + if ( + a.length === b.length && + (a.every((val, i) => val === b[i]) || aTotal === bTotal) + ) { console.debug(`Completed event match on key keypressCountHistory:`, a); } else { if (a.length !== b.length) { - notMatching.push(`keypressCountHistory (length differs)`); + notMatching.push( + `keypressCountHistory (length differs ${a.length} vs ${b.length})`, + ); mismatchedKeys.push("keypressCountHistory_length"); console.error( `Completed event length mismatch on key keypressCountHistory: ${a.length} vs ${b.length}`, ); } else { - notMatching.push(`keypressCountHistory (values differ)`); + notMatching.push( + `keypressCountHistory (values differ) (total ${aTotal} vs ${bTotal})`, + ); mismatchedKeys.push("keypressCountHistory"); console.error( `Completed event mismatch on key keypressCountHistory:`, @@ -1154,6 +1175,45 @@ function compareCompletedEvents( } } + { + const dur = (ce2.keyDuration === "toolong" ? [] : ce2.keyDuration).reduce( + (acc, val) => { + if (val === undefined) return acc; + return acc + val; + }, + 0, + ); + const over = ce2.keyOverlap; + const sp = (ce2.keySpacing === "toolong" ? [] : ce2.keySpacing).reduce( + (acc, val) => { + if (val === undefined) return acc; + return acc + val; + }, + 0, + ); + const space = ce2.startToFirstKey + ce2.lastKeyToEnd; + const total = Numbers.roundTo2((space + sp) / 1000); + const delta = Numbers.roundTo2(Math.abs(ce2.testDuration - total)); + if (delta >= 0.1) { + notMatching.push( + `testDuration vs key timings (difference of ${delta} seconds)`, + ); + mismatchedKeys.push("testDuration_keyTimings"); + console.error( + `Completed event mismatch on testDuration vs key timings: testDuration ${ce2.testDuration} vs total key timings ${total}`, + { + testDuration: ce2.testDuration, + keyTimingsTotal: total, + keyDuration: dur, + keyOverlap: over, + keySpacing: sp, + startToFirstKey: ce2.startToFirstKey, + lastKeyToEnd: ce2.lastKeyToEnd, + }, + ); + } + } + if (notMatching.length === 0) { if (ALWAYSREPORT) { showSuccessNotification("Completed events match", { important: true }); @@ -1222,7 +1282,7 @@ function compareCompletedEvents( difficulty: ce.difficulty, duration: ce.testDuration, funboxes: getActiveFunboxNames().join(","), - version: 1, + version: 5, // ce: ce as Record, // ce2: ce2 as Record, }, diff --git a/packages/contracts/src/results.ts b/packages/contracts/src/results.ts index aa4dfa0ff2c6..96ad178a2177 100644 --- a/packages/contracts/src/results.ts +++ b/packages/contracts/src/results.ts @@ -75,7 +75,7 @@ export const ReportCompletedEventMismatchRequestSchema = z.object({ difficulty: DifficultySchema.optional(), duration: z.number().max(200).optional(), funboxes: z.string().max(100).optional(), - version: z.literal(1), + version: z.literal(5), // ce: z.record(z.unknown()), // ce2: z.record(z.unknown()), });