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)
})
})