import { readFileSync } from 'node:fs';
import type { AssignmentProposal } from '@pac/types';
import { modelFacts } from '../src/modules/plan/assignment-facts';
import { join } from 'node:path';
import { TEMPERATURE_TOOL_DESC } from '@pac/types';
import {
  PERSONA_TAGS_DESC,
  POTENTIAL_TREATMENT_DESC,
} from '../src/modules/mcp/persona-tags.desc';

/**
 * 「模型自己编了个诊所 id,于是整批人凭空消失」的回归(2026-08-06 实测)。
 *
 * ── 事故经过 ──────────────────────────────────────────────────────
 * 主管点了矩阵的「充填 · 2–3 年」(138 人),接着说「我只需要男性患者」。
 * 助手回:「这个格子当前是空的(0 人)」,还给了两条像模像样的原因
 * (「这档人群本身就少」「画像里没有性别标记」),然后建议换档位。
 *
 * 真因:`get_cohort_attributes` 的 `clinicId` 是 `z.string()` **必填、无兜底**。
 * 模型手里没有真 id 又必须填,于是编了 `"CL001"`。SQL 完全正确,
 * 只是 `target_clinic_id = 'CL001'` 一条也匹配不上 → cohortSize=0。
 *
 * 🔴 **最坏的地方不是查不到,是"查不到"看起来像个结论**:
 *   0 是一个合法返回值,模型拿到 0 就去解释"为什么这批人是空的",越解释越可信。
 *   全程零报错,而主管刚在矩阵上看过 138。这正是 T14「口径对数」要防的形状。
 *
 * 这里锁三件事(都做错了不会报错):
 *   ① 不传 clinicId → 用登录人的第一个诊所,⛔ 不许要求模型必填
 *   ② 传了范围外的 id → **抛错**,⛔ 绝不能当成"这个诊所没人"返回 0
 *   ③ 收 personaTags 的工具必须挂全维度字典 —— 猜错的维度会被静默丢掉
 */

const FACTORY = readFileSync(
  join(__dirname, '../src/modules/mcp/mcp-server.factory.ts'),
  'utf8',
);

describe('MCP 诊所 id —— 不许模型自己编', () => {
  test('⭐⭐ 所有收 clinicId 的工具都走 CLINIC_ID_SCHEMA(可选 + 明说别编)', () => {
    // ⛔ 必填的 z.string() 会逼模型编一个出来 —— 这正是事故的成因
    expect(FACTORY).not.toMatch(/clinicId:\s*z\.string\(\)(?!\s*\.optional)/);
    expect(FACTORY).toMatch(/const CLINIC_ID_SCHEMA[\s\S]*?\.optional\(\)/);
  });

  test('⭐⭐ 每一处 clinicId 落到查询前都过 resolveClinicId(范围外要抛错,不是返回 0)', () => {
    // 出现几次 clinicId 作为查询入参,就该有几次 resolveClinicId
    const guarded = FACTORY.match(/resolveClinicId\(scope,/g) ?? [];
    expect(guarded.length).toBeGreaterThanOrEqual(4);
  });

  /**
   * 🔴🔴 **闸只能有一份实现,且 REST 与 MCP 都要走它**(2026-08-10 测试机实测的越权)。
   *
   * 原来 MCP 里抄了一份 `resolveClinicId`。抄一份的直接后果是**补的时候只补了一边**:
   * 北京朝阳公园的主管带着杭州大厦的 id 请求 `/plans/assignments/workload`,
   * 拿到了 **200 + 那家 26 位客服的姓名与负载**;`/agents`、`/plans/matrix` 同样敞着。
   *
   * ⚠️ 触发它的不是攻击,是**前端第一帧**(session 没回来时回落到"全部诊所")——
   *   前端也修了,但 ⛔ 查询串是用户可改的,服务端这道闸不能省。
   */
  const GUARD = readFileSync(
    join(__dirname, '../src/common/decorators/resolve-clinic-id.ts'),
    'utf8',
  );

  test('🔴🔴 闸只有一份实现 —— MCP 不许再抄一份', () => {
    // 范围外必须**抛错**并列出真实 id —— 静默返回 0 是这个 bug 的全部危害
    expect(GUARD).toMatch(/不在当前登录人的数据范围内/);
    expect(GUARD).toMatch(/scope\.clinicIds\.join/);
    expect(GUARD).toMatch(/ForbiddenException/);
    // 集团级(clinicIds 为空)无从校验,必须放行 —— ⛔ 别把空数组当成"没权限"
    expect(GUARD).toMatch(/scope\.clinicIds\.length &&/);
    // ⛔ MCP 只许 import,不许自己再定义一个
    expect(FACTORY).toMatch(/import \{ resolveClinicId \} from/);
    expect(FACTORY).not.toMatch(/function resolveClinicId/);
  });

  test.each([
    ['assignment.controller.ts', 'agents / workload'],
    ['plan.controller.ts', 'matrix'],
  ])('🔴🔴 REST 读接口 %s(%s)也必须过闸', (file) => {
    const src = readFileSync(join(__dirname, `../src/modules/plan/${file}`), 'utf8');
    // 每个收 clinicId 查询参数的读接口,都要有一次 resolveClinicId
    const takes = (src.match(/@Query\('clinicId'\)/g) ?? []).length;
    const guards = (src.match(/resolveClinicId\(scope,/g) ?? []).length;
    expect(takes).toBeGreaterThan(0);
    expect(guards).toBeGreaterThanOrEqual(takes);
    // ⛔ 不许再把裸 clinicId 直接丢进 service(那正是漏的写法)
    expect(src).not.toMatch(/\.list\(scope, clinicId,/);
    expect(src).not.toMatch(/\.workload\(scope, clinicId,/);
    expect(src).not.toMatch(/\.matrix\(scope, clinicId\)/);
  });

  /**
   * 🔴🔴 **clinicId 也可能走 body** —— 同一天补完三个 query 接口后**又漏了这条**。
   *
   * `POST /plans/assignments/propose/refill` 的 clinicId 在请求体里,
   * 按 `@Query('clinicId')` 去数根本数不到。实测:朝阳公园的主管带杭州大厦的 id
   * POST 过来,拿回了**那家诊所患者的真实姓名** —— 比名册更重。
   *
   * ⇒ 提案是**唯一**会吐患者名单的读路径,闸钉在 **service** 里(谁调都拦得住),
   *   ⛔ 不指望每个 controller 记得加 —— 这条测试就是那个"记得"的替代品。
   */
  test('🔴🔴 会吐患者名单的提案路径,闸必须在 service 里(body 传的 clinicId 也要拦)', () => {
    const src = readFileSync(
      join(__dirname, '../src/modules/plan/assignment-proposal.service.ts'),
      'utf8',
    );
    expect(src).toMatch(/const clinicId = resolveClinicId\(scope, input\.clinicId\)/);
    // ⭐ 中间表(cohortPage)与提案同性质 —— 同样会吐患者姓名,闸也必须在 service 里
    expect(src).toMatch(/cohortPage\([\s\S]{0,1200}?resolveClinicId\(scope, input\.clinicId\)/);
    // ⛔ 不许再从 input 里直接解构出来用
    expect(src).not.toMatch(/const \{ clinicId, potentialTreatment \} = input/);
  });

  test('CLINIC_ID_SCHEMA 的说明里必须写明「不知道就不要传」', () => {
    expect(FACTORY).toMatch(/不知道就不要传/);
    expect(FACTORY).toMatch(/get_current_user 返回的 clinicIds/);
  });

  /**
   * 🔴 **产品内的助手已经完全看不到 clinicId 这一格**(2026-08-14)——
   * `AssistantService` 包 MCP 工具时把它摘掉,并注入主管正看着那家(`stripClinicId`)。
   *
   * 上面那条("不知道就不要传")是**说服**模型别编,这条是**让它没得编**:
   * 同一个坑的 ③ 提示词层与 ① 结构层,两条都要在。
   *
   * ⚠️ 这里锁的是**两个名字**:注入按工具名走(同一个 `clinicId` 在两类工具里意思不同 ——
   *   `get_agents` 不传 = 回落第一家,`list_recall_queue` 不传 = **不按诊所过滤**),
   *   而工具名改掉、或 clinicId 换成别的 schema,注入都会**静默失效**:
   *   模型照样看不到这一格,服务端也不再补,于是永远回落第一家 ——
   *   多诊所主管在第二家问"这批人构成",拿到的是第一家的数,零报错。
   */
  test('⭐⭐ 服务端注入的那两个工具:名字与 CLINIC_ID_SCHEMA 的绑定不许漂', () => {
    const ASSISTANT = readFileSync(
      join(__dirname, '../src/modules/assistant/assistant.service.ts'),
      'utf8',
    );
    const decl = ASSISTANT.match(/const CLINIC_SCOPED_TOOLS = new Set\(\[([\s\S]*?)\]\)/);
    expect(decl).not.toBeNull();
    const names = [...(decl?.[1] ?? '').matchAll(/'([^']+)'/g)].map((m) => m[1]).sort();
    expect(names).toEqual(['get_agents', 'get_cohort_attributes']);
    for (const name of names) {
      const at = FACTORY.indexOf(`'${name}',`);
      expect(at).toBeGreaterThan(0);
      expect(FACTORY.slice(at, at + 3000)).toContain('clinicId: CLINIC_ID_SCHEMA');
    }
    // ⛔ 「不传 = 不过滤」的那两个绝不能进来:注入会凭空加一道筛子,而少掉的部分没有提示
    expect(names).not.toContain('list_recall_queue');
    expect(names).not.toContain('recall_queue_stats');
  });
});

describe('personaTags 维度字典 —— 猜错会被静默丢掉,所以必须给全', () => {
  test('⭐ 字典里有性别维度(事故当天主管要的就是它)', () => {
    expect(PERSONA_TAGS_DESC).toMatch(/gender\(性别\)/);
    expect(PERSONA_TAGS_DESC).toContain('male=男');
    expect(PERSONA_TAGS_DESC).toContain('female=女');
  });

  test('⭐⭐ 必须警告「写别的会被静默忽略」—— 那是 personaTagsSql 的真实行为', () => {
    // cohort-filter.personaTagsSql:`if (!dim) continue` —— 认不出的维度直接跳过,
    // 筛选等于没加,人数一个不少地返回,不报错。
    expect(PERSONA_TAGS_DESC).toMatch(/静默忽略/);
  });

  test('⛔ 工具定义里不许再内联第二份简写字典', () => {
    // 只允许从 persona-tags.desc 引入;工厂里不该再有一份 PERSONA_TAG_FILTER_DIMS.map
    expect(FACTORY).not.toMatch(/PERSONA_TAG_FILTER_DIMS\.map/);
    expect(FACTORY).toMatch(/from '\.\/persona-tags\.desc'/);
  });
});

/**
 * 「助手对主管念代码」的回归(2026-08-06 产品走查)。
 *
 * 实测原话:
 *   ·「「2–3 年」对应的是 `cold_3y` 档,该档目前整体为空。」
 *   ·「放宽温度:改成「充填 · 窗口外全体」( `cold` )或「充填 · 1–2 年」( `cold_2y` )」
 *   ·「换病种:比如「种植 · 2–3 年」…」
 *
 * 主管是门诊经理:界面上**没有** `cold_3y`,**没有**「温度」这个词,那一行也不叫「病种」。
 * 他看到不认识的英文词,第一反应是系统坏了 —— 要么不信这个数,要么回头来问,
 * 两种都是白多出来的一轮。
 *
 * ⚠️ 修法是**两件事一起做**,少一件都不行:
 *   ① 参数说明里必须给全 code↔中文 的对照表 —— 不给,模型没得翻,只能原样念码;
 *   ② 必须有一条硬约束写明"码只用于调工具,不许说出口" —— 只给表不给约束,它照念不误。
 */
describe('助手措辞 —— 码给工具,中文给人', () => {
  const PROMPTS = readFileSync(
    join(__dirname, '../src/modules/assistant/assistant-prompts.ts'),
    'utf8',
  );

  test('⭐⭐ 温度说明:既有中文对照表,又明令不许把码说出口', () => {
    expect(TEMPERATURE_TOOL_DESC).toContain('2–3 年=cold_3y'); // ① 有得翻
    expect(TEMPERATURE_TOOL_DESC).toMatch(/不许说给主管/); // ② 有约束
    expect(TEMPERATURE_TOOL_DESC).toMatch(/不要说「温度」这个词/);
  });

  test('⭐⭐ 潜在治疗说明:同样两件事都要有', () => {
    expect(POTENTIAL_TREATMENT_DESC).toContain('充填=filling');
    expect(POTENTIAL_TREATMENT_DESC).toMatch(/不许说给主管/);
    // ⛔ 「病种」是模型自己发明的叫法,界面上叫「潜在治疗」
    expect(POTENTIAL_TREATMENT_DESC).toMatch(/病种/);
  });

  test('⭐⭐ 主管工作流里有一条独立的「说人话」硬约束,且点名了实际漏出来的那些词', () => {
    expect(PROMPTS).toMatch(/说人话/);
    /**
     * 🔴🔴 **2026-08-14 改判：那份举例从公共层删了，因为它是分配这一条线的词汇。**
     *
     * 原断言要求 base 里点名 `cold_3y` / `personaTags` / `get_cohort_attributes` / 温度。
     * 两个问题：
     *   ① **污染公共层** —— base 是主管和客服共用的，而 `get_cohort_attributes`
     *      是**主管专属工具**（条件注册）：客服那条链路根本看不到它，
     *      却要读一句"别说 get_cohort_attributes"。分配只是将来若干条线里的一条。
     *   ② **本来就是第二份** —— 「这些取值码只用于调工具，一个字都不许说给主管」
     *      在 `TEMPERATURE_TOOL_DESC` 和 `PERSONA_TAGS_DESC` 里各有一份，
     *      挂在**产生这些码的那一格参数上**，模型读到码的同一眼就读到怎么说。
     * ⇒ 公共层只留**不变的规则**（取值码 / 字段名 / 工具名 / 只在代码里存在的概念
     *   都不在他的词汇表里），举例跟着各自的业务线走。
     * ⚠️ 将来哪个词又漏出来，补到**那条线自己的描述**里，⛔ 别再补回公共层。
     */
    // ⚠️ 只看**递给模型**的那部分:注释里怎么写都行(那是给人看的沿革)
    const PROMPT_TEXT = (
      PROMPTS.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '')
    );
    expect(PROMPT_TEXT).toMatch(/取值码、字段名、工具名/);
    expect(PROMPT_TEXT).toMatch(/只在代码里存在的概念/);
    for (const leaked of ['cold_3y', 'personaTags', 'get_cohort_attributes']) {
      expect(PROMPT_TEXT).not.toContain(leaked);
    }
    // 举例活在各自那一格参数上 —— 这两条是它们的新落点
    expect(TEMPERATURE_TOOL_DESC).toMatch(/不许说给主管/);
    expect(PERSONA_TAGS_DESC).toMatch(/不许说给主管/);
    /**
     * 🔴 2026-08-14:替代说法（「往后放一档」）从提示词里**删了** —— 它是第三份。
     *   完整那份挂在 `temperature` 参数上（`TEMPERATURE_TOOL_DESC`），
     *   那才是它该在的地方：取值码就在那一格里，模型读到码的同一眼就读到怎么说。
     * ⇒ 光禁止不够、得给出替代说法这条**没变**，只是断到活着的那一份上。
     */
    expect(TEMPERATURE_TOOL_DESC).toMatch(/往后放一档/);
    expect(PROMPTS).not.toMatch(/往后放一档/);
  });

  /**
   * 取值码不分角色地漏 ⇒ 客服那侧同样要有这条约束。
   *
   * 🔴 2026-08-13 改断言：此前主管层和客服层**各写了一份**,而两份已经漂了
   *   (主管版列了六个内部词,客服版只列两个)。⇒ 整条升进 base,角色层一份不留。
   *   所以这里锁的不再是"客服层也有",而是**只有一份、且在 base**。
   */
  /**
   * 🔴 提示词**自己**不许用禁词表里的词 —— 模型模仿的是你实际怎么写,不是你列了多少禁词。
   *
   * 2026-08-13 产品走查抓到两处：
   *   「他在卡片上确认,这一下才**写库**」    —— 只在代码里存在的词,主管不认识
   *   「封顶在目标**水位**」「**水位法**…」   —— 而「水位」就列在同一份禁词表里
   * ⚠️ 只断角色层：base 的禁词清单本身**必须**出现这些词(它是反例)。
   */
  test('🔴 角色层正文里⛔ 不许出现「写库」「水位」这类只在代码里存在的词', () => {
    const roleLayers = PROMPTS.slice(PROMPTS.indexOf('const DISPATCHER_EXTRA'));
    expect(roleLayers).not.toMatch(/写库/);
    expect(roleLayers).not.toMatch(/水位/);
  });

  /**
   * ⚠️ 2026-08-14 按新分层改锚点(`SYSTEM_PROMPT` 拆成 IDENTITY / EVIDENCE / VOICE,
   *   `DISPATCHER_EXTRA` 改名 `DISPATCHER_ROLE`)。锁的仍是同一件事:
   *   「说人话」在**公共层**一份，⛔ 两个角色层都不许再抄
   *   （2026-08-13 实测它在两个角色层各有一份且已经漂了）。
   */
  test('⭐⭐ 「说人话」只在公共层有一份,⛔ 两个角色层都不许再抄', () => {
    const commonStart = PROMPTS.indexOf('const EVIDENCE = `');
    const roleStart = PROMPTS.indexOf('const DISPATCHER_ROLE = `');
    expect(commonStart).toBeGreaterThan(-1);
    expect(roleStart).toBeGreaterThan(commonStart);
    expect(PROMPTS.slice(commonStart, roleStart)).toMatch(/说人话/);
    expect(PROMPTS.slice(roleStart)).not.toMatch(/说人话/);
  });
});

/**
 * 「同一个治疗项,助手叫它 A、界面叫它 B」的回归(2026-08-06 走查时顺带发现)。
 *
 * `POTENTIAL_TREATMENT_CARD_LABEL`(labels.ts)是**展示措辞的真理源**,
 * 业务 2026-07-29 定过:filling 是「充填」不是「补牙」。矩阵按它渲染。
 * 而 `PERSONA_TAG_FILTER_DIMS` 里另存了一份 zh,漏改成了「补牙」——
 * 于是助手嘴里是「补牙 · 2–3 年」,主管界面上是「充填 · 2–3 年」,像两件事。
 *
 * ⚠️ 两份中文并存是历史结构(筛选面板要 hint,卡片只要名字),短期内不合并;
 *   这条测试就是它们的**唯一**同步保证 —— 改任一处措辞,这里会红。
 */
describe('潜在治疗的中文 —— 两份表必须一致', () => {
  const { PERSONA_TAG_FILTER_DIMS, potentialTreatmentItemName } =
    jest.requireActual<typeof import('@pac/types')>('@pac/types');

  test('⭐⭐ 筛选维度的 zh == 矩阵/卡片实际渲染的中文', () => {
    const dim = PERSONA_TAG_FILTER_DIMS.find((d) => d.key === 'potential_treatment')!;
    for (const o of dim.options) {
      expect([o.value, o.zh]).toEqual([o.value, potentialTreatmentItemName(o.value)]);
    }
  });

  test('业务钉死的那一条:filling = 充填,⛔ 不是补牙', () => {
    expect(potentialTreatmentItemName('filling')).toBe('充填');
    expect(POTENTIAL_TREATMENT_DESC).toContain('充填=filling');
    expect(POTENTIAL_TREATMENT_DESC).not.toContain('补牙');
  });
});

/**
 * 「确认完没配福利 → 提醒补挂」的约束(2026-08-06 产品加)。
 *
 * ⭐ 为什么值得单独锁:这是一条**主动推销**性质的提示,而本系统
 *   **不统计成功**(硬约束第 4 条)。一旦让模型自由发挥,它极容易顺嘴说出
 *   「带福利成功率更高」「转化率能提升」—— 那个数据根本不存在,是凭空造事实(违 T14)。
 *   所以提示词里必须**同时**有:① 提醒的动作 ② 只提一次 ③ 禁止编效果。
 *
 * ⚠️ 界面那一半(assignment-confirm-sheet 在确认时拼进 modelSummary)与提示词这一半
 *   是「双保险」的两条腿,少一条这个功能就要么不触发、要么乱说。
 */
describe('确认后没配福利 → 提醒补挂,但不许编效果', () => {
  const PROMPTS = readFileSync(
    join(__dirname, '../src/modules/assistant/assistant-prompts.ts'),
    'utf8',
  );
  const SHEET = readFileSync(
    join(
      __dirname,
      '../../pac-web/src/components/assistant/assignment-confirm-sheet.tsx',
    ),
    'utf8',
  );

  /**
   * 🔴🔴 **2026-08-15 再改判：确认单里不做福利引导（产品定）。**
   *
   * 沿革三版，⛔ 别把任何一版当"更好的旧做法"捡回来：
   *   ① 前端拼一句助手台词塞进对话 —— 署的是助手的名，而它根本没说过；
   *   ② 界面在确认那一刻往这张单挂一个 `benefit_missing` 引导节点 ——
   *      主管点完确认，卡片里长出一块黄色引导；
   *   ③ **现在**：卡片什么都不摆。提醒挪到**确认之前**由模型问一句
   *      （提示词 ③「他确认」那一节：「要带现在说，不带就算了」），
   *      确认之后正常输出。
   * ⚠️ 意图没变的那几条仍然锁着：不许编效果、喂模型的那份只有事实、
   *   确认那一句自成一条消息、`本批福利` 这个事实始终在（他回头问才答得上来）。
   */

  test('🔴 卡片里**不许**再有确认后的福利引导（三版沿革见上）', () => {
    expect(SHEET).not.toMatch(/key: 'benefit_missing'/);
    // ⚠️ 带逗号 = 真代码；⛔ 别放宽成不带逗号，那会误伤记录沿革的注释
            expect(SHEET).not.toMatch(/stage: 'post_confirm',/);
    // ⛔ 更早那版"前端替助手开口"同样一个字都不许留
    expect(SHEET).not.toMatch(/benefitNudge/);
    expect(SHEET).not.toMatch(/summary \+ benefit/);
    // ⛔ 也不许绕道:确认回调不再往外传 postConfirm
    const CHAT = readFileSync(
      join(__dirname, '../../pac-web/src/components/assistant/assistant-chat.tsx'),
      'utf8',
    );
    expect(CHAT).not.toMatch(/settleSheet\(requestId, assignmentId, postConfirm\)/);
  });

  test('⭐ 提醒挪到**确认之前**，由模型问一句', () => {
    // ③「他确认」那一节里问，⛔ 不是④「确认之后」
    const step3 = PROMPTS.slice(PROMPTS.indexOf('### 他确认'), PROMPTS.indexOf('### 确认之后'));
    expect(step3).toMatch(/还没带福利/);
    expect(step3).toMatch(/不带就算了/);
  });

  /**
   * 🔴 2026-08-15 实测：模型讲区间写的是「每人 8~9 条,分完每人手上 48~49 条」，
   *   而 GFM 默认把**单个**波浪号也当删除线 —— 两个 `~` 配成一对，中间那截被划掉，
   *   主管屏幕上读到的是「每人 **89** 条、手上 **4849** 条」。
   * ⚠️ 这是 `assignment-facts.range()` 那条纪律的另一半：那边把区间拆成两个键，
   *   是因为字符串 `'4~15'` 会被模型读成 415；数据这头堵住了，**呈现这头照样能粘**。
   * ⇒ 渲染层关掉单波浪号。⛔ 这个选项不许删。
   */
  test('🔴 助手正文的 markdown ⛔ 不许把单个波浪号当删除线（8~9 会变成 89）', () => {
    const CHAT = readFileSync(
      join(__dirname, '../../pac-web/src/components/assistant/assistant-chat.tsx'),
      'utf8',
    );
    expect(CHAT).toMatch(/remarkGfm,\s*\{\s*singleTilde:\s*false\s*\}/);
  });

  test('⭐⭐ 确认这一句要自成一条助手消息,⛔ 不能贴在上一条尾巴上', () => {
    const CHAT = readFileSync(
      join(__dirname, '../../pac-web/src/components/assistant/assistant-chat.tsx'),
      'utf8',
    );
    expect(CHAT).toMatch(/appendAssistantNote\(summary, modelFacts, \{ asNewMessage: true \}\)/);
  });

  /**
   * 🔴 这里曾有一条「节点必须**主管看得见**，⛔ 不能只写进模型那份」——
   *   随那个节点一起删于 2026-08-15。它的理由（点确认不触发模型发言，
   *   只写给模型 = 主管永远看不到）**仍然成立且仍然重要**：
   *   所以提醒被挪到了**确认之前**，那时模型正在发言（见上一条断言）。
   *   ⛔ 别据此把节点加回卡片 —— 产品定确认单里不做福利引导。
   */

  test('🔴 喂模型那份只有事实 —— ⛔ 一个字的指令都不许有', () => {
    const facts = /JSON\.stringify\(\{\s*已确认分配:[\s\S]*?\}\),/.exec(SHEET)?.[0] ?? '';
    expect(facts).toContain('批次id');
    for (const w of ['⛔', '不要', '不许', '请', '你要', '照抄']) {
      expect(facts).not.toContain(w);
    }
    // 完整 uuid 必须给:它撤销这批只能靠这个 id
    expect(facts).toMatch(/res\.assignmentId/);
  });

  test('⭐ 界面已经摆出来的东西,⛔ 不许让模型再说一遍', () => {
    // ⚠️ 2026-08-15 起是「不用你**再**提」——③ 允许确认前预告了(产品改判),
    //   到④ 界面接手时,禁的就从"别提"变成"别重复"。⛔ 这一条本身不许删。
    expect(PROMPTS).toMatch(/不用你再?提/);
    // 「只提一次」这条规则**随旧做法一起删掉了** —— 模型压根不提,就没有"提几次"这回事。
    // ⭐ 规则越多每条被遵守的概率越低,能靠结构去掉的就别留在提示词里。
    expect(PROMPTS).not.toMatch(/只提一次/);
  });

  test('⭐ `本批福利` 这个事实要一直给模型 —— 他回头问「这批带福利没」才答得上来', () => {
    expect(SHEET).toMatch(/本批福利: benefit\.trim\(\) \|\| null/);
  });

  test('⭐ 确认摘要里用中文项目名,⛔ 不许出现 endo/filling 这种码', () => {
    // 实测漏过:确认后界面显示「已确认分配:endo · 3 条」
    expect(SHEET).toMatch(/potentialTreatmentItemName\(sheet\.potentialTreatment\)/);
  });

  /**
   * ⚠️ 2026-08-15 起只锁**提示词**这一侧：卡片里那段福利文案连同引导节点一起删了，
   *   ⛔ 别为了"两侧对称"再往卡片里塞一句禁令 —— 那里已经不说福利了。
   */
  test('⭐⭐ 提示词里必须写明「⛔ 不许说成功率/转化率更高」', () => {
    expect(PROMPTS).toMatch(/成功率/); // 出现在禁令里
    expect(PROMPTS).toMatch(/不统计成功|不许说/);
  });

  /**
   * ⚠️ 2026-08-15 现场段改成 `选人 → 分人 → 他确认 → 确认之后` 四步分节后，
   *   「确认之后」这半句由**小标题**承担 —— ⛔ 别再去匹配
   *   「确认之后福利仍然可以补挂」这种整句：位置本身就是它的适用范围，
   *   锁整句等于把"这条规矩必须写成一句话"也锁了进去。
   * ⇒ 锁的是**这一步在、且三条不变量都在**。
   */
  test('提示词里要说清"确认后仍可补挂、但人员时效改不了"', () => {
    expect(PROMPTS).toMatch(/### 确认之后/);
    expect(PROMPTS).toMatch(/补挂/);
    expect(PROMPTS).toMatch(/人员和时效改不了/);
    expect(PROMPTS).toMatch(/撤销重分/);
  });
});

/**
 * 「三行破折号夹一行真话」的回归(2026-08-06 产品走查)。
 *
 * `plan_summaries.content` 里还有大量**老行是纯文本一句话**。服务端兼容时把整句落到
 * `problem`、其余三段填「—」(parseBriefContent)。实测该接口对老行返回:
 *   { who: "—", history: "—", problem: "重要价值客16;47龋齿…", hook: "—" }
 * 前端照直渲染四行,主管看到的就是三行「—」夹一行真话 —— 像加载失败。
 *
 * ⚠️ 修法是**前端不渲染占位符**,⛔ 不是"把老行丢掉重跑 LLM"(那会让每个存量 plan
 *   打开时白烧一次调用),也⛔ 不是"渲染成空行"(空行一样占位)。
 */
describe('四点简报 —— 占位符那几行不渲染', () => {
  const DETAIL = readFileSync(
    join(__dirname, '../../pac-web/src/components/plan-detail/plan-detail-app.tsx'),
    'utf8',
  );

  test('⭐⭐ 渲染前必须过滤占位符', () => {
    expect(DETAIL).toMatch(/BRIEF_PLACEHOLDERS\.has/);
    expect(DETAIL).toMatch(/\.filter\(\(t\) => !BRIEF_PLACEHOLDERS/);
  });

  test('⭐ 占位符集合要覆盖服务端兼容老行时填的那个值(「—」)', () => {
    const set = /const BRIEF_PLACEHOLDERS = new Set\(\[([^\]]*)\]\)/.exec(DETAIL)?.[1] ?? '';
    for (const p of ['—', '-', '']) expect(set).toContain(`'${p}'`);
  });
});

/**
 * 「点了这个会怎么样」那句提示的回归(2026-08-06 走查)。
 *
 * 原来只按 `drivesStatus` 查一张三项的表,于是「秒挂」显示
 * 「本次任务保留,等下次跟进」—— 而它刚被配上 30 天冷静期,**下次跟进是一个月以后**。
 * 客服看到"等下次跟进"会以为过两天还能再打,实际这单一个月不会再出现。
 *
 * ⚠️ 抑制期是**用户能感知的后果**,不说就是骗人(T14)。同理「再考虑」是
 *   abandoned + 7 天,说成"将关闭"会让客服以为这人废了,白白再手动跟一遍。
 */
describe('通话结果提示 —— 必须说出抑制期', () => {
  const FORM = readFileSync(
    join(__dirname, '../../pac-web/src/components/plan-detail/outcome-form.tsx'),
    'utf8',
  );

  test('⭐⭐ 提示函数要同时吃 drivesStatus 和 suppressDays', () => {
    expect(FORM).toMatch(/function stateHint\(drives: string, days: number \| null, scheduled: boolean\)/);
    // ⛔ 不许退回只查 drivesStatus 的老写法
    expect(FORM).not.toMatch(/const STATE_HINTS/);
  });

  test('⭐ keep + 有抑制期 → 明说多少天内不再出现', () => {
    expect(FORM).toMatch(/天内不再出现/);
  });

  test('⭐ abandoned + 有限抑制期 → 说明它会自己回来,⛔ 别说成彻底关闭', () => {
    expect(FORM).toMatch(/天后若仍有需要会重新出现/);
    expect(FORM).toMatch(/SUPPRESS_PERMANENT_DAYS/); // 永久档要单独分岔
  });

  test('⭐ 约定下次回访 → 抑制点是那个日期,不是天数', () => {
    expect(FORM).toMatch(/到您约定的日期再跟进/);
  });
});

/**
 * 「待分配」批量处置的约束(2026-08-06 产品走查)。
 *
 * ── 事故经过 ──────────────────────────────────────────────────────
 * 取消自动改派后,待分配一次能有 55 人。主管说「把待分配的人分给客服,尽量齐平」,
 * 助手回:「我没有把患者从专属客服手里挪给别人的权限 —— 这属于关系层面的决定,
 * 只有您能拍板」,然后建议他**逐条拖 55 次**。
 *
 * 🔴 那句话本身没错,但用错了地方:**决定已经是主管做的了**,助手拒绝的是「执行」。
 *   边界应该是「决定他做,执行你做」—— 拒绝执行不是保护,是甩锅。
 *
 * ⚠️ 另一半同样重要:主管嘴上说"随机",要的是**齐平**。⛔ 不能真随机 ——
 *   同一张确认单每次算出不同结果,会毁掉"同样的输入两次算出同样的分法"这条
 *   他敢按确认键的前提(assignment-proposal 里 pickEvenly / lowest 都为此不用随机数)。
 */
describe('待分配的批量处置 —— 决定他做,执行你做', () => {
  const SHEET = readFileSync(
    join(__dirname, '../../pac-web/src/components/assistant/assignment-confirm-sheet.tsx'),
    'utf8',
  );
  const PROMPTS = readFileSync(
    join(__dirname, '../src/modules/assistant/assistant-prompts.ts'),
    'utf8',
  );
  const SVC = readFileSync(
    join(__dirname, '../src/modules/assistant/assistant.service.ts'),
    'utf8',
  );

  test('⭐⭐ 契约表达得了「整组一次性处置」—— 否则助手只能逐条改派', () => {
    const { SheetEditOpSchema } = jest.requireActual<typeof import('@pac/types')>('@pac/types');
    const ok = (o: unknown) => SheetEditOpSchema.safeParse(o).success;
    expect(ok({ select: { group: 'pending' }, action: 'assign', to: { mode: 'balance' } })).toBe(true);
    expect(
      ok({
        select: { group: 'pending' },
        action: 'assign',
        to: { mode: 'balance', agents: ['张悦'] },
      }),
    ).toBe(true);
    expect(ok({ select: { group: 'pending' }, action: 'remove' })).toBe(true);
  });

  test('⭐ 工具的 enum 里要有三个轴,否则模型根本发不出来', () => {
    expect(SVC).toMatch(/enum: \['patients', 'agent', 'pending', 'batch'\]/);
    expect(SVC).toMatch(/enum: \['assign', 'remove', 'set_expiry', 'set_benefit'\]/);
    expect(SVC).toMatch(/enum: \['owner', 'balance'\]/);
  });

  test('⭐⭐ 界面按水位法铺,⛔ 不许用随机数', () => {
    expect(SHEET).toMatch(/谁手上少先给谁/);
    // ⛔ 确认单的任何一处都不许出现随机 —— 它会毁掉可重现性
    expect(SHEET).not.toMatch(/Math\.random/);
  });

  test('⭐⭐ 候选客服必须含「一条都没分到的」—— 他们恰恰最空', () => {
    expect(SHEET).toMatch(/sheet\.skippedAgents\.map/);
  });

  test('⭐⭐ 提示词要点名禁止「我不能替您做这个决定」这种甩锅', () => {
    // ⚠️ 标点写成 `[，,]`:提示词的中文标点 2026-08-11 统一改成全角了(模型在照抄它),
    //   ⛔ 别把断言钉死在某一种标点上 —— 那种失败与被测行为无关,只是噪音。
    /**
     * ⚠️ 2026-08-14：角色层拆分后没有小标题了（只剩三段权责边界），
     *   「决定他做，执行你做」那个 `###` 标题随之消失 —— 同一件事现在是开篇第一句。
     */
    expect(PROMPTS).toMatch(/他做决定[，,]你把决定落成动作/);
    expect(PROMPTS).toMatch(/甩锅/);
    // 同时仍然保留"不许自作主张"那一半 —— 两半缺一不可
    expect(PROMPTS).toMatch(/不许自作主张/);
  });

  /**
   * ⚠️ 2026-08-13：这条约束**只留在工具描述里**（`edit_assignment_sheet`）。
   *   在此之前 DISPATCHER_EXTRA 里也抄了一份 —— 而 assistant-prompts 的文件头写着
   *   「工具自己的坑留在工具描述里，写进工作流约束等于开第二份副本，两边会各自漂移」。
   *   ⇒ 断它在**该在的地方**，⛔ 别断它有两份。
   */
  /**
   * ⚠️ 2026-08-14 改断言:同一条约束搬到了 `to` **参数**上（工具描述只回答"是什么/何时调"，
   *   每一格怎么填归那一格）。锁的仍是同一件事:balance 是确定性的,⛔ 不许复述成"我随机分了"。
   */
  test('⭐ 不许把"铺平"说成"随机分了" —— 约束落在 to 参数上', () => {
    expect(SVC).toMatch(/balance 是按在手量铺的，不是随机/);
    expect(SVC).toMatch(/界面回报里说的是铺给了谁几条，照那句转述/);
  });
});

/**
 * 「卡片上看得见的人,指令必须能指到」的回归(2026-08-06 实测)。
 *
 * 主管对着卡片说「把常瀚越分配给刘艳阳」,助手回:
 *   「确认单里没有『常瀚越』,跳过」
 * —— 而常瀚越的名字**就印在卡片的「待分配」那一组里**。
 *
 * 真因:`findPatients` 只搜 `items`(已落人的),没搜 `pending`。
 * 🔴 取消自动改派之后,待分配可能占一批人的大半 —— 只搜一半等于**对话这条路对他们失效**,
 *   主管只能一个个手动拖。
 */
describe('待分配的人 —— 对话指令要能指到', () => {
  const SHEET = readFileSync(
    join(__dirname, '../../pac-web/src/components/assistant/assignment-confirm-sheet.tsx'),
    'utf8',
  );

  test('⭐⭐ findPatients 必须同时搜「已落人」和「待分配」', () => {
    // 两半都要搜:已落人的 + 还在待分配的(后者按草稿算,见"同批次多条指令"那组)
    expect(SHEET).toMatch(/items\.filter\(\(i\) => !dropDraft\.has\(i\.planId\)\)\.filter\(hit\)/);
    expect(SHEET).toMatch(/pendingRest\(\)\.filter\(hit\)/);
  });

  test('⭐ 给待分配的人改时效要说明"分给客服后才生效"(否则主管以为已进批次)', () => {
    expect(SHEET).toMatch(/分给客服后这个时效才生效/);
  });

  test('⭐⭐ findAgent 也要能指到「本批未分到」的客服 —— 他们恰恰手上最空', () => {
    // 实测:「把陈浡龙分配给刘艳阳」→「没找到客服『刘艳阳』」,
    // 而刘艳阳的名字就印在卡片下面那行「本批未分到」里。
    expect(SHEET).toMatch(/sheet\.skippedAgents\.find/);
  });

  test('⭐ 对没分到条目的客服执行「移除」→ 说清"本来就没有",⛔ 不报"已移除 0 条"', () => {
    expect(SHEET).toMatch(/本批一条都没分到,没什么可动的/);
  });

  test('🔴🔴 拖给「未分到」的客服后,组标题不许退化成 id + 在手 0', () => {
    // 实测显示成「#678 1 条(在手 0 → 1)」——而王锐实际在手 56。
    // 真因:nameOf / inHandOf 只查了 byAgent(提案时就有条目的人)。
    expect(SHEET).toMatch(/sheet\.skippedAgents\.map\(\(a\) => \[a\.userId, a\.name\]/);
    expect(SHEET).toMatch(/sheet\.skippedAgents\.map\(\(a\) => \[a\.userId, a\.inHand\]/);
  });

  test('⭐ 已经被分到条目的客服要从「本批未分到」里剔掉(否则一人两处)', () => {
    expect(SHEET).toMatch(/const idleAgents = useMemo/);
  });

  test('⭐ 「待分配」可收拢,但**默认展开**(默认藏起来就回到"悄悄没了")', () => {
    expect(SHEET).toMatch(/useState\(true\);?\s*$/m); // pendingOpen 初值 true
    expect(SHEET).toMatch(/setPendingOpen/);
    expect(SHEET).toMatch(/\{pendingOpen && \(/);
  });

  test('⛔ 客服姓名的复制按钮不能嵌在展开按钮里(button 套 button 是非法 HTML)', () => {
    // 展开的点击区被拆成"箭头"和"条数"两块,姓名夹在中间只管复制
    expect(SHEET).toMatch(/aria-label=\{isOpen \? '收起' : '展开'\}/);
  });

  test('⭐ 姓名可点击复制,且⛔ 不能吃掉整行的拖拽', () => {
    expect(SHEET).toMatch(/function CopyableName/);
    expect(SHEET).toMatch(/draggable=\{false\}/); // 不加会被当成拖文本
    expect(SHEET).toMatch(/e\.stopPropagation\(\)/);
    // 两处行都要用上:客服组里的 + 待分配里的
    // 三处都要用上:客服组标题 + 客服组里的患者 + 待分配里的患者
    expect((SHEET.match(/<CopyableName /g) ?? []).length).toBeGreaterThanOrEqual(3);
  });
});

/**
 * 「同一批指令里,后一条覆盖了前一条」的回归(2026-08-06 实测)。
 *
 * 主管一句话给了两条指令:
 *   「李汝明分配给李闻,**剩下的**都分配给高瑞珍」
 * `move_patient(李汝明→李闻)` 先执行,但紧接着的 `assign_pending` 读的还是**旧的**
 * `moveByPlan`(setState 在同一次 effect 里读不到刚写的值)——
 * 于是李汝明仍被当成"待分配",连同其余 31 人一起铺给了高瑞珍。
 *
 * 🔴 最坏的地方:**两条都回报了"已执行"**。主管看到「李汝明 → 李闻」的回执,
 *   而卡片上李闻名下根本没有他 —— 界面说做了、实际被后一条冲掉了(违 T14)。
 *
 * ⚠️ 这类 bug 只在**一句话里给多条指令**时出现,单条指令永远测不出来。
 */
describe('同批次多条指令 —— 后一条要看得见前一条', () => {
  const SHEET = readFileSync(
    join(__dirname, '../../pac-web/src/components/assistant/assignment-confirm-sheet.tsx'),
    'utf8',
  );

  test('⭐⭐ 循环内读写**草稿**,⛔ 不许直接读 state', () => {
    expect(SHEET).toMatch(/const moveDraft: Record<string, string> = \{ \.\.\.moveByPlan \}/);
    expect(SHEET).toMatch(/const dropDraft = new Set\(dropped\)/);
    // 待分配的"还剩谁"必须按草稿算,否则刚被改派的人还会被当成待分配
    expect(SHEET).toMatch(/const pendingRest = \(\) =>/);
    expect(SHEET).toMatch(/!dropDraft\.has\(p\.planId\) && moveDraft\[p\.planId\] == null/);
  });

  test('⭐⭐ 草稿在循环结束后**一次性**提交,且用函数式合并', () => {
    expect(SHEET).toMatch(/setMoveByPlan\(\(m\) => \(\{ \.\.\.m, \.\.\.moveDraft \}\)\)/);
    expect(SHEET).toMatch(/setDropped\(\(sset\) => new Set\(\[\.\.\.sset, \.\.\.dropDraft\]\)\)/);
  });

  test('⭐ 铺平时的水位要按草稿算经办人(同批次刚改派的人不能算在旧客服头上)', () => {
    expect(SHEET).toMatch(/const who = moveDraft\[it\.planId\] \?\? it\.assignee/);
  });
});

/**
 * 确认单话术的**可读性**约束(2026-08-06 走查)。
 *
 * 原来助手每出一版都要念三大段:选人口径 + 待分配解释 + 四条操作指引。
 * 主管的原话是「密密麻麻不一定看得懂」。拆出来的具体毛病:
 *   ① **数字打架** —— 「这批挑了 50 人」紧跟着「10 人分给 6 位客服」,
 *      主管第一反应是"到底分了几个"(真相:50 = 已排 10 + 待定 40,但没人说)。
 *   ② **括号套括号** —— 「…(最多的是康慧捧)(他们原本还有 951 条在手)」。
 *   ③ **每轮重复四条操作指引** —— 第一次有用,一天看好几版就全是噪音。
 *   ④ **内部词** —— 「落库」「排队」。
 */
describe('确认单话术 —— 让主管一眼看懂', () => {
  const SVC_PROPOSAL = readFileSync(
    join(__dirname, '../src/modules/plan/assignment-proposal.service.ts'),
    'utf8',
  );
  const SVC_ASSIST = readFileSync(
    join(__dirname, '../src/modules/assistant/assistant.service.ts'),
    'utf8',
  );
  const PROMPTS = readFileSync(
    join(__dirname, '../src/modules/assistant/assistant-prompts.ts'),
    'utf8',
  );
  const SHEET = readFileSync(
    join(__dirname, '../../pac-web/src/components/assistant/assignment-confirm-sheet.tsx'),
    'utf8',
  );

  test('⭐⭐ 有待分配时必须给出**等式**,否则两个数字看着打架', () => {
    // 等式改成了"分段加粗起头"的形式:待分配自成一段,且标题里就带数字
    expect(SVC_PROPOSAL).toMatch(/\*\*⚠️ 其中 \$\{pending\.length\} 人要您定\*\*/);
    /**
     * 🔴 2026-08-14 改断言:「已排好 N 人 · M 位客服」原本断的是 `basisNote` 里的模板,
     *   而 basisNote **只写不读**、已删。这句话真正的出处一直是**卡片自己**
     *   （它跟「待分配」成对出现,合起来才读得出 N 含不含待分配的那些）。
     * ⇒ 断到活着的那一处。
     */
    const CARD = readFileSync(
      join(__dirname, '../../pac-web/src/components/assistant/assignment-confirm-sheet.tsx'),
      'utf8',
    );
    expect(CARD).toMatch(/已排好 \{items\.length\} 人 · \{groups\.length\} 位客服/);
  });

  test('⛔ 不许再出现括号套括号的负载描述', () => {
    expect(SVC_PROPOSAL).not.toMatch(/最多的是\$\{top\?\.name\}\)`\)/);
    expect(SVC_PROPOSAL).not.toMatch(/他们原本还有 \$\{x\.inHandTotal\} 条在手/);
  });

  test('⛔ 内部词清理:落库 / 排队', () => {
    expect(SVC_PROPOSAL).toMatch(/还没轮到/);
    expect(SVC_ASSIST).not.toMatch(/一条都还没落库/);
  });

  /**
   * 🔴 **键名就是提示词的一个面。** 2026-08-14 走查：模型对主管说
   *   「这只是提案，尚未**写入数据库**」—— 出处不是提示词（那天刚把「写库」换成人话），
   *   而是 `propose_assignment` 返回值里的键名 `已写入数据库: false`。
   *   给了名字就等于给了先验,而键名又必须是中文(模型照着键名说才对)
   *   ⇒ ⛔ 不想让它说的词,就别做成键名。
   * ⚠️ 断言只覆盖**返回给模型的那个对象**;注释里出现「数据库」不算(本注释自己就有)。
   */
  /**
   * 🔴 **模型能念出口的中文有四个来源,不是一个。**
   *   ① 系统提示词  ② 工具描述  ③ 给模型的返回值(键名和值)  ④ 前端回灌进历史的文本
   *
   * 2026-08-14 一次走查里**②③④ 全中**：提示词那天刚把「写库」换成人话,模型却仍然
   *   对主管说「这只是提案,尚未写入数据库」「在那之前一个字都没落库」—— 出处是
   *   `propose_assignment` 的工具描述（「一个字都没写库」）、返回值键名（`已写入数据库`）、
   *   以及 chat-blocks 回灌的作废确认单 note（「一条都没落库」）。
   * ⇒ 只扫提示词是不够的。这条断言把四个面一起钉住。
   *
   * ⚠️ 只查**字符串字面量**：注释里当然要出现这些词（本注释自己就是），
   *   而注释不进模型上下文。
   */
  test('🔴 四个面的字符串字面量里⛔ 都不许有「落库/写库/数据库」', () => {
    const FILES: Record<string, string> = {
      '提示词': PROMPTS,
      '工具描述(本地)': SVC_ASSIST,
      '工具描述(MCP)': readFileSync(
        join(__dirname, '../src/modules/mcp/mcp-server.factory.ts'),
        'utf8',
      ),
      '回灌历史(前端)': readFileSync(
        join(__dirname, '../../pac-web/src/components/assistant/chat-blocks.ts'),
        'utf8',
      ),
    };
    const banned = /数据库|落库|写库|入库/;
    // 先去注释：JSDoc 里用反引号写内联代码（`写库`）会被下面的字面量正则当成模板串。
    // ⚠️ `[^:]` 是为了不误伤 `https://` —— ⛔ 别简化成 /\/\/.*$/。
    const strip = (s: string): string =>
      s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
    for (const [name, src] of Object.entries(FILES)) {
      const literals = strip(src).match(/'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`/g) ?? [];
      expect({ [name]: literals.filter((s) => banned.test(s)) }).toEqual({ [name]: [] });
    }
  });

  test('🔴 给模型的返回值键名里⛔ 不许有「数据库」这种只在代码里存在的词', () => {
    const ret = SVC_ASSIST.slice(
      SVC_ASSIST.indexOf('确认单已呈现'),
      SVC_ASSIST.indexOf('...modelFacts(sheet)'),
    );
    expect(ret).toMatch(/已经分下去了/); // 这条事实本身必须还在(①级防「已分配」幻觉)
    expect(ret.replace(/\/\*[\s\S]*?\*\//g, '')).not.toMatch(/数据库|落库|写库/);
  });

  /**
   * 🔴 「主管不知道能让助手干什么」这个诉求的落点**换过两次**：
   *   ① 最早要求助手每轮在消息里念一遍 → 第二次起全是噪音（那段比确认单还长）;
   *   ② 2026-08-06 搬成卡片上常驻的一份**通用例句清单**;
   *   ③ 2026-08-13 清单删掉 —— 由**引导节点**回答，它是针对这一批的具体选项
   *      （带人数、可点），比通用例句强得多。
   * ⇒ 不变的只有一条：⛔ 助手不许每轮复述选项。
   */
  test('⭐⭐ 助手不复述选项;卡片上的通用例句清单已被引导节点取代', () => {
    // ⚠️ 2026-08-14 措辞泛化：「选项」「按钮」都是**界面部件**，界面一改这句就过期 ——
    //   改成「可选动作」并去掉"那排按钮"的描述（同轮把 base 里的部件名一起清了）。
    expect(PROMPTS).toMatch(/不要把可选动作列一遍/);
    // ⛔ 别把通用例句清单加回卡片 —— 两处都在教"能做什么",通用的永远比不过当下那几个按钮
    expect(SHEET).not.toMatch(/可以直接跟我说/);
  });

  test('⭐ 待分配的行要显示医生 —— 与已分配的行字段一致', () => {
    // 两组显示的字段不一样,主管会以为待分配是"另一种东西"
    expect((SHEET.match(/主治 \{/g) ?? []).length).toBeGreaterThanOrEqual(2);
  });
});

/**
 * 「重新排一版(尽量排满)」的约束(2026-08-06 产品定,改掉了当天早些时候的"顶替候选")。
 *
 * ── 第一版为什么不行 ────────────────────────────────────────────
 * 最初的做法是服务端预先算一份「可顶替名单」下发。实测当场露馅:
 * 池子里有 **17 位无主**患者,而取数窗口只有 `1.5×N + 20 = 95` 行,
 * 排在 95 名之外的 14 位**根本没被取回** —— 界面只敢说「用 3 位顶替」,
 * 而主管刚在同一段话里读到"17 人无主"。**候选来源被取数窗口限死了,不是池子。**
 *
 * ⇒ 改成:服务端只回答**能不能**(canRefill),主管点一下 → **重新出一版**:
 *   取数窗口放大到 `4×N + 100`,落人时遇到专属排满的**直接跳过**继续往后取。
 *
 * 🔴 底线没变:**不动任何人的专属关系**。⛔ 一条都不许从专属客服手里挪走。
 * ⚠️ 代价是这批人整体排名往后走 —— 产品判定可以接受:
 *   「排名不重要,重要的是这批人共享同一组特征」。
 *
 * ── 第二版又炸了一次(2026-08-06 当天晚些时候,实测)────────────────
 * 「尽量排满」下 `chosen` 变成**整个取回窗口**,而水位是拿 `chosen.length` 算的 ——
 * 水位于是被窗口撑大(实测 62 → 69),那批"专属排满"的人原地进了**同一位客服**手里。
 * 底线没破(进的是本人的客服),但主管看到的是「19 位无主怎么顶掉了 34 位」:**数对不上**。
 * ── 第三版(定稿):**只换"挑谁",落人一个字不改** ─────────────────
 * 产品原话:「为什么要纠结排名,50 个里 19 位无主 31 位有主重新定确认单不行吗」。
 * ⇒ 把池子里**无主的全换进来**,其余按优先级用有专属的补满 N,然后走**完全一样**的三趟。
 *   排不进的照样进待分配,卡片形状一个字不变 —— 实测 拟分 16·待分配 34 → 拟分 31·待分配 19。
 * ⛔ 别再说「顶替 / 凑满」:发生的只是"这批人换过了",没有任何人被顶掉。
 *
 * 🔴 两条不许回头的路:
 *   ⛔ 落人不许再有 `stopAt` / 跳过 / 超水位补发这类分支(那是第二版的病根);
 *   ⛔ `chosen` 不许再是取数窗口 —— 它必须恒等于"这一批要分的 N 个人"。
 */
/**
 * 🔴🔴 **确认单指令 = 选谁(select)× 干什么(action)× 给谁(to)。**
 *
 * ── 为什么推翻原来那串字面量(2026-08-06 产品判定)─────────────────
 * 原设计是 8 条并列的 action:`remove_patient` / `remove_agent` / `move_patient` /
 * `assign_pending` / `assign_pending_to_owner` / `drop_pending` / `set_expiry` / `set_benefit`。
 * 摊开看它们根本不是 8 件事,是**三个轴被压成了一维** —— 每加一种"选谁"或"给谁",
 * 条数就要乘一遍,而没被乘出来的格子,主管说到时模型只能掉进最像的那一格。
 *
 * 当天实测栽的那次:主管说「把待分配的患者**各自分给各自的专属客服**」,
 * 而「待分配 × 改派 × 各自的专属」这一格当时是空的 —— 只有铺平可用,
 * 18 个人被散给了 17 位**别人**。产品原话:「测出一条加一条?应该给助手通用的工具」。
 *
 * ⚠️ **正交化不消灭"枚举漏值"**,少写一个 mode 照样会栽。它买到的是:
 *   ① 加维度不再乘一遍 ② 缺的组合是**表格里的空格**(看得见) ③ 覆盖度能按矩阵测。
 * ⚠️ 有一条边界拿不掉:指令由**界面**拿自己那份确认单执行(模型没有 planId,故意的),
 *   所以做不到"给它通用能力让它自己写" —— 只能把词汇表做得足够表达。
 *
 * 🔴 下面这张矩阵就是"覆盖度"本身:**空格在这里是看得见的**。
 */
describe('确认单指令 —— 三个正交的轴', () => {
  const { SheetEditOpSchema } = jest.requireActual<typeof import('@pac/types')>('@pac/types');
  const ok = (o: unknown) => SheetEditOpSchema.safeParse(o).success;
  const SVC = readFileSync(
    join(__dirname, '../src/modules/assistant/assistant.service.ts'),
    'utf8',
  );
  const SHEET = readFileSync(
    join(__dirname, '../../pac-web/src/components/assistant/assignment-confirm-sheet.tsx'),
    'utf8',
  );

  /**
   * 主管**说得出口**的话 → 该拼成什么。
   * ⭐ 加一行比加一条指令便宜得多 —— 这正是正交化要换来的东西。
   */
  const SAYS: [string, unknown][] = [
    ['把王强移出这批', { select: { group: 'patients', patients: ['王强'] }, action: 'remove' }],
    [
      '把王强和李莉都给张悦',
      {
        select: { group: 'patients', patients: ['王强', '李莉'] },
        action: 'assign',
        to: { mode: 'balance', agents: ['张悦'] },
      },
    ],
    ['把张悦名下的都移出去', { select: { group: 'agent', agent: '张悦' }, action: 'remove' }],
    [
      '张悦名下的分给别人',
      { select: { group: 'agent', agent: '张悦' }, action: 'assign', to: { mode: 'balance' } },
    ],
    [
      '🔴 待分配的各自给回自己的专属客服(实测栽过的那句)',
      { select: { group: 'pending' }, action: 'assign', to: { mode: 'owner' } },
    ],
    [
      '待分配的平均分下去',
      { select: { group: 'pending' }, action: 'assign', to: { mode: 'balance' } },
    ],
    [
      '待分配的分给张悦和李莉',
      {
        select: { group: 'pending' },
        action: 'assign',
        to: { mode: 'balance', agents: ['张悦', '李莉'] },
      },
    ],
    ['待分配的先都不管', { select: { group: 'pending' }, action: 'remove' }],
    // ⭐ 下面三条在旧设计里**一条都表达不了** —— 正交化白捡的
    ['待分配的时效给 5 天', { select: { group: 'pending' }, action: 'set_expiry', days: 5 }],
    [
      '张悦名下的时效给 5 天',
      { select: { group: 'agent', agent: '张悦' }, action: 'set_expiry', days: 5 }
    ],
    ['王强的单给 5 天', { select: { group: 'patients', patients: ['王强'] }, action: 'set_expiry', days: 5 }],
    ['整批给 7 天', { select: { group: 'batch' }, action: 'set_expiry', days: 7 }],
    ['这批带上老客户复查免挂号费', { select: { group: 'batch' }, action: 'set_benefit', text: '老客户复查免挂号费' }],
    ['撤掉福利', { select: { group: 'batch' }, action: 'set_benefit', text: '' }],
  ];

  test.each(SAYS)('⭐ 主管说「%s」→ 契约里拼得出来', (_say, op) => {
    expect(ok(op)).toBe(true);
  });

  test('🔴🔴 owner 与 balance 是**两个不同的值**,不是一个默认行为', () => {
    // 这就是当天那次事故的正题:少了 owner 这一格,模型只能用 balance 干反面的事
    expect(ok({ select: { group: 'pending' }, action: 'assign', to: { mode: 'owner' } })).toBe(true);
    expect(ok({ select: { group: 'pending' }, action: 'assign', to: { mode: 'balance' } })).toBe(true);
    // ⛔ owner 不吃 agents:收谁由患者自己的专属关系决定,轮不到点名
    //   ⚠️ 2026-08-14 只改了措辞(「不吃」→「不看」),这条锁的仍是同一件事
    expect(SVC).toMatch(/mode=owner 时不看这个参数/);
  });

  /**
   * 🔴 **缺参数在 schema 就拦掉**,⛔ 别留给界面运行时兜 ——
   *   界面兜的话只能回一句"没执行",而模型不知道自己少填了什么,下一轮还会再错一次。
   */
  test.each([
    ['assign 没给 to', { select: { group: 'pending' }, action: 'assign' }],
    ['set_expiry 没给 days', { select: { group: 'batch' }, action: 'set_expiry' }],
    ['set_benefit 没给 text', { select: { group: 'batch' }, action: 'set_benefit' }],
    ['福利挂到了个人头上', { select: { group: 'patients', patients: ['王强'] }, action: 'set_benefit', text: 'x' }],
    ['整批"移出自己"', { select: { group: 'batch' }, action: 'remove' }],
    ['整批当改派对象', { select: { group: 'batch' }, action: 'assign', to: { mode: 'balance' } }],
    ['patients 一个都没给', { select: { group: 'patients', patients: [] }, action: 'remove' }],
  ])('⛔ %s → schema 直接拒掉', (_why, op) => {
    expect(ok(op)).toBe(false);
  });

  /**
   * 🔴🔴 **2026-08-14 重写 `edit_assignment_sheet` 的工具描述之后改判。**
   *
   * 教「三问怎么拼」这条没变,但**每一格该填什么搬到了那一格自己的参数说明里** ——
   * 原来 owner/balance 的判别在工具描述和 `to` 参数上各写了一份,改一处就会漂。
   * ⇒ 工具描述只回答「是什么 / 什么时候调 / 后果」,判据锁到参数上。
   * ⚠️ 那次实测事故（他说「各自分给各自的专属客服」,助手用了铺平,18 人被散给 17 位别人）
   *   现在以**关键词表**的形式活着 —— 五个词一个都不能少,少一个就少一条判据。
   * ⛔ JSON 拼法举例整块删了:例子里的具体人名和天数紧挨着要填的那几格,是白给的锚;
   *   三段结构由 schema 本身表达。
   */
  test('🔴🔴 三问结构在工具描述里,每一格怎么填在参数里', () => {
    expect(SVC).toMatch(/\*\*选谁\*\*（select）· \*\*干什么\*\*（action）· \*\*给谁\*\*（to/);
    expect(SVC).toMatch(/别去背短语/);
    // owner/balance 的判据:五个关键词逐个列全
    // ⚠️ 断言不跨字符串拼接点(源码里这几句被 `' +` 断开了)
    expect(SVC).toMatch(/他说「各自 \/ 自己的 \/ 本来的/);
    expect(SVC).toMatch(/谁的客户归谁」指的是 owner/);
    expect(SVC).toMatch(/「平均分 \/ 谁手上少给谁 \/ 铺下去/);
    expect(SVC).toMatch(/随机分一分」指的是 balance/);
    // ⛔ 举例里不许再出现具体人名(白给模型一个照填的锚)
    for (const n of ['王强', '张悦', '李莉', '杨丽华']) {
      expect(SVC.split('description:').slice(1).join('')).not.toContain(`"${n}"`);
    }
  });

  test('🔴🔴 界面侧 owner:收的人必须是患者**自己的** ownerUserId', () => {
    expect(SHEET).toMatch(/moveDraft\[id\] = owner\.id/);
    // ⛔ 专属客服不在名册的分不下去,如实报数,别顺手改派给别人
    expect(SHEET).toMatch(/查不到在岗的专属客服,没动他们/);
  });

  test('⭐ 两种铺法的回话措辞必须能一眼分开', () => {
    expect(SHEET).toMatch(/各自还给自己的专属客服/);
    expect(SHEET).toMatch(/谁手上少先给谁\*\*铺给/);
  });

  test('🔴 铺平算水位时要**排除本次要改派的这批** —— 否则他们被算了两遍', () => {
    expect(SHEET).toMatch(/const moving = new Set\(sel\.planIds\)/);
    expect(SHEET).toMatch(/moving\.has\(it\.planId\)/);
  });
});

describe('重新排一版 —— 只换挑谁,不动落人', () => {
  const SVC = readFileSync(
    join(__dirname, '../src/modules/plan/assignment-proposal.service.ts'),
    'utf8',
  );
  const SHEET = readFileSync(
    join(__dirname, '../../pac-web/src/components/assistant/assignment-confirm-sheet.tsx'),
    'utf8',
  );

  test('🔴🔴 尽量排满时取数窗口必须放大 —— 这正是"17 位无主只顶了 3 位"的病根', () => {
    expect(SVC).toMatch(/input\.preferPlaceable\s*\n?\s*\?\s*Math\.ceil\(batchSize \* 4\) \+ 100/);
  });

  test('🔴🔴 落人**只有一套**:重排不许在这里开分支', () => {
    // 第二版的病根:`stopAt` 让落人跳过排满的,水位跟着窗口一起飘。
    // ⚠️ 只禁**代码**里的用法 —— 上面那段"为什么栽过"的注释要留着,它是这条约束的理由
    expect(SVC).not.toMatch(/stopAt(\?:|\s*!=|\s*=[^=]|\s*\?\?)/);
    expect(SVC).not.toMatch(/overWaterline/);
    // 底线:没有"改派"这条路 —— 源码里只剩注释提到那个已废弃的标记
    expect(SVC).not.toMatch(/AssignStrategy\.SPREAD_OVERFLOW/);
  });

  test('🔴🔴 重排只换"挑谁" —— 无主的全换进来,有专属的**按客服轮着取**', () => {
    expect(SVC).toMatch(/repickPlaceable\(ranked, dedicated,/);
    // ⛔ 直取前 N 名会把名额全给专属大户(111/165 属同一人),另两位客服的余量白白空着
    expect(SVC).toMatch(/for \(const c of free\.slice\(0, target\)\) picked\.add/);
  });

  test('⭐ 水位只由「本批 N」决定 —— chosen 恒等于这一批要分的人', () => {
    expect(SVC).toMatch(/Math\.ceil\(\(inHandSum \+ chosen\.length\) \/ agents\.length\)/);
    // 第二版把整个窗口丢了进来,62 当场变 69
    expect(SVC).not.toMatch(/ranked\.map\(\(c, i\) => \(\{/);
  });

  /**
   * 🔴 **运营结论（opsNote）2026-08-13 整块删除。**
   *
   * 2026-08-04 产品评审要的"结论性的东西"（这活多大）诉求没变，只是出口换了 ——
   * 同一批数现在有三处，卡片那份是第三遍：模型正文 + `batch_size_basis` + `daily_overload`。
   * ⇒ 卡片那个位置改放**只有卡片能做的事**（拖、点 ×、逐条改时效）。
   * ⚠️ 这条测试锁的是**不许加回来**。
   */
  test('⛔ opsNote 已删,卡片那个位置放的是"这张单能怎么改"', () => {
    expect(SHEET).not.toMatch(/opsNote/);
    expect(SVC).not.toMatch(/const opsNote =/);
    // 换上去的是卡片专属操作 —— 模型说不了,也做不成引导节点
    expect(SHEET).toMatch(/拖动患者/);
    // ⚠️ 只在还能改的时候显示 —— 已确认/已撤销时它是误导
    //   ⛔ 别把 class 抄进正则(2026-08-13 栽过:补了个 `border-b`,而那是产品特意去掉的),
    //     这里只锁**结构**。
    expect(SHEET).toMatch(/\{!readOnly && \(\s*<div className="[^"]*bg-slate-50/);
  });

  test('🔴🔴 重排换了什么必须由**卡片自己**说 —— 助手不会重新说一遍', () => {
    // 重排走 HTTP 只换卡片,上面那段旧话还在("34 人要您定"),不交代就是两份打架的口径
    expect(SVC).toMatch(/refillNote:/);
    expect(SVC).toMatch(/已重挑这 \$\{target\} 人/);
    expect(SHEET).toMatch(/sheet\.refillNote/);
  });

  test('⛔ 不许再把重排说成「顶替 / 凑满」—— 实际只是这批人换过了', () => {
    expect(SVC).not.toMatch(/换一批\*\*专属没排满的\*\*人来凑/);
    expect(SVC).not.toMatch(/已凑满/);
    expect(SHEET).not.toMatch(/顶替/);
  });

  test('⭐⭐ 被换进来的标 swap,⛔ 不混进 rank', () => {
    expect(SVC).toMatch(/wouldRank\.has\(c\.patientId\) \? 'rank' : 'swap'/);
    const { AssignmentItemSchema } = jest.requireActual<typeof import('@pac/types')>('@pac/types');
    expect(
      AssignmentItemSchema.safeParse({
        planId: '00000000-0000-4000-8000-000000000000',
        assigneeUserId: 'u1',
        assignStrategy: 'spread_no_dedicated',
        selectionMode: 'swap',
      }).success,
    ).toBe(true);
  });

  test('⭐⭐ 服务端只回答"能不能",⛔ 不下发顶替名单(名单会被窗口限死)', () => {
    expect(SVC).toMatch(/const canRefill = raw\.pending\.length > 0 && ownership\.free > 0/);
    expect(SVC).not.toMatch(/swapCandidates/);
  });

  /**
   * 🔴 重排的入口 2026-08-13 从**卡片按钮**搬到了**引导节点**（「换无主患者补上」）。
   *   同一个动作两个入口必然漂，而且主管要在两处之间猜哪个是哪个。
   */
  test('⭐⭐ 重排只有引导节点一个入口,⛔ 卡片上不再有按钮', () => {
    expect(SHEET).not.toMatch(/重新排一版,尽量排满/);
    const SIGNALS = readFileSync(
      join(__dirname, '../src/modules/plan/assignment-signals.ts'),
      'utf8',
    );
    expect(SIGNALS).toMatch(/换无主患者补上/);
    expect(SIGNALS).toMatch(/PENDING_REFILL/);
    // ⛔ 名次不该出现在界面上(产品:排名不重要)
    expect(SHEET).not.toMatch(/排名靠后/);
  });

  test('⭐ 重排要把条件原样带回 —— 少带一个就退回"沿用上一次",人数悄悄变了', () => {
    // ⚠️ 执行处已搬到 use-assistant-chat（按钮不在卡片上了）
    const HOOK = readFileSync(
      join(__dirname, '../../pac-web/src/components/assistant/use-assistant-chat.ts'),
      'utf8',
    );
    expect(HOOK).toMatch(/targetCount: s\.batchSize/);
    expect(HOOK).toMatch(/potentialTreatment: s\.potentialTreatment/);
    expect(HOOK).toMatch(/temperature: s\.temperature/);
  });

  /**
   * 🔴 2026-08-12(P2):由「断源码里两个字段的先后」改成**断真实产出的键顺序**。
   *
   * 原来的写法 grep 的是 `sheet.basisNote` / `sheet.pendingNote` 在源码里的位置 ——
   * 台词稿一删就整条失效,而它锁的那个不变式(**已排好在前、要他定的在后**)其实还在。
   * ⇒ 断行为不断源码。
   */
  // 🔴 2026-08-14：由三段改两段 —— 「要主管定的」搬进「怎么派的」（键序即段序，它属于派活）。
  //   ⚠️ 这条锁的不变式**没变**：先说人怎么来的，再说怎么派的。
  test('⭐ 给模型的事实:怎么选的 → 怎么派的', () => {
    const keys = Object.keys(
      modelFacts({
        potentialTreatment: 'implant',
        temperature: 'hot',
        candidateTotal: 100,
        target: 50,
        batchSize: 50,
        basis: 'default',
        basisFrom: null,
        expiresInDays: 3,
        placed: 50,
        pending: [],
        byAgent: [],
        skippedAgents: [],
        signals: [],
      } as unknown as AssignmentProposal),
    );
    expect(keys).toEqual(['怎么选的', '怎么派的']);
  });
});

/**
 * 工具描述与实现**漂移**的回归 —— 这一类错的共同点是:两边都不报错。
 *
 * 2026-08-14 走查一次抓到两处,都在主管每天要用的那条路上:
 *   ① `targetCount` / `expiresInDays` 写着「不传自动**沿用上一次**的值」——
 *      而沿用 2026-08-12 就被产品砍掉了(`basis` 只可能是 explicit/default,
 *      `inherited` 那一支只为读老快照保留)。后果不是"少说一句":模型会**故意不传**,
 *      以为能继承他上次说的「这批 200 人」,实际回落到公式/默认值。
 *   ② `propose_assignment` 描述里两次出现「基数」,而那个词**就列在禁说清单里**。
 *      实测原话:「比估算的基数（120）少得多」——⇒ 禁令拦不住你自己在描述里用的词。
 */
describe('工具描述不许与实现漂移', () => {
  /** 只取**递给模型**的字符串:注释里怎么写都行(那是给人看的) */
  const modelFacing = (rel: string): string => {
    const raw = readFileSync(join(__dirname, rel), 'utf8')
      .replace(/\/\*[\s\S]*?\*\//g, '')
      .replace(/^[ \t]*\/\/.*$/gm, '');
    return (raw.match(/'(?:[^'\\]|\\.)*'/g) ?? []).join('\n');
  };
  const LOCAL_TOOLS = modelFacing('../src/modules/assistant/assistant.service.ts');
  const MCP_TOOLS = modelFacing('../src/modules/mcp/mcp-server.factory.ts');

  test('🔴 ⛔ 不许再说「沿用上一次」的人数/天数 —— 那个机制 2026-08-12 就断开了', () => {
    // ⚠️ 精调(agentOverrides)**确实**会沿用,所以只断人数与天数这两句
    expect(LOCAL_TOOLS).not.toMatch(/不传自动沿用/);
    expect(LOCAL_TOOLS).toMatch(/他上一次说的数不会带到这一批/);
    expect(LOCAL_TOOLS).toMatch(/他上一次说的天数不会带到这一批/);
  });

  test('🔴 「基数」这类只在代码里存在的词,⛔ 不许出现在工具描述里', () => {
    // 提示词里禁的是模型**说出口**,但它说出口的词正是从这里学的
    for (const src of [LOCAL_TOOLS, MCP_TOOLS]) {
      for (const w of ['基数', '水位', '探索配额', '排序键']) {
        expect(src).not.toContain(w);
      }
    }
  });

  /**
   * ⭐ **判据里不带具体数字** —— 举例紧挨着的就是要填的那一格。
   *
   * 原文是「他明确说**「这批 200 人」**时才传」「他明确说了天数（**「给 5 天」**）时才传」。
   * 200 和 5 对"他说了数字没有"这个判据一点信息都不加,却白给模型一个可以照填的锚。
   * ⚠️ 这条**不是**事故回归(没实测到模型真填过 200)——是一次零成本的删除:
   *   判据本身不需要数字来说明,那就别留。
   * ⛔ 但 `ops` 那几个拼法示例里的数字要留:那是在演示「他话里的数字落到 days」这件事本身,
   *   两边都出现才看得出映射(而且上面写着「⛔ 别当成清单」)。
   */
  test('⭐ 参数判据里⛔ 不留具体数字的举例(白给模型一个照填的锚)', () => {
    expect(LOCAL_TOOLS).not.toMatch(/这批 200 人/);
    expect(LOCAL_TOOLS).not.toMatch(/「给 5 天」/);
    expect(LOCAL_TOOLS).toMatch(/只在他说了具体数量时传/);
    expect(LOCAL_TOOLS).toMatch(/只在他说了天数时传/);
  });

  test('⭐ 范围写进 schema,⛔ 不是只写在描述里 —— 结构管得住的别写成一句话', () => {
    const SRC = readFileSync(
      join(__dirname, '../src/modules/assistant/assistant.service.ts'),
      'utf8',
    );
    // 服务端夹的三处:dailyCalls 1–60 / exploreRatio 0–0.2 / 时效 ≤90(与落库 schema 对齐)
    expect(SRC).toMatch(/minimum: 1,\s*\n\s*maximum: 60/);
    expect(SRC).toMatch(/minimum: 0,\s*\n\s*maximum: 0\.2/);
    expect(SRC).toMatch(/maximum: 90/);
    // ⚠️ maxThisBatch **允许 0**(= 这轮不给他),⛔ 别写成 exclusiveMinimum
    expect(SRC).toMatch(/maxThisBatch: \{ type: 'number', minimum: 0 \}/);
  });
});
