First implementation of 10 finger typing
This commit is contained in:
199
tippen/src/lib/__tests__/curriculum.test.ts
Normal file
199
tippen/src/lib/__tests__/curriculum.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user