diff --git a/client/src/components/MnemonicBuilder.tsx b/client/src/components/MnemonicBuilder.tsx index ddfb5fd..cdc7613 100644 --- a/client/src/components/MnemonicBuilder.tsx +++ b/client/src/components/MnemonicBuilder.tsx @@ -101,7 +101,7 @@ export function MnemonicBuilder({ token, onComplete }: Props) { type="text" value={phrase} onChange={e => { setPhrase(e.target.value); setError(null) }} - placeholder={`e.g. "${examplePhrase(pattern)}"`} + placeholder={suggestions.length > 0 ? `e.g. "${suggestions[0]}"` : ''} disabled={saving} style={{ marginBottom: '0.5rem' }} /> @@ -197,8 +197,3 @@ export function MnemonicBuilder({ token, onComplete }: Props) { ) } - -// Generate a simple example phrase for the placeholder -function examplePhrase(pattern: string): string { - return pattern.split('').map(c => c === '.' ? 'run' : 'WALK').join(' ') -} diff --git a/client/src/lib/morse.test.ts b/client/src/lib/morse.test.ts index ba52081..ccf1741 100644 --- a/client/src/lib/morse.test.ts +++ b/client/src/lib/morse.test.ts @@ -3,8 +3,7 @@ import { MORSE, NOVICE_LETTERS, OPERATOR_WORDS, - DOT_WORDS, - DASH_WORDS, + MNEMONIC_SUGGESTIONS, wpmToTiming, DEFAULT_WPM, DEFAULT_TIMING, @@ -74,15 +73,36 @@ describe('wpmToTiming', () => { }) }) -describe('DOT_WORDS and DASH_WORDS', () => { - it('all DOT_WORDS are 3 letters or less', () => { - DOT_WORDS.forEach(w => expect(w.length).toBeLessThanOrEqual(3)) +describe('MNEMONIC_SUGGESTIONS', () => { + it('has suggestions for all 12 novice letters', () => { + NOVICE_LETTERS.forEach(l => { + expect(MNEMONIC_SUGGESTIONS[l]).toBeDefined() + expect(MNEMONIC_SUGGESTIONS[l].length).toBeGreaterThanOrEqual(5) + }) }) - it('all DASH_WORDS are 4 letters or more', () => { - DASH_WORDS.forEach(w => expect(w.length).toBeGreaterThanOrEqual(4)) + it('all suggestions follow the dot/dash word length rules', () => { + for (const letter of NOVICE_LETTERS) { + const pattern = MORSE[letter] + const elements = pattern.split('') + for (const phrase of MNEMONIC_SUGGESTIONS[letter]) { + const words = phrase.split(' ') + expect(words).toHaveLength(elements.length) + words.forEach((w, i) => { + if (elements[i] === '.') expect(w.length).toBeLessThanOrEqual(3) + else expect(w.length).toBeGreaterThanOrEqual(4) + }) + } + } + }) + it('every suggestion has at least one word starting with the target letter', () => { + for (const letter of NOVICE_LETTERS) { + for (const phrase of MNEMONIC_SUGGESTIONS[letter]) { + const words = phrase.split(' ') + const hasAnchor = words.some(w => w[0].toUpperCase() === letter) + expect(hasAnchor).toBe(true) + } + } }) - it('has at least 30 DOT_WORDS', () => expect(DOT_WORDS.length).toBeGreaterThanOrEqual(30)) - it('has at least 30 DASH_WORDS', () => expect(DASH_WORDS.length).toBeGreaterThanOrEqual(30)) }) describe('suggestPhrases', () => { @@ -90,24 +110,9 @@ describe('suggestPhrases', () => { const s = suggestPhrases('A', 5) expect(s).toHaveLength(5) }) - it('each suggestion has correct word count', () => { - const s = suggestPhrases('S', 5) // S = ... (3 words) - s.forEach(phrase => expect(phrase.split(' ')).toHaveLength(3)) - }) - it('suggestions for E are single words ≤3 letters', () => { - const s = suggestPhrases('E', 5) - s.forEach(phrase => { - expect(phrase.split(' ')).toHaveLength(1) - expect(phrase.length).toBeLessThanOrEqual(3) - }) - }) - it('first word starts with the target letter', () => { - for (const letter of ['A','E','T','S','N','H','R','D','L','I','U','O']) { - const s = suggestPhrases(letter, 5) - s.forEach(phrase => { - expect(phrase[0].toUpperCase()).toBe(letter) - }) - } + it('returns all curated suggestions when count exceeds available', () => { + const s = suggestPhrases('I', 100) + expect(s).toHaveLength(MNEMONIC_SUGGESTIONS['I'].length) }) it('returns empty array for unknown letter', () => { expect(suggestPhrases('$', 5)).toEqual([]) diff --git a/client/src/lib/morse.ts b/client/src/lib/morse.ts index f495a33..d75ee99 100644 --- a/client/src/lib/morse.ts +++ b/client/src/lib/morse.ts @@ -46,78 +46,42 @@ export interface ValidationResult { message?: string } -// Words for mnemonic suggestions -export const DOT_WORDS = [ - 'go','do','be','see','run','fly','set','try','can','the','win','get', - 'top','sky','sun','sea','war','key','age','all','now','one','big','far', - 'low','red','old','hot','new','day','eat','ice','joy','leg','man','out', - 'pay','put','raw','sad','shy','son','tap','tin','toe','toy','two','use', - 'way','you','aim','air','ask','bit','cut','dry','fun','god','had','hit', - 'its','law','log','lot','mad','mix','net','not','odd','pet','row','she', - 'up','urn','us','ore','our','oar','oh','ow','ox','nod','nap','nil', - 'nor','dip','dig','dug','den','did','dam','hub','hug','hum','hen', -] - -export const DASH_WORDS = [ - 'walk','sail','camp','hunt','fire','bold','fast','tall','dark','wide', - 'long','deep','hard','warm','cool','loud','slow','sure','true','real', - 'free','gold','star','wave','wind','lion','hawk','rock','iron','rise', - 'lead','dare','race','grow','find','make','keep','know','call','glow', - 'best','blue','book','burn','calm','care','cave','city','coal','code', - 'cold','cook','cost','cure','dawn','deal','deck','dive','door','down', - 'drum','dust','earn','east','edge','epic','face','fact','fair','fall', - 'fame','farm','fate','feel','feet','fill','fine','firm','fish','flag', - 'flow','food','form','fort','game','gate','gear','give','goal','good', - 'gray','grin','grow','gulf','gust','hail','hair','hall','hand','harm', - 'hawk','head','heal','heat','help','high','hike','hill','hold','hole', - 'home','hook','hope','horn','host','huge','idea','iron','jest','join', - 'jump','just','kill','kind','king','kiss','lake','lamp','land','lane', - 'last','late','leaf','leap','lend','lift','like','line','link','live', - 'lock','lone','look','love','lure','main','make','many','mark','mast', - 'meal','mean','meet','mind','mine','miss','mist','more','most','move', - 'much','must','name','need','next','none','noon','note','pain','pale', - 'park','part','pass','path','peak','pick','pile','plan','play','plot', - 'plow','pole','pond','pool','port','pour','pull','push','race','rain', - 'rank','read','reed','rest','ride','ring','rise','risk','road','roam', - 'roar','rock','roll','roof','room','rope','rose','ruin','rule','rush', - 'rust','sage','sale','sand','save','seal','seat','sell','send','ship', - 'shop','shot','shut','side','silk','sing','sink','site','skip','soak', - 'soar','soul','soup','span','spin','spot','spur','star','stay','stem', - 'step','stir','stop','take','tale','talk','tame','tank','task','tell', - 'tent','term','test','than','that','them','they','tide','time','toll', - 'tone','tool','toss','tour','town','trek','trim','trip','tune','turn', - 'veil','vine','vote','wail','wait','wake','wall','warn','wash','wave', - 'weed','week','well','went','wide','wife','wild','will','wind','wine', - 'wing','wire','wise','wish','wolf','wood','wool','word','work','worn', - 'yarn','yell','zone','zoom', - 'oath','once','only','open','ours','over','oven', - 'unit','upon','urge','used', -] - -function pickRandom(arr: T[]): T { - return arr[Math.floor(Math.random() * arr.length)] +// Curated mnemonic phrases per letter. +// Every phrase follows the dot/dash length rule and starts words with the target letter. +// Pattern key: ≤3 letters = dot, 4+ letters = dash +export const MNEMONIC_SUGGESTIONS: Record = { + // A (.-): ≤3 4+ + A: ['an APPLE','an ARROW','an ACORN','an ATLAS','an AWARD','aim AFAR','an ANGEL','ace ARMY'], + // E (.): ≤3 + E: ['egg','elk','ear','eye','end','era','eel','elm'], + // T (-): 4+ + T: ['TREE','TALL','TANK','TIME','TUNE','TOAD','TIDE','TRAIL'], + // I (..): ≤3 ≤3 + I: ['it is','in ice','in ink','is icy','I irk'], + // N (-.): 4+ ≤3 + N: ['noon nap','nine nil','nice nod','navy net','near now','note now','nest no'], + // S (...): ≤3 ≤3 ≤3 + S: ['sun set sky','sky sea sun','she sat shy','sir sat sad','see six spy','sip sip sip','say so sir'], + // H (....): ≤3 ≤3 ≤3 ≤3 + H: ['he has his hat','he hid his hog','he hit his hip','ho hum ho hum','he had his ham','he hug his hen'], + // R (.-.): ≤3 4+ ≤3 + R: ['red ROSE row','rob RICH rat','run RACE run','rip ROPE raw','ram ROAD run','rub RUST raw'], + // D (-..): 4+ ≤3 ≤3 + D: ['dark dim den','deep dry dip','dawn dew dry','dusk dim den','dare dig dry','done did dry'], + // L (.-..): ≤3 4+ ≤3 ≤3 + L: ['lit LAMP lay low','let LUCK lay low','lad LEAP log low','led LION lay low','let LIFE lay low'], + // U (..-): ≤3 ≤3 4+ + U: ['us up UPON','use up URGE','us up UNDER','us up UNTIL','us up UNITY'], + // O (---): 4+ 4+ 4+ + O: ['over only once','open oven oath','obey ours only','opal orbs omen','orca over only'], } export function suggestPhrases(letter: string, count: number): string[] { const upper = letter.toUpperCase() - const pattern = MORSE[upper] - if (!pattern) return [] - const elements = pattern.split('') - // First word should start with the letter - const firstPool = elements[0] === '.' ? DOT_WORDS : DASH_WORDS - const anchorWords = firstPool.filter(w => w[0].toUpperCase() === upper) - const results: string[] = [] - const seen = new Set() - for (let attempt = 0; attempt < count * 30 && results.length < count; attempt++) { - const words = elements.map((el, i) => { - if (i === 0 && anchorWords.length > 0) return pickRandom(anchorWords) - const pool = el === '.' ? DOT_WORDS : DASH_WORDS - return pickRandom(pool) - }) - const phrase = words.join(' ') - if (!seen.has(phrase)) { seen.add(phrase); results.push(phrase) } - } - return results + const phrases = MNEMONIC_SUGGESTIONS[upper] + if (!phrases || phrases.length === 0) return [] + const shuffled = [...phrases].sort(() => Math.random() - 0.5) + return shuffled.slice(0, Math.min(count, shuffled.length)) } export function validatePhrase(phrase: string, letter: string): ValidationResult {