diff --git a/client/src/components/MnemonicBuilder.tsx b/client/src/components/MnemonicBuilder.tsx index b0029e4..ddfb5fd 100644 --- a/client/src/components/MnemonicBuilder.tsx +++ b/client/src/components/MnemonicBuilder.tsx @@ -94,7 +94,8 @@ export function MnemonicBuilder({ token, onComplete }: Props) {

{pattern.length} word{pattern.length !== 1 ? 's' : ''} — - {' '}≤3 letters for dots · (short), 4+ letters for dashes — (long). + {' '}≤3 letters for dots ·, 4+ letters for dashes —. + {' '}Start a word with "{letter}" to anchor it!

{ 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 empty array for unknown letter', () => { expect(suggestPhrases('$', 5)).toEqual([]) }) @@ -108,40 +116,52 @@ describe('suggestPhrases', () => { describe('validatePhrase', () => { it('accepts valid phrase for E (.)', () => { - expect(validatePhrase('go', 'E')).toEqual({ valid: true }) + expect(validatePhrase('eat', 'E')).toEqual({ valid: true }) }) it('accepts 3-letter dot word', () => { - expect(validatePhrase('run', 'E')).toEqual({ valid: true }) + expect(validatePhrase('eat', 'E')).toEqual({ valid: true }) }) it('accepts valid phrase for A (.-)', () => { - expect(validatePhrase('go WALKING', 'A')).toEqual({ valid: true }) + // A = .- → dot + dash; "age" starts with A, "WALKING" is 4+ + expect(validatePhrase('age WALKING', 'A')).toEqual({ valid: true }) }) it('accepts valid phrase for S (...)', () => { - expect(validatePhrase('go go go', 'S')).toEqual({ valid: true }) + // S = ... → three dot words; "sun" starts with S + expect(validatePhrase('sun go go', 'S')).toEqual({ valid: true }) }) it('rejects 4-letter word in dot position', () => { - const r = validatePhrase('walk go go', 'S') + const r = validatePhrase('walk sun go', 'S') expect(r.valid).toBe(false) }) it('accepts 3-letter word in dot position', () => { - expect(validatePhrase('run', 'E')).toEqual({ valid: true }) + expect(validatePhrase('eat', 'E')).toEqual({ valid: true }) }) it('rejects 3-letter word in dash position', () => { - const r = validatePhrase('go go', 'A') // A=.- so second word must be 4+ letters + const r = validatePhrase('age go', 'A') // A=.- so second word must be 4+ letters expect(r.valid).toBe(false) }) it('accepts 4-letter word in dash position', () => { - expect(validatePhrase('go walk', 'A')).toEqual({ valid: true }) + expect(validatePhrase('aim also', 'A')).toEqual({ valid: true }) }) it('rejects wrong word count', () => { - const r = validatePhrase('go go', 'S') + const r = validatePhrase('sun 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') + const r = validatePhrase('SAILING go go', 'S') expect(r.valid).toBe(false) }) + it('rejects phrase without anchor letter', () => { + // A = .- but no word starts with A + const r = validatePhrase('go walk', 'A') + expect(r.valid).toBe(false) + expect(r.message).toContain('"A"') + }) + it('accepts anchor letter in any position', () => { + // A = .- → "go ALSO" — "ALSO" starts with A + expect(validatePhrase('go ALSO', 'A')).toEqual({ valid: true }) + }) it('rejects unknown letter', () => { expect(validatePhrase('test', '1')).toEqual({ valid: false, message: 'Unknown letter' }) }) diff --git a/client/src/lib/morse.ts b/client/src/lib/morse.ts index 92c2377..f495a33 100644 --- a/client/src/lib/morse.ts +++ b/client/src/lib/morse.ts @@ -54,6 +54,8 @@ export const DOT_WORDS = [ '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 = [ @@ -88,19 +90,29 @@ export const DASH_WORDS = [ '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)] +} + 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 => { + const words = elements.map((el, i) => { + if (i === 0 && anchorWords.length > 0) return pickRandom(anchorWords) const pool = el === '.' ? DOT_WORDS : DASH_WORDS - return pool[Math.floor(Math.random() * pool.length)] + return pickRandom(pool) }) const phrase = words.join(' ') if (!seen.has(phrase)) { seen.add(phrase); results.push(phrase) } @@ -134,6 +146,11 @@ export function validatePhrase(phrase: string, letter: string): ValidationResult } } + const hasAnchor = words.some(w => w[0].toUpperCase() === upper) + if (!hasAnchor) { + return { valid: false, message: `At least one word should start with "${upper}"` } + } + return { valid: true } }