spintax.test.js
1 /** 2 * Spintax Utility Tests 3 * 4 * Tests for spin(), generateVariations(), countPossibleVariations(), 5 * validateSpintax(), testSpintax(), and loadAndSpinTemplate(). 6 * Pure functions — no external dependencies except loadAndSpinTemplate (fs I/O). 7 */ 8 9 import { test, describe } from 'node:test'; 10 import assert from 'node:assert/strict'; 11 import { join, dirname } from 'path'; 12 import { fileURLToPath } from 'url'; 13 import { writeFile, mkdir, rm } from 'fs/promises'; 14 15 import { 16 spin, 17 generateVariations, 18 countPossibleVariations, 19 validateSpintax, 20 testSpintax, 21 loadAndSpinTemplate, 22 } from '../../src/utils/spintax.js'; 23 24 // ─── spin() ──────────────────────────────────────────────────────────────────── 25 26 describe('spin', () => { 27 test('returns text unchanged when no spintax present', () => { 28 assert.equal(spin('Hello world'), 'Hello world'); 29 }); 30 31 test('returns null/undefined/empty unchanged', () => { 32 assert.equal(spin(null), null); 33 assert.equal(spin(undefined), undefined); 34 assert.equal(spin(''), ''); 35 }); 36 37 test('picks one option from a single spintax group', () => { 38 const result = spin('{A|B|C}'); 39 assert.ok(['A', 'B', 'C'].includes(result), `Expected A, B, or C but got: ${result}`); 40 }); 41 42 test('replaces all spintax groups', () => { 43 const result = spin('{Hello|Hi} {world|there}'); 44 const options = ['Hello world', 'Hello there', 'Hi world', 'Hi there']; 45 assert.ok(options.includes(result), `Unexpected result: ${result}`); 46 }); 47 48 test('handles text before and after spintax', () => { 49 const result = spin('Start {A|B} End'); 50 assert.ok(['Start A End', 'Start B End'].includes(result)); 51 }); 52 53 test('with seed produces deterministic output', () => { 54 const r1 = spin('{A|B|C|D|E}', 42); 55 const r2 = spin('{A|B|C|D|E}', 42); 56 assert.equal(r1, r2); 57 }); 58 59 test('different seeds produce potentially different output', () => { 60 // Use a wider range — the LCG needs more seeds to hit different buckets 61 const results = new Set(); 62 for (let seed = 0; seed < 1000; seed++) { 63 results.add(spin('{A|B|C|D|E}', seed)); 64 } 65 // Should get more than 1 unique result across 1000 seeds 66 assert.ok(results.size > 1, 'Different seeds should vary results'); 67 }); 68 69 test('handles single-option spintax (no alternatives)', () => { 70 assert.equal(spin('{only}'), 'only'); 71 }); 72 73 test('processes nested spintax from innermost outward', () => { 74 // Inner group resolved first, then outer 75 const result = spin('{Hello|Hi} {beautiful|nice} {world|earth}'); 76 assert.ok(typeof result === 'string'); 77 assert.ok(!result.includes('{')); 78 assert.ok(!result.includes('}')); 79 }); 80 }); 81 82 // ─── generateVariations() ────────────────────────────────────────────────────── 83 84 describe('generateVariations', () => { 85 test('returns the requested number of variations', () => { 86 const variations = generateVariations('{A|B|C|D|E}', 3); 87 assert.equal(variations.length, 3); 88 }); 89 90 test('returns unique variations only', () => { 91 const variations = generateVariations('{A|B|C|D|E}', 5); 92 const unique = new Set(variations); 93 assert.equal(unique.size, variations.length, 'Should contain no duplicates'); 94 }); 95 96 test('returns as many unique variations as possible (capped at available combos)', () => { 97 // Only 2 possible variations for {A|B} 98 const variations = generateVariations('{A|B}', 10); 99 assert.ok(variations.length <= 2, 'Cannot exceed possible unique combinations'); 100 }); 101 102 test('default count is 5', () => { 103 const variations = generateVariations('{A|B|C|D|E|F|G}'); 104 assert.equal(variations.length, 5); 105 }); 106 107 test('returns array of strings', () => { 108 const variations = generateVariations('{Hello|Hi} world'); 109 for (const v of variations) { 110 assert.ok(typeof v === 'string'); 111 } 112 }); 113 114 test('all variations have spintax resolved', () => { 115 const variations = generateVariations('{A|B}'); 116 for (const v of variations) { 117 assert.ok(!v.includes('{')); 118 assert.ok(!v.includes('}')); 119 } 120 }); 121 }); 122 123 // ─── countPossibleVariations() ───────────────────────────────────────────────── 124 125 describe('countPossibleVariations', () => { 126 test('returns 1 for plain text (no spintax)', () => { 127 assert.equal(countPossibleVariations('Hello world'), 1); 128 }); 129 130 test('returns 1 for null/empty', () => { 131 assert.equal(countPossibleVariations(null), 1); 132 assert.equal(countPossibleVariations(''), 1); 133 }); 134 135 test('returns option count for single group', () => { 136 assert.equal(countPossibleVariations('{A|B|C}'), 3); 137 }); 138 139 test('multiplies counts across multiple groups', () => { 140 assert.equal(countPossibleVariations('{A|B} {C|D}'), 4); // 2 × 2 141 }); 142 143 test('handles three groups', () => { 144 assert.equal(countPossibleVariations('{A|B} {C|D} {E|F|G}'), 12); // 2 × 2 × 3 145 }); 146 147 test('counts single-option group as 1', () => { 148 assert.equal(countPossibleVariations('{only}'), 1); 149 }); 150 }); 151 152 // ─── validateSpintax() ───────────────────────────────────────────────────────── 153 154 describe('validateSpintax', () => { 155 test('valid text with no spintax', () => { 156 const result = validateSpintax('Hello world'); 157 assert.equal(result.valid, true); 158 assert.equal(result.errors.length, 0); 159 }); 160 161 test('null/empty is valid', () => { 162 assert.equal(validateSpintax(null).valid, true); 163 assert.equal(validateSpintax('').valid, true); 164 }); 165 166 test('valid single spintax group', () => { 167 assert.equal(validateSpintax('{A|B|C}').valid, true); 168 }); 169 170 test('valid multiple groups', () => { 171 assert.equal(validateSpintax('{Hello|Hi} {world|earth}').valid, true); 172 }); 173 174 test('unbalanced closing brace detected', () => { 175 const result = validateSpintax('Hello} world'); 176 assert.equal(result.valid, false); 177 assert.ok(result.errors.some(e => e.includes('Unbalanced closing brace'))); 178 }); 179 180 test('unclosed opening brace detected', () => { 181 const result = validateSpintax('{A|B'); 182 assert.equal(result.valid, false); 183 assert.ok(result.errors.some(e => e.includes('missing closing'))); 184 }); 185 186 test('empty option {|} detected', () => { 187 const result = validateSpintax('{|}'); 188 assert.equal(result.valid, false); 189 assert.ok(result.errors.some(e => e.includes('Empty spintax option'))); 190 }); 191 192 test('consecutive pipes || detected', () => { 193 const result = validateSpintax('{A||B}'); 194 assert.equal(result.valid, false); 195 assert.ok(result.errors.some(e => e.includes('Empty spintax option'))); 196 }); 197 198 test('deep nesting warning (depth > 5)', () => { 199 // 6 levels deep: {A|{B|{C|{D|{E|{F|G}}}}}} 200 const deep = '{a|{b|{c|{d|{e|{f|g}}}}}}'; 201 const result = validateSpintax(deep); 202 // May be valid but warns about deep nesting 203 assert.ok(result.errors.some(e => e.includes('Deep nesting'))); 204 }); 205 }); 206 207 // ─── testSpintax() ───────────────────────────────────────────────────────────── 208 209 describe('testSpintax', () => { 210 test('returns expected shape', () => { 211 const result = testSpintax('{A|B|C}', 3); 212 assert.ok(typeof result.valid === 'boolean'); 213 assert.ok(Array.isArray(result.errors)); 214 assert.ok(typeof result.stats === 'object'); 215 assert.ok(Array.isArray(result.samples)); 216 }); 217 218 test('stats.possibleVariations matches countPossibleVariations', () => { 219 const text = '{A|B} {C|D|E}'; 220 const result = testSpintax(text, 5); 221 assert.equal(result.stats.possibleVariations, countPossibleVariations(text)); // 6 222 }); 223 224 test('samples length equals requested count (up to unique limit)', () => { 225 const result = testSpintax('{A|B|C|D|E}', 4); 226 assert.ok(result.samples.length <= 4); 227 }); 228 229 test('valid=false for invalid spintax', () => { 230 const result = testSpintax('{A|B', 3); 231 assert.equal(result.valid, false); 232 assert.ok(result.errors.length > 0); 233 }); 234 }); 235 236 // ─── loadAndSpinTemplate() ───────────────────────────────────────────────────── 237 238 describe('loadAndSpinTemplate', () => { 239 const __dirname = dirname(fileURLToPath(import.meta.url)); 240 const tmpTemplatesDir = join(__dirname, '../../data/templates/_test_country_'); 241 242 const testEmailTemplate = { 243 templates: [ 244 { 245 id: 'email_test_01', 246 channel: 'email', 247 author: 'Test', 248 country: '_test_country_', 249 tone: 'friendly', 250 approach: 'direct', 251 subject_spintax: '{Hello|Hi} [firstname|there]', 252 body_spintax: 'We help {businesses|companies} with [service].', 253 }, 254 { 255 id: 'sms_test_01', 256 channel: 'sms', 257 author: 'Test', 258 country: '_test_country_', 259 tone: 'brief', 260 approach: 'direct', 261 subject_spintax: null, 262 body_spintax: '{Quick|Fast} message for [name|you].', 263 }, 264 ], 265 }; 266 267 // Setup: create temp template directory + files 268 test('setup temp templates', async () => { 269 await mkdir(tmpTemplatesDir, { recursive: true }); 270 await writeFile( 271 join(tmpTemplatesDir, 'email.json'), 272 JSON.stringify(testEmailTemplate), 273 'utf-8' 274 ); 275 await writeFile(join(tmpTemplatesDir, 'sms.json'), JSON.stringify(testEmailTemplate), 'utf-8'); 276 }); 277 278 test('loads and spins an email template', async () => { 279 const result = await loadAndSpinTemplate( 280 'email_test_01', 281 { firstname: 'Alice', service: 'web design' }, 282 '_test_country_', 283 'email' 284 ); 285 286 assert.ok(typeof result.subject === 'string'); 287 assert.ok(typeof result.body === 'string'); 288 assert.equal(result.id, 'email_test_01'); 289 assert.equal(result.channel, 'email'); 290 291 // Variables should be resolved 292 assert.ok(result.subject.includes('Alice'), `Subject should have Alice: ${result.subject}`); 293 assert.ok(result.body.includes('web design'), `Body should have service: ${result.body}`); 294 295 // Spintax should be resolved 296 assert.ok(!result.subject.includes('{')); 297 assert.ok(!result.body.includes('{')); 298 }); 299 300 test('uses fallback for missing variable', async () => { 301 const result = await loadAndSpinTemplate( 302 'email_test_01', 303 {}, // no firstname provided 304 '_test_country_', 305 'email' 306 ); 307 308 // [firstname|there] → 'there' (fallback) 309 assert.ok(result.subject.includes('there'), `Subject should have fallback: ${result.subject}`); 310 }); 311 312 test('auto-detects channel from template ID prefix', async () => { 313 const result = await loadAndSpinTemplate('email_test_01', {}, '_test_country_'); 314 assert.equal(result.channel, 'email'); 315 }); 316 317 test('SMS template has null subject', async () => { 318 const result = await loadAndSpinTemplate('sms_test_01', {}, '_test_country_', 'sms'); 319 assert.equal(result.subject, null); 320 assert.ok(typeof result.body === 'string'); 321 }); 322 323 test('parses country from templateId with slash notation', async () => { 324 const result = await loadAndSpinTemplate( 325 '_test_country_/email_test_01', 326 { firstname: 'Bob', service: 'SEO' }, 327 null, // no explicit country 328 'email' 329 ); 330 assert.ok(result.body.includes('SEO')); 331 }); 332 333 test('throws when template file not found', async () => { 334 await assert.rejects( 335 () => loadAndSpinTemplate('email_test_01', {}, 'NONEXISTENT_COUNTRY', 'email'), 336 /Failed to load templates/ 337 ); 338 }); 339 340 test('throws when template ID not found in file', async () => { 341 await assert.rejects( 342 () => loadAndSpinTemplate('email_nonexistent', {}, '_test_country_', 'email'), 343 /Template not found/ 344 ); 345 }); 346 347 // Teardown 348 test('cleanup temp templates', async () => { 349 await rm(tmpTemplatesDir, { recursive: true, force: true }); 350 }); 351 });