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(); }); });