feat: morse data library with validation and challenge selection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 00:10:58 +00:00
parent 6e83b16c4b
commit f7ad8aa3cc
2 changed files with 210 additions and 0 deletions
+110
View File
@@ -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)
})
})
+100
View File
@@ -0,0 +1,100 @@
export const MORSE: Record<string, string> = {
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<string, { correct: number; attempts: number }>,
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]
}