From d83cb98909850cb6f24342ed690eeee32946363f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C4=ABlav=C4=81pi=20Cheesley?= Date: Tue, 22 Sep 2026 18:57:51 +0100 Subject: [PATCH] Build repair drills from exactly the keys that slipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `nextStep` has been promising "a short drill built from those keys, mixed with home row anchors" since scoring landed, and nothing produced one. This is the other half of that sentence: `generateRepairText(lesson, weakKeys)` in the new `src/drill/repair.ts`. Every weak key given appears in the text, and nothing outside the weak keys, their anchors and the separator ever does. The group count is raised to the number of weak keys when a shorter drill was asked for, because a repair drill that silently drops a key is worse than none: the learner practises and the miss survives. The text is checked against both sets before it is handed out, so an omission throws rather than shipping. Anchors are what stop it reading like mashing. Each group sets the weak key among steady keys, chained through the common bigrams that small alphabet can type, and uses a real word from the lesson's pool whenever one is made only of those keys. The weak key lands in a random slot rather than always at the front, and a second placement is never adjacent, because drilling `pp` trains a doubled key rather than the one that slipped. The anchors default to the front of the lesson's cumulative key set, which is the resting position — `generateLadder` builds the home lesson from `board.homePositions` first — and a caller holding the board can pass the real home row instead. Nothing is swallowed. A weak key the lesson cannot type is a fault in whatever selected it, so it throws with the key named; so does whitespace, something that is not one key, and an empty weak-key list, because "repair nothing" is a caller that should not have asked rather than an ordinary drill. Seeded with the same mulberry32 the text generator uses, so a repair drill that goes wrong is reproducible from its lesson, keys and seed. `keystats.ts` already did the weak-key analysis and already refused to draw a conclusion from too few presses, so none of that is repeated here. What it had no way to hand over was a plain ranked list, its shape being two levels deep for the view that reads it; `selectWeakKeys` and `weakKeyCharacters` flatten it, worst first, measured keys only. Wiring this into the result card follows separately, while the UI files are contended. Co-Authored-By: Claude Opus 5 --- src/drill/index.ts | 1 + src/drill/repair.ts | 518 ++++++++++++++++++++++++++++++++++++ src/stats/keystats.ts | 70 +++++ tests/unit/keystats.test.ts | 54 ++++ tests/unit/repair.test.ts | 269 +++++++++++++++++++ 5 files changed, 912 insertions(+) create mode 100644 src/drill/repair.ts create mode 100644 tests/unit/repair.test.ts diff --git a/src/drill/index.ts b/src/drill/index.ts index 50f66f8..6f3dd99 100644 --- a/src/drill/index.ts +++ b/src/drill/index.ts @@ -9,3 +9,4 @@ export * from './limits.js'; export * from './types.js'; export * from './engine.js'; export * from './text.js'; +export * from './repair.js'; diff --git a/src/drill/repair.ts b/src/drill/repair.ts new file mode 100644 index 0000000..c4c507e --- /dev/null +++ b/src/drill/repair.ts @@ -0,0 +1,518 @@ +/** + * Repair drills: given the keys that slipped, something that drills exactly + * those keys and nothing else. + * + * `nextStep` in `scoring.ts` decides that a repair drill is what the learner + * needs and hands over the keys, worded as "a short drill built from those keys, + * mixed with home row anchors". This module is the other half of that sentence. + * + * Three rules, and the whole module is those rules: + * + * **Exactly those keys.** Every weak key handed in appears in the text, and + * nothing outside the weak keys, the anchors and the separator ever does. A + * repair drill that quietly drops a key is worse than no repair drill: the + * learner practises and the miss survives. So the number of groups is at least + * the number of weak keys, whatever length was asked for, and the text is + * checked against both sets before it is handed out. + * + * **Anchors, so it reads like typing.** A bare run of the missed key is mashing, + * not typing, and it drills the key out of the context a finger actually meets + * it in. Each group sets the weak key among steady keys, chained through the + * common bigrams that small alphabet can type, and a real word is used whenever + * the pool holds one made only of those keys. The anchors default to the front + * of the lesson's cumulative key set, which is the resting position: + * `generateLadder` builds the home lesson from `board.homePositions` first, so + * the earliest keys of `lesson.keys` are the ones the hands are already sitting + * on. A caller that knows the board may say so instead, through `anchors`. + * + * **A caller's bug is not swallowed.** A weak key the lesson cannot type is a + * fault in whatever selected it, and dropping it silently would hide that while + * producing a drill that looks fine. It throws, naming the key. So does an empty + * weak-key list: "repair nothing" is not an ordinary drill, it is a caller that + * should not have asked. + * + * On which keys count as weak: that decision belongs to `summariseKeyStats` in + * `../stats/keystats.ts`, and `selectWeakKeys` there is the ranked list this + * function is meant to be fed. In particular `WEAK_KEY_THRESHOLDS.minAttempts` + * is what stops one miss on a key pressed twice from being called a weakness. + * Nothing here re-decides that; a key that arrives here is already weak. + * + * Seeded, like `text.ts`, and never `Math.random`: a repair drill that goes + * wrong has to be reproducible from its lesson, its weak keys and its seed. + * + * DOM-free by rule, like the rest of the drill layer. + */ + +import { topBigrams } from '../ladder/frequency.js'; +import type { Lesson } from '../ladder/generate.js'; +import { drillAlphabet, drillWordPool } from './text.js'; + +export interface RepairDrillOptions { + /** + * Target length in whitespace separated groups. A repair drill never comes + * back shorter than the number of weak keys, because every one of them has to + * appear, so asking for fewer groups than there are keys overshoots. + */ + readonly words?: number; + /** + * Determinism. The same lesson, weak keys and seed always give the same text. + * Defaults to a hash of the lesson id and the weak keys, so a call without one + * is reproducible too, and two different sets of weak keys do not come back + * with the same shape of drill. + */ + readonly seed?: number; + /** + * The steady keys to set the weak ones among. Defaults to the resting keys of + * the lesson, which is what "home row anchors" means when a lesson is all we + * have; a caller holding the board and keymap can pass the real home row. + * + * Every anchor has to be typable by the lesson and must not itself be weak: + * anchoring a miss against another miss is not an anchor. An empty list is + * refused rather than taken to mean "no anchors", because it is far more + * likely to be a caller whose own selection came back empty. + */ + readonly anchors?: readonly string[]; +} + +export class RepairDrillError extends Error { + override readonly name = 'RepairDrillError'; + constructor(problem: string, options?: ErrorOptions) { + super(`Cannot build a repair drill: ${problem}`, options); + } +} + +/** Shorter than a lesson drill on purpose: a repair is a detour, not a lesson. */ +export const DEFAULT_REPAIR_GROUP_COUNT = 10; + +/** + * How many steady keys a repair drill anchors against. Enough for the groups to + * vary, few enough that they stay the keys the hands are resting on rather than + * half the layout. + */ +export const MAX_REPAIR_ANCHORS = 6; + +const MIN_GROUP_LENGTH = 3; +const MAX_GROUP_LENGTH = 5; + +/** How often a group is a real word rather than a built cluster, when one fits. */ +const WORD_BIAS = 0.5; + +/** How often a filled slot follows a common bigram rather than choosing freely. */ +const CHAIN_BIAS = 0.7; + +/** How often a longer group gets the weak key a second time, never adjacent. */ +const SECOND_WEAK_CHANCE = 0.4; + +/** Only the common pairs make a group read like typing; the tail does not. */ +const REPAIR_BIGRAM_LIMIT = 48; + +/** One code point, matched rather than counted, so an astral character is one key. */ +const ONE_CHARACTER = /^[\s\S]$/u; + +const LOWER_CASE_LETTER = /^[a-z]$/u; + +/** + * mulberry32 and FNV-1a, the same pair `text.ts` uses. They are duplicated + * rather than shared because they are four lines each and exporting a random + * number generator from the text module would make it part of that module's + * contract; what matters is that neither module ever reaches for `Math.random`. + */ +function createRandom(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let value = state; + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + return ((value ^ (value >>> 14)) >>> 0) / 0x1_0000_0000; + }; +} + +function hashSeed(text: string): number { + let hash = 0x811c9dc5; + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 0x0100_0193) >>> 0; + } + return hash >>> 0; +} + +function pick(random: () => number, items: readonly T[], what: string): T { + if (items.length === 0) { + throw new RepairDrillError(`there is no ${what} to choose from`); + } + // Clamped, so a generator that ever returned exactly 1 cannot walk off the end. + const index = Math.min(Math.floor(random() * items.length), items.length - 1); + const item = items[index]; + if (item === undefined) { + throw new RepairDrillError(`choosing a ${what} fell off the end of a list of ${items.length}`); + } + return item; +} + +function show(key: string): string { + return JSON.stringify(key); +} + +function list(keys: readonly string[]): string { + return keys.map(show).join(', '); +} + +/** + * Every character this lesson can type. The lesson is the outer boundary: a + * repair drill may narrow what it uses, never widen it. + */ +function alphabetFor(lesson: Lesson): ReadonlySet { + try { + return drillAlphabet(lesson); + } catch (cause) { + throw new RepairDrillError(`lesson "${lesson.id}" has no usable key set`, { cause }); + } +} + +/** + * The weak keys, checked against the lesson and deduplicated. + * + * Nothing is dropped quietly. A key the lesson cannot type, a key that is not + * one character, and whitespace are each a bug in whoever selected them, and + * each throws with the key named. A key listed twice is the one harmless case: + * it asks for nothing the first mention did not, so it collapses. + */ +function checkWeakKeys( + lesson: Lesson, + weakKeys: readonly string[], + allowed: ReadonlySet, +): readonly string[] { + const cleaned: string[] = []; + + for (let index = 0; index < weakKeys.length; index += 1) { + const key = weakKeys[index]; + if (key === undefined) { + throw new RepairDrillError(`weak keys must have no gaps; index ${index} was missing`); + } + if (!ONE_CHARACTER.test(key)) { + throw new RepairDrillError(`${show(key)} is not a single key, so it cannot be drilled`); + } + if (key.trim() === '') { + throw new RepairDrillError( + `${show(key)} is whitespace, which cannot be drilled on its own; scoring already strips it from its weak keys, so a caller reaching here with it has a bug`, + ); + } + if (!allowed.has(key)) { + throw new RepairDrillError( + `lesson "${lesson.id}" cannot type ${show(key)}, so a repair drill for it would be a drill the learner cannot finish`, + ); + } + if (!cleaned.includes(key)) cleaned.push(key); + } + + return cleaned; +} + +/** + * The steady keys a repair drill sets its weak ones among. + * + * Lower case letters first, because those are what the word pool and the bigram + * table are made of; a lesson whose key set holds nothing else anchors against + * whatever it does have. In ladder order, which puts the resting keys first. + * + * Empty is a real answer: a lesson every one of whose keys is weak has nothing + * steady left to lean on. The drill is then the weak keys alone, which is honest + * rather than pretty, and it is the only case in which this module produces + * something that reads like mashing. + */ +export function repairAnchors( + lesson: Lesson, + weakKeys: readonly string[], + options?: RepairDrillOptions, +): readonly string[] { + const allowed = alphabetFor(lesson); + const weak = checkWeakKeys(lesson, weakKeys, allowed); + return chooseAnchors(lesson, weak, allowed, options); +} + +function chooseAnchors( + lesson: Lesson, + weak: readonly string[], + allowed: ReadonlySet, + options: RepairDrillOptions | undefined, +): readonly string[] { + const given = options?.anchors; + if (given !== undefined) return checkAnchors(lesson, given, weak, allowed); + + const weakSet = new Set(weak); + const seen = new Set(); + const letters: string[] = []; + const others: string[] = []; + + for (const key of lesson.keys) { + if (weakSet.has(key) || seen.has(key)) continue; + // A lesson lists its own keys, so anything it lists is typable; the check + // costs nothing and means a hand-made lesson cannot smuggle a key in. + if (!allowed.has(key) || key.trim() === '') continue; + seen.add(key); + if (LOWER_CASE_LETTER.test(key)) letters.push(key); + else others.push(key); + } + + const chosen = letters.length > 0 ? letters : others; + return chosen.slice(0, MAX_REPAIR_ANCHORS); +} + +function checkAnchors( + lesson: Lesson, + given: readonly string[], + weak: readonly string[], + allowed: ReadonlySet, +): readonly string[] { + if (given.length === 0) { + throw new RepairDrillError( + 'an empty anchor list was given; leave anchors out to take the lesson’s resting keys, rather than asking for a drill with nothing to anchor against', + ); + } + + const weakSet = new Set(weak); + const cleaned: string[] = []; + + for (let index = 0; index < given.length; index += 1) { + const anchor = given[index]; + if (anchor === undefined) { + throw new RepairDrillError(`anchors must have no gaps; index ${index} was missing`); + } + if (!ONE_CHARACTER.test(anchor) || anchor.trim() === '') { + throw new RepairDrillError( + `${show(anchor)} is not a single typable key, so it cannot anchor`, + ); + } + if (!allowed.has(anchor)) { + throw new RepairDrillError(`lesson "${lesson.id}" cannot type the anchor ${show(anchor)}`); + } + if (weakSet.has(anchor)) { + throw new RepairDrillError( + `${show(anchor)} is both an anchor and a weak key, and a key that slips cannot steady another one`, + ); + } + if (!cleaned.includes(anchor)) cleaned.push(anchor); + } + + return cleaned; +} + +interface Settings { + readonly groups: number; + readonly seed: number; +} + +function readOptions( + lesson: Lesson, + weak: readonly string[], + options: RepairDrillOptions | undefined, +): Settings { + const groups = options?.words ?? DEFAULT_REPAIR_GROUP_COUNT; + if (!Number.isInteger(groups) || groups < 1) { + throw new RangeError(`words must be a positive integer, got ${JSON.stringify(options?.words)}`); + } + + const seed = options?.seed ?? hashSeed(`${lesson.id}\u0000${weak.join('')}`); + if (!Number.isInteger(seed)) { + throw new RangeError(`seed must be an integer, got ${JSON.stringify(options?.seed)}`); + } + + return { groups, seed: seed >>> 0 }; +} + +/** + * Which key may follow which, over the repair drill's own small alphabet, so a + * built group follows the pairs English actually uses instead of rattling. + */ +function chainsFor(keys: readonly string[]): ReadonlyMap { + const chains = new Map(); + for (const { bigram } of topBigrams(keys, REPAIR_BIGRAM_LIMIT)) { + const first = bigram.slice(0, 1); + const second = bigram.slice(1, 2); + const followers = chains.get(first); + if (followers === undefined) chains.set(first, [second]); + else followers.push(second); + } + return chains; +} + +/** + * For each weak key, the words the lesson can type that are made only of the + * repair drill's own keys and use that one. Usually a handful, often none: the + * built clusters are the fallback and the common case. + */ +function wordsByWeakKey( + lesson: Lesson, + weak: readonly string[], + anchors: readonly string[], +): ReadonlyMap { + const usable = new Set([...weak, ...anchors]); + const pool = drillWordPool(lesson).filter((word) => + Array.from(word).every((character) => usable.has(character)), + ); + + const byKey = new Map(); + for (const key of weak) { + byKey.set( + key, + pool.filter((word) => word.includes(key)), + ); + } + return byKey; +} + +/** + * One group: the weak key set among the anchors, or a real word using it. + * + * The weak key lands in a random slot rather than always at the front, because a + * finger has to find it mid-word as well as from rest, and a second placement is + * never adjacent to the first: drilling `pp` trains a doubled key, which is a + * different skill from the one that slipped. + */ +function buildGroup( + random: () => number, + weakKey: string, + fill: readonly string[], + chains: ReadonlyMap, + words: readonly string[], +): string { + if (words.length > 0 && random() < WORD_BIAS) return pick(random, words, 'word'); + + const length = + MIN_GROUP_LENGTH + Math.floor(random() * (MAX_GROUP_LENGTH - MIN_GROUP_LENGTH + 1)); + const slots: (string | null)[] = Array.from({ length }, () => null); + + const first = Math.min(Math.floor(random() * length), length - 1); + slots[first] = weakKey; + + if (length >= 4 && random() < SECOND_WEAK_CHANCE) { + const offset = 2 + Math.floor(random() * (length - 2)); + const second = (first + offset) % length; + if (Math.abs(second - first) > 1) slots[second] = weakKey; + } + + const out: string[] = []; + for (let index = 0; index < length; index += 1) { + const slot = slots[index]; + if (slot !== null && slot !== undefined) { + out.push(slot); + continue; + } + + const previous = out.at(-1); + const followers = previous === undefined ? undefined : chains.get(previous); + const usable = followers?.filter((follower) => fill.includes(follower)) ?? []; + const chained = usable.length > 0 && random() < CHAIN_BIAS; + out.push(chained ? pick(random, usable, 'anchor') : pick(random, fill, 'anchor')); + } + + return out.join(''); +} + +/** + * Check the text before handing it out, the way `text.ts` does. Everything above + * is meant to make each of these impossible; they are here because a repair + * drill that quietly omitted the key it was built for would look perfectly fine + * and teach nothing. + */ +function assertRepairText( + text: string, + lesson: Lesson, + allowed: ReadonlySet, + weak: readonly string[], + anchors: readonly string[], + separator: string, +): void { + if (text === '') { + throw new RepairDrillError( + `lesson "${lesson.id}" produced no text for ${list(weak)}, which admits nothing to drill`, + ); + } + if (text.trim() !== text || /\s\s/u.test(text)) { + throw new RepairDrillError( + `lesson "${lesson.id}" produced text with padding or doubled spaces: ${show(text)}`, + ); + } + + const permitted = new Set([...weak, ...anchors]); + if (separator !== '') permitted.add(separator); + + for (const character of text) { + if (!allowed.has(character)) { + throw new RepairDrillError( + `lesson "${lesson.id}" cannot type ${show(character)}, which its repair text contains`, + ); + } + if (!permitted.has(character)) { + throw new RepairDrillError( + `the repair text contains ${show(character)}, which is neither a weak key nor an anchor`, + ); + } + } + + const missing = weak.filter((key) => !text.includes(key)); + if (missing.length > 0) { + throw new RepairDrillError( + `the repair text never uses ${list(missing)}, and a repair drill has to drill every key it was given`, + ); + } +} + +/** + * A drill built from exactly these weak keys, set among the lesson's steady + * ones, and the same every time for the same seed. + * + * Throws rather than returning something unusable. An empty string would reach + * the engine as a drill that is already finished; a weak key the lesson cannot + * type is a fault in whoever selected it; and a drill missing one of the keys it + * was built for is the bug this whole feature exists to prevent. + */ +export function generateRepairText( + lesson: Lesson, + weakKeys: readonly string[], + options?: RepairDrillOptions, +): string { + if (weakKeys.length === 0) { + throw new RepairDrillError( + `lesson "${lesson.id}" was asked to repair nothing; a repair drill targets the keys that slipped, and a drill with no target is an ordinary drill`, + ); + } + + const allowed = alphabetFor(lesson); + const weak = checkWeakKeys(lesson, weakKeys, allowed); + const anchors = chooseAnchors(lesson, weak, allowed, options); + const { groups: target, seed } = readOptions(lesson, weak, options); + + const random = createRandom(seed); + const separator = allowed.has(' ') ? ' ' : ''; + const chains = chainsFor([...anchors, ...weak]); + const words = wordsByWeakKey(lesson, weak, anchors); + // Anchors are empty only when every key the lesson knows is weak, and then the + // weak keys anchor each other. Never an empty alphabet: `weak` is checked to + // be non-empty above. + const fill = anchors.length > 0 ? anchors : weak; + + // Every weak key gets a group of its own before any key gets a second, so a + // short drill can never leave one of them undrilled. + const count = Math.max(target, weak.length); + const built: string[] = []; + + try { + for (let index = 0; index < count; index += 1) { + const key = weak[index % weak.length]; + if (key === undefined) { + throw new RepairDrillError(`weak key ${index % weak.length} went missing mid-build`); + } + built.push(buildGroup(random, key, fill, chains, words.get(key) ?? [])); + } + } catch (cause) { + throw new RepairDrillError(`lesson "${lesson.id}" admits no repair drill for ${list(weak)}`, { + cause, + }); + } + + const text = built.join(separator); + assertRepairText(text, lesson, allowed, weak, anchors, separator); + return text; +} diff --git a/src/stats/keystats.ts b/src/stats/keystats.ts index c9e147a..ccc867a 100644 --- a/src/stats/keystats.ts +++ b/src/stats/keystats.ts @@ -550,3 +550,73 @@ function reportSummary( } return `${accuracy}% of ${totalAttempts} presses were right. Your ${worst.label} is where most of the misses are.`; } + +export interface WeakKeySelection { + /** + * Take the keys that are only worth watching as well as the ones that need + * work. Off by default: a repair drill built from everything that has ever + * wobbled is a lesson, not a repair. + */ + readonly includeWatch?: boolean; + /** + * At most this many keys, worst first. Absent means every key that qualifies; + * a caller with a limited amount of room says so rather than slicing after + * the fact, so the ordering is decided in one place. + */ + readonly limit?: number; +} + +/** + * The keys that need work, worst first, flattened out of the finger-and-row + * report. + * + * `summariseKeyStats` already does the analysis: it bands every key, orders the + * worst first inside each row, and refuses to draw a conclusion from too few + * presses. What it does not do is hand back a plain list, because its shape is + * deliberately two levels deep for the view that reads it. This is that list, + * and it is what feeds `nextStep` and `generateRepairText`: a repair drill wants + * keys, not a diagnosis. + * + * Only measured keys are ever returned. `WEAK_KEY_THRESHOLDS.minAttempts` is the + * rule that stops one miss on a key pressed twice from being called a weakness, + * and it is applied by `summariseKeyStats` when the report is built; the check + * here is belt and braces, so that a hand-made report cannot sneak an unmeasured + * key into a drill. + * + * Deterministic: equal need is settled by the character, so the same report + * always produces the same list in the same order. + */ +export function selectWeakKeys( + report: KeyStatsReport, + options?: WeakKeySelection, +): readonly WeakKey[] { + const limit = options?.limit; + if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 0)) { + throw new RangeError(`limit must be a non-negative whole number, got ${String(limit)}`); + } + + const wanted: readonly Severity[] = options?.includeWatch === true ? ['weak', 'watch'] : ['weak']; + + const keys = report.fingers + .flatMap((finger) => finger.rows) + .flatMap((row) => row.keys) + .filter((key) => key.measured && wanted.includes(key.severity)) + .sort((left, right) => { + const need = byNeed(left, right); + return need === 0 ? left.character.localeCompare(right.character) : need; + }); + + return limit === undefined ? keys : keys.slice(0, limit); +} + +/** + * The same list as characters, which is what both `nextStep` and + * `generateRepairText` take. Statistics are keyed by code point, so the + * character comes from the report rather than from an id being treated as one. + */ +export function weakKeyCharacters( + report: KeyStatsReport, + options?: WeakKeySelection, +): readonly string[] { + return selectWeakKeys(report, options).map((key) => key.character); +} diff --git a/tests/unit/keystats.test.ts b/tests/unit/keystats.test.ts index 2c77e13..b32231e 100644 --- a/tests/unit/keystats.test.ts +++ b/tests/unit/keystats.test.ts @@ -19,7 +19,9 @@ import { fingerLabel, InvalidThresholdsError, rowLabel, + selectWeakKeys, summariseKeyStats, + weakKeyCharacters, WEAK_KEY_THRESHOLDS, type FingerGroup, type RowGroup, @@ -265,3 +267,55 @@ describe('the words the grouping is stated in', () => { expect(fingerLabel('right', 'thumb')).toBe('right thumb'); }); }); + +describe('selecting the keys a repair drill should be built from', () => { + it('flattens the grouping into a list, worst first', () => { + // p is missed half the time, m one press in five, . not at all. + const grouped = report({ p: [5, 5], m: [16, 4], '.': [20, 0] }); + expect(weakKeyCharacters(grouped)).toEqual(['p', 'm']); + expect(selectWeakKeys(grouped)[0]?.summary).toBe('“p”, 5 of 10 presses missed, 50%'); + }); + + it('does not call a key weak on the strength of one miss', () => { + // One miss of three presses is half the miss rate of p above and still not + // a conclusion: WEAK_KEY_THRESHOLDS.minAttempts is four. + const grouped = report({ q: [2, 1], p: [5, 5] }); + expect(WEAK_KEY_THRESHOLDS.minAttempts).toBe(4); + expect(weakKeyCharacters(grouped)).toEqual(['p']); + + // A fourth press is the point at which it becomes evidence. + expect(weakKeyCharacters(report({ q: [3, 1] }))).toEqual(['q']); + }); + + it('leaves the keys that are only worth watching out unless asked', () => { + // m is missed one press in ten: worth watching, not worth a repair drill. + const grouped = report({ p: [5, 5], m: [18, 2] }); + expect(weakKeyCharacters(grouped)).toEqual(['p']); + expect(weakKeyCharacters(grouped, { includeWatch: true })).toEqual(['p', 'm']); + }); + + it('takes the worst few when a caller has only so much room', () => { + const grouped = report({ p: [1, 9], m: [5, 5], q: [8, 2] }); + expect(weakKeyCharacters(grouped, { limit: 2 })).toEqual(['p', 'm']); + expect(weakKeyCharacters(grouped, { limit: 0 })).toEqual([]); + }); + + it('is stable when two keys are as bad as each other', () => { + const grouped = report({ m: [5, 5], p: [5, 5] }); + expect(weakKeyCharacters(grouped)).toEqual(['m', 'p']); + }); + + it('refuses a limit it cannot use rather than guessing one', () => { + const grouped = report({ p: [5, 5] }); + expect(() => selectWeakKeys(grouped, { limit: -1 })).toThrow(RangeError); + expect(() => selectWeakKeys(grouped, { limit: 1.5 })).toThrow(RangeError); + }); + + it('never offers a key this layout cannot place', () => { + // A statistic carried over from another layout has no finger and no row, so + // it is reported in `unplaced` and never lands in a drill. + const grouped = report({ '€': [1, 9], p: [5, 5] }); + expect(grouped.unplaced.map((key) => key.character)).toEqual(['€']); + expect(weakKeyCharacters(grouped)).toEqual(['p']); + }); +}); diff --git a/tests/unit/repair.test.ts b/tests/unit/repair.test.ts new file mode 100644 index 0000000..7007630 --- /dev/null +++ b/tests/unit/repair.test.ts @@ -0,0 +1,269 @@ +/** + * Repair drills. + * + * The centre of this file is the exhaustive check: for every lesson of the + * reference ladder, across many seeds and several sets of weak keys, the text + * contains every weak key it was given and contains nothing but weak keys, + * anchors and the separator. Those two together are the feature — a repair drill + * that quietly drops a key looks fine and repairs nothing. + * + * Maltron is a trademark of PCD Maltron Ltd and the layout is used here only as + * test data. + */ + +import { describe, expect, it } from 'vitest'; +import { GLOVE80 } from '../../src/board/glove80.js'; +import { parseMoErgoLayoutText } from '../../src/keymap/moergo.js'; +import { generateLadder, type Lesson } from '../../src/ladder/index.js'; +import { + DEFAULT_REPAIR_GROUP_COUNT, + MAX_REPAIR_ANCHORS, + RepairDrillError, + generateRepairText, + repairAnchors, +} from '../../src/drill/repair.js'; +import { readReferenceLayout } from '../fixtures/index.js'; + +const LADDER: readonly Lesson[] = generateLadder( + parseMoErgoLayoutText(readReferenceLayout(), { board: GLOVE80 }), + GLOVE80, +); + +function lessonById(id: string): Lesson { + const lesson = LADDER.find((candidate) => candidate.id === id); + if (lesson === undefined) throw new Error(`the reference ladder has no lesson "${id}"`); + return lesson; +} + +/** A lesson that is not from the ladder, for the degenerate cases. */ +function madeUpLesson(keys: string, overrides?: Partial): Lesson { + return { + id: 'made-up', + name: 'Made up', + blurb: 'Not from a real layout.', + addedKeys: [...keys], + keys: [...keys], + stage: 'keys', + ...overrides, + }; +} + +const SEEDS = [0, 1, 2, 3, 7, 42, 99, 1234, 65535, 2 ** 31 - 1]; + +/** The keys of a lesson that a weak-key selection could plausibly name. */ +function drillableKeys(lesson: Lesson): readonly string[] { + return lesson.keys.filter((key) => key.trim() !== ''); +} + +describe('every lesson of the reference ladder', () => { + it('drills every weak key it is given, and nothing outside the lesson', () => { + for (const lesson of LADDER) { + const keys = drillableKeys(lesson); + const allowed = new Set(lesson.keys.flatMap((key) => [key, key.toUpperCase()])); + + // The first, the last and a spread of the lesson's own keys: whatever a + // selection came back with, the drill has to cover it. + const selections: readonly (readonly string[])[] = [ + keys.slice(0, 1), + keys.slice(-1), + keys.filter((_, index) => index % 3 === 0).slice(0, 6), + ]; + + for (const weakKeys of selections) { + if (weakKeys.length === 0) continue; + for (const seed of SEEDS) { + const text = generateRepairText(lesson, weakKeys, { seed }); + + expect(text).not.toBe(''); + for (const key of weakKeys) { + expect( + text.includes(key), + `lesson ${lesson.id} seed ${seed} never drilled ${JSON.stringify(key)}: ${text}`, + ).toBe(true); + } + for (const character of text) { + expect( + allowed.has(character) || character === ' ', + `lesson ${lesson.id} seed ${seed} produced ${JSON.stringify(character)}: ${text}`, + ).toBe(true); + } + } + } + } + }); + + it('uses nothing but the weak keys, their anchors and the separator', () => { + for (const lesson of LADDER) { + const weakKeys = drillableKeys(lesson).slice(-2); + if (weakKeys.length === 0) continue; + + const anchors = repairAnchors(lesson, weakKeys); + const permitted = new Set([...weakKeys, ...anchors, ' ']); + + for (const seed of SEEDS) { + for (const character of generateRepairText(lesson, weakKeys, { seed })) { + expect( + permitted.has(character), + `lesson ${lesson.id} reached for ${JSON.stringify(character)}, which is neither weak nor an anchor`, + ).toBe(true); + } + } + } + }); + + it('never pads, and never doubles a space', () => { + for (const lesson of LADDER) { + const weakKeys = drillableKeys(lesson).slice(0, 3); + if (weakKeys.length === 0) continue; + for (const seed of SEEDS) { + const text = generateRepairText(lesson, weakKeys, { seed }); + expect(text.trim()).toBe(text); + expect(text).not.toMatch(/\s\s/u); + } + } + }); +}); + +describe('mixing the weak keys with anchors', () => { + const lesson = lessonById('yp'); + + it('anchors against the lesson’s resting keys, never against a weak one', () => { + const anchors = repairAnchors(lesson, ['p']); + + expect(anchors.length).toBeGreaterThan(0); + expect(anchors.length).toBeLessThanOrEqual(MAX_REPAIR_ANCHORS); + expect(anchors).not.toContain('p'); + expect(anchors).not.toContain(' '); + // The home lesson comes first in the ladder, so the front of the cumulative + // key set is the position the hands are resting in. + const home = new Set(lessonById('home').keys); + for (const anchor of anchors) expect(home.has(anchor)).toBe(true); + }); + + it('reads like typing rather than like mashing', () => { + const anchors = new Set(repairAnchors(lesson, ['p'])); + + for (const seed of SEEDS) { + const text = generateRepairText(lesson, ['p'], { seed }); + const anchored = [...text].filter((character) => anchors.has(character)); + + // More anchor presses than weak ones: the weak key is set among keys the + // learner already has, which is the point of the exercise. + expect(anchored.length).toBeGreaterThan([...text].filter((c) => c === 'p').length); + // And never a doubled weak key, which trains a different skill. + expect(text).not.toContain('pp'); + } + }); + + it('takes anchors from the caller when it is given them', () => { + const text = generateRepairText(lesson, ['p'], { seed: 7, anchors: ['a', 's'] }); + for (const character of text) { + expect(['p', 'a', 's', ' ']).toContain(character); + } + }); + + it('falls back to the weak keys when a lesson has nothing steady left', () => { + // Every key of this lesson is weak, so there is nothing to anchor against. + const tiny = madeUpLesson('ab'); + expect(repairAnchors(tiny, ['a', 'b'])).toEqual([]); + + const text = generateRepairText(tiny, ['a', 'b'], { seed: 3 }); + expect(text).toContain('a'); + expect(text).toContain('b'); + expect(text.replace(/[ab]/gu, '')).toBe(''); + }); + + it('runs the groups together when the lesson has no space', () => { + const noSpace = madeUpLesson('asdfp'); + const text = generateRepairText(noSpace, ['p'], { seed: 1 }); + expect(text).not.toContain(' '); + expect(text).toContain('p'); + }); +}); + +describe('length and determinism', () => { + const lesson = lessonById('punct'); + + it('gives the same text for the same seed, and a different one for another', () => { + const first = generateRepairText(lesson, ['p', ','], { seed: 42 }); + expect(generateRepairText(lesson, ['p', ','], { seed: 42 })).toBe(first); + expect(generateRepairText(lesson, ['p', ','], { seed: 43 })).not.toBe(first); + }); + + it('is reproducible without a seed, and varies with the keys asked for', () => { + expect(generateRepairText(lesson, ['p'])).toBe(generateRepairText(lesson, ['p'])); + expect(generateRepairText(lesson, ['p'])).not.toBe(generateRepairText(lesson, [','])); + }); + + it('asks for the group count it was given', () => { + const text = generateRepairText(lesson, ['p'], { seed: 5, words: 4 }); + expect(text.split(' ')).toHaveLength(4); + + const standard = generateRepairText(lesson, ['p'], { seed: 5 }); + expect(standard.split(' ')).toHaveLength(DEFAULT_REPAIR_GROUP_COUNT); + }); + + it('overshoots a short request rather than dropping a key', () => { + const weakKeys = ['p', ',', 'y', 'b']; + const text = generateRepairText(lesson, weakKeys, { seed: 9, words: 1 }); + + expect(text.split(' ').length).toBeGreaterThanOrEqual(weakKeys.length); + for (const key of weakKeys) expect(text).toContain(key); + }); + + it('refuses a length or a seed that is not a number it can use', () => { + expect(() => generateRepairText(lesson, ['p'], { words: 0 })).toThrow(RangeError); + expect(() => generateRepairText(lesson, ['p'], { words: 2.5 })).toThrow(RangeError); + expect(() => generateRepairText(lesson, ['p'], { seed: 1.5 })).toThrow(RangeError); + }); +}); + +describe('refusing what it cannot honestly drill', () => { + const lesson = lessonById('yp'); + + it('refuses an empty weak-key list rather than building an ordinary drill', () => { + expect(() => generateRepairText(lesson, [])).toThrow(RepairDrillError); + expect(() => generateRepairText(lesson, [])).toThrow(/repair nothing/u); + }); + + it('names the key when the lesson cannot type it', () => { + // z arrives five lessons later; a selection naming it here is a caller bug. + expect(() => generateRepairText(lesson, ['p', 'z'])).toThrow(RepairDrillError); + expect(() => generateRepairText(lesson, ['p', 'z'])).toThrow(/"z"/u); + expect(() => generateRepairText(lesson, ['p', 'z'])).toThrow(/cannot type/u); + }); + + it('refuses whitespace and anything that is not one key', () => { + expect(() => generateRepairText(lesson, [' '])).toThrow(/whitespace/u); + expect(() => generateRepairText(lesson, ['ap'])).toThrow(/not a single key/u); + expect(() => generateRepairText(lesson, [''])).toThrow(RepairDrillError); + }); + + it('collapses a key listed twice, because that asks for nothing new', () => { + expect(generateRepairText(lesson, ['p', 'p'], { seed: 4 })).toBe( + generateRepairText(lesson, ['p'], { seed: 4 }), + ); + }); + + it('refuses anchors that are missing, untypable, weak, or an empty list', () => { + expect(() => generateRepairText(lesson, ['p'], { anchors: [] })).toThrow(/empty anchor list/u); + expect(() => generateRepairText(lesson, ['p'], { anchors: ['z'] })).toThrow(/cannot type/u); + expect(() => generateRepairText(lesson, ['p'], { anchors: ['p'] })).toThrow( + /both an anchor and a weak key/u, + ); + expect(() => generateRepairText(lesson, ['p'], { anchors: [' '] })).toThrow(RepairDrillError); + }); + + it('throws with the original error attached when the lesson itself is unusable', () => { + const empty = madeUpLesson(''); + let thrown: unknown; + try { + generateRepairText(empty, ['p']); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(RepairDrillError); + expect((thrown as RepairDrillError).cause).toBeInstanceOf(Error); + }); +});