Files
musicmouse/tippen/src/lib/__tests__/curriculum.test.ts

237 lines
9.3 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { ELIGIBLE_MODES, FIRST_LESSON_ID, LESSONS, WORLDS, lessonById, lessonsOfWorld, 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.number).toBe(i + 1));
});
it("starts on the two keys with the tactile bumps", () => {
expect(LESSONS[0]!.newKeys).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.newKeys.length, `${lesson.number}: ${lesson.title}`).toBeLessThanOrEqual(2);
}
});
it("teaches world 1 as mirrored finger pairs, one finger per hand", () => {
const pairs = lessonsOfWorld(1).filter((lesson) => lesson.newKeys.length === 2);
expect(pairs.length).toBe(4);
for (const lesson of pairs) {
const [left, right] = lesson.newKeys.map((key) => fingerOf(key)!);
expect(left!.hand).toBe("left");
expect(right!.hand).toBe("right");
// Same finger on each hand - "die Zeigefinger", "die Mittelfinger", …
expect(left!.id.replace("left-", "")).toBe(right!.id.replace("right-", ""));
}
});
it("has the whole home row active by the end of world 1", () => {
const last = lessonsOfWorld(1).at(-1)!;
for (const key of "asdfjklö") expect(last.activeKeys).toContain(key);
expect(last.activeKeys).toContain(" ");
});
it("makes every round a real block of practice, not a handful of keys", () => {
// The first version ran 12 characters in world 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.kind === "sentences") {
expect(lesson.chunks, `${lesson.number}: ${lesson.title}`).toBeGreaterThanOrEqual(10);
} else if (lesson.kind === "words") {
expect(lesson.chunks, `${lesson.number}: ${lesson.title}`).toBeGreaterThanOrEqual(25);
} else {
expect(lesson.chunks * lesson.chunkSize, `${lesson.number}: ${lesson.title}`).toBeGreaterThanOrEqual(60);
}
}
});
it("still starts gentler than it ends", () => {
const length = (lesson: (typeof LESSONS)[number]) => lesson.chunks * lesson.chunkSize;
expect(length(lessonsOfWorld(3)[0]!)).toBeGreaterThan(length(lessonsOfWorld(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.kind === "sentences")) {
expect(lesson.words.length, `${lesson.number}: ${lesson.title}`).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.newKeys) {
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 word of lesson.words) {
for (const char of word) {
expect(
active.has(keyForChar(char)),
`Lektion ${lesson.number} (${lesson.title}): "${word}" braucht "${char}" (Taste ${keyForChar(char)})`,
).toBe(true);
}
}
}
});
it("only uses shifted characters once shift is taught", () => {
const shiftFrom = LESSONS.find((lesson) => lesson.newKeys.includes("⇧"))!.number;
for (const lesson of LESSONS) {
for (const word of lesson.words) {
for (const char of word) {
if (!needsShift(char)) continue;
expect(
lesson.number,
`Lektion ${lesson.number}: "${word}" braucht Umschalt für "${char}"`,
).toBeGreaterThanOrEqual(shiftFrom);
}
}
}
});
it("plays a mode that actually fits its kind", () => {
for (const lesson of LESSONS) {
expect(ELIGIBLE_MODES[lesson.kind], `${lesson.number}: ${lesson.title}`).toContain(lesson.primaryMode);
for (const mode of lesson.bonusModes) {
expect(ELIGIBLE_MODES[lesson.kind], `${lesson.number}: ${lesson.title}`).toContain(mode);
}
}
});
it("never plays the same arcade game twice in a row", () => {
const arcade = LESSONS.filter((lesson) => lesson.kind === "letters");
for (let i = 1; i < arcade.length; i++) {
expect(arcade[i]!.primaryMode, `${arcade[i]!.number}: ${arcade[i]!.title}`).not.toBe(arcade[i - 1]!.primaryMode);
}
});
it("offers every eligible mode this lesson isn't gated on, as a bonus", () => {
for (const lesson of LESSONS) {
const expected = ELIGIBLE_MODES[lesson.kind].filter(
(mode) => (mode === "feed" || mode === "race") && mode !== lesson.primaryMode,
);
expect([...lesson.bonusModes].sort()).toEqual([...expected].sort());
}
});
it("mixes the games instead of always diving - feed and race take a turn as the required mode too", () => {
const words = LESSONS.filter((l) => l.kind === "words");
const sentences = LESSONS.filter((l) => l.kind === "sentences");
expect(words.some((l) => l.primaryMode === "feed")).toBe(true);
expect(words.some((l) => l.primaryMode === "race")).toBe(true);
expect(sentences.some((l) => l.primaryMode === "race")).toBe(true);
});
it("distinguishes a mixed round from a drill - both have no new keys, only one is a review", () => {
const mixed = LESSONS.filter((lesson) => lesson.emphasis === "mixed");
expect(mixed.length).toBeGreaterThan(0);
for (const lesson of mixed) {
expect(lesson.newKeys).toEqual([]);
expect(lesson.isDrill).toBe(false);
}
});
it("gives every isolated round something new to drill", () => {
for (const lesson of LESSONS) {
if (lesson.emphasis === "isolated") expect(lesson.newKeys.length).toBeGreaterThan(0);
}
});
it("teaches capitals only once shift exists", () => {
for (const lesson of LESSONS) {
const hasUppercase = lesson.words.some((word) => word !== word.toLowerCase());
if (hasUppercase) expect(lesson.world).toBeGreaterThanOrEqual(4);
}
});
it("gives every new key a lesson of its own after world 1", () => {
// One key at a time is the pacing decision: world 1 pairs a finger across both
// hands, everything after introduces exactly one key or none.
for (const lesson of LESSONS) {
if (lesson.world === 1) continue;
expect(lesson.newKeys.length, `${lesson.number}: ${lesson.title}`).toBeLessThanOrEqual(2);
}
});
it("follows every pair of new keys with a drill", () => {
const drills = LESSONS.filter((lesson) => lesson.isDrill);
expect(drills.length).toBeGreaterThanOrEqual(15);
// A drill never introduces anything, and always has something to practise.
for (const lesson of drills) {
expect(lesson.newKeys).toEqual([]);
expect(lesson.activeKeys.length).toBeGreaterThan(0);
}
});
it("is long enough to be a real, Duolingo-length course", () => {
expect(LESSONS.length).toBeGreaterThanOrEqual(80);
expect(WORLDS.length).toBe(5);
});
it("never leaves a world without a run of at least three lessons", () => {
for (const world of WORLDS) expect(lessonsOfWorld(world.number).length).toBeGreaterThanOrEqual(3);
});
});
describe("Worlds", () => {
it("has lessons in every world", () => {
for (const world of WORLDS) expect(lessonsOfWorld(world.number).length).toBeGreaterThan(0);
});
it("gives every world its own aquarium creature", () => {
const rewards = WORLDS.map((world) => world.reward);
expect(new Set(rewards).size).toBe(rewards.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)?.number).toBe(1);
expect(lessonById("gibt-es-nicht")).toBeNull();
});
});