feat(01-04): implement typing exercise engine with TDD
- generateLetters: random 8-12 letters from level key set - handleKeyPress: correct/wrong input with adaptive tempo - Adaptive tempo: 3s start, -200ms after 2x correct, +500ms on error - Bounds: min 1500ms, max 4000ms - 18 passing tests covering all behavior Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,174 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
generateLetters,
|
||||||
|
createExerciseState,
|
||||||
|
handleKeyPress,
|
||||||
|
getCurrentLetter,
|
||||||
|
isExerciseComplete,
|
||||||
|
} from "./typing";
|
||||||
|
import { getKeysUpToLevel } from "./levels";
|
||||||
|
|
||||||
|
describe("generateLetters", () => {
|
||||||
|
it("returns 8-12 letters for level 1", () => {
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const letters = generateLetters(1);
|
||||||
|
expect(letters.length).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(letters.length).toBeLessThanOrEqual(12);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only uses keys from level 1 key set (f, j, space)", () => {
|
||||||
|
const validKeys = ["f", "j", " "];
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const letters = generateLetters(1);
|
||||||
|
for (const letter of letters) {
|
||||||
|
expect(validKeys).toContain(letter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only uses keys from levels 1-3 key set for level 3", () => {
|
||||||
|
const validKeys = getKeysUpToLevel(3);
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const letters = generateLetters(3);
|
||||||
|
for (const letter of letters) {
|
||||||
|
expect(validKeys).toContain(letter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createExerciseState", () => {
|
||||||
|
it("sets initial interval to 3000ms", () => {
|
||||||
|
const state = createExerciseState(1);
|
||||||
|
expect(state.intervalMs).toBe(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts at letter index 0 with 0 correct, 0 errors, 0 streak", () => {
|
||||||
|
const state = createExerciseState(1);
|
||||||
|
expect(state.currentLetterIndex).toBe(0);
|
||||||
|
expect(state.totalCorrect).toBe(0);
|
||||||
|
expect(state.totalErrors).toBe(0);
|
||||||
|
expect(state.correctStreak).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleKeyPress", () => {
|
||||||
|
it("increments currentLetterIndex and totalCorrect on correct key", () => {
|
||||||
|
const state = createExerciseState(1);
|
||||||
|
const expected = state.letters[0]!;
|
||||||
|
const result = handleKeyPress(state, expected);
|
||||||
|
expect(result.correct).toBe(true);
|
||||||
|
expect(state.currentLetterIndex).toBe(1);
|
||||||
|
expect(state.totalCorrect).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT increment currentLetterIndex on wrong key, increments totalErrors", () => {
|
||||||
|
const state = createExerciseState(1);
|
||||||
|
// Pick a key that is definitely wrong
|
||||||
|
const expected = state.letters[0]!;
|
||||||
|
const wrongKey = expected === "f" ? "j" : "f";
|
||||||
|
const result = handleKeyPress(state, wrongKey);
|
||||||
|
expect(result.correct).toBe(false);
|
||||||
|
expect(state.currentLetterIndex).toBe(0);
|
||||||
|
expect(state.totalErrors).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increments correctStreak on correct key", () => {
|
||||||
|
const state = createExerciseState(1);
|
||||||
|
const expected = state.letters[0]!;
|
||||||
|
handleKeyPress(state, expected);
|
||||||
|
expect(state.correctStreak).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets correctStreak to 0 on error", () => {
|
||||||
|
const state = createExerciseState(1);
|
||||||
|
// Get one correct to build streak
|
||||||
|
handleKeyPress(state, state.letters[0]!);
|
||||||
|
expect(state.correctStreak).toBe(1);
|
||||||
|
// Now wrong key
|
||||||
|
const expected = state.letters[1]!;
|
||||||
|
const wrongKey = expected === "f" ? "j" : "f";
|
||||||
|
handleKeyPress(state, wrongKey);
|
||||||
|
expect(state.correctStreak).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decreases interval by 200ms after 2 correct in sequence", () => {
|
||||||
|
const state = createExerciseState(1);
|
||||||
|
expect(state.intervalMs).toBe(3000);
|
||||||
|
handleKeyPress(state, state.letters[0]!);
|
||||||
|
handleKeyPress(state, state.letters[1]!);
|
||||||
|
expect(state.intervalMs).toBe(2800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increases interval by 500ms after 1 error", () => {
|
||||||
|
const state = createExerciseState(1);
|
||||||
|
// Force an error
|
||||||
|
const expected = state.letters[0]!;
|
||||||
|
const wrongKey = expected === "f" ? "j" : "f";
|
||||||
|
handleKeyPress(state, wrongKey);
|
||||||
|
expect(state.intervalMs).toBe(3500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("interval never goes below 1500ms", () => {
|
||||||
|
const state = createExerciseState(1);
|
||||||
|
// Force interval down to minimum
|
||||||
|
state.intervalMs = 1600;
|
||||||
|
state.correctStreak = 1;
|
||||||
|
handleKeyPress(state, state.letters[0]!);
|
||||||
|
// After 2 correct streak, -200ms: 1600 -> 1500 (clamped)
|
||||||
|
expect(state.intervalMs).toBe(1500);
|
||||||
|
// Try again: force streak
|
||||||
|
state.correctStreak = 1;
|
||||||
|
handleKeyPress(state, state.letters[1]!);
|
||||||
|
// Should stay at 1500
|
||||||
|
expect(state.intervalMs).toBe(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("interval never goes above 4000ms", () => {
|
||||||
|
const state = createExerciseState(1);
|
||||||
|
state.intervalMs = 3800;
|
||||||
|
const expected = state.letters[0]!;
|
||||||
|
const wrongKey = expected === "f" ? "j" : "f";
|
||||||
|
handleKeyPress(state, wrongKey);
|
||||||
|
// 3800 + 500 = 4300, clamped to 4000
|
||||||
|
expect(state.intervalMs).toBe(4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exercise is complete when currentLetterIndex equals letters.length", () => {
|
||||||
|
const state = createExerciseState(1);
|
||||||
|
// Type all letters correctly
|
||||||
|
let lastResult = { correct: false, exerciseComplete: false, newInterval: 0 };
|
||||||
|
for (let i = 0; i < state.letters.length; i++) {
|
||||||
|
lastResult = handleKeyPress(state, state.letters[i]!);
|
||||||
|
}
|
||||||
|
expect(lastResult.exerciseComplete).toBe(true);
|
||||||
|
expect(isExerciseComplete(state)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getCurrentLetter", () => {
|
||||||
|
it("returns the current letter", () => {
|
||||||
|
const state = createExerciseState(1);
|
||||||
|
expect(getCurrentLetter(state)).toBe(state.letters[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when exercise is complete", () => {
|
||||||
|
const state = createExerciseState(1);
|
||||||
|
state.currentLetterIndex = state.letters.length;
|
||||||
|
expect(getCurrentLetter(state)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isExerciseComplete", () => {
|
||||||
|
it("returns false when exercise is in progress", () => {
|
||||||
|
const state = createExerciseState(1);
|
||||||
|
expect(isExerciseComplete(state)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when all letters are typed", () => {
|
||||||
|
const state = createExerciseState(1);
|
||||||
|
state.currentLetterIndex = state.letters.length;
|
||||||
|
expect(isExerciseComplete(state)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { TypingExerciseState } from "../types";
|
||||||
|
import { getKeysUpToLevel } from "./levels";
|
||||||
|
|
||||||
|
const MIN_LETTERS = 8;
|
||||||
|
const MAX_LETTERS = 12;
|
||||||
|
const INITIAL_INTERVAL = 3000;
|
||||||
|
const INTERVAL_DECREASE = 200;
|
||||||
|
const INTERVAL_INCREASE = 500;
|
||||||
|
const MIN_INTERVAL = 1500;
|
||||||
|
const MAX_INTERVAL = 4000;
|
||||||
|
const STREAK_THRESHOLD = 2;
|
||||||
|
|
||||||
|
export interface KeyPressResult {
|
||||||
|
correct: boolean;
|
||||||
|
exerciseComplete: boolean;
|
||||||
|
newInterval: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateLetters(level: number): string[] {
|
||||||
|
const keys = getKeysUpToLevel(level);
|
||||||
|
const letterKeys = keys.filter((k) => k !== " ");
|
||||||
|
const count =
|
||||||
|
MIN_LETTERS + Math.floor(Math.random() * (MAX_LETTERS - MIN_LETTERS + 1));
|
||||||
|
const letters: string[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
if (keys.includes(" ") && Math.random() < 0.2) {
|
||||||
|
letters.push(" ");
|
||||||
|
} else {
|
||||||
|
letters.push(letterKeys[Math.floor(Math.random() * letterKeys.length)]!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return letters;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExerciseState(level: number): TypingExerciseState {
|
||||||
|
return {
|
||||||
|
currentLetterIndex: 0,
|
||||||
|
letters: generateLetters(level),
|
||||||
|
intervalMs: INITIAL_INTERVAL,
|
||||||
|
correctStreak: 0,
|
||||||
|
totalCorrect: 0,
|
||||||
|
totalErrors: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleKeyPress(
|
||||||
|
state: TypingExerciseState,
|
||||||
|
pressedKey: string,
|
||||||
|
): KeyPressResult {
|
||||||
|
const expectedKey = state.letters[state.currentLetterIndex]!;
|
||||||
|
const correct = pressedKey.toLowerCase() === expectedKey.toLowerCase();
|
||||||
|
|
||||||
|
if (correct) {
|
||||||
|
state.currentLetterIndex++;
|
||||||
|
state.totalCorrect++;
|
||||||
|
state.correctStreak++;
|
||||||
|
|
||||||
|
if (state.correctStreak >= STREAK_THRESHOLD) {
|
||||||
|
state.intervalMs = Math.max(
|
||||||
|
MIN_INTERVAL,
|
||||||
|
state.intervalMs - INTERVAL_DECREASE,
|
||||||
|
);
|
||||||
|
state.correctStreak = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.totalErrors++;
|
||||||
|
state.correctStreak = 0;
|
||||||
|
state.intervalMs = Math.min(
|
||||||
|
MAX_INTERVAL,
|
||||||
|
state.intervalMs + INTERVAL_INCREASE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
correct,
|
||||||
|
exerciseComplete: state.currentLetterIndex >= state.letters.length,
|
||||||
|
newInterval: state.intervalMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentLetter(state: TypingExerciseState): string | null {
|
||||||
|
if (state.currentLetterIndex >= state.letters.length) return null;
|
||||||
|
return state.letters[state.currentLetterIndex]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExerciseComplete(state: TypingExerciseState): boolean {
|
||||||
|
return state.currentLetterIndex >= state.letters.length;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user