Typing lessons like duolingo & musicmouse cleanup

This commit is contained in:
2026-09-12 18:58:02 +02:00
parent 498243af46
commit a5210fead2
50 changed files with 3074 additions and 710 deletions

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { FIRST_LESSON_ID, LESSONS, WORLDS, lessonById, lessonsOfWorld, nextLesson } from "../curriculum";
import { ELIGIBLE_MODES, FIRST_LESSON_ID, LESSONS, WORLDS, lessonById, lessonsOfWorld, nextLesson } from "../curriculum";
import { fingerOf, keyForChar, needsShift } from "../fingers";
describe("LESSONS", () => {
@@ -125,15 +125,52 @@ describe("LESSONS", () => {
}
});
it("only offers word modes where words exist", () => {
it("plays a mode that actually fits its kind", () => {
for (const lesson of LESSONS) {
const wordModes = lesson.modes.some((mode) => mode === "feed" || mode === "race");
expect(wordModes).toBe(lesson.words.length > 0);
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("always offers the pressure-free pearl-diving mode", () => {
for (const lesson of LESSONS) expect(lesson.modes).toContain("pearls");
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", () => {
@@ -154,7 +191,7 @@ describe("LESSONS", () => {
it("follows every pair of new keys with a drill", () => {
const drills = LESSONS.filter((lesson) => lesson.isDrill);
expect(drills.length).toBeGreaterThanOrEqual(12);
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([]);
@@ -162,8 +199,8 @@ describe("LESSONS", () => {
}
});
it("is long enough to be a real course", () => {
expect(LESSONS.length).toBeGreaterThanOrEqual(40);
it("is long enough to be a real, Duolingo-length course", () => {
expect(LESSONS.length).toBeGreaterThanOrEqual(80);
expect(WORLDS.length).toBe(5);
});