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
14 changes: 14 additions & 0 deletions frontend/__tests__/components/ui/form/InputField.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function makeField(
}),
setValue: vi.fn(),
getMeta: () => ({ hasWarning: false, warnings: [] }),
form: { options: { defaultValues: { [name]: value } } } as any,
} as unknown as AnyFieldApi;
}

Expand Down Expand Up @@ -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(() => <InputField field={() => 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);
});
});
31 changes: 31 additions & 0 deletions frontend/__tests__/test/events/stats.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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());
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/ts/components/ui/form/InputField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -167,9 +167,12 @@ function convertValueToString(input: unknown | undefined): string {
}

function convertStringToValue<T extends unknown | undefined>(
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;
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/ts/test/events/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
Expand Down
Loading