feat: morse data library with validation and challenge selection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
}
|
||||
Reference in New Issue
Block a user