Files
time/typy/test/game.test.js
T

256 lines
11 KiB
JavaScript
Raw Normal View History

import test from 'node:test';
import assert from 'node:assert/strict';
import { TypingGame, summarizeRun } from '../src/game.js';
import { generateWords, normalizePassage } from '../src/words.js';
function type(game, word, at, spacing = 100) {
for (const letter of word) game.press(letter, at += spacing);
return at;
}
test('the first letter starts the run; controls and premature spaces do not', () => {
const game = new TypingGame({ words: ['cat'] });
for (const key of ['Shift', 'Escape', 'Backspace', ' ', 'Enter']) game.press(key, 20);
assert.equal(game.snapshot(50).status, 'ready');
const state = game.press('c', 120);
assert.equal(state.status, 'running');
assert.equal(state.startedAt, 120);
assert.equal(state.correctPrefix, 1);
});
test('space advances exact words; an error must be corrected before advancing', () => {
const game = new TypingGame({ words: ['cat', 'dog'] });
type(game, 'cax', 0);
assert.equal(game.snapshot(310).errorIndex, 2);
game.press('Backspace', 400);
game.press('t', 500);
assert.equal(game.snapshot(600).wordIndex, 0);
assert.equal(game.snapshot(600).buffer, 'cat');
game.press(' ', 700);
assert.equal(game.snapshot(700).word, 'dog');
assert.equal(game.snapshot(700).completedWords, 1);
assert.equal(game.snapshot(700).bestStreak, 0);
assert.equal(game.snapshot(700).accuracy, 75);
});
test('an early space is an error and backspace can recover from it', () => {
const game = new TypingGame({ words: ['a'], advance: 'letter' });
game.press('x', 0);
game.press('Backspace', 50);
game.press(' ', 100);
assert.equal(game.snapshot(100).errorIndex, 0);
game.press('Backspace', 150);
game.press('a', 200);
assert.equal(game.snapshot(200).completedWords, 1);
game.finish(1000);
const result = game.result(1000);
assert.equal(result.stats.correctChars, 2);
assert.equal(result.stats.accuracy, 33.3);
});
test('last-letter mode advances immediately with the same word output scoring', () => {
const letter = new TypingGame({ words: ['cat', 'dog'], mode: 'endless', advance: 'letter' });
const space = new TypingGame({ words: ['cat', 'dog'], mode: 'endless', advance: 'space' });
type(letter, 'cat', 0);
type(space, 'cat', 0);
space.press(' ', 400);
letter.finish(1100);
space.finish(1100);
assert.equal(letter.snapshot(1100).word, 'dog');
assert.equal(letter.result(1100).stats.correctChars, 4);
assert.equal(letter.result(1100).stats.wpm, space.result(1100).stats.wpm);
assert.equal(letter.result(1100).stats.bestStreak, 1);
});
test('last-letter mode accepts one habitual separator without an extra attempt or output', () => {
const game = new TypingGame({ words: ['cat', 'dog'], mode: 'endless', advance: 'letter' });
type(game, 'cat', 0);
const before = game.result(300);
const state = game.press(' ', 400);
const after = game.result(400);
assert.equal(state.word, 'dog');
assert.equal(state.buffer, '');
assert.equal(state.errorIndex, -1);
assert.equal(state.correctChars, 4);
assert.equal(state.keystrokes, 3);
assert.equal(state.errors, 0);
assert.deepEqual(after.events, before.events);
type(game, 'dog ', 400);
game.finish(1100);
const result = game.result(1100);
assert.equal(result.stats.completedWords, 2);
assert.equal(result.stats.correctChars, 8);
assert.equal(result.stats.keystrokes, 6);
assert.equal(result.stats.accuracy, 100);
assert.equal(result.stats.bestStreak, 2);
assert.deepEqual(summarizeRun(result), result.stats);
});
test('last-letter separator tolerance does not hide extra or mid-word spaces', () => {
for (const nextKeys of [' ', 'd ']) {
const game = new TypingGame({ words: ['cat', 'dog'], mode: 'endless', advance: 'letter' });
type(game, 'cat', 0);
type(game, nextKeys, 300);
const state = game.snapshot(500);
assert.equal(state.wordIndex, 1);
assert.equal(state.buffer, nextKeys === ' ' ? ' ' : 'd ');
assert.equal(state.errors, 1);
assert.equal(state.errorIndex, nextKeys === ' ' ? 0 : 1);
game.press('Backspace', 600);
type(game, nextKeys === ' ' ? 'dog' : 'og', 600);
game.finish(1500);
const result = game.result(1500);
assert.equal(result.stats.completedWords, 2);
assert.equal(result.stats.correctChars, 8);
assert.equal(result.stats.errors, 1);
assert.equal(result.stats.accuracy, 85.7);
assert.equal(result.stats.bestStreak, 1);
assert.deepEqual(summarizeRun(result), result.stats);
}
});
for (const advance of ['space', 'letter']) {
for (const targetWpm of [100, 160]) {
test(`${advance} advancement scores steady ${targetWpm} WPM spaced typing accurately`, () => {
for (const mode of ['30', '60']) {
const game = new TypingGame({ words: ['test'], mode, advance });
const durationMs = Number(mode) * 1000;
const intervalMs = 12000 / targetWpm;
const outputChars = durationMs / intervalMs;
const input = 'test '.repeat(outputChars / 5);
for (let i = 0; i < input.length; i++) game.press(input[i], i * intervalMs);
const result = game.result(durationMs);
assert.equal(result.durationMs, durationMs);
assert.equal(result.finishedReason, 'time');
assert.equal(result.stats.wpm, targetWpm);
assert.equal(result.stats.correctChars, outputChars);
assert.equal(result.stats.completedWords, outputChars / 5);
assert.equal(result.stats.keystrokes, outputChars * 4 / 5);
assert.equal(result.stats.accuracy, 100);
assert.equal(result.stats.errors, 0);
assert.equal(game.snapshot(durationMs).wpm, targetWpm);
assert.deepEqual(summarizeRun(result), result.stats);
}
});
}
}
test('WPM measures net output and accuracy measures attempts, without deletion farming', () => {
const game = new TypingGame({ words: ['cat'], mode: 'endless' });
game.press('c', 0);
game.press('Backspace', 100);
game.press('c', 200);
game.press('a', 300);
game.press('x', 400);
game.press('Backspace', 500);
game.press('t', 600);
game.press(' ', 700);
game.finish(60000);
const { stats } = game.result(60000);
assert.equal(stats.correctChars, 4);
assert.equal(stats.wpm, 0.8);
assert.equal(stats.keystrokes, 5);
assert.equal(stats.errors, 1);
assert.equal(stats.accuracy, 80);
});
test('timed runs stop at the exact deadline, including when a background tab wakes late', () => {
for (const mode of ['30', '60']) {
const game = new TypingGame({ words: ['cat'], mode });
game.press('c', 500);
game.press('a', 1000);
game.press('t', 500 + Number(mode) * 1000);
const result = game.result(100000);
assert.equal(result.durationMs, Number(mode) * 1000);
assert.equal(result.stats.correctChars, 2);
assert.equal(result.events.length, 2);
assert.equal(game.snapshot(100000).finishedReason, 'time');
}
});
test('50-word runs finish exactly at 50, repeating a short custom passage', () => {
const game = new TypingGame({ words: ['a', 'b'], mode: '50', advance: 'letter' });
for (let i = 0; i < 51; i++) game.press(i % 2 ? 'b' : 'a', i * 300);
const state = game.snapshot(20000);
const result = game.result(20000);
assert.equal(state.status, 'finished');
assert.equal(state.finishedReason, 'words');
assert.equal(result.stats.completedWords, 50);
assert.equal(result.stats.bestStreak, 50);
assert.equal(result.wordTimings.at(-1).index, 49);
});
test('endless repeats the capped passage without ending or losing absolute replay indices', () => {
const game = new TypingGame({ words: ['a', 'b'], mode: 'endless', advance: 'letter' });
for (let i = 0; i < 350; i++) game.press(i % 2 ? 'b' : 'a', i * 200);
assert.equal(game.snapshot(70000).status, 'running');
assert.equal(game.snapshot(70000).wordIndex, 350);
game.finish(70000);
const result = game.result(70000);
assert.equal(result.words.length, 2);
assert.equal(result.stats.completedWords, 350);
assert.equal(result.wordTimings[349].index, 349);
assert.equal(result.stats.sparkline.length <= 80, true);
});
test('tempo completion waits for its beat, then accelerates and shows the next word', () => {
const game = new TypingGame({ words: ['cat', 'dog'], mode: 'tempo', paceWpm: 40 });
type(game, 'cat', 0);
let state = game.snapshot(500);
assert.equal(state.waitingForBeat, true);
assert.equal(state.wordIndex, 0);
assert.equal(state.beatDurationMs, 1200);
game.press('x', 600); // Inputs during a completed beat are ignored.
assert.equal(game.snapshot(600).errors, 0);
state = game.tick(1300);
assert.equal(state.word, 'dog');
assert.equal(state.completedWords, 1);
assert.equal(state.paceWpm, 41.4);
assert.equal(state.beatDurationMs < 1200, true);
assert.equal(game.result(1300).wordTimings[0].typedEnd, 200);
});
test('tempo ends on the third missed beat; partial expired words earn no output', () => {
const game = new TypingGame({ words: ['cat'], mode: 'tempo', paceWpm: 40 });
game.press('c', 0);
const state = game.tick(10000);
const result = game.result(10000);
assert.equal(state.status, 'finished');
assert.equal(state.misses, 3);
assert.equal(state.finishedReason, 'misses');
assert.equal(result.durationMs, 3600);
assert.equal(result.stats.correctChars, 0);
assert.equal(result.wordTimings.length, 3);
assert.equal(result.wordTimings.every(row => row.missed), true);
});
test('privacy-safe replay can independently recompute results and contains no typed characters', () => {
const game = new TypingGame({ words: ['cat'], mode: 'endless', advance: 'letter' });
game.press('q', 0);
game.press('Backspace', 100);
type(game, 'cat', 100);
game.finish(1000);
const result = game.result(1000);
assert.deepEqual(summarizeRun({ ...result, stats: { wpm: 999 } }), result.stats);
assert.equal(JSON.stringify(result.events).includes('"q"'), false);
assert.equal(result.events.every(event => Object.keys(event).sort().join(',') === 'index,t,type'), true);
});
test('a claimed completed word without matching replay outcomes earns no output', () => {
const stats = summarizeRun({
words: ['elephant'], durationMs: 60000, events: [{ t: 0, type: 'hit', index: 0 }],
wordTimings: [{ index: 0, start: 0, end: 60000, completed: true, errors: 0 }]
});
assert.equal(stats.completedWords, 0);
assert.equal(stats.correctChars, 1);
assert.equal(stats.bestStreak, 0);
});
test('passages are deterministic when seeded, capped, and normalized for a keyboard', () => {
assert.deepEqual(generateWords(50, '2026-10-24'), generateWords(50, '2026-10-24'));
assert.notDeepEqual(generateWords(50, '2026-10-24'), generateWords(50, '2026-10-25'));
assert.equal(generateWords(1000).length, 300);
assert.deepEqual(normalizePassage(' “hello”\nworld’s — here\u200B '), ['"hello"', "world's", '-', 'here']);
assert.equal(normalizePassage(Array(400).fill('word').join(' ')).length, 300);
assert.equal(normalizePassage('a'.repeat(100))[0].length, 64);
});