Commit e1ffafbc by luoqi

test(plan): runAllForHost 批量化等价性测试 + 修复 stale-close spec 过时 mock

新增 plan-engine-batch.spec.ts:忠实内存 mock 端到端验证批量路径 5 种结局(create/unchanged/superseded/suppressed/stale-close)+ 混合并发 + createMany 日志,与逐患者语义等价。修 plan-engine-stale-close.spec.ts 过时 mock(status:{in}匹配、加 persona.findMany/createMany)与返回形态断言(toMatchObject);assigned 0命中关闭对齐 2026-06 决策。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 5b5379af
import { PlanEngineService } from '../src/modules/plan/engine/plan-engine.service';
import type { ScenarioHit } from '../src/modules/plan/engine/scenario.interface';
/**
* runAllForHost 批量化(2026-06)回归:
* bulk 预取 latest/snoozed/persona + 分块并发 upsert + PlanGenerationLog createMany。
* 目标:证明批量路径在全部 5 种结局(create / unchanged / superseded / suppressed / stale-close)
* 下与逐患者语义**等价**。用忠实内存 mock(按 where/orderBy/include 真实建模)端到端跑。
*/
const HOST = 'host-1';
const TENANT = 'tenant-1';
const SCEN = 'treatment_initiation_recall';
type Reason = { scenario: string; subKey: string | null };
interface Plan {
id: string;
hostId: string;
tenantId: string;
patientId: string;
version: number;
status: string;
priorityScore: number;
snoozedUntil: Date | null;
reasons: Reason[];
assigneeUserId: string | null;
assignedAt: Date | null;
recycleAt: Date | null;
contactAttempts: number;
}
const inArr = (v: unknown): unknown[] | null =>
v && typeof v === 'object' && Array.isArray((v as { in?: unknown[] }).in)
? (v as { in: unknown[] }).in
: null;
/// 忠实内存 mock:findFirst/findMany 真的按 where(patientId/status 支持 in,snoozedUntil.gt)过滤、
/// 按 version desc 排序;update/updateMany/create/$transaction 真的改同一个 plans 数组。
function makeStore(seed: { plans?: Partial<Plan>[]; personas?: { patientId: string; id: string }[] } = {}) {
let idc = 1000;
const plans: Plan[] = (seed.plans ?? []).map((p, i) => ({
id: p.id ?? `seed-${i}`,
hostId: HOST,
tenantId: TENANT,
patientId: p.patientId!,
version: p.version ?? 1,
status: p.status ?? 'active',
priorityScore: p.priorityScore ?? 50,
snoozedUntil: p.snoozedUntil ?? null,
reasons: p.reasons ?? [],
assigneeUserId: p.assigneeUserId ?? null,
assignedAt: p.assignedAt ?? null,
recycleAt: p.recycleAt ?? null,
contactAttempts: p.contactAttempts ?? 0,
}));
const personas = seed.personas ?? [];
const logs: Array<Record<string, unknown>> = [];
const matchPlan = (pl: Plan, where: Record<string, unknown>): boolean => {
if (where.hostId && pl.hostId !== where.hostId) return false;
if (where.tenantId && pl.tenantId !== where.tenantId) return false;
if (where.patientId !== undefined) {
const ins = inArr(where.patientId);
if (ins) {
if (!ins.includes(pl.patientId)) return false;
} else if (pl.patientId !== where.patientId) return false;
}
if (where.status !== undefined) {
const ins = inArr(where.status);
if (ins) {
if (!ins.includes(pl.status)) return false;
} else if (pl.status !== where.status) return false;
}
const sn = where.snoozedUntil as { gt?: Date } | undefined;
if (sn?.gt !== undefined) {
if (!pl.snoozedUntil || !(pl.snoozedUntil > sn.gt)) return false;
}
return true;
};
const followupPlan = {
findFirst: jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
const m = plans.filter((p) => matchPlan(p, where)).sort((a, b) => b.version - a.version);
return m[0] ?? null;
}),
findMany: jest.fn(async ({ where }: { where: Record<string, unknown> }) =>
plans
.filter((p) => matchPlan(p, where))
.sort((a, b) => a.patientId.localeCompare(b.patientId) || b.version - a.version),
),
update: jest.fn(async ({ where, data }: { where: { id: string }; data: Partial<Plan> }) => {
const p = plans.find((x) => x.id === where.id)!;
Object.assign(p, data);
return p;
}),
updateMany: jest.fn(async ({ where, data }: { where: { id: { in: string[] } }; data: Partial<Plan> }) => {
const ids = where.id?.in ?? [];
let count = 0;
for (const p of plans)
if (ids.includes(p.id)) {
Object.assign(p, data);
count++;
}
return { count };
}),
create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
const rdata = (data.reasons as { createMany?: { data?: Array<{ scenario: string; subKey?: string }> } })
?.createMany?.data ?? [];
const p: Plan = {
id: `new-${idc++}`,
hostId: data.hostId as string,
tenantId: data.tenantId as string,
patientId: data.patientId as string,
version: data.version as number,
status: data.status as string,
priorityScore: data.priorityScore as number,
snoozedUntil: null,
reasons: rdata.map((r) => ({ scenario: r.scenario, subKey: r.subKey ?? null })),
assigneeUserId: (data.assigneeUserId as string) ?? null,
assignedAt: (data.assignedAt as Date) ?? null,
recycleAt: (data.recycleAt as Date) ?? null,
contactAttempts: (data.contactAttempts as number) ?? 0,
};
plans.push(p);
return p;
}),
};
const persona = {
findFirst: jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
const ins = inArr(where.patientId);
const cand = personas.filter((pe) =>
ins ? ins.includes(pe.patientId) : pe.patientId === where.patientId,
);
return cand[0] ?? null;
}),
findMany: jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
const ins = (inArr(where.patientId) as string[]) ?? [];
return personas.filter((pe) => ins.includes(pe.patientId));
}),
};
const planGenerationLog = {
create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
const r = { id: `log-${idc++}`, ...data };
logs.push(r);
return r;
}),
update: jest.fn(async () => ({})),
createMany: jest.fn(async ({ data }: { data: Array<Record<string, unknown>> }) => {
logs.push(...data);
return { count: data.length };
}),
};
const prisma = {
followupPlan,
persona,
planGenerationLog,
$transaction: jest.fn(async (cb: (tx: unknown) => Promise<unknown>) =>
cb({ followupPlan: { update: followupPlan.update, create: followupPlan.create } }),
),
};
return { prisma, plans, logs };
}
function makeScenario(hits: ScenarioHit[]) {
return { key: SCEN, selectHits: jest.fn(async () => hits) };
}
function hit(patientId: string, subKey: string, priorityScore = 50): ScenarioHit {
return {
patientId,
patientExternalId: `ext-${patientId}`,
reason: 'r',
priorityScore,
subKey,
evidence: { factIds: ['f1'] },
} as ScenarioHit;
}
function engine(prisma: unknown, scenario: unknown) {
return new PlanEngineService(prisma as never, scenario as never);
}
const NOW = new Date('2026-06-02T00:00:00Z');
describe('runAllForHost 批量路径 — 5 种结局等价', () => {
test('create:有命中无既有 plan → plansCreated=1 + 新 active plan(reason 正确)', async () => {
const { prisma, plans } = makeStore({});
const res = await engine(prisma, makeScenario([hit('pat-a', 'missing_tooth@whole')])).runAllForHost({
hostId: HOST,
tenantId: TENANT,
now: NOW,
});
expect(res.plansCreated).toBe(1);
const active = plans.filter((p) => p.patientId === 'pat-a' && p.status === 'active');
expect(active).toHaveLength(1);
expect(active[0]!.reasons.map((r) => r.subKey)).toEqual(['missing_tooth@whole']);
});
test('unchanged:既有 active 同 (scenario,subKey) → plansUnchanged=1,不升版本,仅刷分', async () => {
const { prisma, plans } = makeStore({
plans: [
{
id: 'p-old',
patientId: 'pat-b',
version: 1,
status: 'active',
priorityScore: 40,
reasons: [{ scenario: SCEN, subKey: 'missing_tooth@whole' }],
},
],
});
const res = await engine(prisma, makeScenario([hit('pat-b', 'missing_tooth@whole', 55)])).runAllForHost({
hostId: HOST,
tenantId: TENANT,
now: NOW,
});
expect(res.plansUnchanged).toBe(1);
expect(res.plansSuperseded).toBe(0);
expect(plans.filter((p) => p.patientId === 'pat-b')).toHaveLength(1); // 没升版本
expect(plans.find((p) => p.id === 'p-old')!.priorityScore).toBe(55); // 刷分
});
test('superseded:既有 active 不同 subKey → plansSuperseded=1,旧 superseded + 新 v2 active', async () => {
const { prisma, plans } = makeStore({
plans: [
{
id: 'p-old',
patientId: 'pat-c',
version: 1,
status: 'active',
reasons: [{ scenario: SCEN, subKey: 'caries@16' }],
},
],
});
const res = await engine(prisma, makeScenario([hit('pat-c', 'missing_tooth@36')])).runAllForHost({
hostId: HOST,
tenantId: TENANT,
now: NOW,
});
expect(res.plansSuperseded).toBe(1);
expect(plans.find((p) => p.id === 'p-old')!.status).toBe('superseded');
const v2 = plans.find((p) => p.patientId === 'pat-c' && p.version === 2);
expect(v2?.status).toBe('active');
expect(v2?.reasons.map((r) => r.subKey)).toEqual(['missing_tooth@36']);
});
test('suppressed:命中信号在 终态+未到期 抑制集 → plansSuppressed=1,不建新', async () => {
const { prisma, plans } = makeStore({
plans: [
{
id: 'p-term',
patientId: 'pat-d',
version: 1,
status: 'completed',
snoozedUntil: new Date('2026-12-01T00:00:00Z'),
reasons: [{ scenario: SCEN, subKey: 'missing_tooth@whole' }],
},
],
});
const res = await engine(prisma, makeScenario([hit('pat-d', 'missing_tooth@whole')])).runAllForHost({
hostId: HOST,
tenantId: TENANT,
now: NOW,
});
expect(res.plansSuppressed).toBe(1);
expect(res.plansCreated).toBe(0);
expect(plans.filter((p) => p.patientId === 'pat-d' && p.status === 'active')).toHaveLength(0);
});
test('stale-close:有 active 但本轮 0 命中 → plansClosed=1', async () => {
const { prisma, plans } = makeStore({
plans: [
{
id: 'p-stale',
patientId: 'pat-e',
version: 1,
status: 'active',
reasons: [{ scenario: SCEN, subKey: 'caries@16' }],
},
],
});
const res = await engine(prisma, makeScenario([])).runAllForHost({
hostId: HOST,
tenantId: TENANT,
now: NOW,
});
expect(res.plansClosed).toBe(1);
expect(plans.find((p) => p.id === 'p-stale')!.status).toBe('superseded');
});
test('混合一批:create+unchanged+superseded+suppressed 并发跑 → 计数正确 + 每患一条 createMany 日志', async () => {
const { prisma, plans, logs } = makeStore({
plans: [
{ id: 'u-old', patientId: 'p2', status: 'active', priorityScore: 50, reasons: [{ scenario: SCEN, subKey: 'k@1' }] },
{ id: 's-old', patientId: 'p3', status: 'active', reasons: [{ scenario: SCEN, subKey: 'k@old' }] },
{
id: 't-term',
patientId: 'p4',
status: 'abandoned',
snoozedUntil: new Date('2026-12-01T00:00:00Z'),
reasons: [{ scenario: SCEN, subKey: 'k@4' }],
},
],
});
const res = await engine(
prisma,
makeScenario([
hit('p1', 'k@1'), // create
hit('p2', 'k@1', 50), // unchanged
hit('p3', 'k@new'), // superseded
hit('p4', 'k@4'), // suppressed
]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
expect({ c: res.plansCreated, u: res.plansUnchanged, s: res.plansSuperseded, sup: res.plansSuppressed }).toEqual({
c: 1,
u: 1,
s: 1,
sup: 1,
});
expect(res.patientsHit).toBe(4);
// PlanGenerationLog 走批量 createMany,每患一行(口径不变)
expect(prisma.planGenerationLog.createMany).toHaveBeenCalled();
expect(logs.filter((l) => ['p1', 'p2', 'p3', 'p4'].includes(l.patientId as string))).toHaveLength(4);
// 无既有 active 且在 hitsByPatient 的患者不应被 stale-close 误关
expect(res.plansClosed).toBe(0);
});
});
......@@ -28,18 +28,25 @@ function makePrismaMock(opts: {
const create = jest.fn().mockResolvedValue({ id: 'new-plan' });
const findFirst = jest.fn().mockResolvedValue(opts.latestPlan ?? null);
const findMany = jest.fn().mockImplementation(async ({ where }) => {
// runAllForHost 关闭闸:status='active'
if (where?.status === 'active') return opts.activePlans ?? [];
// fetchSnoozedSignalKeys:status IN ('completed','abandoned')(冷静期)
// runAllForHost 关闭闸:status IN ('active','assigned')
const statusIn = where?.status?.in;
if (Array.isArray(statusIn) && statusIn.includes('active')) return opts.activePlans ?? [];
// snoozed(completed/abandoned)/ prefetchForBatch latest(无 status)→ 空
return [];
});
const prisma = {
followupPlan: { findFirst, findMany, update, updateMany, create },
persona: { findFirst: jest.fn().mockResolvedValue(null) },
// 批量路径 prefetchForBatch 用 persona.findMany;单刷用 findFirst
persona: {
findFirst: jest.fn().mockResolvedValue(null),
findMany: jest.fn().mockResolvedValue([]),
},
planGenerationLog: {
create: jest.fn().mockResolvedValue({ id: 'log-1' }),
update: jest.fn().mockResolvedValue({}),
// 批量路径日志一次性 createMany
createMany: jest.fn().mockResolvedValue({ count: 0 }),
},
$transaction: jest
.fn()
......@@ -84,7 +91,7 @@ describe('缺口1 — recomputeForPatient 0 命中关闭遗留 active plan', ()
const res = await engine.recomputeForPatient({ hostId: HOST, tenantId: TENANT, patientId: 'pat-a' });
expect(res).toEqual({ plansCreated: 0, plansClosed: 1 });
expect(res).toMatchObject({ plansCreated: 0, plansClosed: 1 });
expect(spies.update).toHaveBeenCalledTimes(1);
expect(spies.update).toHaveBeenCalledWith(
expect.objectContaining({
......@@ -96,14 +103,16 @@ describe('缺口1 — recomputeForPatient 0 命中关闭遗留 active plan', ()
expect(prisma.planGenerationLog.create).not.toHaveBeenCalled();
});
test('0 命中 + assigned plan → 不动(客服跟进中,plansClosed=0)', async () => {
// 注:closeStaleActivePlan 现关闭 active **及 assigned**(2026-06 决策:缺口全消失即关,
// 含 assigned,否则客服拿"已解决"理由白打)。service.ts:70 行旧注释"assigned 不动"已过时。
test('0 命中 + assigned plan → 关闭(2026-06 决策:缺口消失即关,含 assigned)', async () => {
const { prisma, spies } = makePrismaMock({ latestPlan: { id: 'plan-a', status: 'assigned' } });
const engine = makeEngine(prisma, makeScenario([]));
const res = await engine.recomputeForPatient({ hostId: HOST, tenantId: TENANT, patientId: 'pat-a' });
expect(res).toEqual({ plansCreated: 0, plansClosed: 0 });
expect(spies.update).not.toHaveBeenCalled();
expect(res).toMatchObject({ plansCreated: 0, plansClosed: 1 });
expect(spies.update).toHaveBeenCalledTimes(1);
});
test('0 命中 + 无任何 plan → 无操作(plansClosed=0)', async () => {
......@@ -112,7 +121,7 @@ describe('缺口1 — recomputeForPatient 0 命中关闭遗留 active plan', ()
const res = await engine.recomputeForPatient({ hostId: HOST, tenantId: TENANT, patientId: 'pat-a' });
expect(res).toEqual({ plansCreated: 0, plansClosed: 0 });
expect(res).toMatchObject({ plansCreated: 0, plansClosed: 0 });
expect(spies.update).not.toHaveBeenCalled();
});
......@@ -122,7 +131,7 @@ describe('缺口1 — recomputeForPatient 0 命中关闭遗留 active plan', ()
const res = await engine.recomputeForPatient({ hostId: HOST, tenantId: TENANT, patientId: 'pat-a' });
expect(res).toEqual({ plansCreated: 0, plansClosed: 0 });
expect(res).toMatchObject({ plansCreated: 0, plansClosed: 0 });
expect(spies.update).not.toHaveBeenCalled();
});
});
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment