template-proposals-supplement3.test.js
1 /** 2 * Template Proposals Supplement Test 3 3 * 4 * Targets genuinely uncovered code in src/utils/template-proposals.js 5 * WITHOUT using mock.module() — this avoids V8 coverage interference that 6 * causes supplement2 (which mocks llm-provider + fs) to not contribute its 7 * coverage to the main script's V8 tracking. 8 * 9 * Covers: 10 * - Lines 67-77: extractTemplateFields factor_scores forEach body 11 * - Lines 80-92: extractTemplateFields sections forEach body 12 * - Lines 116-120: extractTemplateFields secondaryWeakness from critical_weaknesses 13 * - Lines 133-136: extractTemplateFields industry from contextual_appropriateness 14 * - Lines 264-303: _extractIndustry (via populateTemplate without analysisData) 15 * - Lines 406-407: selectTemplate throw on empty templates array 16 * - Lines 413-419: selectTemplate hasFirstname=true branch 17 * - Lines 431-435: selectTemplate 1000+ sends conversion-rate sort 18 * - Lines 514-523: isPersonFirstname branches (via populateTemplate with contact.name) 19 * - Line 603: checkForUnfilledTokens throw path 20 */ 21 22 import { test, describe } from 'node:test'; 23 import assert from 'node:assert/strict'; 24 25 import { 26 extractTemplateFields, 27 selectTemplate, 28 populateTemplate, 29 checkForUnfilledTokens, 30 } from '../../src/utils/template-proposals.js'; 31 32 // ─── Helpers ───────────────────────────────────────────────────────────────── 33 34 function makeTemplate(overrides = {}) { 35 return { 36 id: 'email_001', 37 channel: 'email', 38 body_spintax: 39 'Hi [firstname|there], your [industry] website {needs|requires} work at [domain].', 40 subject_spintax: 'Your [industry] website audit', 41 sends: 0, 42 conversions: 0, 43 approach: 'problem-solution', 44 ...overrides, 45 }; 46 } 47 48 function makeSiteData(keyword = 'plumber sydney', domain = 'example.com') { 49 return { domain, keyword }; 50 } 51 52 function makeFields() { 53 return { 54 grade: 'F', 55 score: 55, 56 impact: 25, 57 primaryWeakness: 'weak call-to-action', 58 secondaryWeakness: 'missing trust signals', 59 quickImprovementOpportunity: 'add a clear CTA', 60 evidence: 'No CTA button found', 61 reasoning: 'Missing CTA reduces conversions', 62 industry: 'plumbing', 63 }; 64 } 65 66 // ─── extractTemplateFields: factor_scores new format (lines 67-77) ──────────── 67 68 describe('extractTemplateFields — factor_scores branch', () => { 69 test('covers forEach body when factor_scores is present', () => { 70 const data = { 71 factor_scores: { 72 call_to_action: { score: 2, evidence: 'No CTA button', reasoning: 'Weak CTA' }, 73 trust_signals: { score: 4, evidence: 'Few reviews', reasoning: 'Missing testimonials' }, 74 headline_quality: { score: 6, evidence: 'Generic headline', reasoning: 'Vague copy' }, 75 }, 76 overall_calculation: { conversion_score: 38, letter_grade: 'F' }, 77 }; 78 const fields = extractTemplateFields(data); 79 // FACTOR_LABELS now uses tradie-friendly language 80 assert.ok(fields.primaryWeakness.includes('call') || fields.primaryWeakness.includes('contact')); 81 assert.ok(fields.secondaryWeakness.includes('reviews') || fields.secondaryWeakness.includes('legit') || fields.secondaryWeakness.includes('trust')); 82 }); 83 84 test('handles factor without score (criteria.score is not a number)', () => { 85 const data = { 86 factor_scores: { 87 call_to_action: { score: 2, evidence: 'No CTA' }, 88 missing_score: { evidence: 'No score here' }, // no score → skipped 89 }, 90 overall_calculation: { conversion_score: 40, letter_grade: 'F' }, 91 }; 92 const fields = extractTemplateFields(data); 93 // FACTOR_LABELS now uses tradie-friendly language for call_to_action 94 assert.ok(fields.primaryWeakness.includes('call') || fields.primaryWeakness.includes('contact')); 95 }); 96 97 test('uses FACTOR_LABELS mapping for known factor names', () => { 98 const data = { 99 factor_scores: { 100 mobile_responsiveness: { score: 1, evidence: 'Poor mobile', reasoning: 'Not responsive' }, 101 }, 102 overall_calculation: { conversion_score: 20, letter_grade: 'F' }, 103 }; 104 const fields = extractTemplateFields(data); 105 // mobile_responsiveness should be mapped to a human-readable label 106 assert.ok(typeof fields.primaryWeakness === 'string'); 107 }); 108 }); 109 110 // ─── extractTemplateFields: sections legacy format (lines 80-92) ───────────── 111 112 describe('extractTemplateFields — sections legacy format', () => { 113 test('covers sections forEach body when sections present (no factor_scores)', () => { 114 const data = { 115 sections: { 116 call_to_action: { 117 criteria: { 118 cta_button: { 119 score: 2, 120 explanation: 'No visible CTA button', 121 reasoning: 'Visitors cannot convert', 122 }, 123 }, 124 }, 125 trust: { 126 criteria: { 127 testimonials: { 128 score: 4, 129 explanation: 'Few reviews', 130 reasoning: 'Low social proof', 131 }, 132 }, 133 }, 134 }, 135 overall_calculation: { conversion_score: 38, letter_grade: 'F' }, 136 }; 137 const fields = extractTemplateFields(data); 138 assert.equal(typeof fields.primaryWeakness, 'string'); 139 assert.ok(fields.primaryWeakness.length > 0); 140 }); 141 142 test('skips section.criteria entries without a numeric score', () => { 143 const data = { 144 sections: { 145 trust: { 146 criteria: { 147 no_score_item: { explanation: 'Missing score' }, // no score → skipped 148 scored_item: { score: 3, explanation: 'Some issue' }, 149 }, 150 }, 151 }, 152 overall_calculation: { conversion_score: 50, letter_grade: 'F' }, 153 }; 154 const fields = extractTemplateFields(data); 155 assert.ok(fields.primaryWeakness.length > 0); 156 }); 157 158 test('handles section without criteria', () => { 159 const data = { 160 sections: { 161 empty_section: { score: 5 }, // no criteria 162 real_section: { 163 criteria: { button: { score: 2, explanation: 'No button' } }, 164 }, 165 }, 166 overall_calculation: { conversion_score: 45, letter_grade: 'F' }, 167 }; 168 const fields = extractTemplateFields(data); 169 assert.ok(typeof fields.primaryWeakness === 'string'); 170 }); 171 }); 172 173 // ─── extractTemplateFields: critical_weaknesses secondary name (lines 116-120) ─ 174 175 describe('extractTemplateFields — critical_weaknesses secondary weakness', () => { 176 test('uses secondaryWeaknessName from critical_weaknesses[1] when two items present', () => { 177 const data = { 178 factor_scores: { 179 call_to_action: { score: 2, evidence: 'No CTA' }, 180 trust_signals: { score: 4, evidence: 'Few reviews' }, 181 }, 182 critical_weaknesses: ['No clear CTA button.', 'Missing trust signals.'], 183 overall_calculation: { conversion_score: 38, letter_grade: 'F' }, 184 }; 185 const fields = extractTemplateFields(data); 186 // secondaryWeakness should come from critical_weaknesses[1] when available 187 assert.equal(fields.secondaryWeakness, 'missing trust signals'); 188 }); 189 190 test('secondaryWeakness uses critical_weaknesses[1] name field when present', () => { 191 const data = { 192 factor_scores: { 193 call_to_action: { score: 2, evidence: 'No CTA' }, 194 trust_signals: { score: 4, evidence: 'No trust' }, 195 load_speed: { score: 5, evidence: 'Slow' }, 196 }, 197 critical_weaknesses: ['Weak CTA.', 'Poor load speed.'], 198 overall_calculation: { conversion_score: 38, letter_grade: 'F' }, 199 }; 200 const fields = extractTemplateFields(data); 201 assert.equal(fields.secondaryWeakness, 'poor load speed'); 202 }); 203 }); 204 205 // ─── extractTemplateFields: contextual_appropriateness.industry_context (lines 133-136) ─ 206 207 describe('extractTemplateFields — industry from contextual_appropriateness', () => { 208 test('uses industry_context from factor_scores.contextual_appropriateness', () => { 209 const data = { 210 factor_scores: { 211 call_to_action: { score: 2, evidence: 'No CTA' }, 212 contextual_appropriateness: { 213 score: 7, 214 evidence: 'Relevant', 215 reasoning: 'OK', 216 industry_context: 'plumbing', 217 }, 218 }, 219 overall_calculation: { conversion_score: 38, letter_grade: 'F' }, 220 }; 221 const fields = extractTemplateFields(data); 222 assert.equal(fields.industry, 'plumbing'); 223 }); 224 225 test('falls back to local service when no contextual_appropriateness', () => { 226 const data = { 227 factor_scores: { 228 call_to_action: { score: 2, evidence: 'No CTA' }, 229 }, 230 overall_calculation: { conversion_score: 38, letter_grade: 'F' }, 231 }; 232 const fields = extractTemplateFields(data); 233 assert.equal(fields.industry, 'local service'); 234 }); 235 }); 236 237 // ─── checkForUnfilledTokens throw path (line 603) ──────────────────────────── 238 239 describe('checkForUnfilledTokens', () => { 240 test('throws when text contains unfilled [token] placeholder', () => { 241 assert.throws( 242 () => checkForUnfilledTokens('Hello [name], this is a test', 'body'), 243 /Unfilled token \[name\]/ 244 ); 245 }); 246 247 test('throws on [multi_word_token] style tokens', () => { 248 assert.throws( 249 () => checkForUnfilledTokens('Dear [first_name] — your [business_name] site', 'subject'), 250 /Unfilled token/ 251 ); 252 }); 253 254 test('does not throw when text is empty/null', () => { 255 assert.doesNotThrow(() => checkForUnfilledTokens('', 'body')); 256 assert.doesNotThrow(() => checkForUnfilledTokens(null, 'body')); 257 assert.doesNotThrow(() => checkForUnfilledTokens(undefined, 'body')); 258 }); 259 260 test('does not throw for normal text without placeholders', () => { 261 assert.doesNotThrow(() => 262 checkForUnfilledTokens('Hi there, your website needs improvement.', 'body') 263 ); 264 }); 265 266 test('throws on first [token] found (not [Token] with uppercase)', () => { 267 // The regex is /\[[a-z_]+\]/ — only lowercase letters and underscores 268 assert.doesNotThrow(() => checkForUnfilledTokens('Hello [NAME] test', 'body')); 269 assert.throws(() => checkForUnfilledTokens('Hello [name] test', 'body'), /Unfilled token/); 270 }); 271 }); 272 273 // ─── selectTemplate throw on empty templates (lines 406-407) ───────────────── 274 275 describe('selectTemplate — edge cases', () => { 276 test('throws when templates array is empty', () => { 277 assert.throws(() => selectTemplate([], makeFields(), 'email'), /No templates available/); 278 }); 279 280 test('throws when templates is null', () => { 281 assert.throws(() => selectTemplate(null, makeFields(), 'email'), /No templates available/); 282 }); 283 284 test('throws when templates is undefined', () => { 285 assert.throws(() => selectTemplate(undefined, makeFields(), 'email'), /No templates available/); 286 }); 287 288 // ─── selectTemplate hasFirstname=true (lines 413-419) ───────────────────── 289 290 test('prefers templates with [firstname] in body when hasFirstname=true', () => { 291 const withFirstname = makeTemplate({ 292 id: 'named_001', 293 body_spintax: 'Hi [firstname|there], your website needs work.', 294 }); 295 const withoutFirstname = makeTemplate({ 296 id: 'anon_001', 297 body_spintax: 'Your website needs improvement.', 298 }); 299 const result = selectTemplate([withFirstname, withoutFirstname], makeFields(), 'email', true); 300 // Should prefer the named template 301 assert.equal(result.id, 'named_001'); 302 }); 303 304 test('falls back to all templates when hasFirstname=true but no templates have [firstname]', () => { 305 const t1 = makeTemplate({ id: 'anon_001', body_spintax: 'Your website needs work.' }); 306 const t2 = makeTemplate({ id: 'anon_002', body_spintax: 'We noticed some issues.' }); 307 const result = selectTemplate([t1, t2], makeFields(), 'email', true); 308 // Falls back to full pool — should return one of the two templates 309 assert.ok(result.id === 'anon_001' || result.id === 'anon_002'); 310 }); 311 312 test('prefers templates with [firstname] in subject_spintax when hasFirstname=true', () => { 313 const withFirstnameInSubject = makeTemplate({ 314 id: 'named_subj_001', 315 body_spintax: 'Your website needs work.', 316 subject_spintax: 'Hi [firstname|there] — website audit', 317 }); 318 const plain = makeTemplate({ id: 'plain_001', body_spintax: 'Your website.' }); 319 const result = selectTemplate([withFirstnameInSubject, plain], makeFields(), 'email', true); 320 assert.equal(result.id, 'named_subj_001'); 321 }); 322 323 // ─── selectTemplate 1000+ sends conversion-rate sort (lines 431-435) ────── 324 325 test('sorts by conversion rate when both templates have 1000+ sends', () => { 326 const highConv = makeTemplate({ id: 'high_conv', sends: 1200, conversions: 120 }); // 10% 327 const lowConv = makeTemplate({ id: 'low_conv', sends: 1100, conversions: 55 }); // 5% 328 const result = selectTemplate([lowConv, highConv], makeFields(), 'email'); 329 // Higher conversion rate should be preferred 330 assert.equal(result.id, 'high_conv'); 331 }); 332 333 test('handles zero conversions with 1000+ sends', () => { 334 const zeroConv = makeTemplate({ id: 'zero_conv', sends: 1500, conversions: 0 }); // 0% 335 const someConv = makeTemplate({ id: 'some_conv', sends: 1000, conversions: 10 }); // 1% 336 const result = selectTemplate([zeroConv, someConv], makeFields(), 'email'); 337 // Higher conversion rate (someConv = 1%) should win 338 assert.equal(result.id, 'some_conv'); 339 }); 340 341 test('uses sends-based rotation when at least one template has fewer than 1000 sends', () => { 342 const lowSends = makeTemplate({ id: 'low_sends', sends: 50, conversions: 5 }); 343 const highSends = makeTemplate({ id: 'high_sends', sends: 1500, conversions: 150 }); 344 const result = selectTemplate([lowSends, highSends], makeFields(), 'email'); 345 // Lower sends should be preferred (rotation testing) 346 assert.equal(result.id, 'low_sends'); 347 }); 348 }); 349 350 // ─── _extractIndustry via populateTemplate (lines 264-303) ──────────────────── 351 352 describe('populateTemplate — _extractIndustry coverage via no-analysisData calls', () => { 353 const template = makeTemplate(); 354 const fields = makeFields(); 355 356 test('covers _extractIndustry single-word keyword path', () => { 357 const siteData = makeSiteData('plumber'); // 1 word 358 const result = populateTemplate(template.body_spintax, fields, siteData, null, null); 359 assert.ok(result.includes('plumber')); 360 }); 361 362 test('covers _extractIndustry 4-word keyword path (strips last word)', () => { 363 const siteData = makeSiteData('plumber sydney inner west'); // 4 words 364 const result = populateTemplate(template.body_spintax, fields, siteData, null, null); 365 // _extractIndustry returns 'plumber sydney inner' (strips last word) 366 assert.ok(typeof result === 'string'); 367 }); 368 369 test('covers _extractIndustry 3-word keyword path (returns all 3)', () => { 370 const siteData = makeSiteData('hot water repairs'); // 3 words 371 const result = populateTemplate(template.body_spintax, fields, siteData, null, null); 372 assert.ok(typeof result === 'string'); 373 }); 374 375 test('covers _extractIndustry compound 2-word keyword (in COMPOUND_SERVICES)', () => { 376 const siteData = makeSiteData('pest control'); // in COMPOUND_SERVICES set 377 const result = populateTemplate(template.body_spintax, fields, siteData, null, null); 378 // Should use 'pest control' as industry (not strip to 'pest') 379 assert.ok(result.includes('pest control')); 380 }); 381 382 test('covers _extractIndustry non-compound 2-word keyword (strips location)', () => { 383 const siteData = makeSiteData('plumber sydney'); // 2 words, not in COMPOUND_SERVICES 384 const result = populateTemplate(template.body_spintax, fields, siteData, null, null); 385 // Should use 'plumber' (first word, strips 'sydney') 386 assert.ok(result.includes('plumber')); 387 }); 388 389 test('covers _extractIndustry null/empty keyword (returns local service)', () => { 390 const siteData = makeSiteData(null); 391 const result = populateTemplate(template.body_spintax, fields, siteData, null, null); 392 assert.ok(typeof result === 'string'); 393 }); 394 395 test('_extractIndustry 5-word keyword (strips last word)', () => { 396 const siteData = makeSiteData('emergency plumber sydney inner west'); // 5 words 397 const result = populateTemplate(template.body_spintax, fields, siteData, null, null); 398 assert.ok(typeof result === 'string'); 399 }); 400 }); 401 402 // ─── isPersonFirstname via populateTemplate (lines 514-523) ────────────────── 403 404 describe('populateTemplate — isPersonFirstname coverage via contact.name', () => { 405 const template = makeTemplate(); 406 const fields = makeFields(); 407 const siteData = makeSiteData('plumber sydney'); 408 409 test('covers isPersonFirstname true-path with valid firstname', () => { 410 const result = populateTemplate(template.body_spintax, fields, siteData, { name: 'John' }); 411 // 'John' is a valid firstname — greeting should use the name 412 assert.ok(result.includes('John') || result.includes('Hi')); 413 }); 414 415 test('covers isPersonFirstname with name containing digits (returns false)', () => { 416 // 'Jo2hn' has a digit → isPersonFirstname returns false → greeting = '' 417 const result = populateTemplate(template.body_spintax, fields, siteData, { name: 'Jo2hn' }); 418 assert.ok(typeof result === 'string'); 419 }); 420 421 test('covers isPersonFirstname with NON_PERSON_WORD (office → returns false)', () => { 422 const result = populateTemplate(template.body_spintax, fields, siteData, { name: 'office' }); 423 assert.ok(typeof result === 'string'); 424 }); 425 426 test('covers isPersonFirstname with 3+ word name (returns false)', () => { 427 const result = populateTemplate(template.body_spintax, fields, siteData, { 428 name: 'Head Of Marketing', 429 }); 430 assert.ok(typeof result === 'string'); 431 }); 432 433 test('covers isPersonFirstname short name length check (1-char name → false)', () => { 434 const result = populateTemplate(template.body_spintax, fields, siteData, { name: 'A' }); 435 assert.ok(typeof result === 'string'); 436 }); 437 438 test('covers isPersonFirstname with hyphenated valid name (Mary-Jane)', () => { 439 // 'Mary-Jane' has 1 hyphen (split produces 2 parts, < 3), no digits, length OK 440 const result = populateTemplate(template.body_spintax, fields, siteData, { 441 name: 'Mary-Jane', 442 }); 443 assert.ok(typeof result === 'string'); 444 }); 445 446 test('covers isPersonFirstname with triple-hyphen name (returns false)', () => { 447 // 'a-b-c' has 3 parts after split → triple-hyphen check returns false 448 const result = populateTemplate(template.body_spintax, fields, siteData, { name: 'a-b-c' }); 449 assert.ok(typeof result === 'string'); 450 }); 451 });