diff --git a/frontend/__tests__/test/events/helpers.spec.ts b/frontend/__tests__/test/events/helpers.spec.ts index 6cd541f717b9..70c8c98e5659 100644 --- a/frontend/__tests__/test/events/helpers.spec.ts +++ b/frontend/__tests__/test/events/helpers.spec.ts @@ -21,7 +21,7 @@ let wordIndex = 0; function insert( chars: string, inputType: InsertInputType = "insertText", - overrides: Partial<{ inputStopped: boolean }> = {}, + overrides: { inputStopped?: true } = {}, ): InputEvent[] { return [...chars].map((char) => { nextMs += 10; @@ -35,8 +35,6 @@ function insert( inputType, data: char, correct: true, - isCompositionEnding: false, - inputStopped: false, ...overrides, }, }; @@ -278,8 +276,6 @@ describe("getInputFromDom", () => { charIndex: 2, wordIndex: 0, correct: true, - isCompositionEnding: false, - inputStopped: false, inputValue: "abc", }, }, @@ -294,8 +290,6 @@ describe("getInputFromDom", () => { charIndex: 3, wordIndex: 0, correct: true, - isCompositionEnding: false, - inputStopped: false, }, }, ]; @@ -324,8 +318,6 @@ describe("findInputValueMismatches", () => { charIndex: 0, wordIndex: 0, correct: true, - isCompositionEnding: false, - inputStopped: false, inputValue: "a", }, }, @@ -339,8 +331,6 @@ describe("findInputValueMismatches", () => { charIndex: 1, wordIndex: 0, correct: true, - isCompositionEnding: false, - inputStopped: false, inputValue: "ab", }, }, @@ -360,8 +350,6 @@ describe("findInputValueMismatches", () => { charIndex: 0, wordIndex: 0, correct: true, - isCompositionEnding: false, - inputStopped: false, inputValue: "DIFFERENT", }, }, diff --git a/frontend/src/ts/commandline/lists.ts b/frontend/src/ts/commandline/lists.ts index 21345d8bd27f..f579bb2a44a2 100644 --- a/frontend/src/ts/commandline/lists.ts +++ b/frontend/src/ts/commandline/lists.ts @@ -40,6 +40,8 @@ import { showFpsCounter, } from "../components/layout/overlays/FpsCounter"; import { applyConfigFromJson } from "../config/lifecycle"; +import { getAllTestEvents } from "../test/events/data"; +import * as TestWords from "../test/test-words"; const challengesPromise = JSONData.getChallengeList(); challengesPromise @@ -304,6 +306,28 @@ export const commands: CommandsSubgroup = { }); }, }, + { + id: "copyResultData", + display: "Copy result data", + alias: "stats events", + icon: "fa-cog", + visible: false, + exec: async (): Promise => { + navigator.clipboard + .writeText( + JSON.stringify({ + events: getAllTestEvents(), + words: TestWords.words.list, + }), + ) + .then(() => { + showSuccessNotification("Copied to clipboard"); + }) + .catch((e: unknown) => { + showErrorNotification("Failed to copy to clipboard", { error: e }); + }); + }, + }, { id: "fpsCounter", display: "FPS counter...", diff --git a/frontend/src/ts/components/dev/DevTools.tsx b/frontend/src/ts/components/dev/DevTools.tsx index 189a0a825a98..8e72594a021f 100644 --- a/frontend/src/ts/components/dev/DevTools.tsx +++ b/frontend/src/ts/components/dev/DevTools.tsx @@ -13,6 +13,11 @@ if (import.meta.env.DEV) { default: m.DevOptionsModal, })), ); + const LazyTestDataPreviewModal = lazy(async () => + import("../modals/TestDataPreviewModal").then((m) => ({ + default: m.TestDataPreviewModal, + })), + ); const LazySolidDevtoolsOverlay = lazy(async () => import("@solid-devtools/overlay").then((m) => ({ @@ -33,6 +38,7 @@ if (import.meta.env.DEV) { + ); diff --git a/frontend/src/ts/components/modals/DevOptionsModal.tsx b/frontend/src/ts/components/modals/DevOptionsModal.tsx index d67af0d71692..9548ea09a7fc 100644 --- a/frontend/src/ts/components/modals/DevOptionsModal.tsx +++ b/frontend/src/ts/components/modals/DevOptionsModal.tsx @@ -166,6 +166,11 @@ export function DevOptionsModal(): JSXElement { label: () => "Disable Slow Timer Fail", onClick: disableSlowTimerFail, }, + { + icon: "fa-vials", + label: () => "Test Data Preview", + onClick: () => showModal("TestDataPreview"), + }, ]; const addDebugInboxItem = (rewardType: "xp" | "badge" | "none"): void => { diff --git a/frontend/src/ts/components/modals/TestDataPreviewModal.tsx b/frontend/src/ts/components/modals/TestDataPreviewModal.tsx new file mode 100644 index 000000000000..74819d38e9d4 --- /dev/null +++ b/frontend/src/ts/components/modals/TestDataPreviewModal.tsx @@ -0,0 +1,1434 @@ +import { + createEffect, + createMemo, + createSignal, + For, + JSXElement, + onCleanup, + Show, + untrack, +} from "solid-js"; + +import type { + InputEventNoMs, + TestEvent, + TestEventType, +} from "../../test/events/types"; + +import { hideModal } from "../../states/modals"; +import { getInputFromDom } from "../../test/events/helpers"; +import { cn } from "../../utils/cn"; +import { AnimatedModal } from "../common/AnimatedModal"; +import { Button } from "../common/Button"; + +type TestContext = { + events: TestEvent[]; + words: string[]; +}; + +type Stage = "input" | "preview"; + +const EVENT_TYPES: TestEventType[] = [ + "input", + "keydown", + "keyup", + "timer", + "composition", +]; + +const TIMELINE_TRACK_HEIGHT = 8; +const TIMELINE_TRACK_GAP = 2; +const TIMELINE_LANE_GAP = 6; +const TIMELINE_PADDING_MS = 125; + +const TYPE_BG: Record = { + keydown: "bg-text", + keyup: "bg-error", + input: "bg-main", + timer: "bg-sub", + composition: "bg-sub-alt", +}; + +type TimelineSegment = { + start: number; + end: number; + kind: "bar" | "dot"; + type: TestEventType; + label?: string; + topPx: number; + bg?: string; +}; + +type RawSegment = Omit; + +function buildLanes( + events: TestEvent[], + visible: Set, +): { segments: TimelineSegment[]; totalHeight: number } { + const byType = new Map(); + for (const t of EVENT_TYPES) byType.set(t, []); + + const pairKeys = visible.has("keydown") && visible.has("keyup"); + const pendingDown = new Map(); + + for (const e of events) { + if (e.type === "keydown") { + if (pairKeys) { + pendingDown.set(e.data.code, e.testMs); + } else if (visible.has("keydown")) { + byType.get("keydown")?.push({ + start: e.testMs, + end: e.testMs, + kind: "dot", + type: "keydown", + label: e.data.code, + }); + } + } else if (e.type === "keyup") { + if (pairKeys) { + const start = pendingDown.get(e.data.code); + if (start !== undefined) { + byType.get("keydown")?.push({ + start, + end: e.testMs, + kind: "bar", + type: "keydown", + label: e.data.code, + }); + pendingDown.delete(e.data.code); + } else { + byType.get("keyup")?.push({ + start: e.testMs, + end: e.testMs, + kind: "dot", + type: "keyup", + label: e.data.code, + }); + } + } else if (visible.has("keyup")) { + byType.get("keyup")?.push({ + start: e.testMs, + end: e.testMs, + kind: "dot", + type: "keyup", + label: e.data.code, + }); + } + } else if (visible.has(e.type)) { + const seg: RawSegment = { + start: e.testMs, + end: e.testMs, + kind: "dot", + type: e.type, + }; + if (e.type === "input" && e.data.inputType.startsWith("delete")) { + seg.bg = "bg-error"; + } + byType.get(e.type)?.push(seg); + } + } + + if (pairKeys) { + for (const [code, start] of pendingDown) { + byType.get("keydown")?.push({ + start, + end: start, + kind: "dot", + type: "keydown", + label: code, + }); + } + } + + const segments: TimelineSegment[] = []; + let y = 0; + let firstLane = true; + + for (const t of EVENT_TYPES) { + const segs = byType.get(t) ?? []; + if (segs.length === 0) continue; + if (!firstLane) y += TIMELINE_LANE_GAP; + firstLane = false; + + const sorted = [...segs].sort((a, b) => a.start - b.start); + let tracksUsed = 1; + + if (t === "keydown") { + const trackEnds: number[] = []; + for (const seg of sorted) { + let track = trackEnds.findIndex((end) => end < seg.start); + if (track === -1) { + track = trackEnds.length; + trackEnds.push(seg.end); + } else { + trackEnds[track] = seg.end; + } + segments.push({ + ...seg, + topPx: y + track * (TIMELINE_TRACK_HEIGHT + TIMELINE_TRACK_GAP), + }); + } + tracksUsed = Math.max(1, trackEnds.length); + } else { + for (const seg of sorted) { + segments.push({ ...seg, topPx: y }); + } + } + + y += + tracksUsed * TIMELINE_TRACK_HEIGHT + + (tracksUsed - 1) * TIMELINE_TRACK_GAP; + } + + return { segments, totalHeight: Math.max(y, TIMELINE_TRACK_HEIGHT) }; +} + +function parseContext(raw: string): TestContext { + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== "object") { + throw new Error("Expected an object"); + } + if (parsed === null) { + throw new Error("Expected an object, got null"); + } + if (!("events" in parsed) || !("words" in parsed)) { + throw new Error("Expected { events: TestEvent[], words: string[] }"); + } + if (typeof parsed.words === "string") { + parsed.words = parsed.words.split(" "); + } + if (!Array.isArray((parsed as TestContext).events)) { + throw new Error("Expected { events: TestEvent[], words: string[] }"); + } + if (!Array.isArray((parsed as TestContext).words)) { + throw new Error("Expected { events: TestEvent[], words: string[] }"); + } + return parsed as TestContext; +} + +function visualizeWhitespace(s: string): string { + return s.replace(/ /g, "␣").replace(/\t/g, "→").replace(/\n/g, "↵"); +} + +function inputsPerWord(events: TestEvent[], wordCount: number): string[] { + const buckets = new Map(); + for (const e of events) { + if (e.type !== "input") continue; + const bucket = buckets.get(e.data.wordIndex) ?? []; + bucket.push(e); + buckets.set(e.data.wordIndex, bucket); + } + return Array.from({ length: wordCount }, (_, i) => + getInputFromDom(buckets.get(i) ?? []), + ); +} + +export function TestDataPreviewModal(): JSXElement { + const [stage, setStage] = createSignal("input"); + const [raw, setRaw] = createSignal(""); + const [ctx, setCtx] = createSignal(null); + const [err, setErr] = createSignal(null); + + const reset = (): void => { + setStage("input"); + setRaw(""); + setCtx(null); + setErr(null); + }; + + const onShow = (): void => { + try { + const parsed = parseContext(raw()); + setCtx(parsed); + setErr(null); + setStage("preview"); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } + }; + + return ( + + +
+ + +
{err()}
+
+
+
+
+
+ + setStage("input")} + /> + +
+ ); +} + +function PreviewContent(props: { + ctx: TestContext; + onBack: () => void; +}): JSXElement { + const maxMs = untrack(() => + Math.ceil( + props.ctx.events.reduce((m, e) => (e.testMs > m ? e.testMs : m), 0), + ), + ); + const [videoUrl, setVideoUrl] = createSignal(null); + const [videoEl, setVideoEl] = createSignal( + undefined, + ); + const [videoDurationMs, setVideoDurationMs] = createSignal( + null, + ); + + type SyncKind = "start" | "end"; + type Mark = { id: string; videoMs: number; sync?: SyncKind }; + + let nextMarkSerial = 0; + const generateMarkId = (): string => `mark-${++nextMarkSerial}`; + + const [marks, setMarks] = createSignal([]); + // eventIndex (index into props.ctx.events) → mark id + const [eventToMark, setEventToMark] = createSignal>( + {}, + ); + + const syncStartMark = createMemo(() => + marks().find((m) => m.sync === "start"), + ); + const syncEndMark = createMemo(() => marks().find((m) => m.sync === "end")); + + const eventIndexForMark = (id: string): number | undefined => { + const assignments = eventToMark(); + for (const [k, v] of Object.entries(assignments)) { + if (v === id) return Number(k); + } + return undefined; + }; + + const syncStartEvent = createMemo(() => { + const m = syncStartMark(); + if (m === undefined) return undefined; + const idx = eventIndexForMark(m.id); + if (idx === undefined) return undefined; + return props.ctx.events[idx]; + }); + const syncEndEvent = createMemo(() => { + const m = syncEndMark(); + if (m === undefined) return undefined; + const idx = eventIndexForMark(m.id); + if (idx === undefined) return undefined; + return props.ctx.events[idx]; + }); + + const isSyncable = createMemo( + () => syncStartEvent() !== undefined && syncEndEvent() !== undefined, + ); + const [syncEnabled, setSyncEnabled] = createSignal(false); + const isSynced = createMemo(() => isSyncable() && syncEnabled()); + + createEffect(() => { + if (!isSyncable() && syncEnabled()) setSyncEnabled(false); + }); + + const toggleSync = (): void => { + if (!isSyncable()) return; + setSyncEnabled(!syncEnabled()); + }; + + const videoMapping = createMemo((): { startEff: number; slope: number } => { + if (!isSynced()) return { startEff: 0, slope: 1 }; + const sM = syncStartMark() as Mark; + const eM = syncEndMark() as Mark; + const sE = syncStartEvent() as TestEvent; + const eE = syncEndEvent() as TestEvent; + const a = sE.testMs; + const b = eE.testMs; + if (b === a) return { startEff: sM.videoMs, slope: 1 }; + const slope = (eM.videoMs - sM.videoMs) / (b - a); + return { startEff: sM.videoMs - a * slope, slope }; + }); + + const testMsToVideoMs = (testMs: number): number => { + const { startEff, slope } = videoMapping(); + return startEff + testMs * slope; + }; + + const videoMsToTestMs = (videoMs: number): number => { + const { startEff, slope } = videoMapping(); + if (slope === 0) return 0; + return (videoMs - startEff) / slope; + }; + + const videoBarRange = createMemo(() => { + if (!isSynced()) return undefined; + const dur = videoDurationMs(); + if (dur === null) return undefined; + const start = videoMsToTestMs(0); + const end = videoMsToTestMs(dur); + return { start, end }; + }); + + const timelineMarks = createMemo( + (): { testMs: number; label: string; sync?: SyncKind }[] => { + if (!isSynced()) return []; + return marks().map((m) => ({ + testMs: videoMsToTestMs(m.videoMs), + label: m.sync ?? m.id, + sync: m.sync, + })); + }, + ); + + const driftData = createMemo( + (): { eventTestMs: number; driftMs: number; label: string }[] => { + if (!isSynced()) return []; + const points: { eventTestMs: number; driftMs: number; label: string }[] = + []; + for (const mark of marks()) { + if (mark.sync !== undefined) continue; + const idx = eventIndexForMark(mark.id); + if (idx === undefined) continue; + const event = props.ctx.events[idx]; + if (event === undefined) continue; + const mappedTestMs = videoMsToTestMs(mark.videoMs); + points.push({ + eventTestMs: event.testMs, + driftMs: mappedTestMs - event.testMs, + label: mark.id, + }); + } + points.sort((a, b) => a.eventTestMs - b.eventTestMs); + return points; + }, + ); + + const timelineMinMs = createMemo(() => { + const bar = videoBarRange(); + let min = -TIMELINE_PADDING_MS; + if (bar !== undefined) { + min = Math.min(min, bar.start - TIMELINE_PADDING_MS); + } + return min; + }); + const timelineMaxMs = createMemo(() => { + const bar = videoBarRange(); + let max = maxMs + TIMELINE_PADDING_MS; + if (bar !== undefined) { + max = Math.max(max, bar.end + TIMELINE_PADDING_MS); + } + return max; + }); + + const [currentMs, setCurrentMs] = createSignal(maxMs); + + const visibleEvents = createMemo(() => + props.ctx.events.filter((e) => e.testMs <= currentMs()), + ); + + const finalInputs = untrack(() => + inputsPerWord(props.ctx.events, props.ctx.words.length), + ); + + const liveInputs = createMemo(() => + inputsPerWord(visibleEvents(), props.ctx.words.length), + ); + + const simulatedInput = createMemo(() => + visualizeWhitespace( + liveInputs() + .filter((w) => w.length > 0) + .join(""), + ), + ); + + const currentWordIndex = createMemo(() => { + const ev = visibleEvents(); + for (let i = ev.length - 1; i >= 0; i--) { + const e = ev[i]; + if (e?.type === "input") return e.data.wordIndex; + } + return -1; + }); + + const [visibleTypes, setVisibleTypes] = createSignal>( + new Set(EVENT_TYPES), + ); + + const toggleType = (t: TestEventType): void => { + setVisibleTypes((prev) => { + const next = new Set(prev); + if (next.has(t)) next.delete(t); + else next.add(t); + return next; + }); + }; + + const filteredEvents = createMemo(() => + props.ctx.events.filter((e) => visibleTypes().has(e.type)), + ); + + const filteredEventsWithIndex = createMemo(() => + props.ctx.events + .map((event, originalIndex) => ({ event, originalIndex })) + .filter(({ event }) => visibleTypes().has(event.type)), + ); + + const timelineLanes = createMemo(() => + buildLanes(props.ctx.events, visibleTypes()), + ); + + const currentEventIndex = createMemo(() => { + const events = filteredEvents(); + let idx = -1; + let best = -Infinity; + for (let i = 0; i < events.length; i++) { + const ms = (events[i] as TestEvent).testMs; + if (ms <= currentMs() && ms > best) { + idx = i; + best = ms; + } + } + return idx; + }); + + let wordsScrollEl: HTMLDivElement | undefined; + let eventsScrollEl: HTMLDivElement | undefined; + + const scrollRowIntoView = ( + container: HTMLDivElement | undefined, + idx: number, + ): void => { + if (container === undefined || idx < 0) return; + const row = container.querySelector(`[data-row="${idx}"]`); + if (row === null) return; + const containerRect = container.getBoundingClientRect(); + const rowRect = row.getBoundingClientRect(); + const stickyHead = + container.querySelector("thead")?.getBoundingClientRect() + .height ?? 0; + if (rowRect.top < containerRect.top + stickyHead) { + container.scrollTop -= containerRect.top + stickyHead - rowRect.top; + } else if (rowRect.bottom > containerRect.bottom) { + container.scrollTop += rowRect.bottom - containerRect.bottom; + } + }; + + createEffect(() => { + scrollRowIntoView(wordsScrollEl, currentWordIndex()); + }); + + createEffect(() => { + scrollRowIntoView(eventsScrollEl, currentEventIndex()); + }); + + const [playing, setPlaying] = createSignal(false); + let rafId: number | undefined; + let lastFrame: number | undefined; + + const stopPlay = (): void => { + if (rafId !== undefined) cancelAnimationFrame(rafId); + rafId = undefined; + lastFrame = undefined; + setPlaying(false); + }; + + const tick = (now: number): void => { + const el = videoEl(); + if (el !== undefined && isSynced() && Number.isFinite(el.currentTime)) { + const next = videoMsToTestMs(el.currentTime * 1000); + if (next >= timelineMaxMs()) { + setCurrentMs(timelineMaxMs()); + stopPlay(); + return; + } + setCurrentMs(next); + rafId = requestAnimationFrame(tick); + return; + } + if (lastFrame === undefined) { + lastFrame = now; + rafId = requestAnimationFrame(tick); + return; + } + const dt = now - lastFrame; + lastFrame = now; + const next = currentMs() + dt; + if (next >= timelineMaxMs()) { + setCurrentMs(timelineMaxMs()); + stopPlay(); + return; + } + setCurrentMs(Math.round(next)); + rafId = requestAnimationFrame(tick); + }; + + const togglePlay = (): void => { + if (playing()) { + stopPlay(); + } else { + if (currentMs() >= timelineMaxMs()) setCurrentMs(timelineMinMs()); + setPlaying(true); + lastFrame = undefined; + rafId = requestAnimationFrame(tick); + } + }; + + onCleanup(stopPlay); + + const step = (delta: number): void => { + stopPlay(); + const next = Math.max( + timelineMinMs(), + Math.min(timelineMaxMs(), currentMs() + delta), + ); + setCurrentMs(next); + }; + + const goToStart = (): void => { + stopPlay(); + setCurrentMs(timelineMinMs()); + }; + + const goToEnd = (): void => { + stopPlay(); + setCurrentMs(timelineMaxMs()); + }; + + const goNextEvent = (): void => { + stopPlay(); + const events = filteredEvents(); + let bestMs: number | null = null; + for (const e of events) { + if (e.testMs > currentMs() && (bestMs === null || e.testMs < bestMs)) { + bestMs = e.testMs; + } + } + setCurrentMs(bestMs ?? timelineMaxMs()); + }; + + const goPrevEvent = (): void => { + stopPlay(); + const events = filteredEvents(); + let bestMs: number | null = null; + for (const e of events) { + if (e.testMs < currentMs() && (bestMs === null || e.testMs > bestMs)) { + bestMs = e.testMs; + } + } + setCurrentMs(bestMs ?? timelineMinMs()); + }; + + const onPickVideo = (e: Event): void => { + const input = e.currentTarget as HTMLInputElement; + const file = input.files?.[0]; + if (file === undefined) return; + const prev = videoUrl(); + if (prev !== null) URL.revokeObjectURL(prev); + setVideoUrl(URL.createObjectURL(file)); + }; + + const clearVideo = (): void => { + const url = videoUrl(); + if (url !== null) URL.revokeObjectURL(url); + setVideoUrl(null); + setVideoDurationMs(null); + }; + + onCleanup(() => { + const url = videoUrl(); + if (url !== null) URL.revokeObjectURL(url); + }); + + createEffect(() => { + const el = videoEl(); + if (el === undefined) return; + if (!isSynced()) return; + const t = testMsToVideoMs(currentMs()) / 1000; + if (!Number.isFinite(t) || t < 0) return; + if (Number.isFinite(el.duration) && t > el.duration) return; + if (Math.abs(el.currentTime - t) > 0.001) { + el.currentTime = t; + } + }); + + createEffect(() => { + if (!isSynced()) return; + const vps = videoPlayState(); + if (vps !== playing()) { + if (vps) { + if (currentMs() >= timelineMaxMs()) setCurrentMs(timelineMinMs()); + setPlaying(true); + lastFrame = undefined; + rafId = requestAnimationFrame(tick); + } else { + stopPlay(); + } + } + }); + + createEffect(() => { + const el = videoEl(); + if (el === undefined) return; + if (!isSynced()) return; + if (playing()) { + void el.play().catch(() => undefined); + } else { + el.pause(); + } + }); + + const addMark = (sync?: SyncKind): void => { + const el = videoEl(); + if (el === undefined) return; + if (sync !== undefined && marks().some((m) => m.sync === sync)) return; + const frameMs = videoFrameTimeMs(); + const currentMsFromEl = el.currentTime * 1000; + const videoMs = frameMs ?? currentMsFromEl; + setMarks([...marks(), { id: generateMarkId(), videoMs, sync }]); + }; + + const removeMark = (id: string): void => { + setMarks(marks().filter((m) => m.id !== id)); + const next: Record = {}; + for (const [k, v] of Object.entries(eventToMark())) { + if (v !== id) next[Number(k)] = v; + } + setEventToMark(next); + }; + + const updateMarkVideoMs = (id: string, videoMs: number): void => { + setMarks(marks().map((m) => (m.id === id ? { ...m, videoMs } : m))); + }; + + const assignMarkToEvent = ( + eventIndex: number, + markId: string | null, + ): void => { + const next: Record = {}; + for (const [k, v] of Object.entries(eventToMark())) { + const ki = Number(k); + if (ki === eventIndex) continue; + if (markId !== null && v === markId) continue; + next[ki] = v; + } + if (markId !== null) next[eventIndex] = markId; + setEventToMark(next); + }; + + const [videoPlayState, setVideoPlayState] = createSignal(false); + const [videoCurrentMs, setVideoCurrentMs] = createSignal(0); + const [videoFrameTimeMs, setVideoFrameTimeMs] = createSignal( + null, + ); + const [videoFps, setVideoFps] = createSignal(30); + const frameMs = (): number => 1000 / videoFps(); + const currentFrameIndex = (): number | null => { + const t = videoFrameTimeMs(); + if (t === null) return null; + return Math.round((t / 1000) * videoFps()); + }; + + createEffect(() => { + const el = videoEl(); + if (el === undefined) return; + type Metadata = { mediaTime: number }; + type RVFCElement = HTMLVideoElement & { + requestVideoFrameCallback?: ( + cb: (now: number, metadata: Metadata) => void, + ) => number; + }; + const rvfcEl = el as RVFCElement; + if (typeof rvfcEl.requestVideoFrameCallback !== "function") return; + let lastMt = -1; + const samples: number[] = []; + const cb = (_now: number, metadata: Metadata): void => { + const mt = metadata.mediaTime; + setVideoFrameTimeMs(mt * 1000); + if (lastMt >= 0) { + const dt = mt - lastMt; + if (dt > 0 && dt < 0.1) { + samples.push(1 / dt); + if (samples.length > 30) samples.shift(); + const sorted = [...samples].sort((a, b) => a - b); + const median = sorted[Math.floor(sorted.length / 2)]; + if (median !== undefined) setVideoFps(median); + } + } + lastMt = mt; + rvfcEl.requestVideoFrameCallback?.(cb); + }; + rvfcEl.requestVideoFrameCallback(cb); + }); + + const seekVideoMs = (videoMs: number): void => { + const el = videoEl(); + if (el === undefined) return; + const dur = Number.isFinite(el.duration) ? el.duration : videoMs / 1000; + el.currentTime = Math.max(0, Math.min(dur, videoMs / 1000)); + }; + + const toggleVideoPlay = (): void => { + const el = videoEl(); + if (el === undefined) return; + if (el.paused) { + void el.play().catch(() => undefined); + } else { + el.pause(); + } + }; + + const videoStepFrame = (delta: number): void => { + const el = videoEl(); + if (el === undefined) return; + el.pause(); + const t = el.currentTime + (delta * frameMs()) / 1000; + el.currentTime = Math.max( + 0, + Math.min(Number.isFinite(el.duration) ? el.duration : t, t), + ); + }; + + return ( +
+ +
+ SYNCED — video locked to timeline +
+
+
+
+ +
+
+
Time
+
+ {currentMs().toFixed(2)} / {maxMs} ms +
+
+
+
+ 0}> + + + + {/* setCurrentMs(Number(e.currentTarget.value))} + class="w-full" + /> */} +
+ +
+
+
Video
+ + +
+ 0}> +
+ + {(mark) => { + const assignedIdx = (): number | undefined => + eventIndexForMark(mark.id); + return ( +
+ {mark.sync ?? mark.id} + + updateMarkVideoMs( + mark.id, + Number(e.currentTarget.value), + ) + } + class="w-24 rounded bg-bg p-1 text-text" + /> + + {assignedIdx() !== undefined + ? `→ event #${assignedIdx()}` + : "(unassigned)"} + + +
+ ); + }} +
+
+
+ + + seekVideoMs(Number(e.currentTarget.value))} + class="w-full" + /> +
+
+
+
+ +
+
Simulated input
+
+ {simulatedInput()} +
+
+ +
+
Words
+
(wordsScrollEl = el)} + class="bg-bg-secondary max-h-64 overflow-auto rounded" + > + + + + + + + + + + + {(word, i) => ( + + + + + + )} + + +
#targetinput
{i()}{visualizeWhitespace(word)} + {visualizeWhitespace(finalInputs[i()] ?? "")} +
+
+
+ +
+
+
+ Events ({filteredEvents().length}/{props.ctx.events.length}) +
+
+ + {(t) => ( +
+
+
(eventsScrollEl = el)} + class="bg-bg-secondary max-h-96 overflow-auto rounded" + > + + + + + + + + + + + + {({ event, originalIndex }, i) => ( + currentMs() && "opacity-40", + )} + > + + + + + + )} + + +
timetypemarkdata
+ {event.testMs.toFixed(2)} + {event.type} + + + {JSON.stringify(event.data)} +
+
+
+
+ ); +} + +function Timeline(props: { + segments: TimelineSegment[]; + totalHeight: number; + minMs: number; + maxMs: number; + currentMs: number; + onSeek: (ms: number) => void; + videoBar?: { start: number; end: number }; + marks?: { testMs: number; label: string; sync?: "start" | "end" }[]; +}): JSXElement { + const range = (): number => Math.max(props.maxMs - props.minMs, 1); + const scale = (ms: number): number => ((ms - props.minMs) / range()) * 100; + const dotSize = 4; + const dotOffset = (TIMELINE_TRACK_HEIGHT - dotSize) / 2; + const segmentYOffset = (): number => + props.videoBar !== undefined + ? TIMELINE_TRACK_HEIGHT + TIMELINE_LANE_GAP + : 0; + const adjustedHeight = (): number => props.totalHeight + segmentYOffset(); + + const [zoom, setZoom] = createSignal(1); + let scrollEl: HTMLDivElement | undefined; + let containerEl: HTMLDivElement | undefined; + + const seekFromPointer = (clientX: number): void => { + if (containerEl === undefined) return; + const rect = containerEl.getBoundingClientRect(); + const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + props.onSeek(Math.round(props.minMs + pct * range())); + }; + + const onPointerDown = (e: PointerEvent): void => { + e.preventDefault(); + (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); + seekFromPointer(e.clientX); + }; + + const onPointerMove = (e: PointerEvent): void => { + const el = e.currentTarget as HTMLDivElement; + if (!el.hasPointerCapture(e.pointerId)) return; + seekFromPointer(e.clientX); + }; + + const onPointerUp = (e: PointerEvent): void => { + const el = e.currentTarget as HTMLDivElement; + if (el.hasPointerCapture(e.pointerId)) { + el.releasePointerCapture(e.pointerId); + } + }; + + const onWheel = (e: WheelEvent): void => { + e.preventDefault(); + if (scrollEl === undefined) return; + const rect = scrollEl.getBoundingClientRect(); + const viewX = e.clientX - rect.left; + const contentX = viewX + scrollEl.scrollLeft; + const oldZoom = zoom(); + const factor = e.deltaY > 0 ? 1 / 1.2 : 1.2; + const newZoom = Math.max(1, Math.min(50, oldZoom * factor)); + if (newZoom === oldZoom) return; + setZoom(newZoom); + requestAnimationFrame(() => { + if (scrollEl === undefined) return; + const newContentX = contentX * (newZoom / oldZoom); + scrollEl.scrollLeft = newContentX - viewX; + }); + }; + + return ( +
(scrollEl = el)} + class="relative w-full overflow-x-auto" + onWheel={onWheel} + > +
(containerEl = el)} + class="relative cursor-ew-resize touch-none overflow-hidden bg-bg select-none" + style={{ + height: `${adjustedHeight()}px`, + width: `${zoom() * 100}%`, + }} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerCancel={onPointerUp} + > + + {(bar) => ( +
+ )} +
+ + {(seg) => { + if (seg.kind === "bar") { + return ( +
+ ); + } + return ( +
+ ); + }} +
+ + {(mark) => ( +
+ )} +
+
+
+
+ ); +} + +function DriftChart(props: { + data: { eventTestMs: number; driftMs: number; label: string }[]; + minMs: number; + maxMs: number; +}): JSXElement { + const HEIGHT = 60; + const range = (): number => Math.max(props.maxMs - props.minMs, 1); + const xPct = (ms: number): number => ((ms - props.minMs) / range()) * 100; + const yMaxAbs = (): number => { + let max = 50; + for (const p of props.data) { + if (Math.abs(p.driftMs) > max) max = Math.abs(p.driftMs); + } + return Math.ceil(max); + }; + const yPx = (drift: number): number => + HEIGHT / 2 - (drift / yMaxAbs()) * (HEIGHT / 2 - 4); + + return ( +
+
+ +{yMaxAbs()}ms +
+
+ −{yMaxAbs()}ms +
+
+ drift +
+
+
+ + {(p) => ( +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index 998711ca3c0c..9610924ce769 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -221,20 +221,20 @@ export async function onInsertText(options: OnInsertTextParams): Promise { // (e.g. beforeTestWordChange's updateWordLetters, getWordBurst) see the // completed event in derivation. Otherwise the just-typed trigger char // (space/newline) is missing — visible as missing \n element in zen mode. - const isCommitSpace = charIsSpace && !shouldInsertSpace; logTestEvent("input", now, { inputType: "insertText", data, correct, wordIndex, charIndex: testInput.length, - isCompositionEnding: isCompositionEnding === true, - inputStopped: removeLastChar, + isCompositionEnding: isCompositionEnding ? true : undefined, + inputStopped: removeLastChar ? true : undefined, // when shouldInsertSpace is true, the space char was already inserted via // syncWithInputElement above — only append " " for the advance-space case, // else recorded inputValue ends up with a doubled trailing space. - inputValue: inputValueAfterEvent + (isCommitSpace ? " " : ""), - isCommitSpace: isCommitSpace ? true : undefined, + inputValue: + inputValueAfterEvent + (charIsSpace && !shouldInsertSpace ? " " : ""), + commitsWord: shouldGoToNextWord ? true : undefined, }); // going to next word diff --git a/frontend/src/ts/states/modals.ts b/frontend/src/ts/states/modals.ts index 47b7f82d525b..9c8389cba168 100644 --- a/frontend/src/ts/states/modals.ts +++ b/frontend/src/ts/states/modals.ts @@ -7,6 +7,7 @@ export type ModalId = | "Commandline" | "DevOptions" | "DevInboxPicker" + | "TestDataPreview" | "RegisterCaptcha" | "Alerts" | "SimpleModal" diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index 110dbf3f3cc7..42a35ee45a4a 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -296,7 +296,7 @@ function countCharsForWords( const endsWithCommitSpace = lastEvent !== undefined && lastEvent.data.inputType === "insertText" && - lastEvent.data.isCommitSpace === true; + lastEvent.data.commitsWord === true; const c = countChars( simulatedInput, diff --git a/frontend/src/ts/test/events/types.ts b/frontend/src/ts/test/events/types.ts index 9f113b3d6b12..06afb2b07aad 100644 --- a/frontend/src/ts/test/events/types.ts +++ b/frontend/src/ts/test/events/types.ts @@ -89,11 +89,11 @@ export type InputEventData = inputType: InsertInputType; data: string; correct: boolean; - isCompositionEnding: boolean; - inputStopped: boolean; + isCompositionEnding?: true; + inputStopped?: true; // true when this was a space that advanced to the next word (commit // attempt) rather than being inserted as a literal character - isCommitSpace?: true; + commitsWord?: true; }) | (BaseInputEventData & { inputType: DeleteInputType; diff --git a/frontend/src/ts/test/test-stats.ts b/frontend/src/ts/test/test-stats.ts index 2bec988f6bd9..b11bf388e6a2 100644 --- a/frontend/src/ts/test/test-stats.ts +++ b/frontend/src/ts/test/test-stats.ts @@ -277,10 +277,6 @@ function countChars(final = false): CharCount { Config.mode === "time" || (Config.mode === "custom" && CustomText.getLimit().mode === "time"); - if (final) { - console.log("filan"); - } - for (let i = 0; i < inputWords.length; i++) { const inputWord = inputWords[i] as string; let targetWord = targetWords[i] as string; diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index 849c4e269d7d..3e4af969739e 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -347,7 +347,7 @@ async function _startOld(): Promise { expected: expected, nextDelay: delay, }); - const drift = Math.abs(interval - delay); + const drift = Numbers.roundTo2(Math.abs(interval - delay)); checkIfTimerIsSlow(drift); timer = setTimeout(function () { if (!TestState.isActive) {