Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions src/utils/can-join.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { canJoin } from './can-join';

describe('canJoin', () => {
it('allows joining onto an empty left segment', () => {
expect(canJoin('', 'kra')).toBe(true);
expect(canJoin('', 'a')).toBe(true);
});

it('allows joining an empty right segment', () => {
expect(canJoin('mor', '')).toBe(true);
});

it('rejects illegal consonant clusters at word start', () => {
expect(canJoin('t', 'kra')).toBe(false);
expect(canJoin('th', 'drel')).toBe(false);
expect(canJoin('sh', 'sa')).toBe(false);
expect(canJoin('ch', 'xa')).toBe(false);
expect(canJoin('z', 'fi')).toBe(false);
expect(canJoin('n', 'na')).toBe(false);
});

it('accepts legal consonant clusters at word start', () => {
expect(canJoin('t', 'ra')).toBe(true);
expect(canJoin('th', 'ren')).toBe(true);
expect(canJoin('s', 'la')).toBe(true);
expect(canJoin('c', 'ra')).toBe(true);
expect(canJoin('ph', 'ra')).toBe(true);
});

it('rejects illegal consonant clusters mid-word', () => {
expect(canJoin('morauz', 'gri')).toBe(false);
expect(canJoin('velas', 'yan')).toBe(false);
expect(canJoin('fenax', 'ka')).toBe(false);
});

it('accepts legal consonant clusters mid-word', () => {
expect(canJoin('vel', 'ka')).toBe(true);
expect(canJoin('bron', 'drel')).toBe(true);
expect(canJoin('morauz', 'za')).toBe(true);
expect(canJoin('fenax', 'ta')).toBe(true);
expect(canJoin('velan', 'yan')).toBe(true);
});

it('rejects vowel seams that create a run of three or more vowels', () => {
expect(canJoin('velau', 'ilo')).toBe(false);
expect(canJoin('minea', 'oro')).toBe(false);
expect(canJoin('lunia', 'ei')).toBe(false);
});

it('accepts vowel seams that keep vowel runs at two', () => {
expect(canJoin('vela', 'ilo')).toBe(true);
expect(canJoin('min', 'oro')).toBe(true);
expect(canJoin('feno', 'une')).toBe(true);
});

it('rejects un-English double vowels at the seam', () => {
expect(canJoin('vela', 'ava')).toBe(false);
expect(canJoin('mini', 'ilo')).toBe(false);
expect(canJoin('lunu', 'ulo')).toBe(false);
});

it('requires a single vowel after qu', () => {
expect(canJoin('qu', 'ara')).toBe(true);
expect(canJoin('qu', 'a')).toBe(true);
expect(canJoin('qu', 'kra')).toBe(false);
expect(canJoin('qu', 'ee')).toBe(false);
expect(canJoin('velaqu', 'e')).toBe(true);
expect(canJoin('velaqu', 'ta')).toBe(false);
});

it('accepts consonant-vowel and vowel-consonant seams', () => {
expect(canJoin('vel', 'ara')).toBe(true);
expect(canJoin('vela', 'kra')).toBe(true);
});
});
29 changes: 29 additions & 0 deletions src/utils/can-join.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { INITIAL_CLUSTERS, MEDIAL_CLUSTERS } from './clusters';

const BANNED_VOWEL_PAIRS: ReadonlySet<string> = new Set(['aa', 'ii', 'uu']);

const isVowel = (char: string): boolean => 'aeiou'.includes(char);

const leadingVowelRun = (s: string): string => s.match(/^[aeiou]+/)?.[0] ?? '';

const trailingVowelRun = (s: string): string => s.match(/[aeiou]+$/)?.[0] ?? '';

const leadingConsonantRun = (s: string): string => s.match(/^[^aeiou]+/)?.[0] ?? '';

const trailingConsonantRun = (s: string): string => s.match(/[^aeiou]+$/)?.[0] ?? '';

export const canJoin = (left: string, right: string): boolean => {
if (left === '' || right === '') return true;
const lastChar = left[left.length - 1];
const firstChar = right[0];
if (BANNED_VOWEL_PAIRS.has(`${lastChar}${firstChar}`)) return false;
if (left.endsWith('qu') && !isVowel(firstChar)) return false;
if (isVowel(lastChar) && isVowel(firstChar)) {
return trailingVowelRun(left).length + leadingVowelRun(right).length <= 2;
}
if (isVowel(lastChar) || isVowel(firstChar)) return true;
const leftRun = trailingConsonantRun(left);
const seamCluster = leftRun + leadingConsonantRun(right);
const seamAtWordStart = leftRun.length === left.length;
return seamAtWordStart ? INITIAL_CLUSTERS.has(seamCluster) : MEDIAL_CLUSTERS.has(seamCluster);
};
31 changes: 31 additions & 0 deletions src/utils/clusters.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { INITIAL_CLUSTERS, MEDIAL_CLUSTERS } from './clusters';

describe('cluster whitelists', () => {
it.each([
['INITIAL_CLUSTERS', INITIAL_CLUSTERS],
['MEDIAL_CLUSTERS', MEDIAL_CLUSTERS],
])('%s contains only lowercase consonant sequences', (_, clusters) => {
expect([...clusters].filter((cluster) => !/^[b-df-hj-np-tv-z]+$/.test(cluster))).toEqual([]);
});

it.each([
['INITIAL_CLUSTERS', INITIAL_CLUSTERS],
['MEDIAL_CLUSTERS', MEDIAL_CLUSTERS],
])('%s contains only clusters of two or three consonants', (_, clusters) => {
expect([...clusters].filter((cluster) => cluster.length < 2 || cluster.length > 3)).toEqual([]);
});

it('INITIAL_CLUSTERS excludes clusters English never starts words with', () => {
expect(INITIAL_CLUSTERS.has('tk')).toBe(false);
expect(INITIAL_CLUSTERS.has('zf')).toBe(false);
expect(INITIAL_CLUSTERS.has('vr')).toBe(false);
expect(INITIAL_CLUSTERS.has('nn')).toBe(false);
});

it('MEDIAL_CLUSTERS excludes clusters English never uses mid-word', () => {
expect(MEDIAL_CLUSTERS.has('zg')).toBe(false);
expect(MEDIAL_CLUSTERS.has('zgr')).toBe(false);
expect(MEDIAL_CLUSTERS.has('xk')).toBe(false);
});
});
137 changes: 137 additions & 0 deletions src/utils/clusters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
export const INITIAL_CLUSTERS: ReadonlySet<string> = new Set([
'bl',
'br',
'cl',
'cr',
'dr',
'dw',
'fl',
'fr',
'gl',
'gr',
'kl',
'kr',
'pl',
'pr',
'sc',
'sk',
'sl',
'sm',
'sn',
'sp',
'st',
'sw',
'tr',
'tw',
'wr',
'ch',
'ph',
'sh',
'th',
'wh',
'chr',
'phr',
'shr',
'thr',
'scr',
'spl',
'spr',
'str',
]);

export const MEDIAL_CLUSTERS: ReadonlySet<string> = new Set([
'bl',
'br',
'cl',
'cr',
'dr',
'fl',
'fr',
'gl',
'gr',
'kl',
'kr',
'pl',
'pr',
'sc',
'sk',
'sl',
'sm',
'sn',
'sp',
'st',
'tl',
'tr',
'vr',
'ch',
'ph',
'sh',
'th',
'wh',
'lb',
'lc',
'ld',
'lf',
'lg',
'lk',
'lm',
'lp',
'ls',
'lt',
'lv',
'ly',
'mb',
'mp',
'nc',
'nd',
'nf',
'ng',
'nj',
'nk',
'nl',
'ns',
'nt',
'nv',
'ny',
'nz',
'rb',
'rc',
'rd',
'rf',
'rg',
'rk',
'rl',
'rm',
'rn',
'rp',
'rs',
'rt',
'rv',
'ry',
'xt',
'bb',
'dd',
'ff',
'gg',
'll',
'mm',
'nn',
'pp',
'rr',
'ss',
'tt',
'zz',
'ldr',
'ltr',
'mbr',
'mpr',
'ndr',
'nfl',
'ngr',
'nkr',
'nsl',
'ntr',
'rtr',
'str',
'xtr',
]);
14 changes: 14 additions & 0 deletions src/utils/generator.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import { INITIAL_CLUSTERS } from './clusters';
import { generateName } from './generator';
import { createRng } from './rng';

Expand Down Expand Up @@ -142,6 +143,19 @@ describe('generator', () => {
expect(name).toBe(name.toLowerCase());
});

it('never starts a name with an illegal consonant cluster', () => {
const config = { minLength: 6, maxLength: 10 };
const offenders = Array.from({ length: 5000 }, (_, i) => {
const [name] = generateName(createRng(i), config);
return name;
}).filter((name) => {
const run = name.match(/^[^aeiou]+/)?.[0] ?? '';
return run.length >= 2 && !INITIAL_CLUSTERS.has(run);
});

expect(offenders).toEqual([]);
});

it('generates high-quality unique names', () => {
const config = { minLength: 6, maxLength: 10 };
const names = new Set<string>();
Expand Down
Loading
Loading