First implementation of 10 finger typing

This commit is contained in:
2026-09-11 21:55:45 +02:00
parent 7e5fd5ab75
commit f97de193d8
63 changed files with 8275 additions and 0 deletions

View File

@@ -0,0 +1,199 @@
import { describe, expect, it } from "vitest";
import { FIRST_LESSON_ID, LESSONS, WELTEN, lessonById, lessonsOfWelt, nextLesson } from "../curriculum";
import { fingerOf, keyForChar, needsShift } from "../fingers";
describe("LESSONS", () => {
it("has unique ids and consecutive numbers", () => {
const ids = LESSONS.map((lesson) => lesson.id);
expect(new Set(ids).size).toBe(ids.length);
LESSONS.forEach((lesson, i) => expect(lesson.nummer).toBe(i + 1));
});
it("starts on the two keys with the tactile bumps", () => {
expect(LESSONS[0]!.neueKeys).toEqual(["f", "j"]);
expect(FIRST_LESSON_ID).toBe(LESSONS[0]!.id);
});
it("eases in: at most two new keys per lesson, anywhere in the course", () => {
// The first draft opened with all four left-hand keys at once, which is a steep
// first five minutes for a six-year-old. Two at a time, always.
for (const lesson of LESSONS) {
expect(lesson.neueKeys.length, `${lesson.nummer}: ${lesson.titel}`).toBeLessThanOrEqual(2);
}
});
it("teaches Welt 1 as mirrored finger pairs, one finger per hand", () => {
const paare = lessonsOfWelt(1).filter((lesson) => lesson.neueKeys.length === 2);
expect(paare.length).toBe(4);
for (const lesson of paare) {
const [links, rechts] = lesson.neueKeys.map((key) => fingerOf(key)!);
expect(links!.hand).toBe("links");
expect(rechts!.hand).toBe("rechts");
// Same finger on each hand - "die Zeigefinger", "die Mittelfinger", …
expect(links!.id.replace("links-", "")).toBe(rechts!.id.replace("rechts-", ""));
}
});
it("has the whole Grundstellung active by the end of Welt 1", () => {
const letzte = lessonsOfWelt(1).at(-1)!;
for (const key of "asdfjklö") expect(letzte.activeKeys).toContain(key);
expect(letzte.activeKeys).toContain(" ");
});
it("makes every round a real block of practice, not a handful of keys", () => {
// The first version ran 12 characters in Welt 1 - long enough to finish, too short
// for a rhythm to form or for the speed score to mean anything.
for (const lesson of LESSONS) {
if (lesson.art === "saetze") {
expect(lesson.chunks, `${lesson.nummer}: ${lesson.titel}`).toBeGreaterThanOrEqual(10);
} else if (lesson.art === "woerter") {
expect(lesson.chunks, `${lesson.nummer}: ${lesson.titel}`).toBeGreaterThanOrEqual(25);
} else {
expect(lesson.chunks * lesson.chunkSize, `${lesson.nummer}: ${lesson.titel}`).toBeGreaterThanOrEqual(60);
}
}
});
it("still starts gentler than it ends", () => {
const laenge = (lesson: (typeof LESSONS)[number]) => lesson.chunks * lesson.chunkSize;
expect(laenge(lessonsOfWelt(3)[0]!)).toBeGreaterThan(laenge(lessonsOfWelt(1)[0]!));
});
it("has more sentences to draw from than a round uses, so a round never has to repeat", () => {
for (const lesson of LESSONS.filter((l) => l.art === "saetze")) {
expect(lesson.woerter.length, `${lesson.nummer}: ${lesson.titel}`).toBeGreaterThanOrEqual(lesson.chunks);
}
});
it("grows activeKeys monotonically - no lesson ever loses a key", () => {
let previous: string[] = [];
for (const lesson of LESSONS) {
for (const key of previous) expect(lesson.activeKeys).toContain(key);
previous = [...lesson.activeKeys];
}
});
it("introduces every new key into its own activeKeys", () => {
for (const lesson of LESSONS) {
for (const key of lesson.neueKeys) {
if (key === "⇧") continue; // Shift is not a character the generator can emit.
expect(lesson.activeKeys).toContain(key);
}
}
});
it("covers the whole German alphabet plus the umlauts by the end", () => {
const final = new Set(LESSONS.at(-1)!.activeKeys);
for (const key of "abcdefghijklmnopqrstuvwxyzäöü") expect(final.has(key)).toBe(true);
});
it("assigns every active key to a real finger", () => {
for (const lesson of LESSONS) {
for (const key of lesson.activeKeys) expect(fingerOf(key)).not.toBeNull();
}
});
it("only lists words its own activeKeys can type", () => {
// Compared by physical key, not by character: "?" is Shift+ß and "A" is Shift+a,
// so a lesson can type them as soon as it has the ß or a key.
for (const lesson of LESSONS) {
const active = new Set(lesson.activeKeys);
for (const wort of lesson.woerter) {
for (const char of wort) {
expect(
active.has(keyForChar(char)),
`Lektion ${lesson.nummer} (${lesson.titel}): "${wort}" braucht "${char}" (Taste ${keyForChar(char)})`,
).toBe(true);
}
}
}
});
it("only uses shifted characters once the Umschalttaste is taught", () => {
const shiftAb = LESSONS.find((lesson) => lesson.neueKeys.includes("⇧"))!.nummer;
for (const lesson of LESSONS) {
for (const wort of lesson.woerter) {
for (const char of wort) {
if (!needsShift(char)) continue;
expect(
lesson.nummer,
`Lektion ${lesson.nummer}: "${wort}" braucht Umschalt für "${char}"`,
).toBeGreaterThanOrEqual(shiftAb);
}
}
}
});
it("only offers word modes where words exist", () => {
for (const lesson of LESSONS) {
const wortModi = lesson.modi.some((modus) => modus === "fuettern" || modus === "rennen");
expect(wortModi).toBe(lesson.woerter.length > 0);
}
});
it("always offers the pressure-free Perlentaucher", () => {
for (const lesson of LESSONS) expect(lesson.modi).toContain("perlen");
});
it("teaches capitals only once the Umschalttaste exists", () => {
for (const lesson of LESSONS) {
const hatGross = lesson.woerter.some((wort) => wort !== wort.toLowerCase());
if (hatGross) expect(lesson.welt).toBeGreaterThanOrEqual(4);
}
});
it("gives every new key a lesson of its own after Welt 1", () => {
// One key at a time is the pacing decision: Welt 1 pairs a finger across both
// hands, everything after introduces exactly one key or none.
for (const lesson of LESSONS) {
if (lesson.welt === 1) continue;
expect(lesson.neueKeys.length, `${lesson.nummer}: ${lesson.titel}`).toBeLessThanOrEqual(2);
}
});
it("follows every pair of new keys with an Übung", () => {
const uebungen = LESSONS.filter((lesson) => lesson.istUebung);
expect(uebungen.length).toBeGreaterThanOrEqual(12);
// An Übung never introduces anything, and always has something to practise.
for (const lesson of uebungen) {
expect(lesson.neueKeys).toEqual([]);
expect(lesson.activeKeys.length).toBeGreaterThan(0);
}
});
it("is long enough to be a real course", () => {
expect(LESSONS.length).toBeGreaterThanOrEqual(40);
expect(WELTEN.length).toBe(5);
});
it("never leaves a Welt without a run of at least three lessons", () => {
for (const welt of WELTEN) expect(lessonsOfWelt(welt.nummer).length).toBeGreaterThanOrEqual(3);
});
});
describe("Welten", () => {
it("has lessons in every Welt", () => {
for (const welt of WELTEN) expect(lessonsOfWelt(welt.nummer).length).toBeGreaterThan(0);
});
it("gives every Welt its own aquarium creature", () => {
const belohnungen = WELTEN.map((welt) => welt.belohnung);
expect(new Set(belohnungen).size).toBe(belohnungen.length);
});
});
describe("navigation", () => {
it("chains every lesson to the next and stops at the end", () => {
for (let i = 0; i < LESSONS.length - 1; i++) {
expect(nextLesson(LESSONS[i]!.id)?.id).toBe(LESSONS[i + 1]!.id);
}
expect(nextLesson(LESSONS.at(-1)!.id)).toBeNull();
expect(nextLesson("gibt-es-nicht")).toBeNull();
});
it("looks lessons up by id", () => {
expect(lessonById(FIRST_LESSON_ID)?.nummer).toBe(1);
expect(lessonById("gibt-es-nicht")).toBeNull();
});
});