Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 115 additions & 12 deletions frontend/__tests__/test/events/stats.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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));
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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=496msroundTo2(0.496)=0.5 → end boundary added → [1, 2]
expect(getKeypressesPerSecond()).toEqual([1, 2]);
});
});
Expand Down
18 changes: 12 additions & 6 deletions frontend/src/ts/test/events/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
70 changes: 65 additions & 5 deletions frontend/src/ts/test/test-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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:`,
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -1222,7 +1282,7 @@ function compareCompletedEvents(
difficulty: ce.difficulty,
duration: ce.testDuration,
funboxes: getActiveFunboxNames().join(","),
version: 1,
version: 5,
// ce: ce as Record<string, unknown>,
// ce2: ce2 as Record<string, unknown>,
},
Expand Down
2 changes: 1 addition & 1 deletion packages/contracts/src/results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
});
Expand Down
Loading