Commit 7808f53c by luoqi

fix(plan): unchanged 分支支持 reason 就地刷新 —— 修存量 plan 的 signals 不生效

## 问题

引擎变化判定只比 `(scenario, subKey)` 集合;集合没变就走 unchanged 分支,
**plan_reasons 一个字都不改**。于是"只改 signals 内容"的算法升级对存量 plan
永远不生效 —— 生产 3,170 条高龄缺牙即使上线了按年龄排治疗(signals 新增
focusCategory / patientAge),仍显示「目标·种植」。

原先只能删库重算(丢认领、丢版本号、丢触达计数)或写一次性 SQL 回填(逻辑重复、
易与引擎口径漂移)。

## 做法

unchanged 分支新增就地刷新:**不升版本、不动认领/状态/抑制窗/触达计数**。
同时补 plan.goal 的比较(goal 是静态文案,算法改措辞要能落到存量)。

刻意不动的字段:closedReason / closedAt(关闭状态)、source / sourceActorId /
campaignId(来源溯源)、lifecycle;已关闭的 reason 行直接跳过。

##  关键:只在语义变化时才写

reason 文案与 signals 都内嵌天数(`${days} 天前` / `signals.daysSince`),天天变。
无条件刷新 = 每日重算把全部 plan_reasons 重写一遍(生产 23 万+ plan),纯废写 + WAL 膨胀。

判据(reason-refresh.ts):比 **signals(去掉 daysSince)+ evidence.factIds(排序后)**,
**不解析文案** —— reason 文案完全由 signals 派生(诊断名←triggers、牙位←toothPosition、
治疗类目←expectedCategories+patientAge),所以比 signals 既充分又不依赖文案格式。

踩到并修掉的坑:**Postgres jsonb 不保留键序**(按键长+字节序重排),直接 JSON.stringify
比较会让库里读出的和新算出的永远不等 → 每次都判"变了"。本地实测连跑三次每次都刷新。
改用递归排序键的规范化序列化后,第二三次不再写。

## 验证
- 382 单测通过(新增 16 例 reason-refresh 单元 + 2 例批量路径集成)
- 本地端到端(90 岁吕学文,人为退回存量旧状态 + 认领 + 触达 2 次):
  第 1 次重算 → 「reason 就地刷新 3 条」,signals 补上 focusCategory/patientAge、
  文案变「未启动活动义齿 / 种植」、goal 换高龄版;
  version 仍为 1、status=assigned、assignee=u-tester、contactAttempts=2 全部未动;
  第 2、3 次重算 → 0 条刷新(幂等,不产生废写)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent e8e121c5
Pipeline #3440 failed in 0 seconds
...@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client'; ...@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { TreatmentInitiationRecallScenario } from './scenarios/treatment-initiation-recall.scenario'; import { TreatmentInitiationRecallScenario } from './scenarios/treatment-initiation-recall.scenario';
import type { PlanScenarioPlugin, ScenarioHit, ScenarioScope } from './scenario.interface'; import type { PlanScenarioPlugin, ScenarioHit, ScenarioScope } from './scenario.interface';
import { reasonNeedsRefresh } from './reason-refresh';
/** /**
* PlanEngineService — 跑所有 scenario plugin,产 FollowupPlan + PlanReason * PlanEngineService — 跑所有 scenario plugin,产 FollowupPlan + PlanReason
...@@ -380,6 +381,9 @@ export class PlanEngineService { ...@@ -380,6 +381,9 @@ export class PlanEngineService {
// 不该升版本,就地改即可 —— 否则存量 plan 永远停在旧诊所,重算修不好)。 // 不该升版本,就地改即可 —— 否则存量 plan 永远停在旧诊所,重算修不好)。
const patch: Prisma.FollowupPlanUpdateInput = {}; const patch: Prisma.FollowupPlanUpdateInput = {};
if (latest.priorityScore !== newPriorityScore) patch.priorityScore = newPriorityScore; if (latest.priorityScore !== newPriorityScore) patch.priorityScore = newPriorityScore;
// goal 是静态文案(不含天数)→ 直接比。算法改了措辞(如高龄缺牙 goal 改「活动义齿为主」)
// 要能落到存量 plan,否则详情页「本次目标」永远是旧话。
if ((latest.goal ?? null) !== (head.goal ?? null)) patch.goal = head.goal ?? null;
if (latest.targetClinicId !== targetClinicId) { if (latest.targetClinicId !== targetClinicId) {
patch.targetClinicId = targetClinicId; patch.targetClinicId = targetClinicId;
// ⭐ 归属漂移 + 已认领 → 顺带返池:诊所隔离是硬边界,单子漂出原认领人 scope 后 // ⭐ 归属漂移 + 已认领 → 顺带返池:诊所隔离是硬边界,单子漂出原认领人 scope 后
...@@ -396,8 +400,58 @@ export class PlanEngineService { ...@@ -396,8 +400,58 @@ export class PlanEngineService {
(latest.status === 'assigned' ? '(原 assigned → 返池)' : ''), (latest.status === 'assigned' ? '(原 assigned → 返池)' : ''),
); );
} }
// ⭐ reason 就地刷新(2026-07):(scenario, subKey) 没变、但 **signals 内容变了** 的算法升级
// ——典型是缺牙按年龄排治疗(signals 新增 focusCategory / patientAge)——
// 原先对存量 plan 永远不生效(生产 3,170 条高龄缺牙仍显示「目标·种植」)。
// 这里就地改:**不升版本、不动认领/状态/抑制窗/触达计数**。
// 判据见 reason-refresh.ts:只比 signals(去 daysSince)+ evidence,
// 否则天数天天变会让每日重算把全部 plan_reasons 重写一遍。
const hitByKey = new Map(usableHits.map((h) => [key(h.scenarioKey, h.subKey), h]));
const staleRows = latest.reasons.filter((r) => {
// 已关闭的 reason 是历史,不再刷新(也不会被"复活",closedReason 本就不在更新字段里)
if (r.closedReason != null) return false;
const h = hitByKey.get(key(r.scenario, r.subKey));
if (h == null) return false;
return reasonNeedsRefresh(
{ signals: r.signals, evidence: r.evidence },
{ signals: h.signals ?? null, evidence: { factIds: h.evidence.factIds } },
);
});
if (Object.keys(patch).length > 0 || staleRows.length > 0) {
await this.prisma.$transaction(async (tx) => {
if (Object.keys(patch).length > 0) { if (Object.keys(patch).length > 0) {
await this.prisma.followupPlan.update({ where: { id: latest.id }, data: patch }); await tx.followupPlan.update({ where: { id: latest.id }, data: patch });
}
for (const r of staleRows) {
const h = hitByKey.get(key(r.scenario, r.subKey))!;
await tx.planReason.update({
where: { id: r.id },
// 语义已变 → 整行重写(天数等易变字段顺带刷新,反正这次要写)。
// ⚠️ 刻意不动:closedReason / closedAt(关闭状态)、source / sourceActorId /
// campaignId(来源溯源)、lifecycle(由 scenario+subKey 决定,未变)。
data: {
reason: h.reason,
priorityScore: h.priorityScore,
signals: h.signals
? (h.signals as unknown as Prisma.InputJsonValue)
: Prisma.JsonNull,
evidence: { factIds: h.evidence.factIds } as Prisma.InputJsonValue,
breakdown: h.priorityBreakdown
? ({
priority: { ...h.priorityBreakdown },
subKey: h.subKey ?? null,
} as Prisma.InputJsonValue)
: Prisma.JsonNull,
},
});
}
});
if (staleRows.length > 0) {
this.logger.log(
`plan ${latest.id} reason 就地刷新 ${staleRows.length} (signals 语义变化,不升版本)`,
);
}
} }
return 'unchanged'; return 'unchanged';
} }
......
/**
* unchanged 分支的「reason 就地刷新」判定。
*
* ── 解决什么问题 ──
* 引擎的变化判定只比 `(scenario, subKey)` 集合;集合没变就走 unchanged 分支,**plan_reasons 一个字都不改**。
* 于是"只改了 signals 内容"的算法升级(如 2026-07 缺牙按年龄排治疗:signals 新增
* focusCategory / patientAge)对**存量 plan 永远不生效** —— 生产实测 3,170 条高龄缺牙
* 仍显示「目标·种植」。要修只能删库重算(丢认领、丢版本号)或写一次性 SQL 回填(逻辑重复)。
* 本模块让 unchanged 分支能就地刷新:**不升版本、不动认领/状态/抑制窗**。
*
* ── 为什么必须"只在语义变化时才写" ──
* reason 文案与 signals 里都内嵌**天数**(`${days} 天前` / `signals.daysSince`),每天都在变。
* 无条件刷新 = 每日重算把全部 plan_reasons 重写一遍(生产 23 万+ plan),纯废写 + WAL 膨胀。
*
* ── 判据:比 signals(去掉 daysSince)+ evidence,**不解析文案** ──
* reason 文案完全由 signals 派生 —— 诊断名 ← triggers[].code、牙位 ← toothPosition、
* 治疗类目 ← expectedCategories + patientAge(年龄适配后)。
* 所以「signals 去掉 daysSince 后相同」⇒「文案只可能差在天数上」。
* 据此判定既充分,又不依赖文案格式(将来 scenario 改文案模板不会引发误刷)。
*/
/**
* 规范化序列化:**递归按键名排序**后再 stringify。
*
* ⚠️ 必须这么做 —— Postgres `jsonb` **不保留键序**(按键长度+字节序重排存储),
* 而代码里构造 signals 的顺序是源码顺序。直接 JSON.stringify 比较,库里读出来的
* 和新算出来的键序必然不同 → 每次都判"变了" → 每日重算把全部 plan_reasons 重写一遍,
* 恰好是本模块要避免的事(本地实测:不加这层,连跑三次每次都刷新)。
*/
function canonical(v: unknown): string {
const norm = (x: unknown): unknown => {
if (Array.isArray(x)) return x.map(norm);
if (x !== null && typeof x === 'object') {
const o = x as Record<string, unknown>;
return Object.keys(o)
.sort()
.reduce<Record<string, unknown>>((acc, k) => {
if (o[k] !== undefined) acc[k] = norm(o[k]);
return acc;
}, {});
}
return x;
};
return JSON.stringify(norm(v));
}
/** 稳定投影:剔除随时间必变、不代表语义变化的字段 */
function stableSignals(signals: unknown): unknown {
if (signals == null || typeof signals !== 'object') return signals ?? null;
// daysSince 是生成那刻的快照,展示层用 signalOccurredAt 实时重算(见 applyLiveDays),
// 它变化不代表召回语义变了 → 不作为刷新依据。
const { daysSince: _drop, ...rest } = signals as Record<string, unknown>;
return rest;
}
/** evidence.factIds 顺序在不同批次可能不同,排序后再比,避免顺序抖动触发误刷 */
function stableEvidence(evidence: unknown): unknown {
if (evidence == null || typeof evidence !== 'object') return null;
const ids = (evidence as { factIds?: unknown }).factIds;
return Array.isArray(ids) ? [...ids].map(String).sort() : null;
}
export interface ReasonContent {
signals: unknown;
evidence: unknown;
}
/**
* 该 reason 行是否需要就地刷新。
* true = 语义内容变了(signals 或证据集变了)→ 整行重写(含天数等易变字段,反正要写)。
* false = 只是天数漂移 → 不写。
*/
export function reasonNeedsRefresh(oldRow: ReasonContent, nextHit: ReasonContent): boolean {
return (
canonical(stableSignals(oldRow.signals)) !== canonical(stableSignals(nextHit.signals)) ||
canonical(stableEvidence(oldRow.evidence)) !== canonical(stableEvidence(nextHit.evidence))
);
}
...@@ -12,7 +12,15 @@ const HOST = 'host-1'; ...@@ -12,7 +12,15 @@ const HOST = 'host-1';
const TENANT = 'tenant-1'; const TENANT = 'tenant-1';
const SCEN = 'treatment_initiation_recall'; const SCEN = 'treatment_initiation_recall';
type Reason = { scenario: string; subKey: string | null }; // id / evidence / signals:unchanged 分支的 reason 就地刷新要用(见 reason-refresh.ts)
type Reason = {
scenario: string;
subKey: string | null;
id?: string;
evidence?: unknown;
signals?: unknown;
closedReason?: string | null;
};
interface Plan { interface Plan {
id: string; id: string;
hostId: string; hostId: string;
...@@ -156,17 +164,24 @@ function makeStore(seed: { plans?: Partial<Plan>[]; personas?: { patientId: stri ...@@ -156,17 +164,24 @@ function makeStore(seed: { plans?: Partial<Plan>[]; personas?: { patientId: stri
}), }),
}; };
// unchanged 分支的 reason 就地刷新会用到(见 reason-refresh.ts)
const planReason = { update: jest.fn(async (_args: unknown) => ({})) };
const prisma = { const prisma = {
followupPlan, followupPlan,
planReason,
persona, persona,
planGenerationLog, planGenerationLog,
// 最后到诊诊所查询(prefetchForBatch):本套件不测归属 → 返空 = 回退兜底 head.targetClinicId // 最后到诊诊所查询(prefetchForBatch):本套件不测归属 → 返空 = 回退兜底 head.targetClinicId
$queryRaw: jest.fn(async (): Promise<Array<{ patient_id: string; clinic_id: string }>> => []), $queryRaw: jest.fn(async (): Promise<Array<{ patient_id: string; clinic_id: string }>> => []),
$transaction: jest.fn(async (cb: (tx: unknown) => Promise<unknown>) => $transaction: jest.fn(async (cb: (tx: unknown) => Promise<unknown>) =>
cb({ followupPlan: { update: followupPlan.update, create: followupPlan.create } }), cb({
followupPlan: { update: followupPlan.update, create: followupPlan.create },
planReason: { update: planReason.update },
}),
), ),
}; };
return { prisma, plans, logs }; return { prisma, plans, logs, planReason };
} }
function makeScenario(hits: ScenarioHit[]) { function makeScenario(hits: ScenarioHit[]) {
...@@ -226,6 +241,88 @@ describe('runAllForHost 批量路径 — 5 种结局等价', () => { ...@@ -226,6 +241,88 @@ describe('runAllForHost 批量路径 — 5 种结局等价', () => {
expect(plans.find((p) => p.id === 'p-old')!.priorityScore).toBe(55); // 刷分 expect(plans.find((p) => p.id === 'p-old')!.priorityScore).toBe(55); // 刷分
}); });
// ⭐ 2026-07:(scenario,subKey) 没变、但 signals 语义变了(如缺牙按年龄排治疗新增
// focusCategory / patientAge)—— 原先走 unchanged 分支一个字都不改,存量 plan 永远修不好。
test('unchanged + signals 语义变化 → reason 就地刷新,**不升版本、不动认领**', async () => {
const { prisma, plans, planReason } = makeStore({
plans: [
{
id: 'p-stale',
patientId: 'pat-age',
version: 3,
status: 'assigned',
assigneeUserId: 'u-cs',
contactAttempts: 2,
priorityScore: 50,
reasons: [
{
id: 'r-stale',
scenario: SCEN,
subKey: 'missing_tooth@16',
evidence: { factIds: ['f1'] },
// 旧 signals:没有年龄适配字段
signals: { subKey: 'missing_tooth', expectedCategories: ['implant', 'prosthodontic'] },
},
],
},
],
});
const h = hit('pat-age', 'missing_tooth@16', 50);
// 新 signals 带上年龄适配结果
(h as { signals?: unknown }).signals = {
subKey: 'missing_tooth',
expectedCategories: ['implant', 'prosthodontic'],
focusCategory: 'prosthodontic',
patientAge: 90,
};
const res = await engine(prisma, makeScenario([h])).runAllForHost({
hostId: HOST, tenantId: TENANT, now: NOW,
});
expect(res.plansUnchanged).toBe(1);
expect(res.plansSuperseded).toBe(0);
expect(plans.filter((p) => p.patientId === 'pat-age')).toHaveLength(1); // 没升版本
const p = plans.find((x) => x.id === 'p-stale')!;
expect(p.version).toBe(3); // 版本号未动
expect(p.status).toBe('assigned'); // 状态未动
expect(p.assigneeUserId).toBe('u-cs'); // ⭐ 认领未丢
expect(p.contactAttempts).toBe(2); // 触达计数未清
// reason 行被就地重写
expect(planReason.update).toHaveBeenCalledTimes(1);
const arg = planReason.update.mock.calls[0]![0] as {
where: { id: string }; data: { signals: Record<string, unknown> };
};
expect(arg.where.id).toBe('r-stale');
expect(arg.data.signals.focusCategory).toBe('prosthodontic');
expect(arg.data.signals.patientAge).toBe(90);
});
test('⭐ unchanged + signals 完全一致 → 不写 reason(防每日重算全量重写)', async () => {
const signals = {
subKey: 'missing_tooth',
expectedCategories: ['implant', 'prosthodontic'],
focusCategory: 'prosthodontic',
patientAge: 90,
daysSince: 100,
};
const { prisma, planReason } = makeStore({
plans: [
{
id: 'p-fresh', patientId: 'pat-same', version: 1, status: 'active', priorityScore: 50,
reasons: [
{ id: 'r1', scenario: SCEN, subKey: 'missing_tooth@16',
evidence: { factIds: ['f1'] }, signals },
],
},
],
});
const h = hit('pat-same', 'missing_tooth@16', 50);
// 只有天数往前走了一天 —— 语义没变
(h as { signals?: unknown }).signals = { ...signals, daysSince: 101 };
await engine(prisma, makeScenario([h])).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
expect(planReason.update).not.toHaveBeenCalled();
});
test('superseded:既有 active 不同 subKey → plansSuperseded=1,旧 superseded + 新 v2 active', async () => { test('superseded:既有 active 不同 subKey → plansSuperseded=1,旧 superseded + 新 v2 active', async () => {
const { prisma, plans } = makeStore({ const { prisma, plans } = makeStore({
plans: [ plans: [
......
import { reasonNeedsRefresh } from '../src/modules/plan/engine/reason-refresh';
/**
* unchanged 分支「reason 就地刷新」判定回归。
*
* 背景:引擎变化判定只比 (scenario, subKey) 集合,集合没变就走 unchanged 分支、plan_reasons 一字不改。
* 于是"只改 signals 内容"的算法升级对存量 plan 永远不生效 —— 生产 3,170 条高龄缺牙即使
* 上线了按年龄排治疗,仍显示「目标·种植」。
*
* 修复引入的**新风险**(本文件主要防这个):reason 文案与 signals 都内嵌天数,天天变。
* 若无条件刷新,每日重算会把全部 plan_reasons(生产 23 万+ plan)重写一遍 —— 纯废写。
* 所以判据必须**忽略纯时间漂移**,只认语义变化。
*/
const BASE = {
subKey: 'missing_tooth',
triggers: [{ type: 'diagnosis', code: 'K08' }],
toothPosition: '16;17',
daysSince: 118,
signalOccurredAt: '2026-03-28T00:00:00.000Z',
expectedCategories: ['implant', 'prosthodontic'],
};
const EV = { factIds: ['f1', 'f2'] };
const row = (signals: unknown, evidence: unknown = EV) => ({ signals, evidence });
describe('reasonNeedsRefresh — 只在语义变化时才写', () => {
test('⭐ 完全相同 → 不刷新', () => {
expect(reasonNeedsRefresh(row({ ...BASE }), row({ ...BASE }))).toBe(false);
});
test('⭐ 只有 daysSince 变(天天都会发生)→ **不刷新**,否则每日重算全量重写', () => {
expect(reasonNeedsRefresh(row({ ...BASE, daysSince: 118 }), row({ ...BASE, daysSince: 119 }))).toBe(
false,
);
// 跨很多天也一样
expect(reasonNeedsRefresh(row({ ...BASE, daysSince: 1 }), row({ ...BASE, daysSince: 999 }))).toBe(
false,
);
});
test('⭐ 新增 focusCategory / patientAge(本次年龄适配)→ 刷新', () => {
const next = { ...BASE, focusCategory: 'prosthodontic', patientAge: 90 };
expect(reasonNeedsRefresh(row({ ...BASE }), row(next))).toBe(true);
});
test('⭐ focusCategory 改变(如患者过生日跨过 70 岁线)→ 刷新', () => {
const before = { ...BASE, focusCategory: 'implant', patientAge: 70 };
const after = { ...BASE, focusCategory: 'prosthodontic', patientAge: 71 };
expect(reasonNeedsRefresh(row(before), row(after))).toBe(true);
});
test('牙位变化 → 刷新(新长出缺牙,文案要跟着变)', () => {
expect(reasonNeedsRefresh(row({ ...BASE }), row({ ...BASE, toothPosition: '16;17;27' }))).toBe(true);
});
test('expectedCategories 变化 → 刷新(排除闸口径变了)', () => {
expect(
reasonNeedsRefresh(row({ ...BASE }), row({ ...BASE, expectedCategories: ['prosthodontic'] })),
).toBe(true);
});
test('触发信号锚点变化 → 刷新(换了更早/更晚的诊断)', () => {
expect(
reasonNeedsRefresh(row({ ...BASE }), row({ ...BASE, signalOccurredAt: '2026-01-01T00:00:00.000Z' })),
).toBe(true);
});
test('⭐ 旧 plan 无 signals(历史数据)→ 刷新,让它补上', () => {
expect(reasonNeedsRefresh(row(null), row({ ...BASE }))).toBe(true);
});
test('新旧都无 signals → 不刷新(没东西可补)', () => {
expect(reasonNeedsRefresh(row(null), row(null))).toBe(false);
});
});
describe('reasonNeedsRefresh — evidence 维度', () => {
test('证据集变化(命中新 fact)→ 刷新', () => {
expect(reasonNeedsRefresh(row(BASE, { factIds: ['f1'] }), row(BASE, { factIds: ['f1', 'f3'] }))).toBe(
true,
);
});
test('⭐ factIds 只是顺序不同 → **不刷新**(批次间顺序会抖,不能当变化)', () => {
expect(
reasonNeedsRefresh(row(BASE, { factIds: ['f2', 'f1'] }), row(BASE, { factIds: ['f1', 'f2'] })),
).toBe(false);
});
test('evidence 缺失 / 形状异常不炸,按无证据处理', () => {
expect(reasonNeedsRefresh(row(BASE, null), row(BASE, null))).toBe(false);
expect(reasonNeedsRefresh(row(BASE, {}), row(BASE, null))).toBe(false);
});
});
describe('⭐ 红线:jsonb 键序不能被当成变化', () => {
// Postgres jsonb 不保留键序(按键长+字节序重排),库里读出来的对象键序 ≠ 代码构造顺序。
// 不做规范化的话每次比较都判"变了" → 每日重算全量重写(本地实测连跑三次每次都刷新)。
test('顶层键序不同 → 不刷新', () => {
const asWritten = {
subKey: 'missing_tooth', triggers: [{ type: 'diagnosis', code: 'K08' }],
toothPosition: '27', daysSince: 119, signalOccurredAt: '2026-03-27T00:00:00.000Z',
expectedCategories: ['implant', 'prosthodontic'], focusCategory: 'prosthodontic', patientAge: 90,
};
// 库里 jsonb 实际存回来的顺序(生产实测形态)
const asStoredByJsonb = {
subKey: 'missing_tooth', triggers: [{ code: 'K08', type: 'diagnosis' }],
daysSince: 119, patientAge: 90, focusCategory: 'prosthodontic', toothPosition: '27',
signalOccurredAt: '2026-03-27T00:00:00.000Z', expectedCategories: ['implant', 'prosthodontic'],
};
expect(reasonNeedsRefresh(row(asStoredByJsonb), row(asWritten))).toBe(false);
});
test('嵌套对象(triggers 元素)键序不同 → 不刷新', () => {
const a = { ...BASE, triggers: [{ type: 'diagnosis', code: 'K08' }] };
const b = { ...BASE, triggers: [{ code: 'K08', type: 'diagnosis' }] };
expect(reasonNeedsRefresh(row(a), row(b))).toBe(false);
});
test('数组**顺序**仍然算变化(expectedCategories 是有序的:首项=主推)', () => {
const a = { ...BASE, expectedCategories: ['implant', 'prosthodontic'] };
const b = { ...BASE, expectedCategories: ['prosthodontic', 'implant'] };
expect(reasonNeedsRefresh(row(a), row(b))).toBe(true);
});
});
describe('红线:刷新判据不依赖文案格式', () => {
test('⭐ signals 相同 → 判不刷新,与 reason 文案无关', () => {
// reason 文案由 signals 派生(诊断名←triggers、牙位←toothPosition、
// 治疗类目←expectedCategories+patientAge),所以比 signals 就够;
// 不解析「N 天前」这类文本 —— 将来 scenario 改文案模板不会引发误刷。
expect(reasonNeedsRefresh(row({ ...BASE }), row({ ...BASE }))).toBe(false);
});
});
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