/ tests / proposals / template-proposals-supplement3.test.js
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  });