Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -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 });
32 changes: 28 additions & 4 deletions src/ui/drill-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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);
},
};
Expand Down
89 changes: 74 additions & 15 deletions src/ui/load-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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 <the next lesson>" 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) {
Expand Down Expand Up @@ -218,15 +277,15 @@ 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();
},
});

// 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 {
Expand Down
52 changes: 32 additions & 20 deletions tests/e2e/drill.spec.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -61,7 +70,7 @@ async function startDrill(page: Page): Promise<void> {
}

test.beforeEach(async ({ page }) => {
await page.goto('./');
await gotoApp(page);
await loadReferenceLayout(page);
await expect(page.locator('#drill-section')).toBeVisible();
});
Expand All @@ -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');
Expand All @@ -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<string> =>
style(page, `#drill-text .drill-char[data-mark="${mark}"]`, property);
Expand All @@ -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])`;
Expand All @@ -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)),
);
});

Expand All @@ -162,16 +175,15 @@ 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();
});

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();
Expand Down Expand Up @@ -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);

Expand All @@ -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');
Expand Down
Loading
Loading