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
1 change: 1 addition & 0 deletions src/drill/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export * from './limits.js';
export * from './types.js';
export * from './engine.js';
export * from './text.js';
export * from './sprint.js';
export * from './repair.js';
35 changes: 32 additions & 3 deletions src/ui/drill-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ import {
} from '../drill/sprint.js';
import type { Keymap } from '../keymap/types.js';
import { keyStatId } from '../stats/storage.js';
import { required } from './dom.js';
import { announceInto, required } from './dom.js';

/**
* A sample drill, and the seam where generated text will arrive.
Expand Down Expand Up @@ -147,6 +147,12 @@ export interface DrillViewOptions {
* not pass this, and the button offers another go at this lesson instead.
*/
readonly onAdvance?: () => void;
/**
* Called when the learner takes up an offer to drill the keys that slipped,
* with exactly the keys the scoring picked out. Like `onAdvance`, the offer is
* only made when there is something wired up to deliver it.
*/
readonly onRepair?: (weakKeys: readonly string[]) => 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 @@ -343,8 +349,11 @@ export function createDrillView(options: DrillViewOptions): DrillView {
const lessonName = options.lessonName ?? null;
const nextLessonName = options.nextLessonName ?? null;
const onAdvance = options.onAdvance ?? null;
const onRepair = options.onRepair ?? null;
/** Whether the result on screen earned a move to the next lesson. */
let advanceEarned = false;
/** The keys the result on screen offers to repair, empty when it offers none. */
let repairOffered: readonly string[] = [];
const limitMs = options.limitMs ?? NO_LIMIT;
const session =
options.session ?? createDrillSession(options.now === undefined ? {} : { now: options.now });
Expand Down Expand Up @@ -430,10 +439,14 @@ export function createDrillView(options: DrillViewOptions): DrillView {
el.resultWhy.textContent = '';
el.continue.textContent = '';
advanceEarned = false;
repairOffered = [];
}

function announce(message: string): void {
el.progress.textContent = message;
// Through the shared helper, because a live region only speaks when its
// contents change: mistyping the same key twice in a row produces the same
// sentence twice, and the second one was silent.
announceInto(el.progress, message);
}

function setCaptureState(message: string): void {
Expand Down Expand Up @@ -668,7 +681,17 @@ export function createDrillView(options: DrillViewOptions): DrillView {
// 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';
// A repair is offered only when scoring picked keys out AND something is
// wired up to build one. Same rule as advancing: never name a thing on a
// button that the button will not do.
repairOffered =
!advanceEarned && onRepair !== null && score.nextStep.repairKeys.length > 0
? score.nextStep.repairKeys
: [];
el.continue.textContent =
advanceEarned || repairOffered.length > 0
? score.nextStep.label
: `Drill this ${mode === 'lesson' ? 'lesson' : mode} again`;
el.result.hidden = false;

// Unhidden first, then written, so the assertive region announces the result
Expand Down Expand Up @@ -860,6 +883,12 @@ export function createDrillView(options: DrillViewOptions): DrillView {
onAdvance();
return;
}
if (repairOffered.length > 0 && onRepair !== null) {
const keys = repairOffered;
repairOffered = [];
onRepair(keys);
return;
}
startDrill();
}

Expand Down
54 changes: 54 additions & 0 deletions src/ui/load-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
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 { generateRepairText, RepairDrillError } from '../drill/repair.js';
import { highestUnlockedLesson } from '../drill/scoring.js';
import { SprintError, sprintLesson, sprintWordCount } from '../drill/sprint.js';
import { summariseKeymap } from './layout-summary.js';
Expand Down Expand Up @@ -226,6 +227,30 @@ export function wireUp(root: ParentNode = document, options: WireUpOptions = {})
// 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,
// Taking up the offer to repair rebuilds the drill from exactly the keys
// that slipped. The keys come from scoring, which took them from this
// lesson's own statistics, so they are typeable here by construction --
// but generateRepairText throws if they are not, and that throw is
// surfaced rather than caught and turned back into an ordinary drill. A
// repair drill that quietly practises the wrong keys is the whole thing
// this feature exists to avoid.
...(lesson === null
? {}
: {
onRepair: (weakKeys: readonly string[]): void => {
let text: string;
try {
text = generateRepairText(lesson, weakKeys, { seed: seed() });
} catch (cause) {
if (cause instanceof RepairDrillError) {
showError(`Could not build a repair drill for ${lesson.name}: ${cause.message}`);
return;
}
throw cause;
}
showRepair(keymap, lesson, text);
},
}),
// And the offer is real: taking it rebuilds the drill on the next lesson
// and remembers where the learner has got to.
...(nextLesson === null
Expand Down Expand Up @@ -323,6 +348,35 @@ export function wireUp(root: ParentNode = document, options: WireUpOptions = {})
* `showError` hides every section, which is right for a layout that would not
* parse and wrong here: the lesson on screen is still perfectly good.
*/
/**
* Replaces the drill surface with a repair drill built from the keys that
* slipped, on the lesson they slipped in.
*
* It carries the lesson's id, so the keys it practises go back into the same
* per-key statistics the weak-key report reads. It carries no next lesson: a
* repair is not a rung and must never look like one, or a learner could climb
* the ladder by fumbling.
*/
function showRepair(keymap: Keymap, lesson: Lesson, text: string): void {
clearError();
drillView?.destroy();
drillView = createDrillView({
board: GLOVE80,
keymap,
text,
session,
mode: 'repair',
lessonId: lesson.id,
lessonName: lesson.name,
nextLessonName: null,
root,
onNextKey: followNextKey,
onScored: saveAndRefresh,
});
drillSection.hidden = false;
drillStart.focus();
}

function showSprintError(message: string): void {
error.textContent = message;
error.hidden = false;
Expand Down
18 changes: 18 additions & 0 deletions tests/e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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 { sprintLesson, sprintWordCount } from '../../src/drill/sprint.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';
Expand Down Expand Up @@ -141,3 +142,20 @@ export function expectedKeyHand(character: string): string {
}
return key.hand;
}

/**
* The text a sprint will drill, worked out the same way the app works it out.
*
* With no saved progress only the first rung is unlocked, so that is what a
* sprint runs across.
*/
export function expectedSprintText(limitMs: number): string {
return generateDrillText(sprintLesson(referenceLadder(), 0), {
seed: DRILL_SEED,
words: sprintWordCount(limitMs),
});
}

export function sprintPrefix(count: number, limitMs: number): string {
return [...expectedSprintText(limitMs)].slice(0, count).join('');
}
77 changes: 56 additions & 21 deletions tests/e2e/pending.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,20 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { expect, test } from '@playwright/test';
import {
DRILL_SEED,
REFERENCE_LAYOUT_PATH,
drillPrefix,
drillCharAt,
wrongKeyFor,
expectedDrillText,
expectedKeyDescription,
gotoApp,
loadReferenceLayout,
referenceLadder,
expectedSprintText,
sprintPrefix,
} from './helpers.js';
import { sprintLesson, sprintWordCount } from '../../src/drill/sprint.js';
import { generateDrillText } from '../../src/drill/text.js';
import type { Page } from '@playwright/test';

/**
* The text a sprint will drill, worked out the way the app works it out: the
* pinned seed, and the whole key set unlocked so far. With no saved progress
* that is the first rung, so this is the ladder's first lesson's keys rather
* than its text.
*/
function expectedSprintText(limitMs: number): string {
return generateDrillText(sprintLesson(referenceLadder(), 0), {
seed: DRILL_SEED,
words: sprintWordCount(limitMs),
});
}

function sprintPrefix(count: number, limitMs: number): string {
return [...expectedSprintText(limitMs)].slice(0, count).join('');
}

/**
* Tab forward until the element with this id has focus, returning every stop on
* the way so a failure says where focus actually went. Out here rather than in a
Expand Down Expand Up @@ -131,7 +115,58 @@ test.describe('the trainer journeys', () => {
await expect(page.locator('#drill-text')).not.toHaveText(expectedDrillText(0));
});

test.fixme('builds a repair drill from exactly the keys that were missed', () => {});
test('builds a repair drill from exactly the keys that were missed', async ({ page }) => {
await gotoApp(page);
await loadReferenceLayout(page);
await page.getByRole('button', { name: 'Start drill' }).click();

// Fumble the third key repeatedly, then type the rest cleanly. Enough
// mistakes to stay under two stars, so the result offers a repair rather
// than the next rung -- a learner must not climb the ladder by fumbling.
const fumbled = drillCharAt(2);
await page.keyboard.type(drillPrefix(2), { delay: 15 });
for (let attempt = 0; attempt < 5; attempt += 1) {
await page.keyboard.press(wrongKeyFor(fumbled));
await page.keyboard.press('Backspace');
}
await page.keyboard.type(expectedDrillText().slice(2), { delay: 15 });
await expect(page.locator('#drill-result')).toBeVisible();

// The offer names the key that slipped. The card presents it upper case, so
// the comparison ignores case rather than assuming which one it picked.
const offer = page.locator('#drill-continue');
await expect(offer).toContainText(new RegExp(`repair`, 'i'));
await expect(offer).toContainText(
new RegExp(fumbled.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'),
);

await offer.click();

// The repair drill is built from exactly that key, plus anchors it can
// type. Every character in it must be one the lesson has unlocked.
const drillText = page.locator('#drill-text');
await expect(drillText).toContainText(fumbled);
await expect(drillText).not.toHaveText(expectedDrillText());

// Read back for the set check below, which needs the characters themselves
// rather than a match against them.
const repair = await drillText.innerText();

const lesson = referenceLadder()[0];
expect(lesson).toBeDefined();
const allowed = new Set([...lesson!.keys, ' ']);
for (const character of repair) {
expect(allowed.has(character), `repair drill used ${JSON.stringify(character)}`).toBe(true);
}

// And it is typeable: the surface accepts the first key of it.
await page
.getByRole('button', { name: /Start|Resume/ })
.first()
.click();
await page.keyboard.press([...repair][0] ?? 'a');
await expect(page.locator('#drill-text .drill-char[data-mark="correct"]')).toHaveCount(1);
});

test('runs a sprint to its limit, and offers an untimed option', async ({ page }) => {
// The page's own clock is faked, so a thirty second sprint costs no wall
Expand Down
17 changes: 2 additions & 15 deletions tests/e2e/sprint.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { expect, test, type Page } from '@playwright/test';
import { NO_LIMIT, SPRINT_DURATIONS_MS } from '../../src/drill/limits.js';
import { sprintLesson, sprintWordCount } from '../../src/drill/sprint.js';
import { generateDrillText } from '../../src/drill/text.js';
import {
DRILL_SEED,
expectedDrillText,
expectNoAxeViolations,
gotoApp,
loadReferenceLayout,
referenceLadder,
expectedSprintText,
sprintPrefix,
} from './helpers.js';

/**
Expand All @@ -23,18 +22,6 @@ import {
* the generator fails these rather than being silently agreed with.
*/

/** With no saved progress only the first rung is unlocked, so that is the sprint. */
function expectedSprintText(limitMs: number): string {
return generateDrillText(sprintLesson(referenceLadder(), 0), {
seed: DRILL_SEED,
words: sprintWordCount(limitMs),
});
}

function sprintPrefix(count: number, limitMs: number): string {
return [...expectedSprintText(limitMs)].slice(0, count).join('');
}

/** Choose a duration and start a sprint with it. */
async function startSprint(page: Page, limitMs: number): Promise<void> {
await page.locator('#sprint-duration').selectOption(String(limitMs));
Expand Down
Loading