/ tests / proposals / template-proposals-supplement2.test.js
template-proposals-supplement2.test.js
  1  /**
  2   * Template Proposals Supplement Test 2
  3   *
  4   * Targets genuinely uncovered code paths in src/utils/template-proposals.js:
  5   *   - Lines 58-69:   extractTemplateFields null/no-sections default return
  6   *   - Lines 78-86:   factor_scores new-format path in extractTemplateFields
  7   *   - Lines 89-104:  sections legacy format path in extractTemplateFields
  8   *   - Lines 109-113: factors[0] fallback when no factors extracted
  9   *   - Lines 126-130: critical_weaknesses secondary weakness name branch
 10   *   - Lines 222-230: loadTemplates English flat-path fallback
 11   *   - Lines 249-270: selectTemplate throw on empty/null and 1000+ conversion rate sort
 12   *   - Lines 440-461: translateWeaknessIfNeeded (via generateTemplateProposal)
 13   *   - Lines 468-487: shortenSmsWithHaiku exported function
 14   *   - Lines 508-521: non-English language translation in generateTemplateProposal
 15   *   - Lines 534-545: SMS too-long re-spin and Haiku shorten path
 16   *   - Line 552:      fallback subject line in generateTemplateProposal
 17   */
 18  
 19  process.env.DATABASE_PATH = '/tmp/test-template-proposals-supplement2.db';
 20  process.env.NODE_ENV = 'test';
 21  process.env.LOGS_DIR = '/tmp/test-logs';
 22  process.env.OPENROUTER_API_KEY = 'test-key-supplement2';
 23  
 24  import { test, describe, mock } from 'node:test';
 25  import assert from 'node:assert/strict';
 26  import * as realFs from 'fs';
 27  
 28  // ─────────────────────────────────────────────────────────────
 29  // Mock llm-provider so callLLM is fully under test control
 30  // ─────────────────────────────────────────────────────────────
 31  const callLLMMock = mock.fn(async () => ({ content: 'mocked translation' }));
 32  
 33  mock.module('../../src/utils/llm-provider.js', {
 34    namedExports: {
 35      callLLM: callLLMMock,
 36      getProvider: mock.fn(() => 'openrouter'),
 37      getProviderDisplayName: mock.fn(() => 'OpenRouter'),
 38    },
 39    defaultExport: { callLLM: callLLMMock },
 40  });
 41  
 42  // Mock llm-usage-tracker to prevent DB access in tests
 43  mock.module('../../src/utils/llm-usage-tracker.js', {
 44    namedExports: {
 45      logLLMUsage: mock.fn(() => {}),
 46    },
 47  });
 48  
 49  // Mock readFileSync so loadTemplates is fully under test control;
 50  // keep all other fs exports intact (logger needs existsSync, etc.)
 51  const readFileSyncMock = mock.fn(realFs.readFileSync);
 52  mock.module('fs', {
 53    namedExports: {
 54      ...realFs,
 55      readFileSync: readFileSyncMock,
 56    },
 57  });
 58  
 59  const {
 60    extractTemplateFields,
 61    loadTemplates,
 62    selectTemplate,
 63    populateTemplate,
 64    generateTemplateProposal,
 65    shortenSmsWithHaiku,
 66  } = await import('../../src/utils/template-proposals.js');
 67  
 68  // ─────────────────────────────────────────────────────────────
 69  // Shared helpers
 70  // ─────────────────────────────────────────────────────────────
 71  
 72  /**
 73   * Build scoreData using the NEW flat factor_scores format.
 74   * This exercises lines 75-87 (the if(scoreData.factor_scores) branch).
 75   */
 76  function makeFactorScoreData(overrides = {}) {
 77    return {
 78      factor_scores: {
 79        call_to_action: { score: 2, evidence: 'No clear CTA button', reasoning: 'Weak CTA' },
 80        trust_signals: { score: 4, evidence: 'Few reviews', reasoning: 'Missing testimonials' },
 81        headline_quality: { score: 6, evidence: 'Generic headline', reasoning: 'Vague copy' },
 82        contextual_appropriateness: {
 83          score: 7,
 84          evidence: 'Relevant',
 85          reasoning: 'OK',
 86          industry_context: 'plumbing',
 87        },
 88      },
 89      overall_calculation: {
 90        conversion_score: 38,
 91        letter_grade: 'F',
 92      },
 93      ...overrides,
 94    };
 95  }
 96  
 97  /**
 98   * Build a minimal templates array for loadTemplates mocking.
 99   */
100  function makeMockTemplates(contact_method = 'email') {
101    return [
102      {
103        id: `${contact_method}_s2_001`,
104        channel: contact_method,
105        body_spintax: 'Hi [firstname|there], we noticed [secondary_weakness] at [domain].',
106        subject_spintax: '{Your|The} [kwd] website audit',
107        sends: 0,
108        conversions: 0,
109        approach: 'problem-solution',
110        tested: false,
111      },
112    ];
113  }
114  
115  function mockReadFileWithTemplates(contact_method = 'email') {
116    readFileSyncMock.mock.mockImplementation(() =>
117      JSON.stringify({ templates: makeMockTemplates(contact_method) })
118    );
119  }
120  
121  // Standard analyzeScoreJson response — used by generateTemplateProposal Pass 1.
122  // Any test calling generateTemplateProposal must return this format on the first callLLM call.
123  const ANALYSIS_MOCK_RESPONSE = JSON.stringify({
124    recommendation: 'add a clear call-to-action button',
125    industry: 'plumbing',
126    recommendation_sms: 'fix CTA',
127  });
128  
129  // ─────────────────────────────────────────────────────────────
130  // 1. extractTemplateFields — new factor_scores format (lines 78-86)
131  // ─────────────────────────────────────────────────────────────
132  
133  describe('extractTemplateFields — factor_scores new format', () => {
134    test('reads primaryWeakness from lowest-score factor_scores entry', () => {
135      const fields = extractTemplateFields(makeFactorScoreData());
136      // call_to_action has score 2 (lowest) → mapped via FACTOR_LABELS
137      assert.equal(fields.primaryWeakness, "no clear way to call or book — visitors don't know how to contact you");
138    });
139  
140    test('reads secondaryWeakness from second-lowest factor_scores entry', () => {
141      const fields = extractTemplateFields(makeFactorScoreData());
142      // trust_signals has score 4 (second lowest) → mapped via FACTOR_LABELS
143      assert.equal(fields.secondaryWeakness, "no reviews or licences visible on your site — nothing to prove you're legit");
144    });
145  
146    test('sets quickImprovementOpportunity from QUICK_FIX_LABELS for the primary key', () => {
147      const fields = extractTemplateFields(makeFactorScoreData());
148      // call_to_action key → QUICK_FIX_LABELS.call_to_action
149      assert.ok(fields.quickImprovementOpportunity.includes('call-to-action'));
150    });
151  
152    test('uses evidence field from factor_scores criteria (new format)', () => {
153      const fields = extractTemplateFields(makeFactorScoreData());
154      assert.equal(fields.evidence, 'No clear CTA button');
155    });
156  
157    test('extracts industry from factor_scores.contextual_appropriateness.industry_context', () => {
158      const fields = extractTemplateFields(makeFactorScoreData());
159      assert.equal(fields.industry, 'plumbing');
160    });
161  
162    test('falls back to key.replace(/_/g, space) for unknown factor names', () => {
163      const data = {
164        factor_scores: {
165          unknown_custom_factor: { score: 1, evidence: 'Bad', reasoning: 'Very bad' },
166        },
167        overall_calculation: { conversion_score: 10, letter_grade: 'F' },
168      };
169      const fields = extractTemplateFields(data);
170      // Not in FACTOR_LABELS → name = 'unknown custom factor'
171      assert.equal(fields.primaryWeakness, 'unknown custom factor');
172    });
173  
174    test('skips factor_scores entries where score is not a number', () => {
175      const data = {
176        factor_scores: {
177          bad_entry: { score: 'not-a-number', evidence: 'x', reasoning: 'x' },
178          good_entry: { score: 3, evidence: 'Real issue', reasoning: 'Real reason' },
179        },
180        overall_calculation: { conversion_score: 20, letter_grade: 'F' },
181      };
182      const fields = extractTemplateFields(data);
183      // Only good_entry has a numeric score — it becomes primary
184      assert.equal(fields.primaryWeakness, 'good entry');
185    });
186  
187    test('returns grade from overall_calculation when using factor_scores', () => {
188      const fields = extractTemplateFields(makeFactorScoreData());
189      assert.equal(fields.grade, 'F');
190      assert.equal(fields.score, 38);
191    });
192  
193    test('clamps impact between 20 and 50 for factor_scores path', () => {
194      const fields = extractTemplateFields(makeFactorScoreData());
195      assert.ok(fields.impact >= 20 && fields.impact <= 50, `impact ${fields.impact} out of range`);
196    });
197  });
198  
199  // ─────────────────────────────────────────────────────────────
200  // 2. extractTemplateFields — critical_weaknesses secondary name (lines 126-130)
201  // ─────────────────────────────────────────────────────────────
202  
203  describe('extractTemplateFields — critical_weaknesses secondary weakness', () => {
204    test('uses critical_weaknesses[1] as secondary weakness name when two items present', () => {
205      const data = makeFactorScoreData({
206        critical_weaknesses: ['No clear CTA.', 'Missing trust signals.'],
207      });
208      const fields = extractTemplateFields(data);
209      // secondaryWeaknessName = cw[1] lowercased with trailing dot stripped
210      assert.equal(fields.secondaryWeakness, 'missing trust signals');
211    });
212  
213    test('uses critical_weaknesses[0] when only one item present', () => {
214      const data = makeFactorScoreData({
215        critical_weaknesses: ['Weak headline copy.'],
216      });
217      const fields = extractTemplateFields(data);
218      assert.equal(fields.secondaryWeakness, 'weak headline copy');
219    });
220  
221    test('strips trailing period from critical_weaknesses entry', () => {
222      const data = makeFactorScoreData({
223        critical_weaknesses: ['Something.', 'Another issue.'],
224      });
225      const fields = extractTemplateFields(data);
226      assert.ok(!fields.secondaryWeakness.endsWith('.'));
227    });
228  
229    test('lowercases first character of critical_weaknesses entry', () => {
230      const data = makeFactorScoreData({
231        critical_weaknesses: ['Unclear Value Proposition.'],
232      });
233      const fields = extractTemplateFields(data);
234      assert.equal(fields.secondaryWeakness[0], fields.secondaryWeakness[0].toLowerCase());
235    });
236  
237    test('falls back to factor-based secondary when critical_weaknesses is empty array', () => {
238      const data = makeFactorScoreData({ critical_weaknesses: [] });
239      const fields = extractTemplateFields(data);
240      // Empty array → no cwSecondary → uses factors[1]
241      assert.equal(fields.secondaryWeakness, "no reviews or licences visible on your site — nothing to prove you're legit");
242    });
243  
244    test('ignores non-array critical_weaknesses values', () => {
245      const data = makeFactorScoreData({ critical_weaknesses: 'not an array' });
246      const fields = extractTemplateFields(data);
247      // Non-array → treated as [] → uses factor-based secondary
248      assert.equal(fields.secondaryWeakness, "no reviews or licences visible on your site — nothing to prove you're legit");
249    });
250  });
251  
252  // ─────────────────────────────────────────────────────────────
253  // 3. loadTemplates — English flat-path fallback (lines 222-230)
254  // ─────────────────────────────────────────────────────────────
255  
256  describe('loadTemplates — English flat-path fallback', () => {
257    test('tries lang-specific path first, then falls back to flat path for English', () => {
258      let callCount = 0;
259      const capturedPaths = [];
260      readFileSyncMock.mock.mockImplementation(path => {
261        callCount++;
262        capturedPaths.push(String(path));
263        // First call (lang-specific path like AU/en/email.json) throws
264        if (callCount === 1) throw new Error('ENOENT: no such file');
265        // Second call (flat path like AU/email.json) succeeds
266        return JSON.stringify({ templates: makeMockTemplates('email') });
267      });
268  
269      const templates = loadTemplates('AU', 'en', 'email');
270      assert.equal(templates.length, 1);
271      assert.equal(templates[0].id, 'email_s2_001');
272      assert.equal(callCount, 2);
273      // First path should include '/en/'
274      assert.ok(capturedPaths[0].includes('/en/'), `Expected /en/ in: ${capturedPaths[0]}`);
275    });
276  
277    test('returns templates from flat path when lang-specific path has empty templates', () => {
278      let callCount = 0;
279      readFileSyncMock.mock.mockImplementation(() => {
280        callCount++;
281        if (callCount === 1) {
282          // lang-specific path returns empty templates array
283          return JSON.stringify({ templates: [] });
284        }
285        // flat path returns valid templates
286        return JSON.stringify({ templates: makeMockTemplates('email') });
287      });
288  
289      const templates = loadTemplates('GB', 'en', 'email');
290      assert.equal(templates.length, 1);
291    });
292  
293    test('throws when lang is non-English and no lang-specific template found', () => {
294      readFileSyncMock.mock.mockImplementation(() => {
295        throw new Error('ENOENT: no such file');
296      });
297  
298      assert.throws(
299        () => loadTemplates('DE', 'de', 'email'),
300        err => err.message.includes('No templates for DE/de/email')
301      );
302    });
303  
304    test('attempts flat path as fallback for all languages (including non-English)', () => {
305      let callCount = 0;
306      readFileSyncMock.mock.mockImplementation(() => {
307        callCount++;
308        throw new Error('ENOENT');
309      });
310  
311      try {
312        loadTemplates('FR', 'fr', 'sms');
313      } catch (_) {
314        // expected to throw
315      }
316      // Tries lang-specific path (FR/fr/sms.json) + flat path (FR/sms.json) — at least 2 attempts
317      assert.ok(callCount >= 2, `Should try multiple paths, got ${callCount}`);
318    });
319  });
320  
321  // ─────────────────────────────────────────────────────────────
322  // 4. shortenSmsWithHaiku — exported function (lines 468-487)
323  // ─────────────────────────────────────────────────────────────
324  
325  describe('shortenSmsWithHaiku', () => {
326    test('returns shortened text when LLM returns a shorter non-empty string', async () => {
327      const longText = 'A'.repeat(200);
328      const shortText = 'A'.repeat(100);
329      // polishProposalWithHaiku expects JSON { body: '...' }
330      callLLMMock.mock.mockImplementationOnce(async () => ({
331        content: JSON.stringify({ body: shortText }),
332      }));
333  
334      const result = await shortenSmsWithHaiku(longText);
335      assert.equal(result, shortText);
336    });
337  
338    test('returns original text when LLM returns a longer string', async () => {
339      const original = 'Short SMS text here';
340      const longer = `${original} with extra content added by LLM`;
341      callLLMMock.mock.mockImplementationOnce(async () => ({ content: longer }));
342  
343      const result = await shortenSmsWithHaiku(original);
344      assert.equal(result, original);
345    });
346  
347    test('returns original text when LLM returns empty content', async () => {
348      const original = 'Some SMS text';
349      callLLMMock.mock.mockImplementationOnce(async () => ({ content: '' }));
350  
351      const result = await shortenSmsWithHaiku(original);
352      assert.equal(result, original);
353    });
354  
355    test('returns original text when LLM returns null content', async () => {
356      const original = 'Some SMS text';
357      callLLMMock.mock.mockImplementationOnce(async () => ({ content: null }));
358  
359      const result = await shortenSmsWithHaiku(original);
360      assert.equal(result, original);
361    });
362  
363    test('returns original text when LLM throws an error', async () => {
364      const original = 'SMS that fails to shorten';
365      callLLMMock.mock.mockImplementationOnce(async () => {
366        throw new Error('API rate limit exceeded');
367      });
368  
369      const result = await shortenSmsWithHaiku(original);
370      assert.equal(result, original);
371    });
372  });
373  
374  // ─────────────────────────────────────────────────────────────
375  // 5. generateTemplateProposal — SMS too-long path (lines 534-545)
376  // ─────────────────────────────────────────────────────────────
377  
378  describe('generateTemplateProposal — SMS too-long shortening path', () => {
379    test('triggers re-spin loop when SMS proposal exceeds 160 chars', async () => {
380      // Use a template body with only populated tokens that guarantees > 160 chars
381      const longBody =
382        'Hi [firstname|there], I have thoroughly reviewed your [industry] website at [domain] and our comprehensive audit shows multiple significant conversion issues that are currently preventing potential customers from taking action on your site.';
383      readFileSyncMock.mock.mockImplementation(() =>
384        JSON.stringify({
385          templates: [
386            {
387              id: 'sms_long_001',
388              channel: 'sms',
389              body_spintax: longBody,
390              subject_spintax: null,
391              sends: 0,
392              conversions: 0,
393            },
394          ],
395        })
396      );
397  
398      // Call 1 = analyzeScoreJson; call 2 = polishProposalWithHaiku/shortenSmsWithHaiku
399      callLLMMock.mock.mockImplementationOnce(async () => ({ content: ANALYSIS_MOCK_RESPONSE }));
400      callLLMMock.mock.mockImplementation(async () => ({
401        content: JSON.stringify({ body: 'Short SMS.' }),
402      }));
403  
404      const siteData = { domain: 'example.com', country_code: 'AU', keyword: 'plumber' };
405      const scoreData = makeFactorScoreData();
406      const contact = { name: 'John', channel: 'sms', uri: '+61412345678' };
407  
408      const result = await generateTemplateProposal(siteData, scoreData, contact);
409      assert.ok(result.proposalText, 'should have proposal text');
410      assert.equal(result.templateId, 'sms_long_001');
411    });
412  
413    test('calls Haiku shortener when re-spinning still leaves SMS over 160 chars', async () => {
414      // Build a body that always renders well over 160 chars regardless of spin
415      const alwaysLongBody =
416        'Hi there, I have reviewed your website at [domain] and found significant conversion issues including [secondary_weakness] which is seriously affecting your bottom line right now according to our analysis team.';
417      readFileSyncMock.mock.mockImplementation(() =>
418        JSON.stringify({
419          templates: [
420            {
421              id: 'sms_always_long',
422              channel: 'sms',
423              body_spintax: alwaysLongBody,
424              subject_spintax: null,
425              sends: 0,
426              conversions: 0,
427            },
428          ],
429        })
430      );
431  
432      const shortened = 'Short version.';
433      // Call 1 = analyzeScoreJson; call 2 = polishProposalWithHaiku (shorten)
434      callLLMMock.mock.mockImplementationOnce(async () => ({ content: ANALYSIS_MOCK_RESPONSE }));
435      callLLMMock.mock.mockImplementation(async () => ({
436        content: JSON.stringify({ body: shortened }),
437      }));
438  
439      const siteData = { domain: 'longdomain.com', country_code: 'AU', keyword: 'dentist' };
440      const scoreData = makeFactorScoreData();
441      const contact = { channel: 'sms', uri: '+61400000000' };
442  
443      const result = await generateTemplateProposal(siteData, scoreData, contact);
444      // Since the body renders over 160 chars and Haiku returns something shorter,
445      // the result should be the Haiku-shortened version
446      assert.ok(result.proposalText.length <= shortened.length || result.proposalText.length < 161);
447    });
448  });
449  
450  // ─────────────────────────────────────────────────────────────
451  // 6. generateTemplateProposal — non-English translation path (lines 508-521)
452  // ─────────────────────────────────────────────────────────────
453  
454  describe('generateTemplateProposal — non-English language translation', () => {
455    test('generates proposal for non-English language_code via analyzeScoreJson', async () => {
456      const mockTemplates = makeMockTemplates('email');
457      readFileSyncMock.mock.mockImplementation(() => JSON.stringify({ templates: mockTemplates }));
458  
459      // Call 1 = analyzeScoreJson (language passed in); call 2 = polishProposalWithHaiku
460      callLLMMock.mock.mockImplementationOnce(async () => ({ content: ANALYSIS_MOCK_RESPONSE }));
461      callLLMMock.mock.mockImplementation(async () => ({
462        content: JSON.stringify({ body: 'translated text' }),
463      }));
464  
465      const siteData = {
466        domain: 'example.de',
467        country_code: 'DE',
468        language_code: 'de',
469        keyword: 'klempner',
470      };
471      const scoreData = makeFactorScoreData();
472      const contact = { name: 'Hans', channel: 'email', uri: 'hans@example.de' };
473  
474      const result = await generateTemplateProposal(siteData, scoreData, contact);
475      assert.ok(result.proposalText, 'should produce a proposal');
476      assert.equal(result.templateId, 'email_s2_001');
477    });
478  
479    test('does not translate when language_code is en', async () => {
480      mockReadFileWithTemplates('email');
481      let callLLMCount = 0;
482      callLLMMock.mock.mockImplementation(async () => {
483        callLLMCount++;
484        // Call 1 = analyzeScoreJson; call 2 = polishProposalWithHaiku — no translation calls for English
485        if (callLLMCount === 1) return { content: ANALYSIS_MOCK_RESPONSE };
486        return { content: JSON.stringify({ body: 'polished text' }) };
487      });
488  
489      const siteData = {
490        domain: 'example.co.uk',
491        country_code: 'GB',
492        language_code: 'en',
493        keyword: 'plumber',
494      };
495      const scoreData = makeFactorScoreData();
496      const contact = { channel: 'email', uri: 'info@example.co.uk' };
497  
498      const result = await generateTemplateProposal(siteData, scoreData, contact);
499      // Haiku analysis + polish = 2 calls for any language; no extra translation calls for English
500      assert.ok(
501        callLLMCount <= 2,
502        'Only Haiku analysis and polish should be called, no translation for English'
503      );
504      assert.ok(result.proposalText, 'Should generate a proposal for English');
505    });
506  
507    test('does not translate when language_code is null', async () => {
508      mockReadFileWithTemplates('email');
509      let callLLMCount = 0;
510      callLLMMock.mock.mockImplementation(async () => {
511        callLLMCount++;
512        // Call 1 = analyzeScoreJson; call 2 = polishProposalWithHaiku — no translation for null language
513        if (callLLMCount === 1) return { content: ANALYSIS_MOCK_RESPONSE };
514        return { content: JSON.stringify({ body: 'polished text' }) };
515      });
516  
517      const siteData = {
518        domain: 'example.com',
519        country_code: 'US',
520        language_code: null,
521        keyword: 'dentist',
522      };
523      const scoreData = makeFactorScoreData();
524      const contact = { channel: 'email', uri: 'info@example.com' };
525  
526      const result = await generateTemplateProposal(siteData, scoreData, contact);
527      // Haiku analysis + polish = 2 calls; no extra translation calls when language_code is null
528      assert.ok(
529        callLLMCount <= 2,
530        'Only Haiku analysis and polish should be called, no translation for null language'
531      );
532      assert.ok(result.proposalText, 'Should generate a proposal when language_code is null');
533    });
534  
535    test('handles unknown language code gracefully (no translation, no throw)', async () => {
536      mockReadFileWithTemplates('email');
537      // Call 1 = analyzeScoreJson; call 2 = polishProposalWithHaiku — no translation for unknown code
538      callLLMMock.mock.mockImplementationOnce(async () => ({ content: ANALYSIS_MOCK_RESPONSE }));
539      callLLMMock.mock.mockImplementation(async () => ({
540        content: JSON.stringify({ body: 'polished text' }),
541      }));
542  
543      const siteData = {
544        domain: 'example.com',
545        country_code: 'AU',
546        language_code: 'xx', // Not in LANG_NAMES
547        keyword: 'plumber',
548      };
549      const scoreData = makeFactorScoreData();
550      const contact = { channel: 'email', uri: 'info@example.com' };
551  
552      // Should not throw even with unknown language code
553      const result = await generateTemplateProposal(siteData, scoreData, contact);
554      assert.ok(result.proposalText);
555    });
556  });
557  
558  // ─────────────────────────────────────────────────────────────
559  // 7. generateTemplateProposal — fallback subject line (line 552)
560  // ─────────────────────────────────────────────────────────────
561  
562  describe('generateTemplateProposal — fallback subject line', () => {
563    test('throws when all subject_spintax resolve to empty string after population', async () => {
564      // Template with subject_spintax that contains ONLY an unknown placeholder
565      // populateTemplate resolves [nonexistent_merge_field] → '' (falsy) → no usable subject
566      // Current behavior: throws rather than silently omitting subject
567      readFileSyncMock.mock.mockImplementation(() =>
568        JSON.stringify({
569          templates: [
570            {
571              id: 'email_emptysub_001',
572              channel: 'email',
573              body_spintax: 'Hello [firstname|there], check out [domain].',
574              subject_spintax: '[nonexistent_merge_field]',
575              sends: 0,
576              conversions: 0,
577            },
578          ],
579        })
580      );
581      // Call 1 = analyzeScoreJson (must succeed); subject check throws before polish is called
582      callLLMMock.mock.mockImplementationOnce(async () => ({ content: ANALYSIS_MOCK_RESPONSE }));
583      callLLMMock.mock.mockImplementation(async () => ({ content: null }));
584  
585      const siteData = { domain: 'test.com', country_code: 'AU', keyword: 'builder' };
586      const scoreData = makeFactorScoreData();
587      const contact = { channel: 'email', uri: 'test@test.com' };
588  
589      await assert.rejects(
590        () => generateTemplateProposal(siteData, scoreData, contact),
591        /No usable subject_spintax found/,
592        'Should throw when all subject candidates resolve to empty'
593      );
594    });
595  
596    test('returns null subject for sms channel regardless of template subject_spintax', async () => {
597      readFileSyncMock.mock.mockImplementation(() =>
598        JSON.stringify({
599          templates: [
600            {
601              id: 'sms_s2_subj',
602              channel: 'sms',
603              body_spintax: 'Hi, check [domain].',
604              subject_spintax: 'Has subject', // should be ignored for sms
605              sends: 0,
606              conversions: 0,
607            },
608          ],
609        })
610      );
611      // Call 1 = analyzeScoreJson; call 2 = polishProposalWithHaiku (null falls back to original)
612      callLLMMock.mock.mockImplementationOnce(async () => ({ content: ANALYSIS_MOCK_RESPONSE }));
613      callLLMMock.mock.mockImplementation(async () => ({ content: null }));
614  
615      const siteData = { domain: 'test.com', country_code: 'AU', keyword: 'plumber' };
616      const scoreData = makeFactorScoreData();
617      const contact = { channel: 'sms', uri: '+61400000000' };
618  
619      const result = await generateTemplateProposal(siteData, scoreData, contact);
620      assert.equal(result.subjectLine, null, 'SMS proposals should have null subjectLine');
621    });
622  
623    test('form channel uses subject line (treated same as email)', async () => {
624      readFileSyncMock.mock.mockImplementation(() =>
625        JSON.stringify({
626          templates: [
627            {
628              id: 'email_form_001',
629              channel: 'email',
630              body_spintax: 'Hi [firstname|there], we can help [domain].',
631              subject_spintax: '{Great|Good} news for [domain]',
632              sends: 0,
633              conversions: 0,
634            },
635          ],
636        })
637      );
638      // Call 1 = analyzeScoreJson; call 2 = polishProposalWithHaiku (null falls back to original)
639      callLLMMock.mock.mockImplementationOnce(async () => ({ content: ANALYSIS_MOCK_RESPONSE }));
640      callLLMMock.mock.mockImplementation(async () => ({ content: null }));
641  
642      const siteData = { domain: 'formsite.com', country_code: 'AU', keyword: 'plumber' };
643      const scoreData = makeFactorScoreData();
644      const contact = { channel: 'form', uri: 'https://formsite.com/contact' };
645  
646      const result = await generateTemplateProposal(siteData, scoreData, contact);
647      assert.ok(result.subjectLine !== null, 'form channel should have a subject line');
648    });
649  });
650  
651  // ─────────────────────────────────────────────────────────────
652  // 8. populateTemplate — [firstname|fallback] pattern
653  // ─────────────────────────────────────────────────────────────
654  
655  describe('populateTemplate — firstname fallback patterns', () => {
656    const siteData = { domain: 'mysite.com', keyword: 'electrician' };
657    const fields = {
658      primaryWeakness: 'weak call-to-action',
659      secondaryWeakness: 'insufficient trust signals',
660      quickImprovementOpportunity: 'add a clear CTA button',
661      evidence: 'No CTA found',
662      reasoning: 'Visitors are confused',
663      industry: 'electrical',
664      score: 40,
665      grade: 'F',
666      impact: 35,
667    };
668  
669    test('[firstname|there] falls back to "there" when no valid name', () => {
670      const result = populateTemplate('Hi [firstname|there]!', fields, siteData, null);
671      assert.ok(result.includes('there'), `Expected "there" in: ${result}`);
672    });
673  
674    test('[firstname|there] uses real name when valid person name provided', () => {
675      const contact = { name: 'Alice' };
676      const result = populateTemplate('Hi [firstname|there]!', fields, siteData, contact);
677      assert.ok(result.includes('Alice'), `Expected "Alice" in: ${result}`);
678    });
679  
680    test('[domain] placeholder is populated', () => {
681      const result = populateTemplate('Site: [domain]', fields, siteData);
682      assert.ok(result.includes('mysite.com'), `Expected "mysite.com" in: ${result}`);
683    });
684  
685    test('[grade] placeholder is populated', () => {
686      const result = populateTemplate('Grade: [grade]', fields, siteData);
687      assert.ok(result.includes('F'), `Expected "F" in: ${result}`);
688    });
689  
690    test('[industry] is resolved from analysisData', () => {
691      const analysisData = { industry: 'electrical', recommendation: 'upgrade your website' };
692      const result = populateTemplate('Industry: [industry]', fields, siteData, null, analysisData);
693      assert.ok(result.includes('electrical'), `Expected "electrical" in: ${result}`);
694    });
695  
696    test('NON_PERSON_WORDS like "team" reject name as greeting', () => {
697      const contact = { name: 'team' };
698      const result = populateTemplate('Hi [firstname|there]!', fields, siteData, contact);
699      assert.ok(
700        result.includes('there'),
701        `Expected "there" fallback for "team" name, got: ${result}`
702      );
703    });
704  
705    test('names with digits are rejected as non-person', () => {
706      const contact = { name: 'John123' };
707      const result = populateTemplate('Hi [firstname|there]!', fields, siteData, contact);
708      assert.ok(result.includes('there'), `Expected "there" for name with digits, got: ${result}`);
709    });
710  
711    test('very short names (1 char) are rejected as non-person', () => {
712      const contact = { name: 'A' };
713      const result = populateTemplate('Hi [firstname|there]!', fields, siteData, contact);
714      assert.ok(result.includes('there'), `Expected "there" for single-char name, got: ${result}`);
715    });
716  
717    test('triple-hyphenated names are rejected as non-person', () => {
718      const contact = { name: 'Mary-Jane-Watson-Smith' };
719      // 4 parts split by hyphen → triple-hyphen check (>2) fails
720      const result = populateTemplate('Hi [firstname|there]!', fields, siteData, contact);
721      assert.ok(result.includes('there'), `Expected "there" for triple-hyphen name, got: ${result}`);
722    });
723  });
724  
725  // ─────────────────────────────────────────────────────────────
726  // 9. extractTemplateFields — null/no-sections default return (lines 58-69)
727  // ─────────────────────────────────────────────────────────────
728  
729  describe('extractTemplateFields — null and no-sections defaults', () => {
730    test('returns default fields when scoreData is null', () => {
731      const fields = extractTemplateFields(null);
732      assert.equal(fields.primaryWeakness, 'weak call-to-action');
733      assert.equal(fields.secondaryWeakness, 'unclear value proposition');
734      assert.equal(fields.grade, 'F');
735      assert.equal(fields.score, 0);
736      assert.equal(fields.impact, 30);
737      assert.equal(fields.industry, 'local service');
738      assert.ok(fields.evidence.length > 0);
739      assert.ok(fields.reasoning.length > 0);
740      assert.ok(fields.quickImprovementOpportunity.length > 0);
741    });
742  
743    test('returns default fields when scoreData is undefined', () => {
744      const fields = extractTemplateFields(undefined);
745      assert.equal(fields.primaryWeakness, 'weak call-to-action');
746      assert.equal(fields.grade, 'F');
747    });
748  
749    test('returns default fields when scoreData has neither sections nor factor_scores', () => {
750      const fields = extractTemplateFields({
751        overall_calculation: { conversion_score: 20, letter_grade: 'F' },
752      });
753      assert.equal(fields.primaryWeakness, 'weak call-to-action');
754      assert.equal(fields.grade, 'F');
755    });
756  });
757  
758  // ─────────────────────────────────────────────────────────────
759  // 10. extractTemplateFields — sections legacy format (lines 89-104)
760  // ─────────────────────────────────────────────────────────────
761  
762  describe('extractTemplateFields — sections legacy format', () => {
763    test('extracts factors from nested sections.criteria format', () => {
764      const data = {
765        sections: {
766          conversion: {
767            criteria: {
768              cta_clarity: { score: 2, explanation: 'No button', reasoning: 'Weak CTA' },
769              trust_signals: {
770                score: 4,
771                explanation: 'Few reviews',
772                reasoning: 'Missing testimonials',
773              },
774            },
775          },
776        },
777        overall_calculation: { conversion_score: 45, letter_grade: 'F' },
778      };
779      const fields = extractTemplateFields(data);
780      assert.equal(fields.primaryWeakness, 'cta_clarity');
781      assert.equal(fields.secondaryWeakness, 'trust_signals');
782    });
783  
784    test('skips criteria entries with non-numeric score in sections format', () => {
785      const data = {
786        sections: {
787          main: {
788            criteria: {
789              bad: { score: 'not-a-number', explanation: 'x' },
790              good: { score: 3, explanation: 'Real problem', reasoning: 'Reason' },
791            },
792          },
793        },
794        overall_calculation: { conversion_score: 30, letter_grade: 'F' },
795      };
796      const fields = extractTemplateFields(data);
797      assert.equal(fields.primaryWeakness, 'good');
798    });
799  
800    test('skips section that has no criteria property', () => {
801      const data = {
802        sections: {
803          empty_section: { score: 5 }, // no .criteria
804          real_section: {
805            criteria: {
806              my_criterion: { score: 3, explanation: 'Issue', reasoning: 'Reason' },
807            },
808          },
809        },
810        overall_calculation: { conversion_score: 30, letter_grade: 'F' },
811      };
812      const fields = extractTemplateFields(data);
813      assert.equal(fields.primaryWeakness, 'my_criterion');
814    });
815  
816    test('uses explanation as reasoning fallback in sections format', () => {
817      const data = {
818        sections: {
819          main: {
820            criteria: {
821              crit: { score: 3, explanation: 'Explanation text', reasoning: '' },
822            },
823          },
824        },
825        overall_calculation: { conversion_score: 30, letter_grade: 'F' },
826      };
827      const fields = extractTemplateFields(data);
828      // reasoning falls back to explanation when reasoning is empty string
829      assert.equal(fields.reasoning, 'Explanation text');
830    });
831  });
832  
833  // ─────────────────────────────────────────────────────────────
834  // 11. extractTemplateFields — factors[0] fallback (lines 109-113)
835  // ─────────────────────────────────────────────────────────────
836  
837  describe('extractTemplateFields — empty factors fallback', () => {
838    test('uses default primaryWeakness when no valid criteria found', () => {
839      const data = {
840        sections: {
841          main: {
842            criteria: {
843              bad: { score: 'NaN', explanation: 'Bad' },
844              // no numeric score criteria
845            },
846          },
847        },
848        overall_calculation: { conversion_score: 20, letter_grade: 'F' },
849      };
850      const fields = extractTemplateFields(data);
851      assert.equal(fields.primaryWeakness, 'weak call-to-action');
852    });
853  
854    test('uses default secondaryWeakness when only one valid criterion found', () => {
855      const data = {
856        sections: {
857          main: {
858            criteria: {
859              only_one: { score: 5, explanation: 'Issue', reasoning: 'Reason' },
860            },
861          },
862        },
863        overall_calculation: { conversion_score: 50, letter_grade: 'F' },
864      };
865      const fields = extractTemplateFields(data);
866      assert.equal(fields.primaryWeakness, 'only_one');
867      assert.equal(fields.secondaryWeakness, 'unclear value proposition');
868    });
869  });
870  
871  // ─────────────────────────────────────────────────────────────
872  // 12. selectTemplate — throws on empty/null; 1000+ conversion sort (lines 249-270)
873  // ─────────────────────────────────────────────────────────────
874  
875  describe('selectTemplate — edge cases and conversion rate sort', () => {
876    test('throws with empty templates array', () => {
877      assert.throws(
878        () => selectTemplate([], {}, 'sms'),
879        err => err.message.includes('No templates available for channel: sms')
880      );
881    });
882  
883    test('throws with null templates', () => {
884      assert.throws(
885        () => selectTemplate(null, {}, 'email'),
886        err => err instanceof Error
887      );
888    });
889  
890    test('weights by conversion rate when both templates have 1000+ sends', () => {
891      const templates = [
892        { id: 'low_conv', sends: 1000, conversions: 10, body_spintax: 'Low' },
893        { id: 'high_conv', sends: 2000, conversions: 400, body_spintax: 'High' },
894      ];
895      const selected = selectTemplate(templates, {}, 'email');
896      // high_conv has 20% rate vs low_conv at 1% — should pick high_conv
897      assert.equal(selected.id, 'high_conv');
898    });
899  
900    test('prefers lower sends template when only one has 1000+ sends', () => {
901      const templates = [
902        { id: 'over_1000', sends: 1500, conversions: 150, body_spintax: 'Over' },
903        { id: 'under_1000', sends: 50, conversions: 0, body_spintax: 'Under' },
904      ];
905      const selected = selectTemplate(templates, {}, 'email');
906      // under_1000 has fewer sends → rotation testing logic
907      assert.equal(selected.id, 'under_1000');
908    });
909  });
910  
911  // ─────────────────────────────────────────────────────────────
912  // 13. loadTemplates — flat path catch (line 228-229) and throw (line 233)
913  // ─────────────────────────────────────────────────────────────
914  
915  describe('loadTemplates — flat path error handling and final throw', () => {
916    test('throws when both lang-specific and flat paths fail for English', () => {
917      readFileSyncMock.mock.mockImplementation(() => {
918        throw new Error('ENOENT');
919      });
920  
921      assert.throws(
922        () => loadTemplates('ZZ', 'en', 'email'),
923        err => err.message.includes('No templates for ZZ/en/email')
924      );
925    });
926  
927    test('throws when lang-specific path has empty templates and flat path throws', () => {
928      let callCount = 0;
929      readFileSyncMock.mock.mockImplementation(() => {
930        callCount++;
931        if (callCount === 1) return JSON.stringify({ templates: [] }); // empty lang-specific
932        throw new Error('ENOENT'); // flat path fails
933      });
934  
935      assert.throws(
936        () => loadTemplates('AU', 'en', 'sms'),
937        err => err.message.includes('No templates for AU/en/sms')
938      );
939    });
940  
941    test('throws when lang-specific path returns no templates array and flat path also returns empty', () => {
942      let callCount = 0;
943      readFileSyncMock.mock.mockImplementation(() => {
944        callCount++;
945        // Both paths return {} with no templates key
946        return JSON.stringify({});
947      });
948  
949      assert.throws(
950        () => loadTemplates('US', 'en', 'email'),
951        err => err.message.includes('No templates for US/en/email')
952      );
953    });
954  });
955  
956  // ─────────────────────────────────────────────────────────────
957  // 14. translateWeaknessIfNeeded error path (lines 458-460)
958  //     Exercised via generateTemplateProposal with non-English language
959  // ─────────────────────────────────────────────────────────────
960  
961  describe('polishProposalWithHaiku — error handling path', () => {
962    test('falls back to original text when Haiku polish throws', async () => {
963      mockReadFileWithTemplates('email');
964      // Call 1 = analyzeScoreJson (succeeds); call 2 = polishProposalWithHaiku (throws → falls back)
965      callLLMMock.mock.mockImplementationOnce(async () => ({ content: ANALYSIS_MOCK_RESPONSE }));
966      callLLMMock.mock.mockImplementation(async () => {
967        throw new Error('LLM API error during polish');
968      });
969  
970      const siteData = {
971        domain: 'example.de',
972        country_code: 'DE',
973        language_code: 'de', // triggers translateWeaknessIfNeeded
974        keyword: 'klempner',
975      };
976      const scoreData = makeFactorScoreData();
977      const contact = { channel: 'email', uri: 'info@example.de' };
978  
979      // Should not throw — polishProposalWithHaiku catches the error and falls back to original text
980      const result = await generateTemplateProposal(siteData, scoreData, contact);
981      assert.ok(result.proposalText, 'should produce a proposal even when polish fails');
982    });
983  });