diff --git a/frontend/__tests__/components/ui/form/InputField.spec.tsx b/frontend/__tests__/components/ui/form/InputField.spec.tsx index aaa221edf9d1..95f269accc50 100644 --- a/frontend/__tests__/components/ui/form/InputField.spec.tsx +++ b/frontend/__tests__/components/ui/form/InputField.spec.tsx @@ -31,6 +31,7 @@ function makeField( }), setValue: vi.fn(), getMeta: () => ({ hasWarning: false, warnings: [] }), + form: { options: { defaultValues: { [name]: value } } } as any, } as unknown as AnyFieldApi; } @@ -159,4 +160,17 @@ describe("InputField", () => { expect(screen.getByRole("spinbutton").getAttribute("value")).toBeNull(); }); + + it("handles empty then numeric input with default value", async () => { + const field = makeField("age", 5); + render(() => field} type="number" />); + const input = screen.getByRole("spinbutton"); + + fireEvent.input(input, { target: { value: "" } }); + fireEvent.blur(input); + + // Set to 6 + fireEvent.input(input, { target: { value: "6" } }); + expect(field.handleChange).toHaveBeenCalledWith(6); + }); }); diff --git a/frontend/__tests__/test/events/stats.spec.ts b/frontend/__tests__/test/events/stats.spec.ts index 862fd8a8db06..2f0a3a1c7479 100644 --- a/frontend/__tests__/test/events/stats.spec.ts +++ b/frontend/__tests__/test/events/stats.spec.ts @@ -450,6 +450,21 @@ describe("stats.ts", () => { ]); }); + it("derives boundaries from wall-clock when a suspended tab froze the timer", () => { + // A backgrounded tab freezes the rAF-driven timer, so no step events fire + // and Time.get() stays 0 — but ~10s of real wall-clock elapsed. testMs + // (performance.now-based) still reflects it, so we get 10 boundaries. + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("keydown", 1050, keyDown()); + logTestEvent("keyup", 1150, keyUp()); + logTestEvent("timer", 11000, timer("end", 0)); + + const eventLog = buildEventLog(); + expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([ + 1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000, 10000, + ]); + }); + it("appends fractional tail for non-timed test with .5s+ remainder", () => { logTestEvent("timer", 1000, timer("start", 0)); logTestEvent("timer", 4500, timer("end", 3)); @@ -701,6 +716,22 @@ describe("stats.ts", () => { expect(getAfkDuration(buildEventLog())).toBe(1); }); + it("counts frozen-tab seconds as AFK when the end event's timer stalled at 0", () => { + // Regression: a suspended tab freezes the timer (Time.get() stuck at 0), + // so the end event reports timer:0 despite ~10s of real elapsed time. + // Deriving buckets from testMs means the idle seconds are still counted + // as AFK instead of collapsing to 0 (which leaked into typed-time/XP). + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("keydown", 1050, keyDown()); + logTestEvent("input", 1100, input()); + logTestEvent("keyup", 1150, keyUp()); + // frozen for the rest of the test; end reports the stalled timer:0 + logTestEvent("timer", 11000, timer("end", 0)); + + // 10 buckets, only the first has activity → 9 idle seconds + expect(getAfkDuration(buildEventLog())).toBe(9); + }); + it("returns 0 when all intervals have keydowns", () => { logTestEvent("timer", 1000, timer("start", 0)); logTestEvent("keydown", 1200, keyDown()); diff --git a/frontend/src/ts/components/ui/form/InputField.tsx b/frontend/src/ts/components/ui/form/InputField.tsx index ad080d184cab..75074d3b5071 100644 --- a/frontend/src/ts/components/ui/form/InputField.tsx +++ b/frontend/src/ts/components/ui/form/InputField.tsx @@ -86,7 +86,7 @@ export function InputField(props: { }} onInput={(e) => { const value: unknown = convertStringToValue( - props.field().state.value, + props.field(), e.target.value, ); props.field().handleChange(value); @@ -167,9 +167,12 @@ function convertValueToString(input: unknown | undefined): string { } function convertStringToValue( - defaultValue: T, + field: AnyFieldApi, newValue: string, ): T | undefined { + const defaultValue: unknown = + // oxlint-disable-next-line typescript/no-unsafe-member-access + field.form.options.defaultValues?.[field.name]; if (defaultValue === undefined || defaultValue === null) return newValue as T; if (newValue === "") return undefined; if (typeof defaultValue === "number") return Number.parseFloat(newValue) as T; diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index 0ae41d6dd6fd..3dcd0af97683 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -56,9 +56,12 @@ export function getTimerBoundaries(eventLog: EventLog): number[] { if (event.type !== "timer") continue; if (event.data.event === "end") { endMs = event.testMs; - // end event's `timer` field is Time.get() at finish — the canonical - // tick count, immune to step-event drift/early-fire artifacts - tickCount = event.data.timer; + // Derive tick count from wall-clock (testMs) rather than the end event's + // Time.get(). The rAF-driven timer freezes on a suspended/backgrounded + // tab, so Time.get() can undercount real elapsed seconds (it stays 0 for + // a fully-frozen tab) — testMs, from performance.now(), always reflects + // true wall-clock. For healthy/mildly-stalled tests the two agree. + tickCount = Math.floor(endMs / 1000); } } if (endMs === undefined) return [];