From f7ad8aa3cc166cd0dd68cdeb34621ffb41da477e Mon Sep 17 00:00:00 2001 From: kitadmin Date: Thu, 30 Apr 2026 00:10:58 +0000 Subject: [PATCH] feat: morse data library with validation and challenge selection Co-Authored-By: Claude Sonnet 4.6 --- client/src/lib/morse.test.ts | 110 +++++++++++++++++++++++++++++++++++ client/src/lib/morse.ts | 100 +++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 client/src/lib/morse.test.ts create mode 100644 client/src/lib/morse.ts diff --git a/client/src/lib/morse.test.ts b/client/src/lib/morse.test.ts new file mode 100644 index 0000000..40b5bc9 --- /dev/null +++ b/client/src/lib/morse.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest' +import { + MORSE, + NOVICE_LETTERS, + OPERATOR_WORDS, + wpmToTiming, + validatePhrase, + pickChallenge, +} from './morse' + +describe('MORSE map', () => { + it('has all 26 letters', () => { + const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('') + letters.forEach(l => expect(MORSE[l]).toBeDefined()) + }) + it('A is .-', () => expect(MORSE['A']).toBe('.-')) + it('S is ...', () => expect(MORSE['S']).toBe('...')) + it('O is ---', () => expect(MORSE['O']).toBe('---')) +}) + +describe('NOVICE_LETTERS', () => { + it('has 12 letters', () => expect(NOVICE_LETTERS).toHaveLength(12)) + it('includes A E T I N S H R D L U O', () => { + ;['A','E','T','I','N','S','H','R','D','L','U','O'].forEach(l => + expect(NOVICE_LETTERS).toContain(l) + ) + }) +}) + +describe('OPERATOR_WORDS', () => { + it('has at least 20 words', () => expect(OPERATOR_WORDS.length).toBeGreaterThanOrEqual(20)) + it('all words use only Novice letters', () => { + const novice = new Set(['A','E','T','I','N','S','H','R','D','L','U','O']) + OPERATOR_WORDS.forEach(word => { + word.split('').forEach(l => expect(novice.has(l)).toBe(true)) + }) + }) +}) + +describe('wpmToTiming', () => { + it('10 WPM gives dot=120ms', () => { + expect(wpmToTiming(10).dot).toBe(120) + }) + it('dash is 3x dot', () => { + const t = wpmToTiming(10) + expect(t.dash).toBe(t.dot * 3) + }) + it('20 WPM gives dot=60ms', () => { + expect(wpmToTiming(20).dot).toBe(60) + }) +}) + +describe('validatePhrase', () => { + it('accepts valid phrase for E (.)', () => { + expect(validatePhrase('go', 'E')).toEqual({ valid: true }) + }) + it('accepts valid phrase for A (.-)', () => { + expect(validatePhrase('go WALKING', 'A')).toEqual({ valid: true }) + }) + it('accepts valid phrase for S (...)', () => { + expect(validatePhrase('go go go', 'S')).toEqual({ valid: true }) + }) + it('rejects wrong word count', () => { + const r = validatePhrase('go go', 'S') + expect(r.valid).toBe(false) + expect(r.message).toContain('3') + }) + it('rejects long word for dot position', () => { + const r = validatePhrase('WALKING go go', 'S') + expect(r.valid).toBe(false) + }) + it('rejects short word for dash position', () => { + const r = validatePhrase('go go', 'A') + expect(r.valid).toBe(false) + }) + it('rejects unknown letter', () => { + expect(validatePhrase('test', '1')).toEqual({ valid: false, message: 'Unknown letter' }) + }) + it('handles empty phrase', () => { + expect(validatePhrase('', 'E')).toEqual({ valid: false, message: expect.stringContaining('1') }) + }) +}) + +describe('pickChallenge', () => { + it('always returns item from pool', () => { + const pool = ['A', 'E', 'T', 'I'] + const result = pickChallenge(pool, {}, []) + expect(pool).toContain(result) + }) + it('avoids last 3 items when pool is large enough', () => { + const pool = ['A', 'E', 'T', 'I', 'N', 'S'] + const history = ['A', 'E', 'T'] + for (let i = 0; i < 20; i++) { + const result = pickChallenge(pool, {}, history) + expect(['A','E','T']).not.toContain(result) + } + }) + it('prefers items with lower accuracy', () => { + const pool = ['A', 'B'] + const stats = { + A: { correct: 9, attempts: 10 }, + B: { correct: 1, attempts: 10 }, + } + let bCount = 0 + for (let i = 0; i < 100; i++) { + if (pickChallenge(pool, stats, []) === 'B') bCount++ + } + expect(bCount).toBeGreaterThan(60) + }) +}) diff --git a/client/src/lib/morse.ts b/client/src/lib/morse.ts new file mode 100644 index 0000000..8f32b92 --- /dev/null +++ b/client/src/lib/morse.ts @@ -0,0 +1,100 @@ +export const MORSE: Record = { + A: '.-', B: '-...', C: '-.-.', D: '-..', E: '.', + F: '..-.', G: '--.', H: '....', I: '..', J: '.---', + K: '-.-', L: '.-..', M: '--', N: '-.', O: '---', + P: '.--.', Q: '--.-', R: '.-.', S: '...', T: '-', + U: '..-', V: '...-', W: '.--', X: '-..-', Y: '-.--', + Z: '--..', + '0': '-----', '1': '.----', '2': '..---', '3': '...--', + '4': '....-', '5': '.....', '6': '-....', '7': '--...', + '8': '---..', '9': '----.', +} + +export const NOVICE_LETTERS = ['A','E','T','I','N','S','H','R','D','L','U','O'] + +export const OPERATOR_WORDS = [ + 'SEA','SUN','TAN','RAN','HIT','RUN','ANT','DEN', + 'HEN','OAR','OUR','USE','TIN','SIN','HIS','AIR', + 'EAR','AND','THE','HOT','NET','SET','TEN','LIT', + 'SIT','LET','NIT','ROT','NUT','DUE','RID','IRE', +] + +export interface Timing { + dot: number + dash: number + elementGap: number + charGap: number + wordGap: number +} + +export function wpmToTiming(wpm: number): Timing { + const dot = Math.round(1200 / wpm) + return { + dot, + dash: dot * 3, + elementGap: dot, + charGap: dot * 3, + wordGap: dot * 7, + } +} + +export const DEFAULT_WPM = 10 +export const DEFAULT_TIMING = wpmToTiming(DEFAULT_WPM) + +export interface ValidationResult { + valid: boolean + message?: string +} + +export function validatePhrase(phrase: string, letter: string): ValidationResult { + const upper = letter.toUpperCase() + const pattern = /^[A-Z]$/.test(upper) ? MORSE[upper] : undefined + if (!pattern) return { valid: false, message: 'Unknown letter' } + + const words = phrase.trim().split(/\s+/).filter(Boolean) + const elements = pattern.split('') + + if (words.length !== elements.length) { + return { + valid: false, + message: `Need ${elements.length} word${elements.length !== 1 ? 's' : ''}, got ${words.length}`, + } + } + + for (let i = 0; i < words.length; i++) { + const isDot = elements[i] === '.' + const isShort = words[i].length <= 2 + if (isDot && !isShort) { + return { valid: false, message: `Word "${words[i]}" should be short (1-2 chars) for a dot` } + } + if (!isDot && isShort) { + return { valid: false, message: `Word "${words[i]}" should be longer (3+ chars) for a dash` } + } + } + + return { valid: true } +} + +export function pickChallenge( + pool: string[], + stats: Record, + recentHistory: string[] +): string { + const available = + pool.length > 3 ? pool.filter(item => !recentHistory.slice(-3).includes(item)) : pool + + const weights = available.map(item => { + const s = stats[item] + if (!s || s.attempts === 0) return 2 + const accuracy = s.correct / s.attempts + return Math.max(0.1, 1 - accuracy) + 0.1 + }) + + const total = weights.reduce((a, b) => a + b, 0) + let r = Math.random() * total + for (let i = 0; i < available.length; i++) { + r -= weights[i] + if (r <= 0) return available[i] + } + return available[available.length - 1] +}