237 lines
9.3 KiB
TypeScript
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();
|
|
});
|
|
});
|