diff --git a/docs/AGENT_BROWSER.md b/docs/AGENT_BROWSER.md index 7a2aed4b0789..4ab11e30f025 100644 --- a/docs/AGENT_BROWSER.md +++ b/docs/AGENT_BROWSER.md @@ -47,6 +47,7 @@ for (let i = 0; i < 30 && !window.__mt; i++) await new Promise((r) => setTimeout | `hideModal(id)` | hide a modal by `ModalId` | | `restartTest()` | restart the typing test | | `typeText(text, msPerChar?)` | type through the real input handlers; `"\n"` is a line break, `"\b"` a backspace. Paced with `msPerChar`, else all in one task | +| `compose(updates)` | IME composition through the real input handlers: each update is the composition so far (eg. `["k", "か"]`), the last one commits | | `wordsSnapshot()` | rendered `#words` children as a flat array: word entries `{ index, classes, letters: [{ text, icon?, classes }] }` mixed with sibling entries `{ sibling: className }` (newlines) | | `caretOffset()` | `{ dx, dy }` px from the caret to where it should be; a few px is on | | `checkLayout()` | test page layout problems, `[]` when right: active word inside the words wrapper, caret and words input on it, 3 full lines (unless all lines show), tape anchor at the tape margin. Run it after any change to the test UI | diff --git a/docs/specs/solid-monkeytype/roadmap.md b/docs/specs/solid-monkeytype/roadmap.md index 0a742528795a..1db4358d975d 100644 --- a/docs/specs/solid-monkeytype/roadmap.md +++ b/docs/specs/solid-monkeytype/roadmap.md @@ -11,7 +11,7 @@ Companion to `spec.md`. Every step is one small change, behaviour-preserving apa Each step can start once the previous step's **bold** items are done. Everything listed on one step can run in parallel. - [ ] 1. **H1–H5** housekeeping, then start all at once: **T0** (merge #8507), **C1**, **EP1**, **EP2**, **L1**, **L2**, plus any of U1, U5, C5, EP4, L4–L11. -- [ ] 2. **TH1** test-page harness and **T1** letter → span + `data-ui-*`. Alongside: U2, U3, U4 (after U1), C2, C3. +- [ ] 2. **TH1** test-page harness, then **T1** letter → span + `data-ui-*`. Alongside: U2, U3, U4 (after U1), C2, C3. - [ ] 3. **T2** break joining. Alongside: EP3. - [ ] 4. **T3** caret boundary. Then L3 and L12 can start. - [ ] 5. **T4** word registry private. @@ -26,7 +26,7 @@ Each step can start once the previous step's **bold** items are done. Everything ``` T: test page (CRITICAL PATH, serial) T0 #8507 ─► T1 letter→span + data-ui-* ─► T2 break-joining ─► T3 caret boundary ─► T4 registry private ─► T5 delete test-ui ─► T6 engine split (T6a–c) ─► T7 drop page-mount workaround - TH1 test-page harness (with T0/T1, before T2) ▲ needs EP1, EP2, L1, L2 ▲ needs L3, L4, L5 + TH1 test-page harness (after T0, before T1) ▲ needs EP1, EP2, L1, L2 ▲ needs L3, L4, L5 │ C: config C1 ─► C2×5, C3 areas (parallel) ──────────────────────────► C4 delete Config + configEvent (needs T6) U: user data U1 ─► U2, U3, U4 (parallel); U5 anytime ─────────────────► U6 delete __nonReactive (needs T6a, T6c) @@ -43,7 +43,7 @@ Every track can start now. These are the only steps that wait on another track: | Step | Waits on | Why | |---|---|---| -| **TH1** test-page harness | T0 (H1 first if it extends the app-shell harness) | Builds on Words owning its layout | +| **TH1** test-page harness | T0 | Builds on Words owning its layout | | **T2** break joining | TH1 | The harness is the safety net from here on | | **T5** delete test-ui | EP1, EP2, L1, L2 | test-ui still calls the timer event, focus and funbox; they need new homes first | | **T6b** restart via flag | C1 | Restart reacts to config changes, so the store must update before the event fires | @@ -74,7 +74,7 @@ Fully independent from start to finish: H, U1–U5, C1–C3, C5, EP1, EP2, EP4, | Done | ID | Size | Step | Blocked by | Unblocks | |---|---|---|---|---|---| -| [ ] | TH1 | L · PR | **Test-page harness**: mount the real TestPage and engine in jsdom, type through WordsInput (input/beforeinput/keydown/composition), advance frames, and assert rendered words/letters via `data-ui-*`, engine phase, live stats and the result. Extends the existing render-words or app-shell harness. Grows with the engine: restart flow after T6b, finish → result/spinner after T6c | T0 (can pair with T1) | T2 onward: every later test-page step keeps it green | +| [x] | TH1 | L · PR | **Test-page harness** (`__tests__/__harness__/test-page.tsx`, own `test-page` vitest project): mounts the real TestPage and engine in jsdom on fake timers, types through WordsInput (input/beforeinput/keydown/composition, the driver `__mt` uses), advances frames, and reads word/letter states (semantic names, not classes), engine phase and live stats. Starter suite in `TestPage.page-spec.tsx`; specs on it are named `*.page-spec.tsx`. Grows with the engine: restart flow after T6b, finish → result/spinner after T6c | T0 | T2 onward: every later test-page step keeps it green | ## Track T: test page (critical path) @@ -83,7 +83,7 @@ Serial; each step needs the previous one. Verify each in the browser (`__mt.chec | Done | ID | Size | Step | Blocked by | Removes / unblocks | |---|---|---|---|---|---| | [ ] | T0 | M · PR #8507 | Merge **#8507**: Words triggers its own layout (flush subscription, config/font/resize effects) | none | test-ui's layout calls | -| [ ] | T1 | L · PR | **`` → ``**, plus `data-ui-element` on words/word/typo hints; move the bundled theme and funbox CSS to the new selectors; old selectors keep working. Benchmark letter rendering with the profiling harness | T0 | The made-up element; proves the theme attribute approach before the caret measures letters | +| [ ] | T1 | L · PR | **`` → ``**, plus `data-ui-element` on words/word/typo hints; move the bundled theme and funbox CSS to the new selectors; old selectors keep working. `data-ui-state` values are the harness's state names; switch its word/letter helpers to `data-ui-*`, specs unchanged. Benchmark letter rendering with the profiling harness | T0, TH1 | The made-up element; proves the theme attribute approach before the caret measures letters | | [ ] | T2 | M · PR | **Break joining** computed in the layout pass, rendered as reactive class/style on Word; delete the break-joining module's DOM writes | T1, TH1 | The only outside writes into Word elements | | [ ] | T3 | L · PR | **Caret boundary**: Words is the only thing that drives the caret and pace caret; line-jump/tape-scroll handoff becomes internal to Words; pace-caret timing moves to a DOM-free module outputting a word/letter target; caret and pace caret get `data-ui-element`. Caret and words layout read the typed input from the render store (input writes it alongside the EventLog), not the engine's `getCurrentInput()`. Decide the caret internals here (reactive, imperative handle, or a mix) | T2 | Outside caret callers (test-ui, timer, focus, WordsInput) | | [ ] | T4 | S · commit | **Word registry private** to Words; only the synchronous overflow check stays exported | T3 | The public word-elements bridge | diff --git a/docs/specs/solid-monkeytype/spec.md b/docs/specs/solid-monkeytype/spec.md index 8780c176eeb8..cf5d95260cbf 100644 --- a/docs/specs/solid-monkeytype/spec.md +++ b/docs/specs/solid-monkeytype/spec.md @@ -233,7 +233,7 @@ Good tests check behaviour through the highest available seam: render, act like Seams, highest first: 1. **App-shell harness** (existing): renders the real route tree with fake auth, a test query client and controllable data. Used for routing, guards, loading steps and page lifecycle. Prior art: the app-shell routing spec and the loading-page timing spec. -2. **Test-page harness** (new, the main seam for the test): mounts the *real* TestPage with the real engine, types through WordsInput (input/beforeinput/keydown/composition), advances frames, and checks rendered words/letters (via `data-ui-*`), engine phase, live stats, the finish → result/spinner flow and the restart flow. Built early on the critical path, before the risky steps; every later test-page step keeps it green. Prior art: the render-words harness and the Words, WordsInput and TypoHints specs. +2. **Test-page harness** (`frontend/__tests__/__harness__/test-page.tsx`, usage in its header; the main seam for the test): mounts the *real* TestPage with the real engine, types through WordsInput (input/beforeinput/keydown/composition), advances frames, and checks rendered words/letters (via `data-ui-*`), engine phase, live stats, the finish → result/spinner flow and the restart flow. Built early on the critical path, before the risky steps; every later test-page step keeps it green. Prior art: the render-words harness and the Words, WordsInput and TypoHints specs. 3. **Engine, headless**: commands in, signals out: restart merging and test settings, finish/fail transitions, calculating → result, save status, tags read at finish. No DOM. 4. **Pure modules** (existing pattern): letters/display rules, typo-hint merging, keymap converter, stats as functions of an EventLog, word generation, config setter validation, config metadata. Prior art: the letters/typo-hints specs, the config and config-metadata specs, the stats specs. 5. **Store/state modules**: user collection mutations and `getUser()`, config store apply/reconcile, words-render buffer flush. Assert through their public getters. diff --git a/frontend/__tests__/__harness__/mock-static.ts b/frontend/__tests__/__harness__/mock-static.ts index 19bfdbd34ab8..322532984803 100644 --- a/frontend/__tests__/__harness__/mock-static.ts +++ b/frontend/__tests__/__harness__/mock-static.ts @@ -18,5 +18,8 @@ vi.mock("../../src/ts/utils/json-data", async (importOriginal) => { return { ...original, getLayout: async (name: string) => readJson(`/layouts/${name}.json`), + getLanguage: async (name: string) => readJson(`/languages/${name}.json`), + getCurrentLanguage: async (name: string) => + readJson(`/languages/${name}.json`), }; }); diff --git a/frontend/__tests__/__harness__/test-page.tsx b/frontend/__tests__/__harness__/test-page.tsx new file mode 100644 index 000000000000..e5361c05ce4a --- /dev/null +++ b/frontend/__tests__/__harness__/test-page.tsx @@ -0,0 +1,278 @@ +/** + * Test-page harness: the real TestPage and test engine in jsdom, typed into + * through the words input like a browser does. The main seam for test page + * behaviour: render, type, advance frames, check what's on screen. + * + * Name specs using it `*.page-spec.tsx`: they run in the `test-page` vitest + * project, which has no global DOM query mocks (the engine's vanilla modules + * query the real DOM). Loaded in another project, it throws. + * + * ```tsx + * afterEach(async () => cleanupTestPage()); + * + * it("marks a typo", async () => { + * const page = await renderTestPage({ words: ["the", "fox"] }); + * await page.type("tx"); + * expect(page.word(0).letters()).toEqual([ + * { char: "t", state: "correct" }, + * { char: "h", state: "incorrect" }, + * { char: "e", state: "untyped" }, + * ]); + * expect(page.phase()).toBe("active"); + * }); + * ``` + * + * - `words`: the test shows exactly these words, in order (a custom test). + * Without it the words come from english.json (read from disk), random. + * `config` overrides the default config, eg. `{ mode: "time", time: 15 }`. + * - Fake timers (setTimeout, setInterval, Date, performance and animation + * frames) for the whole spec file, from before anything loads. Animejs is + * real: the test timer and fades run on its rAF loop, so they move with the + * fake clock. `frame()` runs one + * animation frame, `advance(ms)` moves the clock (live stats tick, timed + * tests end). `renderTestPage` resolves once the first test is ready. + * - `type(text)` types through the words input (keydown, beforeinput, input, + * keyup per char; "\b" is backspace, "\n" Enter), `compose(updates)` like + * an IME. Both end with a frame, so the words are rendered: pass + * `{ flush: false }` to check before it (input never renders synchronously). + * - Reading: `word(i).letters()` / `word(i).state`, `phase()`, `liveStats()`. + * Their states are semantic, not css classes, so specs survive markup + * changes. + * - Mocked: firebase (signed out, so a finished test isn't saved), the theme + * controller (indexedDB), the chart (canvas). Stubbed in jsdom: matchMedia + * (nothing matches), scrolling. The engine's debug logs are muted. + * - The page mounts in a router with only the test page, so it reads as the + * test page (typing starts a test only there) and the result's links work. + */ +// static json (layouts, languages) read from disk +import "./mock-static"; +import type { Config as ConfigSchema } from "@monkeytype/schemas/configs"; + +import { cleanup, render } from "@solidjs/testing-library"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RegisteredRouter, + RouterProvider, +} from "@tanstack/solid-router"; +import { vi } from "vitest"; + +import { TestPage } from "../../src/ts/components/pages/test/TestPage"; +import { __testing as configTesting } from "../../src/ts/config/testing"; +import { compose, typeText } from "../../src/ts/dev/typing-driver"; +import { setRouter } from "../../src/ts/router/instance"; +import { + currentLiveStats, + isTestActive, + isResultCalculating, + isTestFinished, + isTestRestarting, +} from "../../src/ts/states/test"; +import * as CustomText from "../../src/ts/test/custom-text"; + +// fake timers, then jsdom gaps: matchMedia (ad controller, on import; no +// query matches) and scrolling (the test keeps itself in view) +vi.hoisted(() => { + // mock-dom's querySelector returns a detached div + if (document.querySelector("body") !== document.body) { + throw new Error( + "The test-page harness needs the real DOM: name the spec *.page-spec.tsx", + ); + } + // before any import: animejs keeps the Date.now and requestAnimationFrame + // it finds on import. Kept for the whole spec file, so it never loses them + vi.useFakeTimers({ + toFake: [ + "setTimeout", + "clearTimeout", + "setInterval", + "clearInterval", + "Date", + "performance", + "requestAnimationFrame", + "cancelAnimationFrame", + ], + }); + Element.prototype.scrollIntoView = () => undefined; + window.scrollTo = () => undefined; + // the engine logs every event and word at debug level + console.debug = () => undefined; + window.matchMedia = (query: string): MediaQueryList => + ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, + }) as unknown as MediaQueryList; +}); +vi.mock("../../src/ts/firebase", () => ({ + app: undefined, + Auth: undefined, + getAuthenticatedUser: () => null, + isAuthAvailable: () => false, +})); +// no canvas in jsdom; the result chart renders nothing +vi.mock("../../src/ts/components/common/ChartJs", () => ({ + ChartJs: () => null, +})); +// opens indexedDB on import +vi.mock("../../src/ts/controllers/theme-controller", () => ({ + randomizeTheme: vi.fn(), +})); + +export type TestPhase = "ready" | "active" | "finished"; + +export type LetterState = + | "untyped" + | "correct" + | "incorrect" + | "extra" + | "dead" + | "deadCorrect"; + +export type WordState = "active" | "typed" | "error" | "upcoming"; + +export type RenderTestPageOptions = { + /** The test shows exactly these words, in order (a custom test). */ + words?: string[]; + config?: Partial; +}; + +export type TestPageHarness = { + /** Types `text`; "\b" is backspace, "\n" Enter. Ends with a frame. */ + type: (text: string, options?: { flush?: boolean }) => Promise; + /** IME composition: each update is the composition so far, the last commits. */ + compose: (updates: string[], options?: { flush?: boolean }) => Promise; + /** Runs one animation frame. */ + frame: () => Promise; + /** Moves the clock by `ms`, running timers and frames on the way. */ + advance: (ms: number) => Promise; + word: (index: number) => { + letters: () => { char: string; state: LetterState }[]; + state: WordState; + }; + phase: () => TestPhase; + liveStats: () => { + wpm?: number; + raw?: number; + acc?: number; + seconds?: number; + }; +}; + +function letterState(letter: Element): LetterState { + const has = (name: string): boolean => letter.classList.contains(name); + if (has("dead")) return has("correct") ? "deadCorrect" : "dead"; + if (has("extra")) return "extra"; + if (has("incorrect")) return "incorrect"; + if (has("correct")) return "correct"; + return "untyped"; +} + +function wordState(word: Element): WordState { + const has = (name: string): boolean => word.classList.contains(name); + if (has("active")) return "active"; + if (has("error")) return "error"; + if (has("typed")) return "typed"; + return "upcoming"; +} + +async function frame(): Promise { + vi.advanceTimersToNextFrame(); + // let the frame's promise chains run + await vi.advanceTimersByTimeAsync(0); +} + +/** Mounts the test page and resolves once its first test is ready. */ +export async function renderTestPage( + options: RenderTestPageOptions = {}, +): Promise { + if (options.words !== undefined) { + // as the custom text modal's simple mode + CustomText.setMode("repeat"); + CustomText.setPipeDelimiter(false); + CustomText.setText(options.words); + CustomText.setLimitMode("word"); + CustomText.setLimitValue(options.words.length); + } + configTesting.replaceConfig({ + ...(options.words !== undefined ? { mode: "custom" } : {}), + ...options.config, + }); + + const root = createRootRoute(); + const router = createRouter({ + routeTree: root.addChildren([ + createRoute({ + getParentRoute: () => root, + path: "/", + staticData: { page: "test" }, + component: TestPage, + }), + ]), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + setRouter(router as unknown as RegisteredRouter); + await router.load(); + const { container } = render(() => ); + + // the restart on mount: fade out, generate words, fade in + for (let i = 0; i < 50 && isTestRestarting(); i++) await frame(); + if (isTestRestarting()) throw new Error("The test page never got ready"); + await frame(); + + const wordElement = (index: number): Element => { + const el = container.querySelector( + `#words .word[data-wordindex="${index}"]`, + ); + if (el === null) throw new Error(`Word ${index} is not rendered`); + return el; + }; + + return { + async type(text, { flush = true } = {}) { + await typeText(text); + if (flush) await frame(); + }, + async compose(updates, { flush = true } = {}) { + compose(updates); + if (flush) await frame(); + }, + frame, + async advance(ms) { + await vi.advanceTimersByTimeAsync(ms); + }, + word: (index) => ({ + letters: () => + [...wordElement(index).querySelectorAll(":scope > letter")].map( + (letter) => ({ + char: letter.textContent ?? "", + state: letterState(letter), + }), + ), + get state() { + return wordState(wordElement(index)); + }, + }), + phase: () => + isTestFinished() ? "finished" : isTestActive() ? "active" : "ready", + liveStats: () => ({ + wpm: currentLiveStats.wpm, + raw: currentLiveStats.raw, + acc: currentLiveStats.acc, + seconds: currentLiveStats.seconds, + }), + }; +} + +/** Unmounts the page, ending its test. */ +export async function cleanupTestPage(): Promise { + // a finishing test can't be stopped: let it finish + for (let i = 0; i < 100 && isResultCalculating(); i++) await frame(); + cleanup(); + setRouter(undefined); +} diff --git a/frontend/__tests__/components/pages/test/TestPage.page-spec.tsx b/frontend/__tests__/components/pages/test/TestPage.page-spec.tsx new file mode 100644 index 000000000000..a56b3fef4e88 --- /dev/null +++ b/frontend/__tests__/components/pages/test/TestPage.page-spec.tsx @@ -0,0 +1,213 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { + cleanupTestPage, + renderTestPage, +} from "../../../__harness__/test-page"; + +afterEach(async () => cleanupTestPage()); + +describe("TestPage", () => { + it("mounts a ready test showing the words", async () => { + const page = await renderTestPage({ words: ["the", "fox"] }); + + expect(page.phase()).toBe("ready"); + expect(page.word(0).state).toBe("active"); + expect(page.word(1).state).toBe("upcoming"); + expect(page.word(1).letters()).toEqual([ + { char: "f", state: "untyped" }, + { char: "o", state: "untyped" }, + { char: "x", state: "untyped" }, + ]); + }); + + it("starts the test on the first letter", async () => { + const page = await renderTestPage({ words: ["the", "fox"] }); + + await page.type("t"); + + expect(page.phase()).toBe("active"); + }); + + it("marks correct and incorrect letters", async () => { + const page = await renderTestPage({ words: ["the", "fox"] }); + + await page.type("tx"); + + expect(page.word(0).letters()).toEqual([ + { char: "t", state: "correct" }, + { char: "h", state: "incorrect" }, + { char: "e", state: "untyped" }, + ]); + }); + + it("adds extra letters past the end of the word", async () => { + const page = await renderTestPage({ words: ["the", "fox"] }); + + await page.type("thee"); + + expect(page.word(0).letters()).toEqual([ + { char: "t", state: "correct" }, + { char: "h", state: "correct" }, + { char: "e", state: "correct" }, + { char: "e", state: "extra" }, + ]); + }); + + it("renders input on the next frame, not synchronously", async () => { + const page = await renderTestPage({ words: ["the", "fox"] }); + + await page.type("t", { flush: false }); + expect(page.word(0).letters()[0]).toEqual({ char: "t", state: "untyped" }); + + await page.frame(); + expect(page.word(0).letters()[0]).toEqual({ char: "t", state: "correct" }); + }); + + describe("space", () => { + it("moves to the next word", async () => { + const page = await renderTestPage({ words: ["the", "fox", "ran"] }); + + await page.type("the "); + + expect(page.word(0).state).toBe("typed"); + expect(page.word(1).state).toBe("active"); + }); + + it("marks a mistyped word as an error", async () => { + const page = await renderTestPage({ words: ["the", "fox", "ran"] }); + + await page.type("th "); + + expect(page.word(0).state).toBe("error"); + expect(page.word(1).state).toBe("active"); + }); + }); + + describe("backspace", () => { + it("deletes the last letter", async () => { + const page = await renderTestPage({ words: ["the", "fox"] }); + + await page.type("tx\b"); + + expect(page.word(0).letters()).toEqual([ + { char: "t", state: "correct" }, + { char: "h", state: "untyped" }, + { char: "e", state: "untyped" }, + ]); + }); + + it("goes back into a mistyped word", async () => { + const page = await renderTestPage({ words: ["the", "fox", "ran"] }); + + await page.type("th \b"); + + expect(page.word(0).state).toBe("active"); + expect(page.word(1).state).toBe("upcoming"); + }); + + it("doesn't go back into a correct word", async () => { + const page = await renderTestPage({ words: ["the", "fox", "ran"] }); + + await page.type("the \b"); + + expect(page.word(0).state).toBe("typed"); + expect(page.word(1).state).toBe("active"); + }); + + it("goes back into a correct word in freedom mode", async () => { + const page = await renderTestPage({ + words: ["the", "fox", "ran"], + config: { freedomMode: true }, + }); + + await page.type("the \b"); + + expect(page.word(0).state).toBe("active"); + }); + }); + + it("stops on a wrong letter with stop on error", async () => { + const page = await renderTestPage({ + words: ["the", "fox"], + config: { stopOnError: "letter" }, + }); + + await page.type("txh"); + + expect(page.word(0).letters()).toEqual([ + { char: "t", state: "correct" }, + { char: "h", state: "correct" }, + { char: "e", state: "untyped" }, + ]); + }); + + it("commits an IME composition", async () => { + const page = await renderTestPage({ words: ["かな", "き"] }); + + await page.compose(["k", "か"]); + + expect(page.phase()).toBe("active"); + expect(page.word(0).letters()).toEqual([ + { char: "か", state: "correct" }, + { char: "な", state: "untyped" }, + ]); + }); + + it("ticks live stats every second", async () => { + const page = await renderTestPage({ words: ["the", "fox", "ran"] }); + + await page.type("the "); + expect(page.liveStats().seconds).toBe(0); + + await page.advance(1000); + + expect(page.liveStats().seconds).toBe(1); + expect(page.liveStats().wpm).toBeGreaterThan(0); + expect(page.liveStats().acc).toBe(100); + }); + + describe("finish", () => { + it("finishes on the last letter of a words test", async () => { + const page = await renderTestPage({ words: ["the", "fox"] }); + + await page.type("the fox"); + // the test fades out before it counts as finished + await page.advance(500); + + expect(page.phase()).toBe("finished"); + }); + + it("finishes when a timed test runs out", async () => { + const page = await renderTestPage({ config: { mode: "time", time: 2 } }); + + await page.type("a"); + await page.advance(1000); + expect(page.phase()).toBe("active"); + + await page.advance(1000); + // the test fades out before it counts as finished + await page.advance(500); + expect(page.phase()).toBe("finished"); + }); + }); + + it("starts each spec on a fresh test", async () => { + // the previous specs left typed, finished tests behind + const page = await renderTestPage({ words: ["the", "fox"] }); + + expect(page.phase()).toBe("ready"); + expect(page.liveStats()).toEqual({ + wpm: undefined, + raw: undefined, + acc: undefined, + seconds: undefined, + }); + expect( + page + .word(0) + .letters() + .map((l) => l.state), + ).toEqual(["untyped", "untyped", "untyped"]); + }); +}); diff --git a/frontend/src/ts/dev/expose.ts b/frontend/src/ts/dev/expose.ts index 612fd7594c21..8ab9e47222f0 100644 --- a/frontend/src/ts/dev/expose.ts +++ b/frontend/src/ts/dev/expose.ts @@ -1,23 +1,20 @@ // Dev-only `window.__mt` handle for poking app internals from the browser console // or browser automation. Loaded via dynamic import behind `import.meta.env.DEV`. -import { getConfig } from "../config/store"; +import type { Command } from "../commandline/types"; +import { Config, getConfig } from "../config/store"; import { setConfig } from "../config/setters"; +import { getInputElement } from "../input/input-element"; import { setPreloading } from "../router/create-router"; import { getRouter } from "../router/instance"; import { navigate } from "../router/navigate"; import { getRoutePage } from "../states/router"; import { hideModal, showModal } from "../states/modals"; +import * as CustomText from "../test/custom-text"; +import { getCurrentInput } from "../test/events/data"; import { restartTest } from "../test/test-logic"; import { quickLogin } from "./quick-login"; import { trackedSignals } from "./signal-tracker"; -import { - caretOffset, - checkLayout, - runCommand, - setCustomText, - typeText, - wordsSnapshot, -} from "./test-helpers"; +import { compose, typeText } from "./typing-driver"; // Modules hot-updated since the page loaded. Kept on window: it survives this // module's own hot update, and the listener registers once per page. @@ -31,6 +28,187 @@ if (window.__mtHotUpdates === undefined) { }); } +type LetterSnapshot = { text: string; icon?: string; classes: string }; +type ChildSnapshot = + | { index: number; classes: string; letters: LetterSnapshot[] } + | { sibling: string }; + +/** The rendered #words children: words with their letters, plus newline siblings. */ +function wordsSnapshot(): ChildSnapshot[] { + const words = document.querySelector("#words"); + if (words === null) return []; + return [...words.children].map((child): ChildSnapshot => { + if (!child.classList.contains("word")) return { sibling: child.className }; + return { + index: Number((child as HTMLElement).dataset["wordindex"]), + classes: child.className, + letters: [...child.querySelectorAll(":scope > letter")].map((letter) => ({ + text: letter.textContent ?? "", + icon: letter.querySelector("i")?.className, + classes: letter.className, + })), + }; + }); +} + +/** + * Px offset of the caret from where it should be: the start edge of the next + * letter to type, or the end edge of the last letter (left/right, mirrored for + * right to left words). Within a few px is on. + */ +function caretOffset(): { dx: number; dy: number } | undefined { + const caret = document.querySelector("#caret"); + const letters = document.querySelectorAll("#words .word.active letter"); + if (caret === null || letters.length === 0) return undefined; + const index = getCurrentInput().length; + const target = (letters[index] ?? letters[letters.length - 1]) as Element; + const targetRect = target.getBoundingClientRect(); + const caretRect = caret.getBoundingClientRect(); + const isRtl = getComputedStyle(target).direction === "rtl"; + const atStart = letters[index] !== undefined; + const targetX = atStart !== isRtl ? targetRect.left : targetRect.right; + return { + dx: Math.round(caretRect.left - targetX), + dy: Math.round(caretRect.top - targetRect.top), + }; +} + +/** Starts a custom test with `text`, as the custom text modal's simple mode. */ +async function setCustomText(text: string): Promise { + const words = text + .replace(/ +/g, " ") + .replace(/( *(\r\n|\r|\n) *)/g, "\n ") + .split(" ") + .filter((word) => word !== ""); + CustomText.setMode("repeat"); + CustomText.setPipeDelimiter(false); + CustomText.setText(words); + CustomText.setLimitMode("word"); + CustomText.setLimitValue(words.length); + setConfig("mode", "custom"); + await restartTest(); +} + +/** + * Layout problems on the test page, empty when it's laid out right: the active + * word inside the words wrapper, the caret and the words input on it, three + * full lines (unless all lines are shown), the tape anchor at the tape margin. + */ +function checkLayout(): string[] { + const wrapper = document.querySelector("#wordsWrapper"); + const words = document.querySelector("#words"); + const active = words?.querySelector(".word.active") ?? null; + if (wrapper === null || words === null || active === null) { + return ["no active word"]; + } + const issues: string[] = []; + const wr = wrapper.getBoundingClientRect(); + const ar = active.getBoundingClientRect(); + const px = Math.round; + + if (ar.top < wr.top - 1 || ar.bottom > wr.bottom + 1) { + issues.push( + `active word ${px(ar.top)}-${px(ar.bottom)} outside wrapper ${px(wr.top)}-${px(wr.bottom)}`, + ); + } + const caret = caretOffset(); + // a few px off is on, more at big font sizes + const caretTolerance = Math.max(4, ar.height * 0.1); + if ( + caret !== undefined && + (Math.abs(caret.dx) > caretTolerance || Math.abs(caret.dy) > caretTolerance) + ) { + issues.push(`caret off by ${caret.dx},${caret.dy}`); + } + const input = getInputElement().getBoundingClientRect(); + if ( + Math.abs(input.top + input.height / 2 - (ar.top + ar.height / 2)) > + ar.height + ) { + issues.push(`words input off the active word by ${px(input.top - ar.top)}`); + } + + if (Config.tapeMode !== "off") { + const margin = wr.left + (wr.width * Config.tapeMargin) / 100; + const letters = active.querySelectorAll("letter"); + const anchor = + Config.tapeMode === "letter" + ? letters[getCurrentInput().length]?.getBoundingClientRect().left + : ar.left; + if (anchor !== undefined && Math.abs(anchor - margin) > 3) { + issues.push(`tape anchor ${px(anchor - margin)} from the tape margin`); + } + } else if ( + Config.mode !== "zen" && + (!Config.showAllLines || Config.mode === "time") + ) { + const wordEls = [...words.querySelectorAll(".word")]; + const tops = [ + ...new Set(wordEls.map((el) => px(el.getBoundingClientRect().top))), + ].sort((a, b) => a - b); + const lineHeight = (tops[1] ?? 0) - (tops[0] ?? 0); + if (tops.length > 1 && Math.abs(wr.height - 3 * lineHeight) > 3) { + issues.push(`wrapper ${px(wr.height)}px, not 3 lines of ${lineHeight}px`); + } + const clipped = wordEls.find((el) => { + const r = el.getBoundingClientRect(); + return ( + r.top >= wr.top - 1 && r.top < wr.bottom - 2 && r.bottom > wr.bottom + 1 + ); + }); + if (clipped !== undefined) { + issues.push(`word ${clipped.dataset["wordindex"]} cut off at the bottom`); + } + } + + const marginTop = words.style.marginTop; + if (marginTop !== "" && marginTop !== "0px") { + issues.push(`#words margin-top ${marginTop} (line jump unfinished)`); + } + return issues; +} + +function findCommand(id: string, list: Command[]): Command | undefined { + for (const command of list) { + if (command.id === id) return command; + // dynamic lists (presets, tags, custom themes) fill in on open + command.subgroup?.beforeList?.(); + const found = + command.subgroup !== undefined + ? findCommand(id, command.subgroup.list) + : undefined; + if (found !== undefined) return found; + } + return undefined; +} + +/** + * Runs a commandline command like the commandline does: opens it (the test + * unfocuses, the out of focus warning blurs the words after 1s), executes the + * command, closes it. Input commands take `input` as typed (no validation). + * Ids: `change` subgroups, `set`, + * `setCustom` inputs (eg. setFontSizeCustom). + */ +async function runCommand(id: string, input?: string): Promise { + // lazy: the lists open indexedDB on import, expose loads in jsdom tests + const { commands } = await import("../commandline/lists"); + const command = findCommand(id, commands.list); + if (command?.exec === undefined) { + throw new Error(`No executable commandline command "${id}"`); + } + showModal("Commandline"); + // the out of focus warning shows 1s after unfocusing + await new Promise((resolve) => setTimeout(resolve, 1100)); + const exec = command.exec as (options: { input?: unknown }) => void; + exec({ + input: + input !== undefined && "inputValueConvert" in command + ? (command.inputValueConvert as (value: string) => unknown)(input) + : input, + }); + hideModal("Commandline"); +} + function findSignal(name: string): (typeof trackedSignals)[number] { const matches = trackedSignals.filter((s) => s.name === name); if (matches.length === 0) { @@ -57,6 +235,7 @@ const mt = { hideModal, restartTest, typeText, + compose, wordsSnapshot, caretOffset, checkLayout, diff --git a/frontend/src/ts/dev/test-helpers.ts b/frontend/src/ts/dev/test-helpers.ts deleted file mode 100644 index d1432fd40d61..000000000000 --- a/frontend/src/ts/dev/test-helpers.ts +++ /dev/null @@ -1,240 +0,0 @@ -// Typing test helpers for `window.__mt` (browser QA and profiling). -import type { Command } from "../commandline/types"; -import { setConfig } from "../config/setters"; -import { Config } from "../config/store"; -import { getInputElement } from "../input/input-element"; -import * as CustomText from "../test/custom-text"; -import { getCurrentInput } from "../test/events/data"; -import { hideModal, showModal } from "../states/modals"; -import { restartTest } from "../test/test-logic"; - -/** "\n" types a line break, "\b" deletes the previous character. */ -function typeChar(char: string): void { - const input = getInputElement(); - if (document.activeElement !== input) input.focus(); - const key = char === "\n" ? "Enter" : char === "\b" ? "Backspace" : char; - input.dispatchEvent( - new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }), - ); - - const inputType = - char === "\n" - ? "insertLineBreak" - : char === "\b" - ? "deleteContentBackward" - : "insertText"; - const data = inputType === "insertText" ? char : null; - const beforeInput = new InputEvent("beforeinput", { - inputType, - data, - bubbles: true, - cancelable: true, - }); - input.dispatchEvent(beforeInput); - if (!beforeInput.defaultPrevented) { - if (inputType === "deleteContentBackward") { - input.value = input.value.slice(0, -1); - } else { - input.setRangeText(char, input.selectionStart, input.selectionEnd, "end"); - } - input.dispatchEvent( - new InputEvent("input", { inputType, data, bubbles: true }), - ); - } - - input.dispatchEvent(new KeyboardEvent("keyup", { key, bubbles: true })); -} - -/** - * Types `text` through the real input handlers, one character every - * `msPerChar` (0: all in one task). "\n" is a line break, "\b" a backspace. - */ -export async function typeText(text: string, msPerChar = 0): Promise { - for (const char of text) { - typeChar(char); - if (msPerChar > 0) { - await new Promise((resolve) => setTimeout(resolve, msPerChar)); - } - } -} - -type LetterSnapshot = { text: string; icon?: string; classes: string }; -type ChildSnapshot = - | { index: number; classes: string; letters: LetterSnapshot[] } - | { sibling: string }; - -/** The rendered #words children: words with their letters, plus newline siblings. */ -export function wordsSnapshot(): ChildSnapshot[] { - const words = document.querySelector("#words"); - if (words === null) return []; - return [...words.children].map((child): ChildSnapshot => { - if (!child.classList.contains("word")) return { sibling: child.className }; - return { - index: Number((child as HTMLElement).dataset["wordindex"]), - classes: child.className, - letters: [...child.querySelectorAll(":scope > letter")].map((letter) => ({ - text: letter.textContent ?? "", - icon: letter.querySelector("i")?.className, - classes: letter.className, - })), - }; - }); -} - -/** - * Px offset of the caret from where it should be: the start edge of the next - * letter to type, or the end edge of the last letter (left/right, mirrored for - * right to left words). Within a few px is on. - */ -export function caretOffset(): { dx: number; dy: number } | undefined { - const caret = document.querySelector("#caret"); - const letters = document.querySelectorAll("#words .word.active letter"); - if (caret === null || letters.length === 0) return undefined; - const index = getCurrentInput().length; - const target = (letters[index] ?? letters[letters.length - 1]) as Element; - const targetRect = target.getBoundingClientRect(); - const caretRect = caret.getBoundingClientRect(); - const isRtl = getComputedStyle(target).direction === "rtl"; - const atStart = letters[index] !== undefined; - const targetX = atStart !== isRtl ? targetRect.left : targetRect.right; - return { - dx: Math.round(caretRect.left - targetX), - dy: Math.round(caretRect.top - targetRect.top), - }; -} - -/** Starts a custom test with `text`, as the custom text modal's simple mode. */ -export async function setCustomText(text: string): Promise { - const words = text - .replace(/ +/g, " ") - .replace(/( *(\r\n|\r|\n) *)/g, "\n ") - .split(" ") - .filter((word) => word !== ""); - CustomText.setMode("repeat"); - CustomText.setPipeDelimiter(false); - CustomText.setText(words); - CustomText.setLimitMode("word"); - CustomText.setLimitValue(words.length); - setConfig("mode", "custom"); - await restartTest(); -} - -/** - * Layout problems on the test page, empty when it's laid out right: the active - * word inside the words wrapper, the caret and the words input on it, three - * full lines (unless all lines are shown), the tape anchor at the tape margin. - */ -export function checkLayout(): string[] { - const wrapper = document.querySelector("#wordsWrapper"); - const words = document.querySelector("#words"); - const active = words?.querySelector(".word.active") ?? null; - if (wrapper === null || words === null || active === null) { - return ["no active word"]; - } - const issues: string[] = []; - const wr = wrapper.getBoundingClientRect(); - const ar = active.getBoundingClientRect(); - const px = Math.round; - - if (ar.top < wr.top - 1 || ar.bottom > wr.bottom + 1) { - issues.push( - `active word ${px(ar.top)}-${px(ar.bottom)} outside wrapper ${px(wr.top)}-${px(wr.bottom)}`, - ); - } - const caret = caretOffset(); - // a few px off is on, more at big font sizes - const caretTolerance = Math.max(4, ar.height * 0.1); - if ( - caret !== undefined && - (Math.abs(caret.dx) > caretTolerance || Math.abs(caret.dy) > caretTolerance) - ) { - issues.push(`caret off by ${caret.dx},${caret.dy}`); - } - const input = getInputElement().getBoundingClientRect(); - if ( - Math.abs(input.top + input.height / 2 - (ar.top + ar.height / 2)) > - ar.height - ) { - issues.push(`words input off the active word by ${px(input.top - ar.top)}`); - } - - if (Config.tapeMode !== "off") { - const margin = wr.left + (wr.width * Config.tapeMargin) / 100; - const letters = active.querySelectorAll("letter"); - const anchor = - Config.tapeMode === "letter" - ? letters[getCurrentInput().length]?.getBoundingClientRect().left - : ar.left; - if (anchor !== undefined && Math.abs(anchor - margin) > 3) { - issues.push(`tape anchor ${px(anchor - margin)} from the tape margin`); - } - } else if ( - Config.mode !== "zen" && - (!Config.showAllLines || Config.mode === "time") - ) { - const wordEls = [...words.querySelectorAll(".word")]; - const tops = [ - ...new Set(wordEls.map((el) => px(el.getBoundingClientRect().top))), - ].sort((a, b) => a - b); - const lineHeight = (tops[1] ?? 0) - (tops[0] ?? 0); - if (tops.length > 1 && Math.abs(wr.height - 3 * lineHeight) > 3) { - issues.push(`wrapper ${px(wr.height)}px, not 3 lines of ${lineHeight}px`); - } - const clipped = wordEls.find((el) => { - const r = el.getBoundingClientRect(); - return ( - r.top >= wr.top - 1 && r.top < wr.bottom - 2 && r.bottom > wr.bottom + 1 - ); - }); - if (clipped !== undefined) { - issues.push(`word ${clipped.dataset["wordindex"]} cut off at the bottom`); - } - } - - const marginTop = words.style.marginTop; - if (marginTop !== "" && marginTop !== "0px") { - issues.push(`#words margin-top ${marginTop} (line jump unfinished)`); - } - return issues; -} - -function findCommand(id: string, list: Command[]): Command | undefined { - for (const command of list) { - if (command.id === id) return command; - // dynamic lists (presets, tags, custom themes) fill in on open - command.subgroup?.beforeList?.(); - const found = - command.subgroup !== undefined - ? findCommand(id, command.subgroup.list) - : undefined; - if (found !== undefined) return found; - } - return undefined; -} - -/** - * Runs a commandline command like the commandline does: opens it (the test - * unfocuses, the out of focus warning blurs the words after 1s), executes the - * command, closes it. Input commands take `input` as typed (no validation). - * Ids: `change` subgroups, `set`, - * `setCustom` inputs (eg. setFontSizeCustom). - */ -export async function runCommand(id: string, input?: string): Promise { - // lazy: the lists open indexedDB on import, expose loads in jsdom tests - const { commands } = await import("../commandline/lists"); - const command = findCommand(id, commands.list); - if (command?.exec === undefined) { - throw new Error(`No executable commandline command "${id}"`); - } - showModal("Commandline"); - // the out of focus warning shows 1s after unfocusing - await new Promise((resolve) => setTimeout(resolve, 1100)); - const exec = command.exec as (options: { input?: unknown }) => void; - exec({ - input: - input !== undefined && "inputValueConvert" in command - ? (command.inputValueConvert as (value: string) => unknown)(input) - : input, - }); - hideModal("Commandline"); -} diff --git a/frontend/src/ts/dev/typing-driver.ts b/frontend/src/ts/dev/typing-driver.ts new file mode 100644 index 000000000000..d7f55dd6c572 --- /dev/null +++ b/frontend/src/ts/dev/typing-driver.ts @@ -0,0 +1,89 @@ +// Types through the real words input handlers, firing the events a browser +// fires. Shared by `window.__mt` (browser QA) and the test-page harness. +import { getInputElement } from "../input/input-element"; + +/** "\n" types a line break, "\b" deletes the previous character. */ +export function typeChar(char: string): void { + const input = getInputElement(); + if (document.activeElement !== input) input.focus(); + const key = char === "\n" ? "Enter" : char === "\b" ? "Backspace" : char; + input.dispatchEvent( + new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }), + ); + + const inputType = + char === "\n" + ? "insertLineBreak" + : char === "\b" + ? "deleteContentBackward" + : "insertText"; + const data = inputType === "insertText" ? char : null; + const beforeInput = new InputEvent("beforeinput", { + inputType, + data, + bubbles: true, + cancelable: true, + }); + input.dispatchEvent(beforeInput); + if (!beforeInput.defaultPrevented) { + if (inputType === "deleteContentBackward") { + input.value = input.value.slice(0, -1); + } else { + input.setRangeText(char, input.selectionStart, input.selectionEnd, "end"); + } + input.dispatchEvent( + new InputEvent("input", { inputType, data, bubbles: true }), + ); + } + + input.dispatchEvent(new KeyboardEvent("keyup", { key, bubbles: true })); +} + +/** + * Types `text` through the real input handlers, one character every + * `msPerChar` (0: all in one task). "\n" is a line break, "\b" a backspace. + */ +export async function typeText(text: string, msPerChar = 0): Promise { + for (const char of text) { + typeChar(char); + if (msPerChar > 0) { + await new Promise((resolve) => setTimeout(resolve, msPerChar)); + } + } +} + +/** + * Composes text like an IME: each of `updates` is the composition so far + * (eg. ["k", "か"]), the last one is committed. + */ +export function compose(updates: string[]): void { + const input = getInputElement(); + if (document.activeElement !== input) input.focus(); + const start = input.selectionStart; + input.dispatchEvent( + new CompositionEvent("compositionstart", { data: "", bubbles: true }), + ); + for (const data of updates) { + input.dispatchEvent( + new CompositionEvent("compositionupdate", { data, bubbles: true }), + ); + const init = { + inputType: "insertCompositionText", + data, + isComposing: true, + bubbles: true, + }; + input.dispatchEvent( + new InputEvent("beforeinput", { ...init, cancelable: true }), + ); + // the composition replaces itself in the input + input.setRangeText(data, start, input.selectionEnd, "end"); + input.dispatchEvent(new InputEvent("input", init)); + } + input.dispatchEvent( + new CompositionEvent("compositionend", { + data: updates[updates.length - 1] ?? "", + bubbles: true, + }), + ); +} diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index fc24ca446be3..d682ae7c5b90 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -100,6 +100,24 @@ export const projects: UserWorkspaceConfig[] = [ resolve, ...worktree, }, + { + ssr: { + noExternal: tanstackSolidNoExternal, + }, + test: { + name: { label: "test-page", color: "magenta" }, + // specs on the test-page harness (__tests__/__harness__/test-page.tsx) + include: ["__tests__/**/*.page-spec.tsx"], + environment: "jsdom", + globalSetup: "__tests__/global-setup.ts", + // no mock-dom: the test page's modules query the real DOM + setupFiles: ["__tests__/__harness__/setup-jsx.ts"], + globals: true, + }, + plugins, + resolve, + ...worktree, + }, ]; export default defineConfig({ test: {