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 });