Archived
Task 4 审查发现 S0d_master_data 固定单一 view 导致 P-10 那份件在引擎里永不可达 (剧本声明的 view 恒压过模型的 say.view),按裁决拆成 S0d_p08_master / S0d_p10_document 两个计划项,各自绑 p08-master-create / p10-document-create;caseFileModel 补 parts_profile_rows、新增哨兵 s_p08_created 串起两步的入场前提;说明书三处 itemId、 S0_bind 的 disable 提示、ASSESSMENT_PHASES 第一段随之更新。steps.test.ts 的 fresh() 补齐 input_confirmed_total / review_approved_total / landing_prepared_total 三面旗 (此前因未绑定名比较求值为假才没暴露)。S0d_p08_master 剧本加「至少留一颗候选」规则, 防止候选全被放弃导致 s_pns_resolved 永远不成立的死锁。46/46 测试与 typecheck 全绿。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh
252 lines
13 KiB
TypeScript
252 lines
13 KiB
TypeScript
/**
|
|
* ACT-WORK-05 差异评估的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
|
|
* 案例活动没有 `run`,自测不跑回合;测的是清单本身与授权面对齐,结构照 E04 / I01。
|
|
*
|
|
* 五组:清单本身合规 / 台账声明 / 案例模型与清单的对齐 / 报告模版:编辑面与只读面各就各位 / 活动入口。
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import { activityManifestSchema } from '@contracts';
|
|
import { createMockHost } from '@sdk';
|
|
import { manifest, D, RULE_VERSION } from '../src/manifest.ts';
|
|
import activity from '../src/index.ts';
|
|
|
|
const ITEMS = ['S0_bind', 'S0d_p08_master', 'S0d_p10_document', 'S1_input_confirm', 'S2_normalize', 'S3_assess', 'S4_review', 'S5_prepare_landing', 'T_release'];
|
|
const NO_DONE_WHEN = new Set(['S0d_p08_master', 'S0d_p10_document', 'S1_input_confirm', 'S4_review', 'T_release']);
|
|
const VIEWS = ['p08-master-create', 'p10-document-create', 'eval-input-list', 'difference-review', 'landing-confirm'];
|
|
const byAddress = (domain: string, object: string) => manifest.declarations.ledger.find((l) => l.domain === domain && l.object === object);
|
|
const viewHtml = (name: string) => manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? '';
|
|
|
|
describe('清单本身合规', () => {
|
|
it('过 @contracts 的 schema', () => {
|
|
expect(() => activityManifestSchema.parse(manifest)).not.toThrow();
|
|
});
|
|
|
|
it('没有静态监听(等外部回音一律落在执行工单上,listen 在本仓不可用)', () => {
|
|
expect(manifest.declarations.listeners).toEqual([]);
|
|
});
|
|
|
|
it('L1/L2 判据都给了谓词;L4 三条不给(裁量项);L5 恰一条', () => {
|
|
for (const c of manifest.contract.acceptanceCriteria) {
|
|
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate, c.key).toBeDefined();
|
|
if (c.level === 'L4') expect(c.predicate, c.key).toBeUndefined();
|
|
}
|
|
expect(manifest.contract.acceptanceCriteria.filter((c) => c.level === 'L4')).toHaveLength(3);
|
|
expect(manifest.contract.acceptanceCriteria.filter((c) => c.level === 'L5')).toHaveLength(1);
|
|
});
|
|
|
|
it('机检谓词只读基座读数与记账旗:不出现活动自报的汇总列名', () => {
|
|
const forbidden = ['relation_total', 'candidate_total', 'recommended_total', 'not_recommended_total'];
|
|
for (const c of manifest.contract.acceptanceCriteria) {
|
|
for (const name of forbidden) expect(c.predicate ?? '', `${c.key} 读了自报数 ${name}`).not.toContain(name);
|
|
}
|
|
});
|
|
|
|
it('三件必交付物都有台账地址;人环预算严格大于「基线 8 + 两轮审核循环 4」', () => {
|
|
expect(manifest.contract.requiredDeliverables).toHaveLength(3);
|
|
for (const d of manifest.contract.requiredDeliverables) expect(d.ledger, d.key).toBeDefined();
|
|
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(12);
|
|
expect(manifest.contract.resources.budget.humanCalls).toBeGreaterThan(12);
|
|
});
|
|
|
|
it('起单只必填 4 项(客户 / 需求 / 原 PN / 候选数组);datasheet 是选填文件', () => {
|
|
expect(manifest.contract.input.data['required']).toEqual(['customerCode', 'requestDescription', 'originalInternalPn', 'candidateInternalPns']);
|
|
const properties = manifest.contract.input.data['properties'] as Record<string, { type?: string; format?: string }>;
|
|
expect(properties['candidateInternalPns']?.type).toBe('array');
|
|
expect(properties['datasheets']?.format).toBe('file');
|
|
});
|
|
|
|
it('五条样例:第一条满载走数据工单那条路,名字 ≤14 字,形状各不相同', () => {
|
|
expect(manifest.samples).toHaveLength(5);
|
|
for (const s of manifest.samples) expect(s.name.length, s.name).toBeLessThanOrEqual(14);
|
|
expect(new Set(manifest.samples.map((s) => JSON.stringify(s.input))).size).toBe(5);
|
|
const first = manifest.samples[0]?.input as { candidateInternalPns?: string[] };
|
|
expect(first.candidateInternalPns).toContain('XL2596S-5.0');
|
|
const newCustomer = manifest.samples.find((s) => (s.input as { customerName?: string }).customerName !== undefined);
|
|
expect(newCustomer).toBeDefined();
|
|
});
|
|
|
|
it('五张视图模版都声明了,剧本每一步引用的 view 都在其中', () => {
|
|
expect(new Set(manifest.assets.viewTemplates.map((v) => v.name))).toEqual(new Set(VIEWS));
|
|
for (const step of manifest.assets.businessModel.steps as ReadonlyArray<{ view?: string }>) {
|
|
if (step.view !== undefined) expect(VIEWS, step.view).toContain(step.view);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('台账声明:授权面与《统一方案》口径', () => {
|
|
it('P-08 一张表:主键 internal_pn,参数集是它的一个必填字段,没有 parts.parameter 子表', () => {
|
|
const p08 = byAddress('parts', 'profile');
|
|
expect(p08?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['internal_pn']);
|
|
expect(p08?.fields.find((f) => f.name === 'normalized_parameter_set')?.required).toBe(true);
|
|
expect(byAddress('parts', 'parameter')).toBeUndefined();
|
|
});
|
|
|
|
it('P-11 主记录的原 PN / 候选 PN 都是引用墙(查无此料当场拒 —— 存在性检查的硬门)', () => {
|
|
const rel = byAddress('substitution', 'relation');
|
|
for (const name of ['original_internal_pn', 'candidate_internal_pn']) {
|
|
expect(rel?.fields.find((f) => f.name === name)?.refersTo, name).toBe('parts.profile.internal_pn');
|
|
}
|
|
});
|
|
|
|
it('客户批准状态**不在声明里**:本活动没有它的写权(只读边界)', () => {
|
|
const rel = byAddress('substitution', 'relation');
|
|
expect(rel?.fields.map((f) => f.name)).not.toContain('customer_approval_status');
|
|
});
|
|
|
|
it('UNKNOWN 必有去向:unknown_disposition 钉在 difference_status = unknown 的行上;四态是 ASCII tally', () => {
|
|
const item = byAddress('substitution', 'difference_item');
|
|
expect(item?.fields.find((f) => f.name === 'unknown_disposition')?.requiredWhen).toEqual({ column: 'difference_status', value: 'unknown' });
|
|
expect(item?.fields.find((f) => f.name === 'difference_status')?.tally).toEqual(['match', 'mismatch', 'unknown', 'conditional']);
|
|
expect(item?.fields.find((f) => f.name === 'substitution_relation_id')?.distinct).toBe(true);
|
|
});
|
|
|
|
it('P-01 回写五项钉在 current_stage = gate03 那一刻(放行前不拦,放行门 L2 数得出来)', () => {
|
|
const p01 = byAddress('demand', 'request');
|
|
for (const name of ['risk_level', 'verification_requirement', 'output_ref', 'next_action', 'current_activity']) {
|
|
expect(p01?.fields.find((f) => f.name === name)?.requiredWhen, name).toEqual({ column: 'current_stage', value: 'gate03' });
|
|
}
|
|
});
|
|
|
|
it('自持单据都带 caseKey eval_ref 且在主键里;放弃候选必须写原因', () => {
|
|
for (const object of ['pn_check', 'input_gap', 'verdict']) {
|
|
const t = byAddress(D, object);
|
|
expect(t?.kind, object).toBe('record');
|
|
expect(t?.caseKey, object).toBe('eval_ref');
|
|
expect(t?.fields.find((f) => f.name === 'eval_ref')?.primaryKey, object).toBe(true);
|
|
}
|
|
expect(byAddress(D, 'pn_check')?.fields.find((f) => f.name === 'abandon_reason')?.requiredWhen).toEqual({ column: 'exists_status', value: 'abandoned' });
|
|
});
|
|
|
|
it('规则版本常量随包冻结', () => {
|
|
expect(RULE_VERSION).toBe('R-2026.07');
|
|
expect(manifest.assets.businessModel.dmn.tables.map((t) => t.id)).toEqual(['d01-difference-status', 'd02-recommendation']);
|
|
});
|
|
});
|
|
|
|
describe('案例模型与清单的对齐', () => {
|
|
it('九个计划项,剧本 item 与 CMMN humanTask id 一一对应', () => {
|
|
const cmmnIds = [...manifest.assets.businessModel.cmmnXml.matchAll(/<humanTask id="([^"]+)"/g)].map((m) => m[1] ?? '');
|
|
expect(cmmnIds).toEqual(ITEMS);
|
|
expect((manifest.assets.businessModel.steps as ReadonlyArray<{ item: string }>).map((s) => s.item)).toEqual(ITEMS);
|
|
});
|
|
|
|
it('五处不给 doneWhen(两张数据工单 / 输入确认 / 审核 / 放行),其余四处都给', () => {
|
|
for (const s of manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; doneWhen?: string }>) {
|
|
if (NO_DONE_WHEN.has(s.item)) expect(s.doneWhen, s.item).toBeUndefined();
|
|
else expect(s.doneWhen, s.item).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('两张数据工单件各自绑自己的 view(P-08 与 P-10 不共用同一份画面)', () => {
|
|
const steps = manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; view?: string }>;
|
|
expect(steps.find((s) => s.item === 'S0d_p08_master')?.view).toBe('p08-master-create');
|
|
expect(steps.find((s) => s.item === 'S0d_p10_document')?.view).toBe('p10-document-create');
|
|
});
|
|
|
|
it('那五项在 CMMN 里都标了人工激活', () => {
|
|
for (const id of NO_DONE_WHEN) {
|
|
const block = new RegExp(`<humanTask id="${id}"[\\s\\S]*?</humanTask>`).exec(manifest.assets.businessModel.cmmnXml)?.[0] ?? '';
|
|
expect(block, id).toContain('manualActivationRule');
|
|
}
|
|
});
|
|
|
|
it('除放行外每一项恰好属于一个阶段;5 段 + 放行 = x/6,与文档 0.5/1/2/3/4/5 六步对齐', () => {
|
|
const phases = manifest.assets.businessModel.phases ?? [];
|
|
expect(phases).toHaveLength(5);
|
|
const phaseItems = phases.flatMap((p) => p.items);
|
|
expect([...phaseItems].sort()).toEqual([...ITEMS.filter((i) => i !== 'T_release')].sort());
|
|
expect(new Set(phaseItems).size).toBe(phaseItems.length);
|
|
});
|
|
|
|
it('说明书点名的计划项都真实存在', () => {
|
|
const mentioned = [...manifest.assets.businessModel.narrative.matchAll(/\b(S\d[a-z]?_[a-zA-Z_]+|T_release)\b/g)].map((m) => m[1] ?? '');
|
|
for (const name of mentioned) expect(ITEMS, `说明书提到但不存在的计划项: ${name}`).toContain(name);
|
|
});
|
|
|
|
it('两种决策卡骨架都在说明书里:候选放弃/创建(create/abandon)与审核(approve/reject/custom:)', () => {
|
|
const n = manifest.assets.businessModel.narrative;
|
|
expect(n).toContain('"kind":"decide","itemId":"S0d_p08_master"');
|
|
expect(n).toContain('"value":"create"');
|
|
expect(n).toContain('"value":"abandon"');
|
|
expect(n).toContain('"kind":"decide","itemId":"S4_review"');
|
|
expect(n).toContain('"approve"');
|
|
expect(n).toContain('"reject"');
|
|
expect(n).toContain('"custom:"');
|
|
});
|
|
|
|
it('T_release 剧本里 judge criterion 的原文逐字对得上 acceptanceCriteria 的 label', () => {
|
|
const tRelease = (manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; guide?: string }>).find((s) => s.item === 'T_release');
|
|
const quoted = [...(tRelease?.guide ?? '').matchAll(/judge criterion="([^"]+)"/g)].map((m) => m[1] ?? '');
|
|
expect(quoted).toHaveLength(3);
|
|
const labels = new Set(manifest.contract.acceptanceCriteria.map((c) => c.label));
|
|
for (const q of quoted) expect(labels.has(q), q).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('报告模版:编辑面与只读面各就各位', () => {
|
|
it('两张数据工单件可加行可编辑:data-edit / data-edit-add 与主键 data-col 都在', () => {
|
|
const p08 = viewHtml('p08-master-create');
|
|
expect(p08).toContain('data-edit="parts.profile"');
|
|
expect(p08).toContain('data-edit-add="parts.profile"');
|
|
expect(p08).toContain('data-col="internal_pn"');
|
|
expect(p08).toContain('data-col="normalized_parameter_set"');
|
|
const p10 = viewHtml('p10-document-create');
|
|
expect(p10).toContain('data-edit="parts.document"');
|
|
expect(p10).toContain('data-col="doc_seq"');
|
|
});
|
|
|
|
it('评估输入清单:缺口表可编辑(supplement),绑定区只读', () => {
|
|
const html = viewHtml('eval-input-list');
|
|
expect(html).toContain(`data-edit="${D}.input_gap"`);
|
|
expect(html).toContain('data-col="supplement"');
|
|
expect(html).toContain(`data-rows="mine:${D}.pn_check"`);
|
|
});
|
|
|
|
it('差异审核与落账确认只读:没有任何 data-edit / data-col', () => {
|
|
for (const name of ['difference-review', 'landing-confirm']) {
|
|
expect(viewHtml(name), name).not.toContain('data-edit=');
|
|
expect(viewHtml(name), name).not.toContain('data-col=');
|
|
}
|
|
});
|
|
|
|
it('每个可编辑格子的 data-col 都在对应台账对象的声明里', () => {
|
|
const cases: Array<[string, string, string]> = [
|
|
['p08-master-create', 'parts', 'profile'],
|
|
['p10-document-create', 'parts', 'document'],
|
|
['eval-input-list', D, 'input_gap'],
|
|
];
|
|
for (const [view, domain, object] of cases) {
|
|
const cols = new Set([...viewHtml(view).matchAll(/data-col="([^"]+)"/g)].map((m) => m[1] ?? ''));
|
|
const declared = new Set(byAddress(domain, object)?.fields.map((f) => f.name));
|
|
for (const c of cols) expect(declared.has(c), `${view}: ${c}`).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('枚举列只用 data-class-of 投影,不摆 ASCII 原值', () => {
|
|
expect(viewHtml('difference-review')).toContain('data-class-of="difference_status"');
|
|
expect(viewHtml('difference-review')).toContain('data-class-of="recommendation_level"');
|
|
expect(viewHtml('difference-review')).not.toContain('data-slot="difference_status"');
|
|
});
|
|
|
|
it('宿主拒推含脚本的模版', async () => {
|
|
const host = createMockHost({ contract: CONTRACT });
|
|
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
|
|
});
|
|
});
|
|
|
|
describe('活动入口', () => {
|
|
it('没有 run —— 行为全在清单里', () => {
|
|
expect(activity.run).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
const CONTRACT = {
|
|
activityId: 'part-substitution-assessment',
|
|
version: '1.0.0',
|
|
data: {},
|
|
message: undefined,
|
|
outputPorts: { mandatory: ['assessment-released'], selective: [] },
|
|
prohibitions: [],
|
|
discretion: [],
|
|
} as const;
|