/ tests / utils / template-proposals.test.js
template-proposals.test.js
  1  /**
  2   * Tests for src/utils/template-proposals.js (pure function exports)
  3   *
  4   * Tests: extractTemplateFields, selectTemplate, populateTemplate,
  5   *        checkForUnfilledTokens, getCurrentSeason, loadTemplates
  6   *
  7   * Excluded: analyzeScoreJson, polishProposal, shortenSmsWithHaiku,
  8   *           generateTemplateProposal — require live LLM API calls.
  9   */
 10  
 11  import { test, describe } from 'node:test';
 12  import assert from 'node:assert/strict';
 13  
 14  import {
 15    extractTemplateFields,
 16    selectTemplate,
 17    populateTemplate,
 18    checkForUnfilledTokens,
 19    getCurrentSeason,
 20    loadTemplates,
 21  } from '../../src/utils/template-proposals.js';
 22  
 23  // ─── extractTemplateFields ────────────────────────────────────────────────────
 24  
 25  describe('extractTemplateFields', () => {
 26    test('returns defaults for null scoreData', () => {
 27      const fields = extractTemplateFields(null);
 28      assert.equal(fields.primaryWeakness, 'weak call-to-action');
 29      assert.equal(fields.grade, 'F');
 30      assert.equal(fields.score, 0);
 31      assert.ok(fields.impact >= 20 && fields.impact <= 50);
 32    });
 33  
 34    test('returns defaults for empty scoreData (no sections or factor_scores)', () => {
 35      const fields = extractTemplateFields({});
 36      assert.equal(fields.primaryWeakness, 'weak call-to-action');
 37    });
 38  
 39    test('extracts primaryWeakness from factor_scores (lowest score wins)', () => {
 40      const scoreData = {
 41        factor_scores: {
 42          call_to_action: { score: 3, evidence: 'No CTA button', reasoning: 'Weak CTA' },
 43          trust_signals: { score: 8, evidence: 'Good reviews', reasoning: 'Strong trust' },
 44          headline_quality: { score: 6, evidence: 'Average headline', reasoning: 'Mediocre' },
 45        },
 46        overall_calculation: { conversion_score: 45 },
 47      };
 48      const fields = extractTemplateFields(scoreData);
 49      // call_to_action has the lowest score (3), so it should be the primary weakness
 50      // FACTOR_LABELS maps this to tradie-friendly language
 51      assert.ok(fields.primaryWeakness.includes('call') || fields.primaryWeakness.includes('contact'));
 52    });
 53  
 54    test('extracts secondaryWeakness from factor_scores', () => {
 55      const scoreData = {
 56        factor_scores: {
 57          call_to_action: { score: 2, evidence: 'No CTA', reasoning: 'CTA weak' },
 58          value_proposition: { score: 3, evidence: 'Unclear value', reasoning: 'VP unclear' },
 59          trust_signals: { score: 8, evidence: 'Good', reasoning: 'Strong' },
 60        },
 61        overall_calculation: { conversion_score: 50 },
 62      };
 63      const fields = extractTemplateFields(scoreData);
 64      assert.ok(fields.secondaryWeakness.length > 0);
 65    });
 66  
 67    test('prefers critical_weaknesses for secondaryWeakness when present', () => {
 68      const scoreData = {
 69        factor_scores: {
 70          call_to_action: { score: 2, evidence: 'No CTA', reasoning: 'Weak' },
 71          value_proposition: { score: 3, evidence: 'Unclear', reasoning: 'Unclear' },
 72        },
 73        critical_weaknesses: ['Missing testimonials', 'No trust badges'],
 74        overall_calculation: { conversion_score: 40 },
 75      };
 76      const fields = extractTemplateFields(scoreData);
 77      // Should use critical_weaknesses[1] (index 1) for secondary
 78      assert.ok(fields.secondaryWeakness.toLowerCase().includes('trust'));
 79    });
 80  
 81    test('extracts score from overall_calculation.conversion_score', () => {
 82      const scoreData = {
 83        factor_scores: {
 84          call_to_action: { score: 5, evidence: 'Ok', reasoning: 'Ok' },
 85        },
 86        overall_calculation: { conversion_score: 65.7 },
 87      };
 88      const fields = extractTemplateFields(scoreData);
 89      assert.equal(fields.score, 66); // Math.round(65.7)
 90    });
 91  
 92    test('clamps impact between 20 and 50', () => {
 93      // Very low scores should give max impact (50)
 94      const scoreData = {
 95        factor_scores: {
 96          call_to_action: { score: 0, evidence: 'No CTA', reasoning: 'None' },
 97          trust_signals: { score: 0, evidence: 'No trust', reasoning: 'None' },
 98          headline_quality: { score: 0, evidence: 'No headline', reasoning: 'None' },
 99        },
100        overall_calculation: { conversion_score: 20 },
101      };
102      const fields = extractTemplateFields(scoreData);
103      assert.ok(fields.impact >= 20 && fields.impact <= 50);
104    });
105  
106    test('extracts industry from contextual_appropriateness when present', () => {
107      const scoreData = {
108        factor_scores: {
109          contextual_appropriateness: {
110            score: 7,
111            evidence: 'Good',
112            reasoning: 'Good',
113            industry_context: 'plumbing services',
114          },
115        },
116        overall_calculation: { conversion_score: 60 },
117      };
118      const fields = extractTemplateFields(scoreData);
119      assert.equal(fields.industry, 'plumbing services');
120    });
121  
122    test('supports legacy sections format', () => {
123      const scoreData = {
124        sections: {
125          conversion: {
126            criteria: {
127              'call-to-action': { score: 2, explanation: 'No CTA found', reasoning: 'Weak CTA' },
128              'trust-signals': { score: 8, explanation: 'Good reviews', reasoning: 'Strong' },
129            },
130          },
131        },
132        overall_calculation: { conversion_score: 45 },
133      };
134      const fields = extractTemplateFields(scoreData);
135      assert.ok(fields.primaryWeakness.length > 0);
136      assert.equal(fields.score, 45);
137    });
138  
139    test('uses quick_improvement_opportunities[1] when available', () => {
140      const scoreData = {
141        factor_scores: {
142          call_to_action: { score: 3, evidence: 'No CTA', reasoning: 'Weak' },
143        },
144        quick_improvement_opportunities: [
145          'Add a contact form above the fold',
146          'Include customer testimonials with photos',
147        ],
148        overall_calculation: { conversion_score: 40 },
149      };
150      const fields = extractTemplateFields(scoreData);
151      // Should use [1] (second entry)
152      assert.ok(fields.quickImprovementOpportunity.toLowerCase().includes('testimonial'));
153    });
154  
155    test('filters non-answer evidence (None found)', () => {
156      const scoreData = {
157        factor_scores: {
158          call_to_action: { score: 2, evidence: 'None found', reasoning: 'Weak CTA' },
159          trust_signals: { score: 4, evidence: 'No trust signals visible', reasoning: 'Needs work' },
160        },
161        overall_calculation: { conversion_score: 30 },
162      };
163      const fields = extractTemplateFields(scoreData);
164      // Should skip "None found" and use secondaryWeakness evidence or fallback
165      assert.ok(!fields.evidence.toLowerCase().includes('none found'));
166    });
167  });
168  
169  // ─── selectTemplate ───────────────────────────────────────────────────────────
170  
171  describe('selectTemplate', () => {
172    const makeTemplates = () => [
173      { id: 'tmpl_01', channel: 'sms', body_spintax: 'Hello [domain]', sends: 0, conversions: 0 },
174      { id: 'tmpl_02', channel: 'sms', body_spintax: 'Hi [domain]', sends: 5, conversions: 1 },
175      {
176        id: 'tmpl_03',
177        channel: 'sms',
178        body_spintax: 'Hey [firstname] at [domain]',
179        sends: 10,
180        conversions: 2,
181      },
182    ];
183  
184    test('throws when no templates available', () => {
185      assert.throws(() => selectTemplate([], {}, 'sms'), /No templates available/);
186    });
187  
188    test('throws for null templates', () => {
189      assert.throws(() => selectTemplate(null, {}, 'sms'), /No templates available/);
190    });
191  
192    test('returns a template object', () => {
193      const result = selectTemplate(makeTemplates(), {}, 'sms');
194      assert.ok(typeof result === 'object');
195      assert.ok('id' in result);
196    });
197  
198    test('prefers template with fewer sends (rotation)', () => {
199      // With deterministic sort, lowest sends should win
200      const templates = [
201        { id: 'high', body_spintax: 'H', sends: 100, conversions: 10 },
202        { id: 'low', body_spintax: 'L', sends: 0, conversions: 0 },
203      ];
204      // Run 10 times — with sends=0 vs 100, low-sends should dominate
205      const results = Array.from({ length: 10 }, () => selectTemplate(templates, {}, 'sms'));
206      const lowSendsCount = results.filter(r => r.id === 'low').length;
207      assert.ok(lowSendsCount >= 7, `Expected low-sends to dominate, got ${lowSendsCount}/10`);
208    });
209  
210    test('prefers named templates when hasFirstname=true', () => {
211      const templates = [
212        { id: 'generic', body_spintax: 'Hi there from [domain]', sends: 0, conversions: 0 },
213        { id: 'named', body_spintax: 'Hi [firstname|there] at [domain]', sends: 50, conversions: 5 },
214      ];
215      // With hasFirstname=true, should prefer the named template even with more sends
216      const results = Array.from({ length: 10 }, () => selectTemplate(templates, {}, 'sms', true));
217      const namedCount = results.filter(r => r.id === 'named').length;
218      assert.ok(namedCount >= 8, `Expected named template to dominate, got ${namedCount}/10`);
219    });
220  
221    test('falls back to all templates when no named templates exist', () => {
222      const templates = [{ id: 'generic', body_spintax: 'Hi there', sends: 0, conversions: 0 }];
223      const result = selectTemplate(templates, {}, 'sms', true);
224      assert.ok(result); // should not throw
225    });
226  
227    test('uses conversion rate for templates with 1000+ sends', () => {
228      const templates = [
229        { id: 'high_conv', body_spintax: 'H', sends: 1000, conversions: 100 }, // 10% rate
230        { id: 'low_conv', body_spintax: 'L', sends: 1000, conversions: 10 }, // 1% rate
231      ];
232      const results = Array.from({ length: 10 }, () => selectTemplate(templates, {}, 'sms'));
233      const highConvCount = results.filter(r => r.id === 'high_conv').length;
234      assert.ok(highConvCount >= 7, `Expected high-conversion to dominate, got ${highConvCount}/10`);
235    });
236  });
237  
238  // ─── populateTemplate ─────────────────────────────────────────────────────────
239  
240  describe('populateTemplate', () => {
241    const siteData = { domain: 'acme-plumbing.com.au', keyword: 'plumber sydney' };
242    const fields = {
243      primaryWeakness: 'weak call-to-action',
244      secondaryWeakness: 'unclear value proposition',
245      grade: 'D',
246      score: 65,
247      industry: 'plumbing',
248      impact: 35,
249      evidence: 'No CTA button found',
250      reasoning: 'CTA improvements increase conversions',
251      quickImprovementOpportunity: 'add a clear CTA button',
252    };
253  
254    test('replaces [domain] token', () => {
255      const result = populateTemplate('Check [domain] for details.', fields, siteData);
256      assert.ok(result.includes('acme-plumbing.com.au'));
257      assert.ok(!result.includes('[domain]'));
258    });
259  
260    test('replaces [grade] token', () => {
261      const result = populateTemplate('Your site got a [grade] grade.', fields, siteData);
262      assert.ok(result.includes('D'));
263    });
264  
265    test('replaces [score] token', () => {
266      const result = populateTemplate('Score: [score]/100', fields, siteData);
267      assert.ok(result.includes('65'));
268    });
269  
270    test('replaces [industry] token using analysisData or extracted keyword', () => {
271      // Without analysisData, industry is extracted from siteData.keyword via _extractIndustry
272      // 'plumber sydney' (2-word) → strips last word → 'plumber'
273      const result = populateTemplate('We help [industry] businesses.', fields, siteData);
274      assert.ok(result.includes('plumber'), `expected 'plumber', got: '${result}'`);
275    });
276  
277    test('uses [firstname|fallback] when no contact name', () => {
278      const result = populateTemplate('Hi [firstname|there]!', fields, siteData);
279      assert.ok(result.includes('there'));
280      assert.ok(!result.includes('[firstname'));
281    });
282  
283    test('uses real firstname when contact has person name', () => {
284      const contact = { name: 'John' };
285      const result = populateTemplate('Hi [firstname|there]!', fields, siteData, contact);
286      assert.ok(result.includes('John'));
287    });
288  
289    test('rejects non-person names (info, support, etc.)', () => {
290      const contact = { name: 'info' };
291      const result = populateTemplate('Hi [firstname|there]!', fields, siteData, contact);
292      // 'info' is in NON_PERSON_WORDS, so fallback 'there' should be used
293      assert.ok(result.includes('there'));
294    });
295  
296    test('uses analysisData recommendation', () => {
297      const analysisData = {
298        recommendation: 'Your page lacks a visible contact button',
299        recommendation_sms: 'no contact button',
300        industry: 'plumbing',
301      };
302      const result = populateTemplate('[recommendation]', fields, siteData, null, analysisData);
303      assert.ok(result.includes('Your page lacks a visible contact button'));
304    });
305  
306    test('adds period to recommendation if missing', () => {
307      const analysisData = {
308        recommendation: 'Your page lacks a contact button',
309        recommendation_sms: 'no button',
310        industry: 'plumbing',
311      };
312      const result = populateTemplate('[recommendation]', fields, siteData, null, analysisData);
313      assert.ok(result.endsWith('.'));
314    });
315  
316    test('does not add double period to recommendation', () => {
317      const analysisData = {
318        recommendation: 'Your page lacks a contact button.',
319        recommendation_sms: 'no button',
320        industry: 'plumbing',
321      };
322      const result = populateTemplate('[recommendation]', fields, siteData, null, analysisData);
323      assert.ok(!result.includes('..'));
324    });
325  
326    test('cleans up spacing artifacts (e.g. "Hi ,")', () => {
327      const contact = { name: 'info' }; // invalid name → empty greeting
328      const result = populateTemplate('Hi [firstname],', fields, siteData, contact);
329      // Should NOT produce "Hi ," — spacing cleanup should handle this
330      assert.ok(!result.includes('Hi ,'));
331    });
332  
333    test('extracts business name from domain', () => {
334      const result = populateTemplate('[business_name]', fields, siteData);
335      assert.ok(result.includes('acme'));
336    });
337  
338    test('resolves spintax {option1|option2}', () => {
339      const results = new Set();
340      for (let i = 0; i < 20; i++) {
341        results.add(populateTemplate('{Hello|Hi|Hey}!', fields, siteData));
342      }
343      // Should have at least 2 distinct variants from spinning
344      assert.ok(results.size >= 1);
345      for (const r of results) {
346        assert.ok(r === 'Hello!' || r === 'Hi!' || r === 'Hey!');
347      }
348    });
349  });
350  
351  // ─── checkForUnfilledTokens ───────────────────────────────────────────────────
352  
353  describe('checkForUnfilledTokens', () => {
354    test('does not throw for fully populated text', () => {
355      assert.doesNotThrow(() =>
356        checkForUnfilledTokens('Hello John, check acme.com for details.', 'body')
357      );
358    });
359  
360    test('throws when [token] remains unfilled', () => {
361      assert.throws(
362        () => checkForUnfilledTokens('Hello [firstname], check [domain].', 'body'),
363        /Unfilled token \[firstname\] in body/
364      );
365    });
366  
367    test('does not throw for null text', () => {
368      assert.doesNotThrow(() => checkForUnfilledTokens(null, 'body'));
369    });
370  
371    test('does not throw for empty string', () => {
372      assert.doesNotThrow(() => checkForUnfilledTokens('', 'body'));
373    });
374  
375    test('throws for [recommendation] token', () => {
376      assert.throws(
377        () => checkForUnfilledTokens('Your site: [recommendation]', 'body'),
378        /Unfilled token \[recommendation\] in body/
379      );
380    });
381  
382    test('includes label in error message', () => {
383      try {
384        checkForUnfilledTokens('[grade] grade', 'subject');
385        assert.fail('should have thrown');
386      } catch (e) {
387        assert.ok(e.message.includes('subject'));
388      }
389    });
390  });
391  
392  // ─── getCurrentSeason ─────────────────────────────────────────────────────────
393  
394  describe('getCurrentSeason', () => {
395    test('returns null for tropical countries (SG)', () => {
396      assert.equal(getCurrentSeason('SG', new Date('2024-06-15')), null);
397    });
398  
399    test('returns null for null country code', () => {
400      assert.equal(getCurrentSeason(null, new Date('2024-06-15')), null);
401    });
402  
403    test('returns a season for unknown country code (treated as Northern hemisphere)', () => {
404      // Unknown country codes are not in TROPICAL_COUNTRIES or SOUTHERN_COUNTRIES
405      // so they're treated as Northern hemisphere and return a season
406      const result = getCurrentSeason('XX', new Date('2024-06-15'));
407      assert.equal(result, 'Summer'); // June = Summer for Northern hemisphere
408    });
409  
410    test('returns Summer for Northern hemisphere in June', () => {
411      assert.equal(getCurrentSeason('US', new Date('2024-06-15')), 'Summer');
412    });
413  
414    test('returns Winter for Northern hemisphere in January', () => {
415      assert.equal(getCurrentSeason('US', new Date('2024-01-15')), 'Winter');
416    });
417  
418    test('returns Spring for Northern hemisphere in March', () => {
419      assert.equal(getCurrentSeason('US', new Date('2024-03-15')), 'Spring');
420    });
421  
422    test('returns Autumn for Northern hemisphere in September', () => {
423      assert.equal(getCurrentSeason('US', new Date('2024-09-15')), 'Autumn');
424    });
425  
426    // Southern hemisphere — seasons are flipped
427    test('returns Winter for Australia in June (southern hemisphere)', () => {
428      assert.equal(getCurrentSeason('AU', new Date('2024-06-15')), 'Winter');
429    });
430  
431    test('returns Summer for Australia in January (southern hemisphere)', () => {
432      assert.equal(getCurrentSeason('AU', new Date('2024-01-15')), 'Summer');
433    });
434  
435    test('returns Autumn for Australia in March (southern hemisphere)', () => {
436      assert.equal(getCurrentSeason('AU', new Date('2024-03-15')), 'Autumn');
437    });
438  
439    test('returns Spring for Australia in September (southern hemisphere)', () => {
440      assert.equal(getCurrentSeason('AU', new Date('2024-09-15')), 'Spring');
441    });
442  
443    test('returns Winter for NZ in July', () => {
444      assert.equal(getCurrentSeason('NZ', new Date('2024-07-15')), 'Winter');
445    });
446  
447    test('tropical country ID returns null', () => {
448      assert.equal(getCurrentSeason('ID', new Date('2024-03-01')), null);
449    });
450  });
451  
452  // ─── loadTemplates ────────────────────────────────────────────────────────────
453  
454  describe('loadTemplates', () => {
455    test('loads AU SMS templates (legacy flat path)', () => {
456      const templates = loadTemplates('AU', 'en', 'sms');
457      assert.ok(Array.isArray(templates));
458      assert.ok(templates.length > 0);
459      assert.ok(templates[0].body_spintax || templates[0].body);
460    });
461  
462    test('loads AU email templates (legacy flat path)', () => {
463      const templates = loadTemplates('AU', 'en', 'email');
464      assert.ok(Array.isArray(templates));
465      assert.ok(templates.length > 0);
466    });
467  
468    test('loads DE SMS templates (language subdirectory path)', () => {
469      const templates = loadTemplates('DE', 'de', 'sms');
470      assert.ok(Array.isArray(templates));
471      assert.ok(templates.length > 0);
472    });
473  
474    test('normalizes ISO 639-2 three-letter code (eng → en)', () => {
475      // AU has flat path for 'en', so 'eng' should normalize to 'en' and work
476      const templates = loadTemplates('AU', 'eng', 'sms');
477      assert.ok(Array.isArray(templates));
478      assert.ok(templates.length > 0);
479    });
480  
481    test('throws for unsupported country with no templates', () => {
482      assert.throws(() => loadTemplates('ZZ', 'en', 'sms'), /No templates for ZZ/);
483    });
484  
485    test('falls back to email for unsupported channels', () => {
486      // 'form' and 'x' and 'linkedin' are not supported — should fall back to email
487      const templates = loadTemplates('AU', 'en', 'form');
488      assert.ok(Array.isArray(templates));
489      assert.ok(templates.length > 0);
490    });
491  
492    test('falls back to native language templates when lang not found', () => {
493      // DE has 'de' subdir. Asking for 'fr' (not present) should fall back to 'de' templates.
494      const templates = loadTemplates('DE', 'fr', 'sms');
495      assert.ok(Array.isArray(templates));
496      assert.ok(templates.length > 0);
497    });
498  });