diff --git a/src/game/typing.test.ts b/src/game/typing.test.ts new file mode 100644 index 0000000..f3680e4 --- /dev/null +++ b/src/game/typing.test.ts @@ -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); + }); +}); diff --git a/src/game/typing.ts b/src/game/typing.ts new file mode 100644 index 0000000..adf9ed0 --- /dev/null +++ b/src/game/typing.ts @@ -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; +}