import { Permission, PlanEventType } from '@pac/types';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
  PlanRearrangeService,
  overdueDaysOf,
  summarizeSkipped,
} from '../src/modules/plan/plan-rearrange.service';

/**
 * 主管调整回归。
 *
 * 三条红线错一条都是**静默**损失:
 *   ① 移出写成 `release` → 污染那一批的退回原因分布,并给原客服记一笔他没做过的退回
 *   ② 动了 `assignment_id` → 原批的 planned 缩水,批次报表的分母塌掉
 *   ③ 整体拒绝而不是逐条落 → 主管调了二十条,因为客服刚打完一条就全废
 */

const OP = 'a1b2c3d4-1111-4222-8333-444455556666';
const SCOPE = {
  hostId: 'h1',
  tenantId: 't1',
  sourceUnits: [] as string[],
  clinicIds: ['c1'],
  userId: 'leader-1',
} as never;
const LEADER = { userId: 'leader-1', permissions: [Permission.PLAN_DISPATCH] };
const CLINIC = 'c1';

type Row = {
  id: string;
  patientId: string;
  status: string;
  assigneeUserId: string | null;
  assignedAt: Date | null;
  assignmentId: string | null;
  assignmentExpiresAt: Date | null;
  supersededAt: Date | null;
};

const row = (over: Partial<Row> & { id: string }): Row => ({
  patientId: `pat-${over.id}`,
  status: 'assigned',
  assigneeUserId: 'agent-A',
  assignedAt: new Date('2026-08-18T00:00:00Z'),
  assignmentId: 'batch-1',
  assignmentExpiresAt: new Date('2026-08-21T15:59:59Z'),
  supersededAt: null,
  ...over,
});

function makeService(opts: { rows: Row[]; alreadyDone?: boolean; applied?: 'all' | 'none' }) {
  const events: Array<Record<string, unknown>> = [];
  const statements: string[] = [];
  const applyAll = (opts.applied ?? 'all') === 'all';

  const runRaw = (sql: { strings?: string[]; values?: unknown[] }) => {
    const text = (sql.strings ?? []).join(' ');
    statements.push(text);
    if (!applyAll) return [];
    // 从 VALUES 里把 plan_id 挑出来(每组的第一个值就是 plan_id)
    const ids = (sql.values ?? []).filter(
      (v): v is string => typeof v === 'string' && opts.rows.some((r) => r.id === v),
    );
    return [...new Set(ids)].map((id) => ({ id }));
  };

  const tx = { $queryRaw: jest.fn(async (sql: never) => runRaw(sql)), planEventLog: {
    createMany: jest.fn(async ({ data }: { data: Array<Record<string, unknown>> }) => {
      events.push(...data);
      return { count: data.length };
    }),
  } };

  const prisma = {
    planEventLog: {
      findFirst: jest.fn(async () => (opts.alreadyDone ? { id: 'e1' } : null)),
    },
    followupPlan: { findMany: jest.fn(async () => opts.rows) },
    host: { findUnique: jest.fn(async () => ({ pullConfig: { timezone: 'Asia/Shanghai' } })) },
    $transaction: jest.fn(async (fn: (t: unknown) => Promise<unknown>) => fn(tx)),
  } as never;

  const roster = { list: jest.fn() } as never;
  return { svc: new PlanRearrangeService(prisma, roster), events, statements };
}

describe('overdueDaysOf —— 与团队状态表同一份判据', () => {
  const now = new Date('2026-08-20T12:00:00Z');

  test('没到期 → null', () => {
    expect(overdueDaysOf(new Date('2026-08-25T00:00:00Z'), null, now)).toBeNull();
  });

  test('没有时限(自认领 / 老数据)→ null', () => {
    expect(overdueDaysOf(null, null, now)).toBeNull();
  });

  test('过了时限 → 天数向下取整', () => {
    expect(overdueDaysOf(new Date('2026-08-18T00:00:00Z'), null, now)).toBe(2);
  });

  test('⭐⭐ 约了下次回访的**不算超期** —— 他打过电话了,不能被指责压着单没动', () => {
    expect(
      overdueDaysOf(new Date('2026-08-10T00:00:00Z'), new Date('2026-09-01T00:00:00Z'), now),
    ).toBeNull();
  });

  test('约的下次已经过去了 → 恢复计超期', () => {
    expect(
      overdueDaysOf(new Date('2026-08-10T00:00:00Z'), new Date('2026-08-15T00:00:00Z'), now),
    ).toBe(10);
  });
});

describe('PlanRearrangeService.submit', () => {
  test('⭐ 幂等:同一个 operationId 重复提交,一个字都不写', async () => {
    const { svc, events } = makeService({ rows: [], alreadyDone: true });
    const r = await svc.submit(SCOPE, LEADER, {
      operationId: OP,
      clinicId: CLINIC,
      moves: [{ planId: 'p1', toUserId: 'agent-B' }],
      expiry: [],
      removes: [],
    });
    expect(r.duplicate).toBe(true);
    expect(r.moved).toBe(0);
    expect(events).toHaveLength(0);
  });

  test('没有派单权 → 直接拒', async () => {
    const { svc } = makeService({ rows: [] });
    await expect(
      svc.submit(SCOPE, { userId: 'staff-1', permissions: [] }, {
        operationId: OP, clinicId: CLINIC, moves: [], expiry: [], removes: [],
      }),
    ).rejects.toThrow(/权限/);
  });

  test('⛔ 同一条既移出又改派 → 意图矛盾,整单拒(⛔ 不猜一个执行)', async () => {
    const { svc } = makeService({ rows: [row({ id: 'p1' })] });
    await expect(
      svc.submit(SCOPE, LEADER, {
        operationId: OP,
        clinicId: CLINIC,
        moves: [{ planId: 'p1', toUserId: 'agent-B' }],
        expiry: [],
        removes: [{ planId: 'p1' }],
      }),
    ).rejects.toThrow(/矛盾/);
  });

  test('⭐⭐ 改派写 transfer、⛔ 不写 release;assignment_id 保留原批;带 operation_id', async () => {
    const { svc, events } = makeService({ rows: [row({ id: 'p1' })] });
    const r = await svc.submit(SCOPE, LEADER, {
      operationId: OP,
      clinicId: CLINIC,
      moves: [{ planId: 'p1', toUserId: 'agent-B' }],
      expiry: [],
      removes: [],
    });
    expect(r.moved).toBe(1);
    expect(events).toHaveLength(1);
    const e = events[0]!;
    expect(e.event).toBe(PlanEventType.TRANSFER);
    expect(e.event).not.toBe(PlanEventType.RELEASE);
    // 变更**后**的归属人 + 原客服进 details(列上只有"变更后",从谁那儿来事后补不回来)
    expect(e.assigneeUserId).toBe('agent-B');
    expect(e.details).toEqual({ from: 'agent-A' });
    expect(e.actorUserId).toBe('leader-1');
    // 🔴 调整不改归属批次
    expect(e.assignmentId).toBe('batch-1');
    expect(e.operationId).toBe(OP);
  });

  test('⭐⭐ 移出写 rearrange_remove —— ⛔ 绝不是 release(那会污染批次退回原因分布)', async () => {
    const { svc, events, statements } = makeService({ rows: [row({ id: 'p1' })] });
    const r = await svc.submit(SCOPE, LEADER, {
      operationId: OP, clinicId: CLINIC, moves: [], expiry: [], removes: [{ planId: 'p1' }],
    });
    expect(r.removed).toBe(1);
    expect(events[0]!.event).toBe(PlanEventType.REARRANGE_REMOVE);
    expect(events.some((e) => e.event === PlanEventType.RELEASE)).toBe(false);
    // 收回后无人归属;原客服仍进 details
    expect(events[0]!.assigneeUserId).toBeNull();
    expect(events[0]!.details).toEqual({ from: 'agent-A' });
    // 🔴 SQL 里⛔ 不许出现这三列 —— 清了就丢掉"这单来自哪批"(批次分母)
    const sql = statements.join(' ');
    expect(sql).not.toMatch(/assignment_id\s*=/);
    expect(sql).not.toMatch(/release_reason/);
    expect(sql).not.toMatch(/snoozed_until\s*=/);
  });

  test('改时限写 expiry_changed,details 记前后', async () => {
    const { svc, events } = makeService({ rows: [row({ id: 'p1' })] });
    const r = await svc.submit(SCOPE, LEADER, {
      operationId: OP, clinicId: CLINIC, moves: [], expiry: [{ planId: 'p1', expiresInDays: 5 }], removes: [],
    });
    expect(r.expiryChanged).toBe(1);
    expect(events[0]!.event).toBe(PlanEventType.EXPIRY_CHANGED);
    const d = events[0]!.details as { from: string | null; to: string; days: number };
    expect(d.days).toBe(5);
    expect(d.from).toBe('2026-08-21T15:59:59.000Z');
    expect(typeof d.to).toBe('string');
  });

  test('⭐⭐ 逐条落:能动的照落,动不了的逐条回报(⛔ 不整体拒绝)', async () => {
    const { svc, events } = makeService({
      rows: [
        row({ id: 'ok' }),
        row({ id: 'done', status: 'completed' }),
        row({ id: 'gone', supersededAt: new Date() }),
        row({ id: 'pooled', status: 'active', assigneeUserId: null }),
        row({ id: 'same' }),
      ],
    });
    const r = await svc.submit(SCOPE, LEADER, {
      operationId: OP,
      clinicId: CLINIC,
      moves: [
        { planId: 'ok', toUserId: 'agent-B' },
        { planId: 'done', toUserId: 'agent-B' },
        { planId: 'gone', toUserId: 'agent-B' },
        { planId: 'pooled', toUserId: 'agent-B' },
        { planId: 'same', toUserId: 'agent-A' }, // 本来就在他手上
        { planId: 'ghost', toUserId: 'agent-B' }, // 不在范围内
      ],
      expiry: [],
      removes: [],
    });
    expect(r.moved).toBe(1);
    expect(events).toHaveLength(1);
    const reasons = Object.fromEntries(r.skipped.map((s) => [s.planId, s.reason]));
    expect(reasons).toEqual({
      done: 'already_done',
      gone: 'superseded',
      pooled: 'not_in_hand',
      same: 'same_assignee',
      ghost: 'out_of_scope',
    });
    expect(r.note).toMatch(/改派 1 条/);
    expect(r.note).toMatch(/另有 5 条没动/);
  });

  test('一条都动不了 → 明确报错,⛔ 不留一个"成功 0 条"的假成功', async () => {
    const { svc } = makeService({ rows: [row({ id: 'p1', status: 'completed' })] });
    await expect(
      svc.submit(SCOPE, LEADER, {
        operationId: OP, clinicId: CLINIC, moves: [{ planId: 'p1', toUserId: 'agent-B' }], expiry: [], removes: [],
      }),
    ).rejects.toThrow(/都动不了/);
  });

  test('乐观并发:期间被别人改派走 → 那条落不上,回报 assignee_changed', async () => {
    const { svc } = makeService({ rows: [row({ id: 'p1' })], applied: 'none' });
    await expect(
      svc.submit(SCOPE, LEADER, {
        operationId: OP, clinicId: CLINIC, moves: [{ planId: 'p1', toUserId: 'agent-B' }], expiry: [], removes: [],
      }),
    ).resolves.toMatchObject({ moved: 0, skipped: [{ planId: 'p1', op: 'move', reason: 'assignee_changed' }] });
  });

  test('诊所不在数据范围内 → 拒', async () => {
    const { svc } = makeService({ rows: [] });
    await expect(
      svc.submit(SCOPE, LEADER, {
        operationId: OP, clinicId: 'other-clinic', moves: [{ planId: 'p1', toUserId: 'b' }], expiry: [], removes: [],
      }),
    ).rejects.toThrow(/不在你的数据范围内/);
  });
});

describe('summarizeSkipped —— 界面与助手念同一句', () => {
  test('按原因归并计数', () => {
    expect(
      summarizeSkipped([
        { planId: 'a', op: 'move', reason: 'already_done' },
        { planId: 'b', op: 'move', reason: 'already_done' },
        { planId: 'c', op: 'remove', reason: 'superseded' },
      ]),
    ).toBe('客服已经处理完了 2 条、这条已被重算取代 1 条');
  });
});

describe('批次报表要认得调整 —— 源码扫描(这条 SQL 没有单测能覆盖到)', () => {
  const SRC = readFileSync(
    join(__dirname, '../src/modules/plan/plan-assignment.service.ts'),
    'utf8',
  );

  test('⭐⭐ 「涉及几人」必须并上 transfer —— 接手人也经手了这一批', () => {
    // 调整不改 assignment_id,被转手的单仍算这一批。只数 assign 的话,
    // 批次上写着「涉及 3 人」而实际有 4 个人碰过 —— 且不会有任何报错。
    expect(SRC).toMatch(
      /count\(DISTINCT assignee_user_id\)\s+FILTER \(WHERE event IN \('assign','transfer'\)\)/,
    );
  });

  test('⛔ 「条数」不许并 transfer —— 它按 patient 去重,转手不该多算一条', () => {
    const plannedLine = SRC.split('\n').find((l) => l.includes('AS planned'));
    expect(plannedLine).toBeDefined();
    expect(plannedLine).not.toMatch(/transfer/);
  });
});

describe('两件工具的边界 —— 源码扫描(实测栽过:主管只想挪一个人,整张单被换掉)', () => {
  const SRC = readFileSync(
    join(__dirname, '../src/modules/assistant/assistant.service.ts'),
    'utf8',
  );
  const slice = (from: string, to: string) => SRC.slice(SRC.indexOf(from), SRC.indexOf(to));
  /** 去注释 —— ⚠️ `[^:]` 是为了不误伤 `https://`(同 mcp-clinic-scope 那份) */
  const strip = (x: string) =>
    x.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');

  test('🔴🔴 改调整单⛔ 不许拦「本轮还没出过」—— 单子多半是上一轮推的', () => {
    // `rearrangePushed` 只在同一轮 chat() 里为真(tools 每轮现建)。
    // 拦了之后主管说「胡剑波分配到张萌这里」,这件工具回「先出一版」,
    // 模型只能去 propose_rearrange 整版重出,他刚调好的全没了。
    // ⚠️ 确认单那边的 edit_assignment_sheet 从来没有这道闸,就是这个原因。
    const edit = slice('tools.edit_rearrange_sheet', 'tools.show_rearrange');
    // ⚠️ 先去注释:execute 里那段红字**就是在讲**这个变量为什么不能用,
    //   不剥掉的话这条断言会被自己的说明文字打败。
    const executeBody = strip(edit.slice(edit.indexOf('execute:')));
    expect(executeBody).not.toMatch(/rearrangePushed/);
  });

  test('⭐ 出调整单那件工具必须说清「已经有一版时不用它」+ 说清后果', () => {
    const propose = slice('tools.propose_rearrange', 'tools.edit_rearrange_sheet');
    expect(propose).toMatch(/眼前已经有一版的时候不用/);
    // ⚠️ 光禁止扶不住它 —— 必须写清再调一次会发生什么
    expect(propose).toMatch(/整版重出/);
    expect(propose).toMatch(/他刚在上面调好的全没了/);
  });

  test('🔴🔴 出完调整单要**停下来等他说**,⛔ 不许自己先排一版', () => {
    // 2026-08-20 实测:主管说「把在手的调一调,别都堆在一个人身上」,
    // 模型当场把最多那位手上的 15 条铺给了别人 —— 主管看到的不再是"现在什么样",
    // 而是"模型觉得该什么样"。把患者从一位客服手里挪走是**关系层面的决定**,
    // 只有主管有资格拍(同 2026-08-06 拿掉自动改派那次的判据)。
    const propose = slice('tools.propose_rearrange', 'tools.edit_rearrange_sheet');
    expect(propose).toMatch(/摆出来的是\*\*原样\*\*/);
    expect(propose).toMatch(/等他说怎么调/);
    expect(propose).toMatch(/关系层面的决定/);
    // 后果:先替他排好了,他看到的就不是"现在什么样"了
    expect(propose).toMatch(/而是你觉得该什么样/);
  });

  test('🔴 改调整单的条件必须是「他说出了要动哪些」,泛泛的不满意不算', () => {
    const edit = slice('tools.edit_rearrange_sheet', 'tools.show_rearrange');
    expect(edit).toMatch(/他说出了要动哪些,才用这件/);
    // 泛泛的不满意 ≠ 指令。⚠️ 断言的是**规则**,⛔ 不是那几句例句 ——
    //   例句多了模型会照着背,而规则才是它要判的东西。
    expect(edit).toMatch(/没说要动谁、给谁、动几条/);
    expect(edit).toMatch(/那三个数是他要定的/);
  });

  test('⛔ 两处回给模型的话里都不许出现「先出一版」这种把它推去重出的措辞', () => {
    // show_rearrange 拿不到单时也一样 —— 那句话会把它推去 propose。
    const show = SRC.slice(SRC.indexOf('tools.show_rearrange'));
    const literals = show.slice(0, show.indexOf('});')).match(/'[^'\n]*'/g) ?? [];
    expect(literals.filter((x) => x.includes('先出一版'))).toEqual([]);
  });
});

describe('调整单与确认单**手感一致** —— 源码扫描(产品:不用二次学习成本)', () => {
  const web = (f: string) =>
    readFileSync(join(__dirname, `../../pac-web/src/components/assistant/${f}`), 'utf8');
  const CONFIRM = web('assignment-confirm-sheet.tsx');
  const REARRANGE = web('rearrange-sheet.tsx');

  test('⭐ 两张单共用同一套呈现原件 —— 抄成两份必然漂,而漂了就是手感不一样', () => {
    for (const src of [CONFIRM, REARRANGE]) {
      expect(src).toMatch(/from '\.\/sheet-primitives'/);
      expect(src).toMatch(/<DaySelect/);
      expect(src).toMatch(/<CopyableName /);
    }
  });

  test('⭐ 拖拽载荷必须是同一个 key —— 不同的话两张单的拖法就成了两回事', () => {
    for (const src of [CONFIRM, REARRANGE]) {
      expect(src).toMatch(/setData\('text\/plan-id'/);
      expect(src).toMatch(/getData\('text\/plan-id'\)/);
    }
  });

  test('⭐ 同样的四件事都得有:汇总抬头 · 操作说明 · 展开名单 · 整宽确认按钮', () => {
    for (const src of [CONFIRM, REARRANGE]) {
      // 抬头:同一支底色 + 同一个人形图标
      expect(src).toMatch(/bg-brand-50\/50/);
      expect(src).toMatch(/<Users className/);
      // 操作说明:同一段灰条,且都挂「我自己说」兜底
      expect(src).toMatch(/展开客服看名单/);
      expect(src).toMatch(/我自己说/);
      // 名单折叠:整宽一条,紧贴确认按钮上方
      expect(src).toMatch(/收起名单/);
      expect(src).toMatch(/展开名单（/);
      // 确认:整宽主按钮
      expect(src).toMatch(/className="w-full gap-1\.5"/);
    }
  });

  test('🔴 右上角⛔ 不许有"一次改全部"的时限控件(2026-08-20 走查拿掉)', () => {
    // 这一版的条目已经在客服手上、各有各的到期时刻。一次改全部 = 把 286 条的截止日
    // 一起推平,而卡片上「已改 N 条」当场变成 290 —— 主管完全看不出自己动了什么。
    // ⚠️ 只查代码:注释里当然要出现这些词(这段红字就是在讲为什么没有它)。
    const strip = (x: string) =>
      x.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
    expect(strip(REARRANGE)).not.toMatch(/全部改成/);
    // 逐条那一个必须还在
    expect(REARRANGE).toMatch(/<DaySelect\s+compact/);
  });

  test('⭐ 「专属」角标要显示 —— 拖走 = 拆一段老关系,他得在动手前看见', () => {
    expect(REARRANGE).toMatch(/dedicated && !moved/);
    expect(REARRANGE).toMatch(/专属/);
    // 与确认单同一支色
    expect(REARRANGE).toMatch(/bg-brand-50 px-1 text-\[10px\] font-normal text-brand-700/);
  });

  test('🔴 放置区是**整组**,⛔ 不只是抬头那一行', () => {
    // 展开后患者名单那一片在视觉上就属于这位客服,主管自然会往那儿放;
    // 只把抬头做成落点,他拖到名单里松手什么都不会发生,而且没有任何提示。
    const group = REARRANGE.slice(REARRANGE.indexOf('key={g.userId}'));
    const head = group.slice(0, group.indexOf('<div className="flex w-full items-center'));
    expect(head).toMatch(/onDragOver=/);
    expect(head).toMatch(/onDrop=/);
    expect(head).toMatch(/setDragOver\(g\.userId\)/); // 落点要高亮,否则他不知道会掉给谁
  });

  test('🔴 拖到边缘要能自动滚 —— 与确认单同一份实现', () => {
    for (const src of [CONFIRM, REARRANGE]) {
      expect(src).toMatch(/const EDGE = 56/);
      expect(src).toMatch(/requestAnimationFrame\(step\)/);
      expect(src).toMatch(/onDragOver=\{onDragOverAnywhere\}/);
      expect(src).toMatch(/onDragEnd=\{stopAutoScroll\}/);
    }
    // ⛔ 名单自己⛔ 不许再套一层滚动 —— 套了就把上面那套自动滚动废掉一半
    expect(REARRANGE).toMatch(/<CardContent className="p-0">/);
  });

  test('⛔ 但**内容**各归各的:调整单里没有"批"的那几样', () => {
    /**
     * 「福利 / 待分配 / 本批多大」都是**批**的概念。调整不建批次、这一版的人来自
     * 若干不同批次 —— 摆在调整单上等于在说一件不存在的事
     * (产品 2026-08-20:两者独立、不要污染)。
     * ⚠️ 只查**代码**,注释里当然要出现这些词(整段红字就是在讲为什么没有它们)。
     * ⚠️ 福利**可以只读展示**(这条来自的那一批挂了什么,转手前他有权知道),
     *   ⛔ 不许可编辑 —— 所以查的是编辑入口,不是"福利"两个字。
     */
    const strip = (x: string) =>
      x.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
    const code = strip(REARRANGE);
    expect(code).not.toMatch(/BenefitPopover/); // 挂福利的入口
    expect(code).not.toMatch(/set_benefit/);
    expect(code).not.toMatch(/待分配/);
    expect(code).not.toMatch(/本批/);
    // 反向:确认单那三样都在,证明这条断言不是因为写错了正则而恒真
    const confirm = strip(CONFIRM);
    expect(confirm).toMatch(/BenefitPopover/);
    expect(confirm).toMatch(/待分配/);
  });
});

/**
 * 🔴 **每一个工具都必须有中文说法** —— 没有的话等待文案会把英文工具名摆给主管看。
 *
 * 2026-08-20 走查抓到的原话:界面上显示「正在调用 propose_rearrange」。
 * `step-trace.tsx` 的兜底是 `正在调用 ${tool}` —— 刻意显示原始英文名(⛔ 不显示空白),
 * 就是为了让"漏配"一眼可见。但"一眼可见"的前提是有人正好在看,
 * ⇒ 用这条测试把它变成"漏配就红"。
 */
describe('工具的中文说法 —— 漏一个就会把英文名摆给主管看', () => {
  const web = (f: string) =>
    readFileSync(join(__dirname, `../../pac-web/src/components/assistant/${f}`), 'utf8');
  const STEP = web('step-trace.tsx');
  const SVC = readFileSync(
    join(__dirname, '../src/modules/assistant/assistant.service.ts'),
    'utf8',
  );
  const MCP = readFileSync(join(__dirname, '../src/modules/mcp/mcp-server.factory.ts'), 'utf8');

  /** step-trace 里登记了哪些 key */
  const mapped = new Set(
    [...STEP.matchAll(/^\s{2}([a-z_]+):\s*\{\s*label:/gm)].map((m) => m[1]!),
  );

  test('⭐ 助手的本地工具全都有中文说法', () => {
    const local = [...SVC.matchAll(/tools\.([a-z_]+)\s*=\s*tool\(/g)].map((m) => m[1]!);
    expect(local.length).toBeGreaterThan(3); // 正则没匹配到东西时这条会先炸,⛔ 别让它恒真
    expect(local.filter((t) => !mapped.has(t))).toEqual([]);
  });

  test('⭐ MCP 注册的工具全都有中文说法', () => {
    const mcp = [...MCP.matchAll(/registerTool\(\s*'([a-z_]+)'/g)].map((m) => m[1]!);
    expect(mcp.length).toBeGreaterThan(5);
    expect(mcp.filter((t) => !mapped.has(t))).toEqual([]);
  });

  test('⛔ 中文说法里不许夹英文工具名(主管的词汇表就是他在界面上见过的那些)', () => {
    const rows = [...STEP.matchAll(/label: '([^']*)', running: '([^']*)'/g)];
    expect(rows.length).toBeGreaterThan(5);
    for (const [, label, running] of rows) {
      expect(label).not.toMatch(/[a-z]_[a-z]/); // snake_case = 工具名
      expect(running).not.toMatch(/[a-z]_[a-z]/);
    }
  });
});

/**
 * 🔴 **主管回收必须在批次表上有出处** —— 2026-08-20 走查。
 *
 * 在此之前,主管在调整单里收回的单子在批次那张表上**凭空消失**:
 * 既不在「没动」(它已经不是 assigned)、也不在「退回」(那是客服的动作)。
 * 主管一对数就发现少了几条,而少的那几条查不出去哪了。
 */
describe('批次表要认得"主管回收"', () => {
  const SRC = readFileSync(
    join(__dirname, '../src/modules/plan/plan-assignment.service.ts'),
    'utf8',
  );
  const UI = readFileSync(
    join(__dirname, '../../pac-web/src/components/supervisor/batch-tracking.tsx'),
    'utf8',
  );

  test('⭐ 账本里单独数一列 recalled', () => {
    expect(SRC).toMatch(
      /count\(DISTINCT plan_id\)\s+FILTER \(WHERE event = 'rearrange_remove'\)\s+AS recalled/,
    );
  });

  test('⛔ 回收⛔ 不许混进 released —— 混了退回原因分布就被主管自己的调度污染', () => {
    // released 只数 release 事件
    expect(SRC).toMatch(/FILTER \(WHERE event = 'release'\)\s+AS released/);
    // 退回原因分布只在 release 那一支里累加(rearrange_remove 那一支⛔ 不碰 reasonCount)
    const recallLoop = SRC.slice(SRC.indexOf('const seenRecall'), SRC.indexOf('const seenRecall') + 700);
    expect(recallLoop).not.toMatch(/reasonCount/);
  });

  test('⭐ 「落回池子」那句要把三种来路都报出来', () => {
    expect(SRC).toMatch(/你自己在调整时收回/);
  });

  test('🔴 分组表头的 colSpan 必须从列名推,⛔ 不许手写数字', () => {
    // 2026-08-20 走查:加「回收」那一列时 colSpan 还写着手抄的 7,分组行少跨一格 ——
    // 最后一列在「还在跑的」「8 月」这两行上留出一块白,而且不报任何错。
    expect(UI).toMatch(/colSpan=\{NUM_COLS\.length \+ 1\}/);
    expect(UI).not.toMatch(/colSpan=\{\d+\}/);
    // 列名也必须只有这一处定义,表头从它 map
    expect(UI).toMatch(/\{NUM_COLS\.map\(/);
  });

  test('⭐ 界面:列头分「客服退回」与「回收」,两处都要', () => {
    // 列表行(常量里)
    expect(UI).toMatch(/const NUM_COLS = \['条数', '已处置', '没动', '客服退回', '回收', '超期', '约上'\]/);
    // 详情里按客服拆
    expect(UI).toMatch(/'客服', '分到', '已处置', '没动', '客服退回', '回收'/);
    // 原因分布只属于客服退回
    expect(UI).toMatch(/客服退回原因分布/);
  });
});
