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 }),