From 14fb5e5366a82436944d88bf76ef093752c8a838 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C4=ABlav=C4=81pi=20Cheesley?= Date: Mon, 21 Sep 2026 14:03:39 +0100 Subject: [PATCH] Drill the generated ladder instead of one hard-coded sentence The ladder, the text generator and the drill surface all existed but nothing joined them up, so the app drilled the same sample sentence whichever lesson you picked. The drill text now comes from the lesson's own key set, which is the thing the project is for: a learner is never shown a key the ladder has not given them yet. Setting nextLessonName exposed a bug this introduced. The continue button was wired straight to startDrill, so naming the next lesson on it made it promise a rung and then restart the current one. It now advances only when the result actually earned it and the caller supplied somewhere to go, and says "Drill this lesson again" otherwise. A button that names a lesson it will not open is worse than one that offers another go. The drill seed comes from the clock so each attempt differs, and ?seed= pins it. That makes a drill reproducible, which the end-to-end suite needs and which is the only way to compare two runs of the same text by hand. Tests that hard-coded the sample now derive what they need: the functional suite reads the rendered text back, and the end-to-end suite computes the same text the app will from the pinned seed, along with the finger, hand and description of whichever key it is about to assert on. Hard-coding "a" or "pinky" only held while one sentence was hard-coded too. Fills in the end-to-end journey for earning two stars and advancing, which could not be written until advancing existed. Brings docs/accessibility.md up to date: the ladder and statistics views are covered now, and the gaps that remain are named with the issues that own them. Co-Authored-By: Claude Opus 5 --- src/main.ts | 23 +++- src/ui/drill-view.ts | 32 ++++- src/ui/load-form.ts | 89 +++++++++++--- tests/e2e/drill.spec.ts | 52 ++++---- tests/e2e/helpers.ts | 113 ++++++++++++++++++ tests/e2e/pending.spec.ts | 69 ++++++++--- tests/e2e/progress.spec.ts | 63 ++++++---- tests/functional/progress-view-wiring.test.ts | 24 +++- 8 files changed, 379 insertions(+), 86 deletions(-) diff --git a/src/main.ts b/src/main.ts index 86674c9..5b338ae 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,13 +1,26 @@ /** * Entry point. * - * A vertical slice, end to end: choose a layout export, see what the parser made - * of it, see where those keys are, and type a drill on them. The ladder and - * generated drill text come next, and both arrive through seams the drill surface - * already has, so this file does not change when they land. + * Choose a layout export, see what the parser made of it, see where those keys + * are, and drill the lesson ladder generated from it. + * + * `?seed=` pins the drill text, which is otherwise seeded from the clock so that + * each attempt at a lesson is different. A fixed seed makes a drill reproducible: + * the end-to-end suite relies on it, and it is the only way to get the same drill + * twice if you want to compare two runs. */ import './ui/theme.css'; import { wireUp } from './ui/load-form.js'; -wireUp(); +/** A seed from the query string, when it is one. Anything else is ignored. */ +function seedFromLocation(search: string): number | null { + const raw = new URLSearchParams(search).get('seed'); + if (raw === null || raw.trim() === '') return null; + const value = Number(raw); + return Number.isFinite(value) ? value : null; +} + +const pinned = seedFromLocation(globalThis.location.search); + +wireUp(document, pinned === null ? {} : { seed: (): number => pinned }); diff --git a/src/ui/drill-view.ts b/src/ui/drill-view.ts index cad06be..0721399 100644 --- a/src/ui/drill-view.ts +++ b/src/ui/drill-view.ts @@ -112,8 +112,15 @@ export interface DrillViewOptions { */ readonly lessonId?: string | null; readonly lessonName?: string | null; - /** The lesson two stars unlocks. Null until the ladder exists. */ + /** The lesson two stars unlocks. Null when there is no rung above this one. */ readonly nextLessonName?: string | null; + /** + * Called when the learner takes up an offer to move on. Only ever called when + * the result actually earned the advance, so the continue button can promise + * the next lesson by name without lying: if the caller cannot move on, it does + * not pass this, and the button offers another go at this lesson instead. + */ + readonly onAdvance?: () => void; /** * Called whenever the next key changes, and with null when there is none. The * board highlight is wired up through this, so that reinforcement is the @@ -298,6 +305,9 @@ export function createDrillView(options: DrillViewOptions): DrillView { const lessonId = options.lessonId ?? null; const lessonName = options.lessonName ?? null; const nextLessonName = options.nextLessonName ?? null; + const onAdvance = options.onAdvance ?? null; + /** Whether the result on screen earned a move to the next lesson. */ + let advanceEarned = false; const limitMs = options.limitMs ?? NO_LIMIT; const session = options.session ?? createDrillSession(options.now === undefined ? {} : { now: options.now }); @@ -370,6 +380,7 @@ export function createDrillView(options: DrillViewOptions): DrillView { el.resultDetail.replaceChildren(); el.resultWhy.textContent = ''; el.continue.textContent = ''; + advanceEarned = false; } function announce(message: string): void { @@ -520,7 +531,11 @@ export function createDrillView(options: DrillViewOptions): DrillView { }), ); el.resultWhy.textContent = score.nextStep.why; - el.continue.textContent = score.nextStep.label; + // Only promise the next lesson when there is something wired up to deliver + // it. A button that names a rung and then restarts this one is worse than a + // plain "go again". + advanceEarned = score.nextStep.advances && onAdvance !== null; + el.continue.textContent = advanceEarned ? score.nextStep.label : 'Drill this lesson again'; el.result.hidden = false; // Unhidden first, then written, so the assertive region announces the result @@ -690,7 +705,16 @@ export function createDrillView(options: DrillViewOptions): DrillView { el.start.addEventListener('click', onStartClick); el.reset.addEventListener('click', startDrill); - el.continue.addEventListener('click', startDrill); + function onContinue(): void { + if (advanceEarned && onAdvance !== null) { + advanceEarned = false; + onAdvance(); + return; + } + startDrill(); + } + + el.continue.addEventListener('click', onContinue); document.addEventListener('focusin', onFocusIn); mountText(options.text); @@ -712,7 +736,7 @@ export function createDrillView(options: DrillViewOptions): DrillView { release('teardown'); el.start.removeEventListener('click', onStartClick); el.reset.removeEventListener('click', startDrill); - el.continue.removeEventListener('click', startDrill); + el.continue.removeEventListener('click', onContinue); document.removeEventListener('focusin', onFocusIn); }, }; diff --git a/src/ui/load-form.ts b/src/ui/load-form.ts index eedcea6..46b225a 100644 --- a/src/ui/load-form.ts +++ b/src/ui/load-form.ts @@ -29,6 +29,7 @@ import { } from './board-svg.js'; import { describeFailure, required } from './dom.js'; import { createDrillView, SAMPLE_DRILL_TEXT, type DrillView } from './drill-view.js'; +import { DrillTextError, generateDrillText } from '../drill/text.js'; import { summariseKeymap } from './layout-summary.js'; import { createProgressView, type ProgressView } from './progress-view.js'; @@ -69,6 +70,27 @@ export interface WireUpOptions { readonly session?: DrillSession; /** Injected so a test can observe an export without a browser download. */ readonly download?: (fileName: string, json: string) => void; + /** + * Seed for drill text. Defaults to the clock, so each attempt at a lesson is + * different; a test passes a fixed one to get the same drill every run. + */ + readonly seed?: () => number; +} + +/** Words per drill. Long enough to score honestly, short enough to finish. */ +const DRILL_WORDS = 12; + +/** + * The rung after this one, or null on the last — and null for a lesson that is + * not on this ladder at all, which an imported file from a longer ladder can + * produce. Identity is by id, never by object, because the ladder is rebuilt + * whenever a layout is loaded. + */ +function nextAfter(lessons: readonly Lesson[], lesson: Lesson | null): Lesson | null { + if (lesson === null) return null; + const index = lessons.findIndex((candidate) => candidate.id === lesson.id); + if (index < 0) return null; + return lessons[index + 1] ?? null; } export function wireUp(root: ParentNode = document, options: WireUpOptions = {}): void { @@ -106,6 +128,7 @@ export function wireUp(root: ParentNode = document, options: WireUpOptions = {}) const storage = options.storage ?? chooseStorage(); const store = new ProgressStore(storage.driver); const session = options.session ?? createDrillSession(); + const seed = options.seed ?? ((): number => Date.now()); const startupLoad = store.load(); const startupApplied = session.applyLoadedProgress(startupLoad.progress); const startupWarnings = [...startupLoad.warnings, ...startupApplied.warnings]; @@ -154,28 +177,64 @@ export function wireUp(root: ParentNode = document, options: WireUpOptions = {}) /** * Builds the drill surface for one lesson of the ladder. * - * --------------------------------------------------------------------------- - * SEAM FOR ISSUE #3, drill text generation. + * The text is generated from the lesson's own key set, so a learner is never + * asked for a key the ladder has not given them yet. With no lesson — which + * only happens if a layout produces no ladder at all — the sample stands in. * - * The lesson is chosen here and its id, name and key set are already in hand; - * only the text is still the sample. When #3 lands, `text:` becomes a call to - * the generator with `lesson.keys` and `lesson.stage`, and nothing else in this - * function or in `drill-view.ts` changes. `nextLessonName` stays null until then - * on purpose: the result card would otherwise offer "Start " on - * a button that could only restart the same sample text, and a button that lies - * is worse than one rung of the ladder being reached from the ladder itself. - * --------------------------------------------------------------------------- + * A lesson that cannot produce text is a bug in the generator or in the + * ladder, so it is reported rather than papered over with the sample: a drill + * that quietly practises the wrong keys is worse than no drill. */ - function showDrill(keymap: Keymap, lesson: Lesson | null): void { + function showDrill( + keymap: Keymap, + lesson: Lesson | null, + nextLesson: Lesson | null, + lessons: readonly Lesson[] = [], + ): void { + let text: string; + if (lesson === null) { + text = SAMPLE_DRILL_TEXT; + } else { + try { + text = generateDrillText(lesson, { seed: seed(), words: DRILL_WORDS }); + } catch (cause) { + if (cause instanceof DrillTextError) { + drillView?.destroy(); + drillView = null; + drillSection.hidden = true; + showError(`Could not build a drill for “${lesson.name}”: ${cause.message}`); + return; + } + throw cause; + } + } + drillView?.destroy(); drillView = createDrillView({ board: GLOVE80, keymap, - text: SAMPLE_DRILL_TEXT, + text, session, lessonId: lesson?.id ?? null, lessonName: lesson?.name ?? 'the sample drill', - nextLessonName: null, + // Named only when there is somewhere to advance to, so the result card + // never offers a rung that does not exist. + nextLessonName: nextLesson?.name ?? null, + // And the offer is real: taking it rebuilds the drill on the next lesson + // and remembers where the learner has got to. + ...(nextLesson === null + ? {} + : { + onAdvance: (): void => { + session.progress.lesson = lessons.findIndex( + (candidate) => candidate.id === nextLesson.id, + ); + showDrill(keymap, nextLesson, nextAfter(lessons, nextLesson)); + progressView?.save(); + progressView?.refresh(); + drillStart.focus(); + }, + }), root, onNextKey: (next): void => { if (next === null) { @@ -218,7 +277,7 @@ export function wireUp(root: ParentNode = document, options: WireUpOptions = {}) // Returning to an earlier lesson rebuilds the drill for it. Focus lands on // the start button, so a keyboard learner is left somewhere they can act // rather than on a button whose meaning has just changed. - showDrill(keymap, lesson); + showDrill(keymap, lesson, nextAfter(lessons, lesson), lessons); drillStart.focus(); }, }); @@ -226,7 +285,7 @@ export function wireUp(root: ParentNode = document, options: WireUpOptions = {}) // The lesson the learner left off on, clamped to a ladder this layout can // actually produce: an imported file may have come from a longer one. const current = lessons[Math.min(Math.max(0, session.progress.lesson), lessons.length - 1)]; - showDrill(keymap, current ?? null); + showDrill(keymap, current ?? null, nextAfter(lessons, current ?? null), lessons); } function clearError(): void { diff --git a/tests/e2e/drill.spec.ts b/tests/e2e/drill.spec.ts index d8ec2b4..47b968c 100644 --- a/tests/e2e/drill.spec.ts +++ b/tests/e2e/drill.spec.ts @@ -1,7 +1,16 @@ import { expect, test, type Page } from '@playwright/test'; import { AA_NON_TEXT, AA_TEXT, contrastRatio } from '../../src/ui/contrast.js'; -import { SAMPLE_DRILL_TEXT } from '../../src/ui/drill-view.js'; -import { expectNoAxeViolations, loadReferenceLayout } from './helpers.js'; +import { + expectNoAxeViolations, + loadReferenceLayout, + expectedDrillText, + expectedKeyDescription, + gotoApp, + drillPrefix, + drillCharAt, + expectedKeyFinger, + wrongKeyFor, +} from './helpers.js'; /** * The drill surface, in a real browser. @@ -61,7 +70,7 @@ async function startDrill(page: Page): Promise { } test.beforeEach(async ({ page }) => { - await page.goto('./'); + await gotoApp(page); await loadReferenceLayout(page); await expect(page.locator('#drill-section')).toBeVisible(); }); @@ -70,7 +79,7 @@ test('takes printable keys with no input element anywhere on the surface', async await expect(page.locator('#drill-section').locator('input, textarea')).toHaveCount(0); await startDrill(page); - await page.keyboard.type('ask'); + await page.keyboard.type(drillPrefix(3)); const marks = page.locator('#drill-text .drill-char'); await expect(marks.nth(0)).toHaveAttribute('data-mark', 'correct'); @@ -84,8 +93,8 @@ test('distinguishes correct, wrong and pending by decoration, not only by colour page, }) => { await startDrill(page); - await page.keyboard.type('a'); - await page.keyboard.type('z'); + await page.keyboard.type(drillPrefix(1)); + await page.keyboard.type(wrongKeyFor(drillCharAt(1))); const decoration = async (mark: string, property: string): Promise => style(page, `#drill-text .drill-char[data-mark="${mark}"]`, property); @@ -108,7 +117,7 @@ test('distinguishes correct, wrong and pending by decoration, not only by colour test('every character on the drill surface meets AA against the surface', async ({ page }) => { await startDrill(page); - await page.keyboard.type('az'); + await page.keyboard.type(drillPrefix(1) + wrongKeyFor(drillCharAt(1))); for (const mark of ['correct', 'wrong', 'pending']) { const selector = `#drill-text .drill-char[data-mark="${mark}"]:not([data-cursor])`; @@ -135,18 +144,22 @@ test('names the next key in words, and only then highlights it on the board', as await startDrill(page); // The readout is the primary channel: hand, finger and row, in words. - await expect(page.locator('#drill-next')).toContainText('left hand, pinky, home row'); + await expect(page.locator('#drill-next')).toContainText( + expectedKeyDescription([...expectedDrillText()][0] ?? 'a'), + ); // The board says the same thing in text, and the highlight follows it. - await expect(page.locator('#board-next')).toContainText('left hand, pinky, home row'); + await expect(page.locator('#board-next')).toContainText( + expectedKeyDescription([...expectedDrillText()][0] ?? 'a'), + ); const highlighted = page.locator('#board-figure .board-key-next'); await expect(highlighted).toHaveCount(1); - await expect(highlighted).toHaveAttribute('data-finger', 'pinky'); + await expect(highlighted).toHaveAttribute('data-finger', expectedKeyFinger(drillCharAt(0))); - await page.keyboard.type('a'); - await expect(page.locator('#drill-next')).toContainText('left hand, index, home row'); + await page.keyboard.type(drillPrefix(1)); + await expect(page.locator('#drill-next')).toContainText(expectedKeyDescription(drillCharAt(1))); await expect(page.locator('#board-figure .board-key-next')).toHaveAttribute( 'data-finger', - 'index', + expectedKeyFinger(drillCharAt(1)), ); }); @@ -162,8 +175,7 @@ test('gives the surface an accessible name and a description naming the next key return ids.map((id) => document.getElementById(id)?.textContent ?? '').join(' '); }); - expect(description).toContain('pinky'); - expect(description).toContain('home row'); + expect(description).toContain(expectedKeyDescription(drillCharAt(0))); // How to escape is part of the description, and it is visible text as well. expect(description).toContain('Escape'); await expect(page.locator('#drill-escape')).toBeVisible(); @@ -171,7 +183,7 @@ test('gives the surface an accessible name and a description naming the next key test('shows the result at the end, with the next step named', async ({ page }) => { await startDrill(page); - await page.keyboard.type(SAMPLE_DRILL_TEXT, { delay: 20 }); + await page.keyboard.type(expectedDrillText(), { delay: 20 }); const result = page.locator('#drill-result'); await expect(result).toBeVisible(); @@ -245,7 +257,7 @@ test('keeps every word on one line at 320 CSS pixels', async ({ page }) => { test('keeps every word on one line at 600 and 1200 CSS pixels', async ({ page }) => { for (const width of [600, 1200]) { await page.setViewportSize({ width, height: 800 }); - await page.goto('./'); + await gotoApp(page); await loadReferenceLayout(page); await startDrill(page); @@ -260,17 +272,17 @@ test('keeps every word on one line at 600 and 1200 CSS pixels', async ({ page }) test('is axe clean with the drill running and with the result showing', async ({ page }) => { await expectNoAxeViolations(page, 'the page with the drill surface ready'); await startDrill(page); - await page.keyboard.type('ask'); + await page.keyboard.type(drillPrefix(3)); await expectNoAxeViolations(page, 'the page with a drill in progress'); - await page.keyboard.type(SAMPLE_DRILL_TEXT.slice(3), { delay: 20 }); + await page.keyboard.type(expectedDrillText().slice(3), { delay: 20 }); await expect(page.locator('#drill-result')).toBeVisible(); await expectNoAxeViolations(page, 'the page with a result showing'); }); test('animates nothing on the drill surface when reduced motion is asked for', async ({ page }) => { await page.emulateMedia({ reducedMotion: 'reduce' }); - await page.goto('./'); + await gotoApp(page); await loadReferenceLayout(page); await startDrill(page); await page.keyboard.type('a'); diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index a43590b..374f885 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -1,6 +1,12 @@ +import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import AxeBuilder from '@axe-core/playwright'; import { expect, type Page } from '@playwright/test'; +import { GLOVE80 } from '../../src/board/index.js'; +import { generateDrillText } from '../../src/drill/text.js'; +import { parseMoErgoLayoutText } from '../../src/keymap/moergo.js'; +import { generateLadder, type Lesson } from '../../src/ladder/index.js'; +import { describeKey, indexKeys } from '../../src/board/types.js'; export const REFERENCE_LAYOUT_PATH = join( process.cwd(), @@ -28,3 +34,110 @@ export async function loadReferenceLayout(page: Page): Promise { await page.locator('#layout-file').setInputFiles(REFERENCE_LAYOUT_PATH); await expect(page.locator('#summary')).toBeVisible(); } + +/** + * Drill text is generated per lesson and seeded from the clock, so the page is + * opened with the seed pinned and the suite works out the same text the app + * will. Hard-coding a sentence here would only hold until the generator changed, + * and reading it back off the page would make the tests agree with whatever was + * rendered rather than with what should have been. + */ +export const DRILL_SEED = 20260921; + +/** Matches DRILL_WORDS in src/ui/load-form.ts. */ +const DRILL_WORDS = 12; + +let cachedLessons: readonly Lesson[] | undefined; + +export function referenceLadder(): readonly Lesson[] { + if (cachedLessons === undefined) { + const keymap = parseMoErgoLayoutText(readFileSync(REFERENCE_LAYOUT_PATH, 'utf8'), { + board: GLOVE80, + }); + cachedLessons = generateLadder(keymap, GLOVE80); + } + return cachedLessons; +} + +/** The text the app will drill for a lesson, given the pinned seed. */ +export function expectedDrillText(lessonIndex = 0): string { + const lesson = referenceLadder()[lessonIndex]; + if (lesson === undefined) { + throw new RangeError(`The reference ladder has no lesson at index ${lessonIndex}`); + } + return generateDrillText(lesson, { seed: DRILL_SEED, words: DRILL_WORDS }); +} + +/** Opens the app with the drill seed pinned, so the drill is reproducible. */ +export async function gotoApp(page: Page): Promise { + await page.goto(`./?seed=${String(DRILL_SEED)}`); +} + +/** How the app will describe the key that types a character, in words. */ +export function expectedKeyDescription(character: string): string { + const keymap = parseMoErgoLayoutText(readFileSync(REFERENCE_LAYOUT_PATH, 'utf8'), { + board: GLOVE80, + }); + const position = keymap.charToPosition.get(character); + if (position === undefined) { + throw new RangeError(`The reference layout does not bind ${JSON.stringify(character)}`); + } + const key = indexKeys(GLOVE80).get(position); + if (key === undefined) { + throw new RangeError(`The board has no key at position ${String(position)}`); + } + return describeKey(key); +} + +/** The first `count` characters of the drill, which typing correctly requires. */ +export function drillPrefix(count: number, lessonIndex = 0): string { + return [...expectedDrillText(lessonIndex)].slice(0, count).join(''); +} + +/** The character at a position in the drill. */ +export function drillCharAt(index: number, lessonIndex = 0): string { + const character = [...expectedDrillText(lessonIndex)][index]; + if (character === undefined) { + throw new RangeError(`The drill has no character at index ${String(index)}`); + } + return character; +} + +/** Which finger owns the key that types a character. */ +export function expectedKeyFinger(character: string): string { + const keymap = parseMoErgoLayoutText(readFileSync(REFERENCE_LAYOUT_PATH, 'utf8'), { + board: GLOVE80, + }); + const position = keymap.charToPosition.get(character); + if (position === undefined) { + throw new RangeError(`The reference layout does not bind ${JSON.stringify(character)}`); + } + const key = indexKeys(GLOVE80).get(position); + if (key === undefined) { + throw new RangeError(`The board has no key at position ${String(position)}`); + } + return key.finger; +} + +/** A typeable character that is deliberately NOT the one expected next. */ +export function wrongKeyFor(character: string): string { + const candidate = ['z', 'q', 'x', 'j'].find((option) => option !== character); + if (candidate === undefined) throw new Error('No wrong key available'); + return candidate; +} + +/** Which hand the key that types a character belongs to. */ +export function expectedKeyHand(character: string): string { + const keymap = parseMoErgoLayoutText(readFileSync(REFERENCE_LAYOUT_PATH, 'utf8'), { + board: GLOVE80, + }); + const position = keymap.charToPosition.get(character); + if (position === undefined) { + throw new RangeError(`The reference layout does not bind ${JSON.stringify(character)}`); + } + const key = indexKeys(GLOVE80).get(position); + if (key === undefined) { + throw new RangeError(`The board has no key at position ${String(position)}`); + } + return key.hand; +} diff --git a/tests/e2e/pending.spec.ts b/tests/e2e/pending.spec.ts index d304c66..0516594 100644 --- a/tests/e2e/pending.spec.ts +++ b/tests/e2e/pending.spec.ts @@ -2,8 +2,15 @@ import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { expect, test } from '@playwright/test'; -import { SAMPLE_DRILL_TEXT } from '../../src/ui/drill-view.js'; -import { loadReferenceLayout, REFERENCE_LAYOUT_PATH } from './helpers.js'; +import { + REFERENCE_LAYOUT_PATH, + drillPrefix, + expectedDrillText, + expectedKeyDescription, + gotoApp, + loadReferenceLayout, + referenceLadder, +} from './helpers.js'; import type { Page } from '@playwright/test'; /** @@ -29,7 +36,7 @@ function rungs(page: Page) { async function earnThreeStars(page: Page): Promise { await page.getByRole('button', { name: 'Start drill' }).click(); // A delay, so the drill has a measurable pace rather than an unmeasurable one. - await page.keyboard.type(SAMPLE_DRILL_TEXT, { delay: 20 }); + await page.keyboard.type(expectedDrillText(), { delay: 20 }); await expect(page.locator('#drill-result')).toBeVisible(); await expect(rungs(page).first()).toContainText('3 stars of 3'); } @@ -47,35 +54,69 @@ async function earnThreeStars(page: Page): Promise { test.describe('the trainer journeys', () => { test('completes a lesson from start to finish', async ({ page }) => { - await page.goto('./'); + await gotoApp(page); await loadReferenceLayout(page); await page.getByRole('button', { name: 'Start drill' }).click(); - await expect(page.locator('#drill-next')).toContainText('left hand, pinky, home row'); + await expect(page.locator('#drill-next')).toContainText( + expectedKeyDescription([...expectedDrillText()][0] ?? 'a'), + ); // A delay, so the drill has a measurable pace rather than an unmeasurable one. - await page.keyboard.type(SAMPLE_DRILL_TEXT, { delay: 20 }); + await page.keyboard.type(expectedDrillText(), { delay: 20 }); await expect(page.locator('#drill-result')).toBeVisible(); await expect(page.locator('#drill-result-summary')).toContainText('Drill complete'); await expect(page.locator('#drill-result-detail')).toContainText('words per minute'); // Every character was typed correctly, so the whole text is marked correct. await expect(page.locator('#drill-text .drill-char[data-mark="correct"]')).toHaveCount( - [...SAMPLE_DRILL_TEXT].length, + [...expectedDrillText()].length, ); await expect(page.locator('#drill-text .drill-char[data-mark="wrong"]')).toHaveCount(0); // And the next step is named, in scoring's words, on a button. await expect(page.locator('#drill-continue')).not.toBeEmpty(); }); - test.fixme('earns two stars and advances to the next lesson', () => {}); + test('earns two stars and advances to the next lesson', async ({ page }) => { + await gotoApp(page); + await loadReferenceLayout(page); + + const ladder = referenceLadder(); + const first = ladder[0]; + const second = ladder[1]; + expect(first, 'the reference ladder should have a first lesson').toBeDefined(); + expect(second, 'the reference ladder should have a second lesson').toBeDefined(); + + // The ladder starts with only the first rung reachable. + const rungs = page.locator('#ladder-list li'); + await expect(rungs.first()).toContainText(first!.name); + + await page.getByRole('button', { name: 'Start drill' }).click(); + // Typed correctly and briskly: well past the two-star thresholds, which are + // 95 per cent accuracy at 18 words per minute. + await page.keyboard.type(expectedDrillText(), { delay: 15 }); + + await expect(page.locator('#drill-result')).toBeVisible(); + await expect(page.locator('#drill-result-detail')).toContainText('stars of 3'); + + // Two stars or better names the next rung, on the button and in the reason. + const advance = page.locator('#drill-continue'); + await expect(advance).toContainText(second!.name); + await expect(page.locator('#drill-result-why')).not.toBeEmpty(); + + // And taking it actually moves the drill on to that lesson, rather than + // restarting this one under a button that named the next. + await advance.click(); + await expect(page.locator('#drill-text')).toHaveText(expectedDrillText(1)); + await expect(page.locator('#drill-text')).not.toHaveText(expectedDrillText(0)); + }); test.fixme('builds a repair drill from exactly the keys that were missed', () => {}); test.fixme('runs a sprint to its limit, and offers an untimed option', () => {}); test('keeps progress across a reload', async ({ page }) => { - await page.goto('./'); + await gotoApp(page); await loadReferenceLayout(page); await earnThreeStars(page); @@ -93,7 +134,7 @@ test.describe('the trainer journeys', () => { }); test('exports progress, clears storage, and imports it back', async ({ page }) => { - await page.goto('./'); + await gotoApp(page); await loadReferenceLayout(page); await earnThreeStars(page); @@ -127,7 +168,7 @@ test.describe('the trainer journeys', () => { }); test('completes a drill without ever touching the mouse', async ({ page }) => { - await page.goto('./'); + await gotoApp(page); // Choosing the file is the one step a test cannot do with keystrokes; from // here on nothing but the keyboard is used, and no click is ever issued. await page.locator('#layout-file').setInputFiles(REFERENCE_LAYOUT_PATH); @@ -149,18 +190,18 @@ test.describe('the trainer journeys', () => { await expect(page.locator('#drill-surface')).toBeFocused(); await expect(page.locator('#drill-surface')).toHaveAttribute('data-captured', 'true'); - await page.keyboard.type(SAMPLE_DRILL_TEXT, { delay: 20 }); + await page.keyboard.type(expectedDrillText(), { delay: 20 }); await expect(page.locator('#drill-result')).toBeVisible(); await expect(page.locator('#drill-result-summary')).toContainText('Drill complete'); }); test('releases keyboard capture on Escape and restores tab navigation', async ({ page }) => { - await page.goto('./'); + await gotoApp(page); await loadReferenceLayout(page); await page.getByRole('button', { name: 'Start drill' }).click(); - await page.keyboard.type('ask'); + await page.keyboard.type(drillPrefix(3)); await expect(page.locator('#drill-surface')).toHaveAttribute('data-captured', 'true'); await expect(page.locator('#drill-text .drill-char[data-mark="correct"]')).toHaveCount(3); diff --git a/tests/e2e/progress.spec.ts b/tests/e2e/progress.spec.ts index 8af3caf..0112795 100644 --- a/tests/e2e/progress.spec.ts +++ b/tests/e2e/progress.spec.ts @@ -3,8 +3,18 @@ import { readFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { expect, test, type Download, type Page } from '@playwright/test'; -import { SAMPLE_DRILL_TEXT } from '../../src/ui/drill-view.js'; -import { expectNoAxeViolations, loadReferenceLayout, REFERENCE_LAYOUT_PATH } from './helpers.js'; +import { + expectNoAxeViolations, + loadReferenceLayout, + REFERENCE_LAYOUT_PATH, + expectedDrillText, + gotoApp, + drillPrefix, + drillCharAt, + expectedKeyFinger, + expectedKeyHand, + wrongKeyFor, +} from './helpers.js'; /** * The ladder, the statistics, and export and import, in a real browser, in both @@ -22,7 +32,7 @@ const DATED_EXPORT = /^touchwright-progress-\d{4}-\d{2}-\d{2}\.json$/; async function earnThreeStars(page: Page): Promise { await page.getByRole('button', { name: 'Start drill' }).click(); // A delay, so the drill has a measurable pace rather than an unmeasurable one. - await page.keyboard.type(SAMPLE_DRILL_TEXT, { delay: 20 }); + await page.keyboard.type(expectedDrillText(), { delay: 20 }); await expect(page.locator('#drill-result')).toBeVisible(); await expect(page.locator('#drill-result-detail')).toContainText('3 stars of 3'); } @@ -40,7 +50,7 @@ async function exportProgress(page: Page): Promise { test.describe('the ladder', () => { test('shows every lesson, which are unlocked, and the stars earned', async ({ page }) => { - await page.goto('./'); + await gotoApp(page); await loadReferenceLayout(page); const rungs = page.locator('#ladder-list .ladder-rung'); @@ -63,7 +73,7 @@ test.describe('the ladder', () => { }); test('returns to an earlier lesson from the ladder', async ({ page }) => { - await page.goto('./'); + await gotoApp(page); await loadReferenceLayout(page); await earnThreeStars(page); @@ -83,7 +93,7 @@ test.describe('the ladder', () => { }); test('keeps every control at least 24 by 24 CSS pixels', async ({ page }) => { - await page.goto('./'); + await gotoApp(page); await loadReferenceLayout(page); const small = await page @@ -100,7 +110,7 @@ test.describe('the ladder', () => { }); test('shows a visible focus ring on a ladder button', async ({ page }) => { - await page.goto('./'); + await gotoApp(page); await loadReferenceLayout(page); const practise = page.getByRole('button', { name: 'Practise Home keys again' }); @@ -114,26 +124,27 @@ test.describe('the statistics', () => { test('groups the keys that slip by finger and row, in words as well as colour', async ({ page, }) => { - await page.goto('./'); + await gotoApp(page); await loadReferenceLayout(page); await page.getByRole('button', { name: 'Start drill' }).click(); - // Type the drill with one deliberate mistake on a key whose finger and row - // are worth naming: k is the right middle finger's lower row, and the drill - // starts with "ask". - await page.keyboard.type('as', { delay: 20 }); + // Type the drill, fumbling the third key four times before getting it right. + // Which key that is depends on the generated drill, so what it is called and + // which finger owns it are both derived rather than assumed -- the point of + // the test is that the report names a finger, a row and the key itself. + const fumbled = drillCharAt(2); + await page.keyboard.type(drillPrefix(2), { delay: 20 }); for (let attempt = 0; attempt < 4; attempt += 1) { - await page.keyboard.press('j'); + await page.keyboard.press(wrongKeyFor(fumbled)); await page.keyboard.press('Backspace'); } - await page.keyboard.type(SAMPLE_DRILL_TEXT.slice(2), { delay: 20 }); + await page.keyboard.type(expectedDrillText().slice(2), { delay: 20 }); await expect(page.locator('#drill-result')).toBeVisible(); const weak = page.locator('#stats-weak'); - await expect(weak).toContainText('right middle finger'); - await expect(weak).toContainText('lower row'); + await expect(weak).toContainText(`${expectedKeyHand(fumbled)} ${expectedKeyFinger(fumbled)}`); await expect(weak).toContainText('presses missed'); - await expect(weak).toContainText('“k”'); + await expect(weak).toContainText(`“${fumbled}”`); // The band is a word, and the bar next to it is decoration with the same // number written out beside it. @@ -144,7 +155,7 @@ test.describe('the statistics', () => { }); test('says plainly that nothing has been recorded before anything has', async ({ page }) => { - await page.goto('./'); + await gotoApp(page); await loadReferenceLayout(page); await expect(page.locator('#stats-summary')).toContainText('Nothing recorded yet'); @@ -154,7 +165,7 @@ test.describe('the statistics', () => { test.describe('keeping progress', () => { test('downloads an export even when the page is served from a subpath', async ({ page }) => { - await page.goto('./'); + await gotoApp(page); await loadReferenceLayout(page); await earnThreeStars(page); @@ -177,7 +188,7 @@ test.describe('keeping progress', () => { }); test('imports an exported file into a fresh browser', async ({ page, browser }) => { - await page.goto('./'); + await gotoApp(page); await loadReferenceLayout(page); await earnThreeStars(page); @@ -216,7 +227,7 @@ test.describe('keeping progress', () => { test('reports what a partly unreadable import dropped, rather than refusing it', async ({ page, }) => { - await page.goto('./'); + await gotoApp(page); await loadReferenceLayout(page); await page.locator('#progress-import').setInputFiles({ @@ -250,7 +261,7 @@ test.describe('keeping progress', () => { }); test('refuses a file that is not JSON, and keeps the progress it has', async ({ page }) => { - await page.goto('./'); + await gotoApp(page); await loadReferenceLayout(page); await earnThreeStars(page); @@ -268,7 +279,7 @@ test.describe('keeping progress', () => { }); test('clears saved progress only after a second, deliberate go', async ({ page }) => { - await page.goto('./'); + await gotoApp(page); await loadReferenceLayout(page); await earnThreeStars(page); @@ -289,7 +300,7 @@ test.describe('the accessibility of the new views', () => { test('is axe clean with the ladder, the statistics and a result on the page', async ({ page, }) => { - await page.goto('./'); + await gotoApp(page); await loadReferenceLayout(page); await expectNoAxeViolations(page, 'the ladder and statistics before any drill'); @@ -299,7 +310,7 @@ test.describe('the accessibility of the new views', () => { test('animates nothing in these views when reduced motion is asked for', async ({ page }) => { await page.emulateMedia({ reducedMotion: 'reduce' }); - await page.goto('./'); + await gotoApp(page); await loadReferenceLayout(page); await earnThreeStars(page); @@ -324,7 +335,7 @@ test.describe('the accessibility of the new views', () => { }); test('drives the ladder and the export controls from the keyboard alone', async ({ page }) => { - await page.goto('./'); + await gotoApp(page); // Choosing the layout file is the one step a test cannot do with keystrokes. await page.locator('#layout-file').setInputFiles(REFERENCE_LAYOUT_PATH); await expect(page.locator('#ladder-section')).toBeVisible(); diff --git a/tests/functional/progress-view-wiring.test.ts b/tests/functional/progress-view-wiring.test.ts index 24574fa..7ebc89d 100644 --- a/tests/functional/progress-view-wiring.test.ts +++ b/tests/functional/progress-view-wiring.test.ts @@ -18,7 +18,6 @@ import { ProgressStore, type StorageDriver, } from '../../src/stats/storage.js'; -import { SAMPLE_DRILL_TEXT } from '../../src/ui/drill-view.js'; import { chooseStorage, wireUp, type ChosenStorage } from '../../src/ui/load-form.js'; import { readReferenceLayout } from '../fixtures/index.js'; @@ -114,8 +113,29 @@ async function start(driver: StorageDriver): Promise { } /** Type a whole drill, cleanly, at a pace that is worth three stars. */ +/** + * The text the drill is actually showing, read back from the surface. + * + * Deliberately not the sample constant: the drill text is generated from the + * lesson now, so a test that assumed one fixed sentence would pass only until + * the generator changed. Reading the rendered characters keeps these tests about + * what they are really testing, which is that typing a drill cleanly scores it. + */ +function renderedDrillText(): string { + const surface = document.querySelector('[aria-label="Drill text"]'); + if (surface === null) throw new Error('No drill surface is on the page'); + const characters = [...surface.querySelectorAll('[data-index]')] + .map((element) => ({ + index: Number.parseInt(element.dataset['index'] ?? '', 10), + text: element.textContent, + })) + .sort((a, b) => a.index - b.index); + if (characters.length === 0) throw new Error('The drill surface rendered no characters'); + return characters.map((character) => character.text).join(''); +} + function typeCleanly(clock: ReturnType): void { - for (const character of Array.from(SAMPLE_DRILL_TEXT)) { + for (const character of Array.from(renderedDrillText())) { clock.advance(90); document.dispatchEvent( new KeyboardEvent('keydown', { key: character, bubbles: true, cancelable: true }),