/ tests / utils / spintax.test.js
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  });