/**
 * draft-plan-script 确定性逻辑单元测试(话术三档重构后,f60f4cc/4fe0d97)。
 *
 * 原则:LLM 不该做的确定性活(日期格式 / FDI 查表 / 去名留称呼 / 病种映射 / 事实块拼装)
 * 全在这些纯函数里,这里锁行为:
 *   - shared/script-facts   智能日期 + 牙位俗称(三档 + 实时教练共用)
 *   - shared/pii            去名留称呼单一源(患者 / 监护人 / 医生)
 *   - shared/disease-knowledge  病种 canonical 名(tier-agnostic)
 *   - tiers/stable/phrasing 稳健档病种口语文案(risks/advantages/复查时长)
 *   - shared/fact-block     标准/深度档厚输入事实块拼装
 */
import {
  smartDateDisplay,
  fdiToFriendly,
  toothFriendly,
} from '../src/modules/ai/calls/draft-plan-script/shared/script-facts';
import {
  deidentifyDoctor,
  callSalutation,
  pickGuardian,
} from '../src/modules/ai/calls/draft-plan-script/shared/pii';
import {
  diseaseLabelForSubKey,
  resolveDiseaseLabel,
} from '../src/modules/ai/calls/draft-plan-script/shared/disease-knowledge';
import { resolveDisease } from '../src/modules/ai/calls/draft-plan-script/tiers/stable/phrasing';
import {
  buildRichFactBlock,
  buildPersonaGuide,
  renderMedicalRecord,
  renderTreatmentPlan,
} from '../src/modules/ai/calls/draft-plan-script/shared/fact-block';
import type {
  ScriptContext,
  ScriptMedicalRecord,
} from '../src/modules/ai/calls/draft-plan-script/shared/input.types';

// ═════════════════════════════════════════════════════════
// shared/script-facts — 智能日期
// ═════════════════════════════════════════════════════════
describe('script-facts | smartDateDisplay(今年→X月X号 / 去年→去年X月 / 更早→XXXX年X月)', () => {
  const now = new Date('2026-06-02T00:00:00Z');

  test('今年 → X月X号', () => {
    expect(smartDateDisplay('2026-01-29T00:00:00Z', now)).toBe('1月29号');
  });

  test('去年 → 去年X月(不带日)', () => {
    expect(smartDateDisplay('2025-12-10', now)).toBe('去年12月');
  });

  test('前年及更早 → XXXX年X月', () => {
    expect(smartDateDisplay('2024-03-15', now)).toBe('2024年3月');
  });

  test('跨年边界:now 在 1 月,上月即"去年"', () => {
    expect(smartDateDisplay('2025-12-31', new Date('2026-01-05'))).toBe('去年12月');
  });

  test('接受 Date 对象输入', () => {
    expect(smartDateDisplay(new Date('2026-03-08'), now)).toBe('3月8号');
  });

  test('空 / 无效输入 → null', () => {
    expect(smartDateDisplay(null, now)).toBeNull();
    expect(smartDateDisplay(undefined, now)).toBeNull();
    expect(smartDateDisplay('not-a-date', now)).toBeNull();
  });
});

// ═════════════════════════════════════════════════════════
// shared/script-facts — 牙位俗称(FDI → 患者口语)
// ═════════════════════════════════════════════════════════
describe('script-facts | fdiToFriendly(FDI → 俗称,粗粒度)', () => {
  test('恒牙:象限定上下,位置定名称', () => {
    expect(fdiToFriendly('11')).toBe('上门牙'); // 1x 上颌 / 中切
    expect(fdiToFriendly('22')).toBe('上门牙'); // 侧切同归门牙
    expect(fdiToFriendly('43')).toBe('下尖牙');
    expect(fdiToFriendly('34')).toBe('下小磨牙'); // 前磨牙
    expect(fdiToFriendly('26')).toBe('上大磨牙'); // 磨牙
    expect(fdiToFriendly('47')).toBe('下大磨牙');
  });

  test('8 号位 → 智齿(不分上下)', () => {
    expect(fdiToFriendly('18')).toBe('智齿');
    expect(fdiToFriendly('48')).toBe('智齿');
  });

  test('乳牙数字记法(象限 5-8):现状"乳"仅拼在门牙,尖牙/磨牙不带', () => {
    expect(fdiToFriendly('51')).toBe('上乳门牙');
    expect(fdiToFriendly('83')).toBe('下尖牙');
    expect(fdiToFriendly('75')).toBe('下小磨牙');
  });

  test('乳牙"象限+字母"记法(host):A/B 门牙、C 尖牙、D/E 磨牙', () => {
    expect(fdiToFriendly('2A')).toBe('上乳门牙');
    expect(fdiToFriendly('3C')).toBe('下乳尖牙');
    expect(fdiToFriendly('4D')).toBe('下乳磨牙');
    expect(fdiToFriendly('1e')).toBe('上乳磨牙'); // 小写兼容
  });

  test('全口标记 → 全口', () => {
    expect(fdiToFriendly('*whole')).toBe('全口');
    expect(fdiToFriendly('whole')).toBe('全口');
  });

  test('解析不出 → null(交 toothFriendly 兜底)', () => {
    expect(fdiToFriendly('99')).toBeNull(); // 象限 9 不存在
    expect(fdiToFriendly('X1')).toBeNull();
    expect(fdiToFriendly('')).toBeNull();
  });
});

describe('script-facts | toothFriendly(多牙位 "/" 分隔,去重保序,兜底原样)', () => {
  test('多牙位数组 → "/" 连接', () => {
    expect(toothFriendly(['11', '36'])).toBe('上门牙/下大磨牙');
  });

  test('同俗称去重(11/21 都是上门牙,只出一次)', () => {
    expect(toothFriendly(['11', '21', '36'])).toBe('上门牙/下大磨牙');
  });

  test('字符串输入:分号 / 逗号 / 中文逗号 / 空格 都是分隔符', () => {
    expect(toothFriendly('11;36')).toBe('上门牙/下大磨牙');
    expect(toothFriendly('11，36')).toBe('上门牙/下大磨牙');
    expect(toothFriendly('11 36')).toBe('上门牙/下大磨牙');
  });

  test('都解析不出 → 兜底原样返回', () => {
    expect(toothFriendly('MB2')).toBe('MB2');
    expect(toothFriendly(['X1', 'X2'])).toBe('X1/X2'); // 数组兜底 join('/')
  });

  test('部分解析得出 → 只保留能解析的俗称', () => {
    expect(toothFriendly(['11', 'X9'])).toBe('上门牙');
  });
});

// ═════════════════════════════════════════════════════════
// shared/pii — 去名留称呼
// ═════════════════════════════════════════════════════════
describe('pii | deidentifyDoctor(医生取姓,模板拼"{姓}医生")', () => {
  test('全名 → 姓', () => {
    expect(deidentifyDoctor('韩维')).toBe('韩');
  });

  test('已带"医生"后缀 → 去后缀避免模板重复', () => {
    expect(deidentifyDoctor('韩医生')).toBe('韩');
    expect(deidentifyDoctor('您的主治医生')).toBe('您的主治');
  });

  test('空 → 泛指"您的主治"', () => {
    expect(deidentifyDoctor(null)).toBe('您的主治');
    expect(deidentifyDoctor('  ')).toBe('您的主治');
  });
});

describe('pii | callSalutation(姓级称呼,绝不出全名)', () => {
  test('成人:姓 + 先生/女士', () => {
    expect(callSalutation('张震校', '男', 35, null)).toBe('张先生');
    expect(callSalutation('徐雅静', '女', 28, null)).toBe('徐女士');
    expect(callSalutation('李四', 'M', 40, null)).toBe('李先生');
    expect(callSalutation('王五', 'female', 40, null)).toBe('王女士');
  });

  test('成人性别未知 → 默认先生', () => {
    expect(callSalutation('赵六', null, 30, null)).toBe('赵先生');
  });

  test('无名 → "您"', () => {
    expect(callSalutation(null, '男', 30, null)).toBe('您');
    expect(callSalutation('', null, null, null)).toBe('您');
  });

  test('未成年(≤12)有监护人姓名 → 监护人姓 + 妈妈女士/爸爸先生', () => {
    expect(callSalutation('张小宝', '男', 8, { relationship: 'mother', name: '李梅' })).toBe('李女士');
    expect(callSalutation('张小宝', '男', 8, { relationship: 'father', name: '王刚' })).toBe('王先生');
  });

  test('未成年无监护人姓名 → "{患者姓}家长"', () => {
    expect(callSalutation('张小宝', '男', 8, null)).toBe('张家长');
    expect(callSalutation('张小宝', '男', 8, { relationship: 'mother', name: '' })).toBe('张家长');
  });

  test('12/13 边界:12 走监护人径,13 走成人径', () => {
    expect(callSalutation('张小宝', '男', 12, null)).toBe('张家长');
    expect(callSalutation('张小宝', '男', 13, null)).toBe('张先生');
  });

  test('年龄未知 → 按成人处理', () => {
    expect(callSalutation('张三', '男', null, null)).toBe('张先生');
  });
});

describe('pii | pickGuardian(优先已建档,关系 妈妈>爸爸>祖辈)', () => {
  test('有姓名优先于关系顺位', () => {
    const g = pickGuardian([
      { relationship: 'mother', relatedPatient: null },
      { relationship: 'father', relatedPatient: { name: '王刚' } },
    ]);
    expect(g).toEqual({ relationship: 'father', relationshipLabel: '爸爸', name: '王刚' });
  });

  test('同为已建档 → 妈妈 > 爸爸', () => {
    const g = pickGuardian([
      { relationship: 'father', relatedPatient: { name: '王刚' } },
      { relationship: 'mother', relatedPatient: { name: '李梅' } },
    ]);
    expect(g?.relationship).toBe('mother');
    expect(g?.relationshipLabel).toBe('妈妈');
  });

  test('未知关系 → label 兜底"家长"', () => {
    const g = pickGuardian([{ relationship: 'uncle', relatedPatient: { name: '张叔' } }]);
    expect(g?.relationshipLabel).toBe('家长');
  });

  test('空关系列表 → null', () => {
    expect(pickGuardian([])).toBeNull();
  });
});

// ═════════════════════════════════════════════════════════
// shared/disease-knowledge — 病种 canonical 名(tier-agnostic)
// ═════════════════════════════════════════════════════════
describe('disease-knowledge | diseaseLabelForSubKey(subKey → 患者口径病种名)', () => {
  test('已映射 subKey → 病种名', () => {
    expect(diseaseLabelForSubKey('missing_tooth')).toBe('缺失牙');
    expect(diseaseLabelForSubKey('perio_no_srp')).toBe('牙周炎');
  });

  test('带 @tooth 后缀 → 去后缀再查', () => {
    expect(diseaseLabelForSubKey('caries_no_filling@16')).toBe('龋齿');
  });

  test('未映射 / 空 → null', () => {
    expect(diseaseLabelForSubKey('unknown_key')).toBeNull();
    expect(diseaseLabelForSubKey(null)).toBeNull();
    expect(diseaseLabelForSubKey('')).toBeNull();
  });
});

describe('disease-knowledge | resolveDiseaseLabel(触发感知)', () => {
  test('lead=医生建议(无诊断)→ 用建议名,不套子场景诊断词', () => {
    const label = resolveDiseaseLabel(
      { subKey: 'missing_tooth', leadTrigger: { type: 'recommendation', code: 'IMPLANT_RECOMMENDED' } },
      '漏治-缺失牙',
    );
    expect(label).toBe('建议种植'); // 不是"缺失牙"
  });

  test('lead=诊断 → subKey 病种名,以诊断为准', () => {
    const label = resolveDiseaseLabel(
      { subKey: 'missing_tooth', leadTrigger: { type: 'diagnosis', code: 'K08' } },
      '漏治-缺失牙',
    );
    expect(label).toBe('缺失牙');
  });

  test('subKey 未映射 → fallbackLabel 兜底', () => {
    expect(resolveDiseaseLabel({ subKey: null, leadTrigger: null }, '漏治-其他')).toBe('漏治-其他');
    expect(resolveDiseaseLabel(null, '漏治-其他')).toBe('漏治-其他');
  });
});

// ═════════════════════════════════════════════════════════
// tiers/stable/phrasing — 稳健档病种口语文案
// ═════════════════════════════════════════════════════════
describe('phrasing | resolveDisease(稳健档:label + risks/advantages/复查时长)', () => {
  test('subKey 精确命中 → label + 文案齐全', () => {
    const d = resolveDisease({ subKey: 'perio_no_srp' }, '漏治-牙周');
    expect(d.label).toBe('牙周炎');
    expect(d.risks.length).toBeGreaterThan(0);
    expect(d.advantages.length).toBeGreaterThan(0);
    expect(d.reviewDuration).toContain('牙周');
    expect(d.ageFit?.青年).toBeTruthy();
  });

  test('jaw_cyst 双字典 key 分列(keypoints=囊肿 / duration=颌骨囊肿)→ risks 不静默丢失', () => {
    const d = resolveDisease({ subKey: 'jaw_cyst' }, '漏治-颌骨囊肿');
    expect(d.label).toBe('颌骨囊肿');
    expect(d.risks.length).toBeGreaterThan(0); // 曾因 key 不一致丢 risks 的回归点
    expect(d.reviewDuration).toContain('囊肿');
  });

  test('subKey 带 @tooth 后缀 → 去后缀命中', () => {
    expect(resolveDisease({ subKey: 'caries_no_filling@16' }, 'x').label).toBe('龋齿');
  });

  test('lead=医生建议 → 病种名换建议名,风险/优势仍按子场景', () => {
    const d = resolveDisease(
      { subKey: 'missing_tooth', leadTrigger: { type: 'recommendation', code: 'IMPLANT_RECOMMENDED' } },
      '漏治-缺失牙',
    );
    expect(d.label).toBe('建议种植');
    // 风险仍是缺失牙口径(种植/修复缺口的风险本就适用)
    expect(d.risks.some((r) => r.includes('缺牙') || r.includes('牙齿'))).toBe(true);
  });

  test('无 subKey → 文本归一兜底(scenarioLabel/reason 含病种词)', () => {
    const d = resolveDisease({ subKey: null, scenarioLabel: '漏治-缺失牙', reason: '缺失牙未修复' }, '其他');
    expect(d.label).toBe('缺失牙');
    expect(d.risks.length).toBeGreaterThan(0);
  });

  test('文本归一按 MISSED_PRIORITY 优先(儿牙早矫 > 缺失牙)', () => {
    const d = resolveDisease({ subKey: null, reason: '儿牙早矫评估;另有缺失牙' }, '其他');
    expect(d.label).toBe('儿牙早矫');
  });

  test('完全未命中 → label 用 fallback,risks 空,复查时长兜底"其他"', () => {
    const d = resolveDisease({ subKey: null, reason: '不构成病种的描述' }, '漏治-其他');
    expect(d.label).toBe('漏治-其他');
    expect(d.risks).toEqual([]);
    expect(d.reviewDuration).toBe('复查检查约30分钟');
  });
});

// ═════════════════════════════════════════════════════════
// shared/fact-block — 厚输入事实块(标准/深度档)
// ═════════════════════════════════════════════════════════
function makeReason(overrides: Partial<ScriptContext['plan']['reasons'][number]> = {}) {
  return {
    scenarioLabel: '漏治-缺失牙',
    subKey: 'missing_tooth',
    dxCode: 'K08',
    leadTrigger: { type: 'diagnosis', code: 'K08' },
    toothPositions: ['11'],
    reason: '缺失牙未修复',
    priorityScore: 90,
    triggerDoctor: '韩维',
    triggerDate: '2026-05-01',
    medicalRecord: null,
    ...overrides,
  };
}

function makeInput(overrides: {
  patient?: Partial<ScriptContext['patient']>;
  plan?: Partial<ScriptContext['plan']>;
  clinicalContext?: Partial<ScriptContext['clinicalContext']>;
  personaHighlights?: ScriptContext['personaHighlights'];
} = {}): ScriptContext {
  return {
    patient: {
      name: '张震校',
      salutation: '张先生',
      gender: '男',
      age: 35,
      guardian: null,
      ...overrides.patient,
    },
    clinicName: '测试口腔',
    plan: {
      primaryScenarioLabel: '漏治-缺失牙',
      primaryScenarioKey: 'missed_diagnosis',
      priorityScore: 90,
      goal: '邀约来院评估缺失牙修复方案',
      reasons: [makeReason()],
      ...overrides.plan,
    },
    personaHighlights: overrides.personaHighlights ?? [],
    clinicalContext: {
      daysSinceLastVisit: 0,
      lastVisit: null,
      lastChiefComplaint: '牙疼来看',
      pendingTreatments: [],
      primaryDoctorName: '韩维',
      recentTreatments: [],
      completedTreatmentCount: 3,
      contactHistory: [],
      ...overrides.clinicalContext,
    },
  };
}

describe('fact-block | buildRichFactBlock(朴素事实块,不出全名)', () => {
  test('开场事实:称呼 / 自报家门 / 医生去名 / 主诉', () => {
    const block = buildRichFactBlock(makeInput());
    expect(block).toContain('称呼:张先生');
    // ⭐ 自报家门 = 「{诊断医生}医生的 + 占位符」,不给人名 —— 姓名在渲染期按登录人回填。
    //   身份是**医生的助理**不是客服(业务口径 2026-07-29,见 agent-identity.ts)
    expect(block).toContain('我是韩医生的【回访客服】');
    // 诊所名仍给,但只作"要提到诊所时用这个"的防编造锚,不再进自报家门
    expect(block).toContain('测试口腔');
    expect(block).not.toContain('我是测试口腔的');
    expect(block).toContain('韩医生'); // 去名留姓
    expect(block).not.toContain('韩维'); // 全名不进 prompt
    expect(block).not.toContain('张震校');
    expect(block).toContain('牙疼来看');
  });

  test('回访人姓名不进 prompt(占位符是唯一形态)', () => {
    const block = buildRichFactBlock(makeInput());
    // 任何具体人名都不该出现在自报家门位置 —— 缓存 per-plan、召回池共享,烤人名会串号
    expect(block).not.toMatch(/我是韩医生的(助理|客服)(主管)?[一-龥]/);
  });

  test('daysSinceLastVisit=0 → 最近就诊显示为今年具体日期(X月X号)', () => {
    const block = buildRichFactBlock(makeInput());
    expect(block).toMatch(/最近一次就诊[^:]*:\d{1,2}月\d{1,2}号/);
  });

  test('无就诊天数 / 无触发日期 → 兜底"上次"', () => {
    const block = buildRichFactBlock(
      makeInput({
        clinicalContext: { daysSinceLastVisit: null },
        plan: { reasons: [makeReason({ triggerDate: null })] },
      }),
    );
    expect(block).toMatch(/最近一次就诊[^:]*:上次/);
  });

  test('牙位渲染成俗称给 LLM 直接用', () => {
    const block = buildRichFactBlock(makeInput());
    expect(block).toContain('牙位:上门牙');
  });

  test('未成年:监护人触达说明 + 拍片硬禁令', () => {
    const block = buildRichFactBlock(
      makeInput({
        patient: {
          age: 8,
          salutation: '李女士',
          guardian: { relationship: 'mother', relationshipLabel: '妈妈', name: '李梅' },
        },
      }),
    );
    expect(block).toContain('打给妈妈');
    expect(block).toContain('称孩子为"宝宝"');
    expect(block).toContain('严禁出现"拍片');
  });

  test('年龄未知同样触发拍片禁令;成人(>18)不触发', () => {
    expect(buildRichFactBlock(makeInput({ patient: { age: null } }))).toContain('严禁出现"拍片');
    expect(buildRichFactBlock(makeInput())).not.toContain('严禁出现"拍片');
  });

  test('单一聚焦:reasons[0] 进"本次问题",其余进"其他可一并关心"', () => {
    const block = buildRichFactBlock(
      makeInput({
        plan: {
          reasons: [
            makeReason(),
            makeReason({ subKey: 'caries_no_filling', scenarioLabel: '漏治-龋齿', toothPositions: ['36'] }),
          ],
        },
      }),
    );
    expect(block).toContain('本次问题:缺失牙');
    expect(block).toMatch(/其他可一并关心[\s\S]*龋齿\(下大磨牙\)/);
  });

  test('只有一条 reason → 不渲染"其他"小节', () => {
    expect(buildRichFactBlock(makeInput())).not.toContain('其他可一并关心');
  });

  test('近期治疗渲染结构化要素(类目/术式/医生/时间)', () => {
    const block = buildRichFactBlock(
      makeInput({
        clinicalContext: {
          recentTreatments: [
            { category: 'periodontic', categoryLabel: '牙周', subtype: '龈上洁治', doctorName: '吴倩', date: '2026.04' },
          ],
        },
      }),
    );
    expect(block).toContain('2026.04 / 牙周 / 龈上洁治 / 吴倩医生');
  });

  test('熟络度信号:给原始事实(次数 + 天数),不贴新/老二分', () => {
    const block = buildRichFactBlock(makeInput());
    expect(block).toContain('已完成 3 次治疗');
    expect(block).toContain('距上次就诊 0 天');
  });
});

describe('fact-block | buildPersonaGuide(画像 → 话术指引,只渲染命中特征)', () => {
  const highlights = [
    { key: 'lifecycle_stage', label: '生命周期', description: '沉睡客户(340天未到诊)' },
    { key: 'contraindication', label: '禁忌', description: '青霉素过敏' },
    { key: 'discount_anchor', label: '折扣锚', description: '历史最低 8 折' },
  ];

  test('full 模式:⚠注意在前,含切入点特征', () => {
    const guide = buildPersonaGuide(highlights, 'full');
    expect(guide).toContain('切入点');
    const warnIdx = guide.indexOf('⚠ 【禁忌】');
    const lifecycleIdx = guide.indexOf('【生命周期】');
    const discountIdx = guide.indexOf('【折扣锚】');
    expect(warnIdx).toBeGreaterThan(-1);
    expect(warnIdx).toBeLessThan(lifecycleIdx); // 禁忌排最前
    expect(lifecycleIdx).toBeLessThan(discountIdx); // 定语气在切入点前
  });

  test('essential 模式:只留 ⚠安全 + 定语气,滤掉切入点(折扣等)', () => {
    const guide = buildPersonaGuide(highlights, 'essential');
    expect(guide).toContain('【禁忌】');
    expect(guide).toContain('【生命周期】');
    expect(guide).not.toContain('【折扣锚】');
    expect(guide).not.toContain('切入点');
  });

  test('未知 key 忽略;全部未命中 → 空串(调用方不追加)', () => {
    expect(buildPersonaGuide([{ key: 'nonsense', label: 'x', description: 'y' }])).toBe('');
    expect(buildPersonaGuide([])).toBe('');
  });
});

// ═════════════════════════════════════════════════════════
// shared/fact-block — 病历 / 治疗计划渲染
// ═════════════════════════════════════════════════════════
function emptyMr(): ScriptMedicalRecord {
  return {
    date: null,
    doctorName: null,
    chiefComplaint: null,
    presentIllness: null,
    pastHistory: null,
    generalCondition: null,
    examFindings: [],
    diagnoses: [],
    disposals: [],
    doctorAdvice: null,
    treatmentPlanText: null,
    diagnosisText: null,
    recommendations: [],
    plannedTreatments: [],
  };
}

describe('fact-block | renderMedicalRecord(SOAP 全字段,空字段略,牙位转俗称)', () => {
  test('null / 全空病历 → 空串', () => {
    expect(renderMedicalRecord(null)).toBe('');
    expect(renderMedicalRecord(emptyMr())).toBe('');
  });

  test('命中字段逐行渲染,牙位转俗称', () => {
    const out = renderMedicalRecord({
      ...emptyMr(),
      date: '2026-05-01',
      presentIllness: '右下后牙冷热刺激痛一周',
      examFindings: [{ toothPosition: '46', message: '深龋近髓' }],
      diagnoses: [{ code: 'K02', nameZh: '龋病', toothPosition: '46', fromImageAi: false }],
      doctorAdvice: '建议尽快充填,避免累及牙髓',
    });
    expect(out).toContain('- 接诊日期:2026-05-01');
    expect(out).toContain('- 现病史:右下后牙冷热刺激痛一周');
    expect(out).toContain('- 检查所见:下大磨牙 深龋近髓');
    expect(out).toContain('- 当次诊断:龋病(下大磨牙)');
    expect(out).toContain('- 医嘱(原话):建议尽快充填');
    expect(out).not.toContain('既往史'); // 空字段不渲染
  });
});

describe('fact-block | renderTreatmentPlan(结构化 planned 主,host 自由文本补)', () => {
  test('subtype 优先,缺 subtype 用类目名;两来源 ";" 拼接', () => {
    const out = renderTreatmentPlan({
      ...emptyMr(),
      plannedTreatments: [
        { categoryLabel: '充填 / 嵌体', subtype: '充填', toothPosition: '46' },
        { categoryLabel: '牙周', subtype: null, toothPosition: null },
      ],
      treatmentPlanText: '择期复查',
    });
    expect(out).toBe('充填(下大磨牙)、牙周;择期复查');
  });

  test('两来源都空 → null(调用方不渲染该行)', () => {
    expect(renderTreatmentPlan(emptyMr())).toBeNull();
  });
});

// =============================================================
// 2026-07 线上反馈:年龄护栏三条(高龄义齿 / 低龄种植闸 / 替牙期早矫措辞)
// 三条同构 —— 都是"按年龄给措辞约束",都不改召回、不改类目集合。
// =============================================================
describe('fact-block | 年龄护栏(高龄义齿 / 低龄种植 / 早矫措辞)', () => {
  const block = (age: number | null) => buildRichFactBlock(makeInput({ patient: { age } }));

  test('⭐ 9 岁:低龄种植闸 + 早矫措辞都上,高龄块不出现', () => {
    const b = block(9);
    expect(b).toContain('不要主推种植');
    expect(b).toContain('颌骨尚未发育完成');
    expect(b).toContain('早期矫治');
    expect(b).not.toContain('先讲活动义齿');
  });

  test('15 岁:仍禁主推种植,但已过替牙期 → 不再强制"早期矫治"措辞', () => {
    const b = block(15);
    expect(b).toContain('不要主推种植');
    expect(b).not.toContain('处替牙期');
  });

  test('35 岁:三条护栏都不出现(中间年龄原样)', () => {
    const b = block(35);
    expect(b).not.toContain('不要主推种植');
    expect(b).not.toContain('处替牙期');
    expect(b).not.toContain('先讲活动义齿');
  });

  test('90 岁:只出高龄义齿块(与低龄块互斥)', () => {
    const b = block(90);
    expect(b).toContain('先讲活动义齿');
    expect(b).not.toContain('颌骨尚未发育完成');
  });

  test('无年龄:不猜 —— 三条护栏都不出(禁拍片 belt 另有兜底,不受影响)', () => {
    const b = block(null);
    expect(b).not.toContain('不要主推种植');
    expect(b).not.toContain('处替牙期');
    expect(b).not.toContain('先讲活动义齿');
  });
});
