import {
  DAILY_CALLS_PER_AGENT,
  TEMPERATURE_SINCE_ZH,
  type AssignmentProposal,
} from '@pac/types';
import type { NarrowFacts } from '../src/modules/plan/cohort-attributes.service';
import {
  ASSIGNMENT_INTENTS,
  computeNarrowSignal,
  computeSignals,
} from '../src/modules/plan/assignment-signals';
import { modelFacts } from '../src/modules/plan/assignment-facts';

/**
 * 引导节点的判定 —— 规格见 docs/design/assignment-agent-flow.md §五。
 *
 * 这里锁的是**判定本身**（什么情况该出、severity 怎么排、默认是不是 no-op），
 * ⛔ 不锁措辞 —— 措辞会随产品走查改，锁住它只会让每次改文案都红一片。
 */

/** 最小提案骨架:只填判定要用到的字段,其余给零值。 */
function proposal(over: Partial<AssignmentProposal> = {}): AssignmentProposal {
  return {
    clinicId: 'c1',
    potentialTreatment: 'implant',
    temperature: 'hot',
    candidateTotal: 1000,
    target: 405,
    batchSize: 405,
    /// ⚠️ 在岗名册人数 —— ⛔ 别拿 `byAgent.length` 顶(那是本批分到人的客服数,实测印错过)
    rosterCount: 27,
    /// ⚠️ 换算尺跟着这一版走(主管改过就是他的数)——⛔ 别在判定里引常量,见 schema 上那段
    dailyCalls: DAILY_CALLS_PER_AGENT,
    expiresInDays: 3,
    basis: 'explicit',
    basisFrom: null,
    agentOverrides: {},
    placed: 405,
    unplaced: 0,
    items: [],
    pending: [],
    canRefill: false,
    poolOwnership: null,
    byAgent: [],
    skippedAgents: [],
    rosterNote: '',
    basisNote: '',
    selectionNote: '',
    pendingNote: '',
    refillNote: '',
    opsNote: '',
    signals: [],
    ...over,
  } as AssignmentProposal;
}

/** @param count 本批给他几条 @param inHandBefore 分配前他手上还有几条 */
const agent = (name: string, count: number, inHandBefore = 0) =>
  ({
    userId: name,
    name,
    inHandBefore,
    count,
    dedicated: 0,
    spread: count,
    loadAfter: inHandBefore + count,
    expiresInDays: 3,
    overridden: false,
  }) as AssignmentProposal['byAgent'][number];

const keys = (p: AssignmentProposal) => computeSignals(p).map((s) => s.key);

describe('引导节点 · 判定', () => {
  test('⭐ 一切正常 → 一个节点都不出', () => {
    expect(keys(proposal())).toEqual([]);
  });

  test('🔴 有待分配 → 出 pending，且 severity 最高（有人会被静默漏掉）', () => {
    const p = proposal({
      pending: [{}] as AssignmentProposal['pending'],
      byAgent: [agent('张悦', 10)],
      basis: 'default',
    });
    const s = computeSignals(p);
    expect(s[0]!.key).toBe('pending');
    expect(s[0]!.tier).toBe('action');
    // ⚠️ 排在纯信息类之前
    expect(s.map((x) => x.severity)).toEqual([...s.map((x) => x.severity)].sort((a, b) => a - b));
  });

  test('⭐ canRefill=false 时，「换无主患者补上」这个选项不出现（⛔ 不给做不到的选项）', () => {
    const no = computeSignals(proposal({ pending: [{}] as AssignmentProposal['pending'] }))[0]!;
    expect(no.options.map((o) => o.intent)).not.toContain(ASSIGNMENT_INTENTS.PENDING_REFILL);
    const yes = computeSignals(
      proposal({ pending: [{}] as AssignmentProposal['pending'], canRefill: true }),
    )[0]!;
    expect(yes.options.map((o) => o.intent)).toContain(ASSIGNMENT_INTENTS.PENDING_REFILL);
  });

  /**
   * 🔴 2026-08-15 由「三个可选动作」改成**只留带数的那一个**（产品定）。
   *   删掉的两个（「改每人每天打几通」「减少本批人数」）一个数都不带 ——
   *   既没算出目标值，也没有预填当前值的输入框，点下去替主管说的那句话
   *   同样是空的（「减少一些」）。⇒ 按钮不带数 ⇒ 严格弱于他自己说一句。
   * ⚠️ 这条断言用 `toEqual` 锁**整个数组**，就是为了拦住"顺手再加一个"。
   */
  test('🔴 手上的量超出时限 → 出 daily_overload，只给带数的那一个动作', () => {
    // 每天 15 通 × 3 天 = 45 条是时限内打得完的量；60 条要 4 天
    const p = proposal({ expiresInDays: 3, byAgent: [agent('李莉', 30), agent('张悦', 60)] });
    const s = computeSignals(p).find((x) => x.key === 'daily_overload')!;
    expect(s).toBeDefined();
    expect(s.title).toContain('张悦');
    expect(s.why).toContain('4 天');
    expect(s.options.map((o) => o.intent)).toEqual([ASSIGNMENT_INTENTS.EXPIRY_SET]);
    // ⭐ 唯一那颗必须带着算好的天数 —— 不带数的按钮就是被删掉的那两个
    expect(s.options.every((o) => o.args?.days === 4)).toBe(true);
  });

  /**
   * 🔴 这个节点**每批基本都会亮**（N 只算新增、不扣在手），产品定这是对的 ——
   * 它是工作量预估，不是异常。⇒ 措辞不许像报错，否则常亮就变成"系统天天出故障"。
   */
  test('🔴 措辞不许像报错，且必须把「本批 / 原本在手」拆开说', () => {
    // 本批 45 + 原本在手 15 = 60 —— 拆开才看得出这批只压了 45
    const p = proposal({ expiresInDays: 3, byAgent: [agent('张悦', 45, 15)] });
    const s = computeSignals(p).find((x) => x.key === 'daily_overload')!;
    for (const w of ['打不完', '超了', '过载', '超出', '警告', '异常']) {
      expect(`${s.title}${s.why}`).not.toContain(w);
    }
    // ⚠️ 增量与总量必须分开 —— 不拆开主管会以为这批一下压了 60 条给他
    expect(s.why).toContain('本批 45 条');
    expect(s.why).toContain('原本在手 15 条');
    /**
     * ⚠️ 默认路径要**说清后果**,⛔ 不制造紧迫感 —— 这条不变,变的是后果本身:
     *   2026-08-19 起到期**不再回池**(回收器已删,沿革见 `plan.module` 的墓碑),
     *   所以原来那句「落回池子、下批还能分」成了一个不会兑现的承诺。
     * ⇒ 现在断的是新事实:超期之后单子**仍在这位客服手上**。
     * ⛔ 「回池」这个词不许再出现在这条里。
     */
    expect(s.defaultLabel).toContain('仍在这位客服手上');
    expect(s.defaultLabel).not.toContain('回池');
    expect(s.defaultLabel).not.toContain('池子');
  });

  test('⭐ 刚好打得完（45 条 / 3 天）→ ⛔ 不出 daily_overload', () => {
    expect(keys(proposal({ expiresInDays: 3, byAgent: [agent('张悦', 45)] }))).not.toContain(
      'daily_overload',
    );
  });

  /**
   * 🔴 2026-08-14 产品定：候选不够时**一条引导都不出**（原来出 `short_supply`，
   *   配「往后放一档 / 换个治疗项」两个按钮）。
   *   理由：**初选是主管自己在矩阵上点的，而矩阵每一格都写着人数** ——
   *   按钮却不带别的格子有多少人，⇒ 严格弱于他身后那张矩阵，还占着最显眼的位置。
   * ⚠️ 「发不满」仍要让他知道，但那是**事实**（facts 里三个数）不是**决定**。
   */
  test('🔴 候选不够 → ⛔ 一条引导都不出（换格子他自己会点）', () => {
    expect(keys(proposal({ candidateTotal: 44, target: 44, batchSize: 405 }))).toEqual([]);
  });

  /**
   * 🔴 2026-08-13：这条从 tier=info 升成 action（产品定）——
   *   它折叠在卡片的「另有 N 条说明」里，而模型正文早就把同一句话说了，等于读两遍。
   *   ⇒ 折叠区去掉，这条跟着正文那一句走（show_guidance）。
   * ⚠️ 两个可调量都要给**输入框**（天数/通数是连续量，列固定档必然漏）。
   */
  test('⭐⭐ 系统默认基数 → 出 batch_size_basis(action)，且两个选项都带输入框', () => {
    const s = computeSignals(proposal({ basis: 'default', byAgent: [agent('a', 1)] })).find(
      (x) => x.key === 'batch_size_basis',
    )!;
    expect(s.tier).toBe('action');
    /**
     * 🔴 两项都必须走 `basis.set`（**重跑算法**），⛔ 不许是 `expiry.set`：
     *   那个只改已经算好这批人的到期日，人数一个不变 —— 而本节点的 why 写着
     *   「改时限或改每天几通，这批人数都会跟着变」。用错了节点就在说谎。
     */
    expect(s.options.map((o) => o.intent)).toEqual(['basis.set', 'basis.set']);
    const byKey = Object.fromEntries(s.options.map((o) => [o.input!.argKey, o.input!]));
    expect(byKey['days']).toMatchObject({ unit: '天' });
    expect(byKey['rate']).toMatchObject({ unit: '通' });
    // 护栏必须在数据里，⛔ 别只靠界面拦
    for (const o of s.options) {
      expect(o.input!.min).toBeGreaterThanOrEqual(1);
      expect(o.input!.max).toBeGreaterThan(o.input!.min);
    }
  });

  test('⛔ 主管自己设过基数/时限 → 不出这条（他知道那两个数哪来的）', () => {
    expect(keys(proposal({ basis: 'explicit' }))).not.toContain('batch_size_basis');
  });

  /**
   * 🔴 口径那条 info 节点**已删**（2026-08-13）：档位口径只剩「距上次到诊多久」一版，
   *   没有第二种可能就没什么可交代的。⚠️ 这条测试锁的是**不许加回来**。
   */
  test('⛔ 不产口径 info 节点 —— 只有一版,说它等于说一句永远为真的话', () => {
    expect(keys(proposal({}))).not.toContain('anchor_nondefault');
  });

  test('⛔ 现在一条 info 层节点都没有 —— 卡片上的「另有 N 条说明」折叠区应当为空', () => {
    const p = proposal({ basis: 'default' });
    expect(computeSignals(p).filter((s) => s.tier === 'info')).toEqual([]);
  });

  test('🔴 每个节点的默认都是 no-op —— 「一条不点直接确认」必须永远安全', () => {
    // ⚠️ 用「候选管够」这一版:候选不够那一版从 2026-08-14 起一条节点都不出了
    const p = proposal({
      pending: [{}] as AssignmentProposal['pending'],
      candidateTotal: 5000,
      target: 405,
      basis: 'default',
      expiresInDays: 3,
      byAgent: [agent('张悦', 90)],
    });
    const s = computeSignals(p);
    expect(s.length).toBeGreaterThanOrEqual(3);
    // ⚠️ 判据:每个 defaultLabel 都在描述"不处理会怎样",⛔ 没有一个是要主管动手的
    for (const x of s) expect(x.defaultLabel).toMatch(/^不处理 = |^不/);
  });

  /**
   * ⚠️ 2026-08-16：`batch_size_basis` 的「候选够不着 N 就不出」那道闸**撤了**（产品指出：
   *   那个理由只考虑往上调，而主管同样可以往下调、少发一些）。⇒ 两种局面它都出，
   *   差别在 `why` 的措辞。下面这条改成断"两种局面都有它"。
   */
  test('🔴 ⛔ 不截断 —— 成立几条出几条，防噪音靠 tier 分层', () => {
    // 局面 A:候选不够（N 是够的，人不够）
    const short = computeSignals(
      proposal({
        pending: [{}] as AssignmentProposal['pending'],
        candidateTotal: 44,
        target: 44,
        basis: 'default',
        byAgent: [agent('张悦', 90)],
      }),
    );
    expect(short.map((x) => x.key)).toEqual(['pending', 'daily_overload', 'batch_size_basis']);
    /**
     * 🔴 候选够不着 N 时,那句话必须**据实**说清方向 —— 往上调确实不会更多。
     *   ⛔ 不许再说「改时限或改每天几通,这批人数都会跟着变」:他往上调一次没反应,
     *   下次就不信这张卡片上的任何一句话了。
     */
    const capped = short.find((x) => x.key === 'batch_size_basis')!;
    expect(capped.why).toContain('往上调不会更多');
    expect(capped.why).toContain('往下调可以少发');
    expect(capped.why).not.toContain('都会跟着变');

    // 局面 B:候选管够（瓶颈是 N）—— 这时才轮到「本批多大是怎么估的」
    const ample = computeSignals(
      proposal({
        pending: [{}] as AssignmentProposal['pending'],
        candidateTotal: 5000,
        target: 405,
        basis: 'default',
        byAgent: [agent('张悦', 90)],
      }),
    );
    expect(ample.map((x) => x.key)).toEqual(['pending', 'daily_overload', 'batch_size_basis']);

    // ⚠️ 2026-08-13：口径那条 info 已删、基数那条升 action ⇒ 现在全是 action。
    for (const s of [short, ample]) expect(s.filter((x) => x.tier === 'info')).toEqual([]);
  });

  /**
   * 🔴 三条判据都要认出「这一版是主管自己收窄来的」（2026-08-13 走查）。
   *   在此之前它们只看这一版的绝对状态，于是主管按消费切完之后：
   *     · short_supply 把他自己造成的"人少"报成故障，还递上两个会**丢掉他刚加的条件**的按钮;
   *     · batch_size_basis 在候选是瓶颈时仍然说「改时限人数会跟着变」—— 那句话是假的;
   *     · narrow 又递上同一把刀（阈值还是他那一刀造出来的），形成自我收窄的螺旋。
   */
  /**
   * ⚠️ 2026-08-14：`short_supply` 整条删了（理由见上面「候选不够 → 一条都不出」那条），
   *   所以这里从"收窄过才不出"改成**两种情况都不出**。
   *   ⛔ 别把它加回来 —— 上面那段沿革记的是它当年为什么该被关掉，
   *   现在的理由更直接：换格子是主管在矩阵上一点的事，卡片上再给按钮是弱化版。
   */
  test('🔴 「候选不足」整条已删 —— ⛔ 收窄过没有、没收窄过也没有', () => {
    const base = {
      candidateTotal: 312,
      target: 312,
      batchSize: 495,
      byAgent: [agent('张悦', 10)],
    } as Partial<AssignmentProposal>;
    expect(keys(proposal(base))).not.toContain('short_supply');
    expect(keys(proposal({ ...base, narrowedBy: { minSpendYuan: 720 } }))).not.toContain(
      'short_supply',
    );
  });

  /**
   * 🔴 2026-08-16 **反过来了**：原来这条锁的是「候选够不着 N 就不出」,
   *   理由是"改 N 往上是空操作,说会变就是说谎"。产品指出:**那只考虑了往上调** ——
   *   候选 312 / N 495 时把每天几通 15→5,N=165 < 312 ⇒ target 真的变成 165。
   *   他想少发一些,而那道闸把入口藏了。
   * ⇒ 现在**两种局面都出**,差别在 `why` 的措辞：够不着时据实说"往上不会更多、往下可以少发"。
   * ⛔ 别再用"点了没反应"当不出的理由 —— 该修的是那句话,不是把节点藏起来。
   */
  test('🔴 候选够不着 N 时照样出「本批多大」—— 他可以往下调、少发一些', () => {
    for (const c of [
      { candidateTotal: 312, target: 312, batchSize: 495 }, // 候选 < N
      { candidateTotal: 495, target: 495, batchSize: 495 }, // 候选 = N
    ]) {
      const s = computeSignals(proposal({ basis: 'default', ...c })).find(
        (x) => x.key === 'batch_size_basis',
      );
      expect({ 局面: c.candidateTotal, 出了吗: s != null }).toEqual({
        局面: c.candidateTotal,
        出了吗: true,
      });
      expect(s!.why).toContain('往下调可以少发');
    }
    // 候选管够时仍是原来那句(往上往下都真的会变)
    const ample = computeSignals(
      proposal({ basis: 'default', candidateTotal: 5000, target: 495, batchSize: 495 }),
    ).find((x) => x.key === 'batch_size_basis')!;
    expect(ample.why).toContain('都会跟着变');
  });

  test('⛔ 模型拿不到 options 的技术细节 —— why 里不许出现 intent id', () => {
    const s = computeSignals(
      proposal({ pending: [{}] as AssignmentProposal['pending'], canRefill: true }),
    );
    for (const x of s) {
      expect(x.why).not.toMatch(/pending\.|expiry\.|cohort\./);
      expect(x.title).not.toMatch(/pending\.|expiry\.|cohort\./);
    }
  });
});

/**
 * 画像亮点 —— 「这一格和同类治疗整体比，哪里不一样」。
 *
 * 🔴 判据是**相对基线的偏离**，⛔ 不是绝对占比：占比高不等于有信息
 *   （「这批 80% 自费」全池也是 80%，说了等于没说）。
 */
/**
 * 「还能再筛一刀」—— 2026-08-13 换掉了原来的「画像亮点」。
 *
 * 🔴 旧版拿倍数/百分点判断"这值不值得注意",那是在**替主管判断**;
 *   新版不比基线,直接把几刀摆出来。⇒ 这里锁的不再是"该不该亮",
 *   而是「摆出来的那几刀本身没说错话」。
 */
describe('引导节点 · 换个条件选这一格', () => {
  const facts = (over: Partial<NarrowFacts> = {}): NarrowFacts => ({
    size: 2509,
    money: { avgYuan: 720, above: 312 },
    dims: [
      { id: 'referral_champion', nameZh: '转介绍达人', options: [
        { value: 'family', zh: '家庭型', count: 8 },
        { value: 'social', zh: '社交型', count: 12 },
      ] },
      { id: 'entitlement_status', nameZh: '权益身份', options: [
        { value: 'pedo_member', zh: '儿牙会员', count: 76 },
        { value: 'high_insurance', zh: '高端保险直付', count: 38 },
      ] },
      { id: 'acquisition_channel', nameZh: '获客渠道', options: [
        { value: 'walk_in', zh: '走入', count: 804 },
        { value: 'word_of_mouth', zh: '口碑客户', count: 561 },
      ] },
    ],
    ...over,
  });
  const labels = (f: NarrowFacts) => computeNarrowSignal(f)?.options.map((o) => o.label) ?? [];

  test('⭐ 四刀 + 兜底 —— 每个选项都把「选完剩多少人」写在脸上', () => {
    expect(labels(facts())).toEqual([
      '只选消费高于这一格平均（¥720）的 312 人',
      '只选转介绍达人那 20 人',
      '只选权益身份是「儿牙会员」的 76 人',
      '只选获客渠道是「走入」的 804 人',
      '按别的条件选',
    ]);
  });

  test('🔴 转介绍两个取值**合起来**算 —— 主管切的是"有没有这个能力",不是哪一型', () => {
    const o = computeNarrowSignal(facts())!.options[1]!;
    expect(o.args?.personaTags).toBe('referral_champion:family,referral_champion:social');
    // ⛔ 文案不许写成「有过转介绍的」:这个维度的门槛是推荐≥3人且有成交
    expect(o.label).toContain('转介绍达人');
    expect(o.label).not.toContain('有过转介绍');
  });

  test('⛔ 一刀切完不够 10 人 → 这个选项不给（转介绍在真实数据里常年只有个位数）', () => {
    const f = facts({ dims: [{ id: 'referral_champion', nameZh: '转介绍达人', options: [
      { value: 'social', zh: '社交型', count: 2 },
    ] }] });
    expect(labels(f)).toEqual(['只选消费高于这一格平均（¥720）的 312 人', '按别的条件选']);
  });

  test('⛔ 候选集不到 50 人 → 整条不出（平均数和"最多的那一项"都成噪音了）', () => {
    expect(computeNarrowSignal(facts({ size: 49 }))).toBeNull();
  });

  test('⛔ 一条选项都够不着 → 不出这个节点（⛔ 别只留一个光秃秃的兜底按钮）', () => {
    expect(computeNarrowSignal(facts({ money: null, dims: [] }))).toBeNull();
  });

  test('🔴 消费那一刀带 minSpendYuan（元），⛔ 不带 personaTags —— 等值字典表达不了「大于」', () => {
    const o = computeNarrowSignal(facts())!.options[0]!;
    expect(o.args?.minSpendYuan).toBe(720);
    expect(o.args?.personaTags).toBeUndefined();
    // 给人看的那两样:中文名 + 选完剩多少(缺了就会把内部码念给主管听)
    expect(o.args?.labelZh).toContain('¥720');
    expect(o.args?.count).toBe(312);
  });

  test('⭐ 权益/渠道取**这一批里最多的那一项**（不是最偏离基线的）', () => {
    const ls = labels(facts());
    expect(ls.some((l) => l.includes('儿牙会员'))).toBe(true);
    expect(ls.some((l) => l.includes('高端保险直付'))).toBe(false);
  });

  /**
   * 🔴 主管切过一刀之后**整条不出**（2026-08-13 产品定，三版沿革见 computeNarrowSignal）。
   *   ⛔ 别改回"只排掉切过的维度"或"降级到卡片上" —— 那两版产品都看过并否了。
   */
  test('🔴 收窄过 → 整条不出（⛔ 不是排掉那一维，也不是挪到卡片）', () => {
    expect(computeNarrowSignal(facts())).not.toBeNull();
    expect(computeNarrowSignal(facts(), { minSpendYuan: 720 })).toBeNull();
    expect(computeNarrowSignal(facts(), { personaTags: 'acquisition_channel:walk_in' })).toBeNull();
    // ⚠️ 空对象 ≠ 收窄过 —— 提案里没追加条件时给的就是 `{}`／null
    expect(computeNarrowSignal(facts(), {})).not.toBeNull();
    expect(computeNarrowSignal(facts(), null)).not.toBeNull();
  });

  /**
   * 🔴 2026-08-14 走查：点完「重新排一版」，卡片上写着「已重挑这 495 人」，
   *   而它**正上方**还挂着「这一格 2509 人，也可以只选其中一类」——
   *   点下去这一版整个作废，主管刚做的动作白做（读起来顺序也是反的）。
   * ⚠️ 闸的本意是**主管已经在这一版上动过手**：收窄是动手，重挑也是动手。
   *   ⛔ 别只挡 `narrowedBy` 就以为挡全了。
   */
  test('🔴 重新排过一版 → 整条不出（跟"收窄过"同一条闸）', () => {
    expect(computeNarrowSignal(facts(), null, false)).not.toBeNull();
    expect(computeNarrowSignal(facts(), null, true)).toBeNull();
    // 两个原因**各自独立**生效，⛔ 别写成"两个都满足才关"
    expect(computeNarrowSignal(facts(), { minSpendYuan: 720 }, false)).toBeNull();
  });

  test('⭐ 归「改人群」那一组 —— ⛔ 既不跟「要主管定的」混，也不跟「改人数」混', () => {
    expect(computeNarrowSignal(facts())!.stage).toBe('cohort');
  });

  /**
   * 🔴 这条锁的是**语义**，不是措辞洁癖（2026-08-13 产品走查抓到的）：
   * 原来写「这批 2663 人还能再筛一刀」，被读成「在本批 495 人之后再筛一遍」——
   * 而它实际是**换个条件重新选初选人群**，选完本批人数会跟着重估。
   */
  test('🔴 ⛔ 不许暗示"在已排好的人里再挑" —— 它是换条件重新选一版', () => {
    const s = computeNarrowSignal(facts())!;
    expect(s.title).toContain('这一格');
    expect(s.title).not.toContain('再筛');
    expect(s.why).toContain('重新选一版');
    // 「这批」在这条链路里同时指过候选集和本批 —— 最不该含糊的就是它
    expect(s.title).not.toContain('这批');
  });

  test('🔴 进 computeSignals 后按 severity 排在待分配之后、纯信息之前', () => {
    const s = computeSignals(
      proposal({
        pending: [{}] as AssignmentProposal['pending'],
        basis: 'default',
        byAgent: [agent('张悦', 10)],
      }),
      [computeNarrowSignal(facts())!],
    );
    expect(s.map((x) => x.key)).toEqual(['pending', 'narrow', 'batch_size_basis']);
  });
});

/**
 * 给模型的事实投影 —— P2 的核心不变式。
 *
 * 🔴 这里锁的是「**返回值里一个字的指令都不许有**」：
 * 成品句子有固定形状，模型学会形状就能凭空背出来（实测背出过「已撤销批次：收回 9 条」，
 * 而撤销工具一次都没调）。结构化事实编不出来 —— 但前提是我们别又把话写回去。
 */
describe('给模型的事实', () => {
  const base = () =>
    proposal({
      candidateTotal: 1000,
      target: 405,
      batchSize: 405,
      placed: 405,
      basis: 'default',
      expiresInDays: 3,
      byAgent: [agent('张悦', 45, 10), agent('李莉', 45)],
      skippedAgents: [{ userId: 'x', name: '王强', inHand: 80 }],
    });

  /**
   * 🔴 2026-08-14 由**三段**改成**两段**：「要主管定的」从顶层搬进「怎么派的」。
   *
   * 键序就是段序，而它属于**派活**。`propose_assignment` 的描述里写了讲述顺序
   * （人对不对 → 分完什么样 → 可动手的就近开放）；顶层挂着第三个键时，
   * 模型只会把它讲成第四段，跟描述打架。
   * ⇒ 每个可动手的东西都嵌在它要改的那一段里（改人群/改人数同理，在「怎么选的」里）。
   */
  test('🔴 顶层两段且顺序固定：怎么选的 → 怎么派的（⛔ 引导不再单独成段）', () => {
    expect(Object.keys(modelFacts(base()))).toEqual(['怎么选的', '怎么派的']);
  });

  /**
   * ⚠️ 2026-08-17 起这条边界有**一处具名例外**：每条引导上的 `开放方式`
   *   （`show_guidance` 的调用纪律，沿革与破例理由见 `assignment-facts.ts` 的 `GUIDANCE_HOWTO`）。
   * ⇒ 例外放行的是"一句指令"，⛔ 不是把禁词表撕了 —— 下面这几个字面**照旧全域禁**，
   *   连那句纪律自己也得守（另有一条测试单独盯着它）。
   */
  test('🔴 ⛔ 里面不许有指令性措辞（`开放方式` 那一句也不例外）', () => {
    const json = JSON.stringify(modelFacts(base()));
    for (const w of ['照抄', '原样', '不要说', '绝不', '⛔', '请把', '转述', '必须说']) {
      expect(json).not.toContain(w);
    }
  });

  test('🔴 ⛔ 不含 planId / 患者姓名（那些走侧信道给界面）', () => {
    const p = base();
    p.items = [{ planId: 'plan-uuid-1', patientName: '张三' }] as never;
    expect(JSON.stringify(modelFacts(p))).not.toContain('plan-uuid-1');
    expect(JSON.stringify(modelFacts(p))).not.toContain('张三');
  });

  test('⭐ 候选不够时，本批人数与「本来能发多少」**分别给** —— 否则主管以为系统偷偷改小了他的设置', () => {
    const f = modelFacts(proposal({ candidateTotal: 44, target: 44, batchSize: 405 })) as {
      怎么选的: Record<string, unknown>;
    };
    expect(f.怎么选的['本批人数']).toBe(44);
    expect(f.怎么选的['人够的话本来能发']).toBe(405);
  });

  /**
   * 🔴 **键名本身就会被念出去** —— 2026-08-14 实测原话：
   *   「这一格总共就 3 人，比估算的**基数**（120）少得多」。
   * 当时这个键叫 `您设的基数`：① 「基数」就列在提示词的禁说清单里，
   * ② 那 120 是系统按「在岗 × 每天几通 × 时限」算的，主管一个数都没设过。
   * ⇒ 禁令拦不住紧挨着数字的**键名**;键名必须自己说人话。
   */
  test('🔴 事实里不许出现「基数」「您设的」这类词（键名会被原样念给主管）', () => {
    const json = JSON.stringify(modelFacts(proposal({ candidateTotal: 44, target: 44, batchSize: 405 })));
    expect(json).not.toContain('基数');
    expect(json).not.toContain('您设的');
  });

  /**
   * 🔴 首次默认那个数是**机器算的**（在岗 × 每天几通 × 时限），主管没设过 ——
   *   给了他就要听一句「估的基数是 45，但这格只有 5 人」（2026-08-14 实测原话）。
   * ⇒ 没有"设置被改小"这回事可防时，这个数就不进模型上下文。
   */
  test('🔴 首次默认的基数**不给模型** —— 那不是他设的，给了只会被解释一遍', () => {
    const f = modelFacts(
      proposal({ candidateTotal: 5, target: 5, batchSize: 45, basis: 'default' }),
    ) as { 怎么选的: Record<string, unknown> };
    expect(f.怎么选的['人够的话本来能发']).toBeUndefined();
  });

  /**
   * 🔴 2026-08-15 实测事故（上海世纪公园）：这句话里的「在岗 N 人」印的是
   *   `byAgent.length` —— **本批分到人的客服数**，于是印出「在岗 **9** 人 × 每天 15 通 × 1 天」，
   *   而主管同一屏「团队现在什么状态」写着 **33 位在岗**。
   *   9×15×1 = 135，连紧挨着的「本批人数 214」都算不出来（真实是 33×15×1=495 被候选压到 214）。
   * ⚠️ 一并锁住**式子要带自己的积**：不给 495，模型手里没有任何能让这句话自洽的数。
   */
  test('🔴「在岗 N 人」取名册人数，⛔ 不是本批分到人的客服数；且式子带自己的积', () => {
    const f = modelFacts(
      proposal({
        basis: 'default',
        rosterCount: 33,
        candidateTotal: 214,
        target: 214,
        batchSize: 495,
        expiresInDays: 1,
        byAgent: [agent('张悦', 7), agent('李莉', 7)],
      }),
    ) as { 怎么选的: Record<string, unknown> };
    const s = String(f.怎么选的['本批人数怎么来的']);
    expect(s).toContain('在岗 33 人');
    expect(s).not.toContain('在岗 2 人');
    expect(s).toContain('= 495 人');
  });

  test('⭐ 沿用/他指定的基数照给（"我上次说 200，这次怎么只有 44"是真问题）', () => {
    const f = modelFacts(
      proposal({ candidateTotal: 44, target: 44, batchSize: 200, basis: 'inherited' }),
    ) as { 怎么选的: Record<string, unknown> };
    expect(f.怎么选的['人够的话本来能发']).toBe(200);
  });

  test('⭐ 候选够时不给「人够的话本来能发」（没有歧义就别多一个数）', () => {
    const f = modelFacts(base()) as { 怎么选的: Record<string, unknown> };
    expect(f.怎么选的['人够的话本来能发']).toBeUndefined();
  });

  test('⭐ 取值码换成中文，⛔ 模型手里不该有 implant / hot 这种词', () => {
    const f = modelFacts(base()) as { 怎么选的: Record<string, unknown> };
    expect(f.怎么选的['治疗项']).not.toBe('implant');
    expect(f.怎么选的['时间档']).not.toBe('hot');
  });

  /**
   * 🔴 2026-08-14 走查：facts 里给的是 `"时间档": "3 年以上"` ——
   *   **「3 年以上」自己说不清是什么的 3 年**。界面上不缺这半句（列头上方就写着
   *   按什么分档，位置本身是上下文）；JSON 里没有位置，模型拿到的是个孤零零的字符串。
   * ⚠️ 修法是把**值**补全到自足，⛔ 不是另加一个「口径」键 ——
   *   只剩末诊一版锚点，单独一个口径字段读起来像"还有别的口径"。
   */
  test('🔴 时间档必须自己说得清是什么的 3 年，⛔ 但不许另开「口径」键', () => {
    const f = modelFacts(base()) as { 怎么选的: Record<string, unknown> };
    expect(String(f.怎么选的['时间档'])).toContain(TEMPERATURE_SINCE_ZH);
    expect(f.怎么选的['口径']).toBeUndefined();
  });

  test('⭐ 要你定的只给 action 层，且不含 intent 技术细节', () => {
    const p = base();
    p.signals = computeSignals(p);
    const f = modelFacts(p) as { 怎么派的: { 要你定的: Array<Record<string, unknown>> } };
    expect(f.怎么派的.要你定的.length).toBeGreaterThan(0);
    for (const x of f.怎么派的.要你定的) {
      // ⚠️ `开放方式` 紧跟标识 —— 2026-08-17 加的调用纪律，沿革见 `GUIDANCE_HOWTO`
      expect(Object.keys(x)).toEqual(['标识', '开放方式', '是什么', '为什么', '不处理会怎样']);
    }
    // ⛔ intent 技术细节不许进 —— 模型说出 `pending.to_owner` 只会吓到主管
    expect(JSON.stringify(f.怎么派的.要你定的)).not.toMatch(/pending\.|expiry\.|cohort\./);
  });

  /**
   * 🔴 2026-08-14 由**序号**改成**中文标识**（沿革见 `GUIDANCE_KIND`）。
   *
   * 序号那版的契约是「编号 = action 层里的第几个」，两边（服务端投影 / 前端还原）
   *   必须用同一个过滤顺序，错了会把按钮插到**另一条**下面且不报错。
   * ⇒ 现在服务端一边解析、一边用（侧信道直接推 `signalKey`），前端不再还原；
   *   模型手里只有中文标识。这条测试锁的是：
   *     ① 每条都有标识  ② 标识两两不同（否则 show_guidance 分不清）
   *     ③ ⛔ 内部 key 不许出现在模型看得到的地方
   */
  test('⭐⭐ 每条引导都有**唯一**的中文标识 —— show_guidance 靠它认人', () => {
    const p = base();
    p.pending = [{}] as AssignmentProposal['pending'];
    p.basis = 'default';
    p.signals = computeSignals(p, [
      { key: 'narrow', severity: 3, tier: 'action', stage: 'cohort',
        title: '还能再筛一刀', why: '', defaultLabel: '', options: [] },
    ]);
    const action = p.signals.filter((s) => s.tier === 'action');
    const f = modelFacts(p) as {
      怎么选的: {
        这一格还能怎么选?: Array<Record<string, unknown>>;
        本批人数还能怎么调?: Array<Record<string, unknown>>;
      };
      怎么派的: { 要你定的?: Array<Record<string, unknown>> };
    };
    const all = [
      ...(f.怎么选的.这一格还能怎么选 ?? []),
      ...(f.怎么选的.本批人数还能怎么调 ?? []),
      ...(f.怎么派的.要你定的 ?? []),
    ];
    // 三组加起来 = 全部 action 条目,⛔ 一条都不许漏
    expect(all.length).toBe(action.length);
    const ids = all.map((x) => x['标识'] as string);
    expect(ids.every((s) => typeof s === 'string' && s.length > 0)).toBe(true);
    expect(new Set(ids).size).toBe(ids.length);
    // ⛔ 内部 key 不许出现（中文标识即使被念出口也是一句正常的话，key 不是）
    for (const k of action.map((s) => s.key)) expect(ids).not.toContain(k);
  });

  test('⭐⭐ 一个键只说一件事：改人群 / 改人数 / 改怎么派，⛔ 不许两组挤一个标题', () => {
    const p = base();
    p.pending = [{}] as AssignmentProposal['pending'];
    p.basis = 'default';
    /**
     * ⚠️ `short_supply`(cohort) 与 `batch_size_basis`(size) 现在**互斥**
     *   （前者要候选 < N，后者要候选 > N）。要凑齐三组，cohort 那一组只能来自
     *   `narrow` —— 它本来就是查库算的，这里当 `extra` 传进去。
     */
    p.signals = computeSignals(p, [
      {
        key: 'narrow',
        severity: 3,
        tier: 'action',
        stage: 'cohort',
        title: '这一格 1000 人，也可以只选其中一类',
        why: '',
        defaultLabel: '不处理 = 就按全部来',
        options: [],
      },
    ]);
    const f = modelFacts(p) as {
      怎么选的: {
        这一格还能怎么选?: Array<Record<string, unknown>>;
        本批人数还能怎么调?: Array<Record<string, unknown>>;
      };
      怎么派的: { 要你定的?: Array<Record<string, unknown>> };
    };
    // ⚠️ 断的是**分组本身**，⛔ 不写死哪几条：以后加节点时这条测试不该跟着改，
    //   它要盯的是「stage 怎么标，事实就怎么分」这个契约。
    const titles = (stage: 'cohort' | 'size' | 'dispatch') =>
      p.signals.filter((s) => s.tier === 'action' && s.stage === stage).map((s) => s.title);
    expect(f.怎么选的.这一格还能怎么选?.map((x) => x['是什么'])).toEqual(titles('cohort'));
    expect(f.怎么选的.本批人数还能怎么调?.map((x) => x['是什么'])).toEqual(titles('size'));
    // 🔴 派活那一组 2026-08-14 从顶层搬进「怎么派的」—— 就近，跟另外两组一致
    expect(f.怎么派的.要你定的?.map((x) => x['是什么'])).toEqual(titles('dispatch'));
    // 🔴 改人数那条**⛔ 不许**混进选人那一组（走查抓到过「还能怎么选：」下面挂着改时限）
    expect(f.怎么选的.这一格还能怎么选 ?? []).not.toContainEqual(
      expect.objectContaining({ 是什么: expect.stringContaining('每天') }),
    );
    // 这份 fixture 三组都得非空,否则这条测试等于什么都没测
    for (const st of ['cohort', 'size', 'dispatch'] as const)
      expect(titles(st).length).toBeGreaterThan(0);
  });

  /**
   * 🔴 每条引导上的 `开放方式` —— 事实层**唯一一处指令**（2026-08-17 产品实测定的）。
   *   沿革、破例理由、撤销条件都写在 `assignment-facts.ts` 的 `GUIDANCE_HOWTO` 上。
   *
   * ⚠️ 这几条锁的是**破例的边界**，⛔ 不是"这句话写得好不好"：
   *   ① 挂在**每一条**上（纪律的作用域就是一条；挂在组上时一组里的两条仍会被攒着）；
   *   ② 位置**紧跟标识**（产品实测有效的那一版就是粘在标识后面的，换位置 = 换了个没测过的东西）；
   *   ③ 措辞与 `show_guidance` 描述同源，且⛔ 不含工具名；
   *   ④ 例外只放行"一句指令"，禁词字面照旧不许出现。
   */
  describe('每条引导上的「开放方式」', () => {
    const items = () => {
      const p = base();
      p.pending = [{}] as AssignmentProposal['pending'];
      p.signals = computeSignals(p, [
        {
          key: 'narrow',
          severity: 3,
          tier: 'action',
          stage: 'cohort',
          title: '这一格 1000 人，也可以只选其中一类',
          why: '',
          defaultLabel: '',
          options: [],
        },
      ]);
      const f = modelFacts(p) as {
        怎么选的: Record<string, unknown>;
        怎么派的: Record<string, unknown>;
      };
      return [
        ...((f.怎么选的['这一格还能怎么选'] ?? []) as Array<Record<string, unknown>>),
        ...((f.怎么选的['本批人数还能怎么调'] ?? []) as Array<Record<string, unknown>>),
        ...((f.怎么派的['要你定的'] ?? []) as Array<Record<string, unknown>>),
      ];
    };

    test('🔴 每一条都带，且**紧跟标识** —— ⛔ 不是每组一份', () => {
      const xs = items();
      expect(xs.length).toBeGreaterThanOrEqual(3); // 三组都得有，否则这条等于没测
      for (const x of xs) {
        expect(Object.keys(x).slice(0, 2)).toEqual(['标识', '开放方式']);
      }
      // ⚠️ 每条都是**同一句** —— ⛔ 别让谁按 stage 改写出第二个版本
      expect(new Set(xs.map((x) => x['开放方式'])).size).toBe(1);
    });

    test('🔴 例外只放行「一句指令」，⛔ 禁词字面照旧管着它', () => {
      const s = String(items()[0]!['开放方式']);
      expect(s.length).toBeGreaterThan(0);
      for (const w of ['照抄', '原样', '不要说', '绝不', '⛔', '请把', '转述', '必须说']) {
        expect(s).not.toContain(w);
      }
    });

    test('⭐ 措辞与 show_guidance 的描述同源，且⛔ 不含工具名', () => {
      const s = String(items()[0]!['开放方式']);
      expect(s).toContain('开放'); // 工具描述的原词：「把他接下来可以动手的事**开放**给他」
      expect(s).toContain('一类'); // 工具描述：「讲完其中**一类**…再讲下**一类**」
      // ⛔ 代码标识符不进事实层 —— 进来早晚被念给主管听（沿革见 GUIDANCE_KIND）
      expect(s).not.toContain('show_guidance');
    });
  });
});

/**
 * 🔴 2026-08-15 实测：「换无专属客服的患者补上」这颗按钮**没说从哪儿补**，
 *   而主管很可能已经收窄过几遍 —— 从他收窄后的那批补，和退回整格重取，差着几千人。
 * ⚠️ 同日还修掉了链路上的漏：前端只回传矩阵两轴，`narrowedBy` 整个没带，
 *   服务端于是真的退回整格 —— 所以这条断言锁的不只是文案，是"文案说的那件事成立"。
 */
describe('引导节点 · 待分配「补上」的取人范围要写在脸上', () => {
  const withPending = (narrowedBy: AssignmentProposal['narrowedBy']) =>
    computeSignals(
      proposal({
        canRefill: true,
        narrowedBy,
        pending: [{}] as AssignmentProposal['pending'],
      }),
    ).find((s) => s.key === 'pending')!;

  const refillLabel = (s: { options?: Array<{ label: string }> }) =>
    (s.options ?? []).find((o) => o.label.includes('补上'))?.label ?? '';

  test('⭐ 他没收窄过 —— 说清是这一格的人', () => {
    expect(refillLabel(withPending(null))).toContain('这一格');
  });

  test('🔴 他收窄过 —— ⛔ 不许再说"这一格"，那会让他以为圈掉的人又回来了', () => {
    const label = refillLabel(withPending({ minSpendYuan: 721 }));
    expect(label).toContain('收窄');
    expect(label).not.toContain('这一格');
  });
});

/**
 * 🔴 2026-08-15 实测：主管问「那 1 位为什么没轮到」，模型答不上来 ——
 *   facts 里只有「本批没分到的客服: 1」，姓名和在手量都没投影出来
 *   （卡片上写着「本批未分到：逯梓茜(在手 193)」，而模型看不见卡片）。
 * ⇒ 补一个在手量：答案就在它与「分完之后每人手上」的对比里
 *   （没分到的 193，分到的分完才 49 —— 水位法一直没轮到他）。
 */
describe('没分到的那几位 —— 「为什么没轮到」要答得上来', () => {
  const facts = (skipped: Array<{ inHand: number }>) =>
    (
      modelFacts(
        proposal({
          byAgent: [agent('张悦', 9, 40)],
          skippedAgents: skipped.map((s, i) => ({
            userId: `u${i}`,
            name: `某${i}`,
            inHand: s.inHand,
          })),
        }),
      ) as { 怎么派的: Record<string, unknown> }
    ).怎么派的;

  test('🔴 有人没分到 → 必须一起给出他们手上有多少', () => {
    const f = facts([{ inHand: 193 }]);
    expect(f['本批没分到的客服']).toBe(1);
    // 单人时区间塌成一个数;与「分完之后每人手上 49」并排，差距一眼看得出
    expect(f['他们现在手上有']).toBe(193);
    expect(f['分完之后每人手上']).toBe(49);
  });

  test('⭐ 多人只给区间 —— ⛔ 不逐人列名（实测有过 24 位，列出来是一屏噪音）', () => {
    expect(facts([{ inHand: 41 }, { inHand: 193 }, { inHand: 88 }])['他们现在手上有']).toEqual({
      最少: 41,
      最多: 193,
    });
  });

  test('⭐ 一个不落时不给这个键（没有主语就别开口）', () => {
    expect(facts([])).not.toHaveProperty('他们现在手上有');
  });
});

/**
 * 🔴 「最忙的那位」要报**一共几位超时限**(2026-08-16 产品定)。
 *   只报最忙一位,主管分不出"个别人忙"和"全队都忙" —— 而这两种局面的处置相反:
 *   前者该给他少分点/改派,后者该减量/延时限。而本节点唯一的选项是「整批时限改成 N 天」,
 *   碰上前者它本身就是错的。
 */
describe('引导节点 · 最忙的那位要说清是一个人还是一片', () => {
  const over = (name: string, loadAfter: number) => ({
    userId: name,
    name,
    count: 5,
    inHandBefore: loadAfter - 5,
    loadAfter,
  });

  test('🔴 多人超时限 → 报「另有 N 位」,并把他引到确认单看逐位明细', () => {
    // 时限 1 天 × 每天 15 通 = 15 条封顶;三个人都超
    const s = computeSignals(
      proposal({
        expiresInDays: 1,
        byAgent: [over('王强', 45), over('李莉', 30), over('赵敏', 20), over('周涛', 9)],
      }),
    ).find((x) => x.key === 'daily_overload')!;
    expect(s.title).toContain('王强'); // 最忙的那位仍然点名
    expect(s.why).toContain('另有 2 位也超过 1 天'); // 3 位超 → 除最忙外还有 2 位
    expect(s.why).toContain('确认单上逐位都写着');
  });

  test('⛔ 只有一个人超 → 那半句不出现(不制造无谓噪音)', () => {
    const s = computeSignals(
      proposal({ expiresInDays: 1, byAgent: [over('王强', 45), over('周涛', 9)] }),
    ).find((x) => x.key === 'daily_overload')!;
    expect(s.why).not.toContain('另有');
  });
});
