This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/activities/part-substitution-assessment/__tests__/activity.test.ts
T
xcsandClaude Opus 5 4bd1a04511 feat(w05): 件③ 落账确认与看板重绑 —— 签之前只核对,硬约束不符逐条点名
Task 9: 新增 landing-confirm(T_release 的终局验收工单,全只读,答「这一
签下去发生什么」——落账去向/结论卡/四态分布/硬约束逐条点名/待验项/发口),
三张件此刻齐了;activity-ledger 看板的 data 声明与 HTML 一并重绑到新九
张表(P-01/P-10 两域随重写撤下),解封 3 条等待三张件齐的 skip。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 20:53:23 +08:00

932 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ACT-WORK-05 差异评估的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
* 案例活动没有 `run`,自测不跑回合;测的是清单本身与授权面对齐,结构照 E04 / I01。
*
* 七组:清单本身合规 / 台账声明 / 案例模型与清单的对齐 / 报告模版:编辑面与只读面各就各位 /
* 徽标别名与值域相关的坑(2026-08-27 实测账,回归网)/ 可编辑件的主键与必填列
* (2026-08-27 复审,回归网)/ 活动入口。
*/
import { describe, expect, it } from 'vitest';
import { activityManifestSchema } from '@contracts';
import { createMockHost } from '@sdk';
import { manifest, D } from '../src/manifest.ts';
import activity from '../src/index.ts';
/*
* 2026-08-29 Task 3 整体重写(spec §4):九步砍成四步,案例模型换成 CMMN + `case-model.ts` 里的
* `ASSESSMENT_STEPS` 四条剧本。这份包内自测原是照旧九步写的,`案例模型与清单的对齐` 这组的多数
* 断言在结构上仍然成立(CMMN humanTask id 与剧本 item 一一对应、除放行外每项恰属一个阶段…),
* 只是数字与名单要随新模型换 —— 下面这几处常量与断言标题已按 `case-model.ts` 的新四步同步更新。
*/
const ITEMS = ['S1_inputs', 'S2_assess', 'S3_review', 'T_release'];
/*
* 2026-08-29 Task 3:四步里只有 `S3_review`(审核,可循环)与 `T_release`(放行)不给
* `doneWhen`——判据不许摆在还欠一张卡的那一步眼前(2026-08-28 用户实测逼出的立墙,
* `humanWrites`/`acceptanceChecklist` 同一课)。`S1_inputs` / `S2_assess` 全 auto,
* 入库事实 / 分析结论一落地引擎就自己收步,不再需要人工激活等待。
*/
const NO_DONE_WHEN = new Set(['S3_review', 'T_release']);
/*
* 2026-08-29 Task 7:五张旧局面收成三张 —— `p08-master-create` / `p10-document-create` /
* `eval-input-list` 三张合并成一张 `input-supplement`(Task 7 已交付);
* `difference-review` / `landing-confirm` 由 Task 8 / 9 各自重做(同名)。
*/
const VIEWS = ['input-supplement', 'difference-review', 'landing-confirm'];
/** 运营中心 L2 沙箱里那张关键台账数据看板 —— 不是工单件,只在「都声明了」这一条里出现;自己那组断言在文件尾 */
const BOARD_VIEW = 'activity-ledger';
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 ?? '';
/* 「台账声明」组用的短写(2026-08-29 重写引入):地址是 `域.对象` 一整串,`byAddress` 拆两段 */
const ledgerOf = (address: string) => manifest.declarations.ledger.find((l) => `${l.domain}.${l.object}` === address);
const colOf = (address: string, name: string) => ledgerOf(address)?.fields.find((f) => f.name === name);
describe('清单本身合规', () => {
it('过 @contracts 的 schema', () => {
expect(() => activityManifestSchema.parse(manifest)).not.toThrow();
});
it('没有静态监听(等外部回音一律落在执行工单上,listen 在本仓不可用)', () => {
expect(manifest.declarations.listeners).toEqual([]);
});
/* 2026-08-29 重写:spec §7 的判据表从九条改成八条(五条机检 L1/L2 + 两条 L4 裁量 + 一条 L5),L4 从三条收到两条 —— 证据链与硬约束两条留 L4,「推荐等级与明细一致」已升级成 L2 机检(level-by-rule) */
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(2);
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);
}
});
/*
* 2026-08-29 重写:必交付物从三件(评估输入清单 / 差异评估结论 / 拟落账内容)收到两件 ——
* 「评估输入清单」那件连同它绑的三张旧单据(pn_check/input_gap/verdict)一起退休。
* 人环预算从 16(候选放弃/创建决策等岔路)收到 8(补数据 / 审核 / 放行三次调人,零工具、
* AI 只出场一次)—— 见「契约 > 预算」那条把 8 钉死;这里只需两个数上下一致。
*/
it('两件必交付物都有台账地址;人环预算恰为 8', () => {
expect(manifest.contract.requiredDeliverables).toHaveLength(2);
for (const d of manifest.contract.requiredDeliverables) expect(d.ledger, d.key).toBeDefined();
expect(manifest.contract.authorization.humanCallBudget).toBe(8);
expect(manifest.contract.resources.budget.humanCalls).toBe(8);
});
/*
* 2026-08-29 重写:启动表单从「需求 + 原PN + 候选数组 + datasheet」四件收到「原PN + 候选」两件
* (单数,一次评估恰一对一候选)—— `requestDescription` / `candidateInternalPns`(数组)/
* `datasheets` 三键随 P-01 需求主记录一起撤下(本活动不再碰它)。逐条断言见「契约 > 启动两参」。
*/
it('起单一格必填都不设(2026-08-28 用户定);客户与需求两类字段已随重写从启动表单撤下', () => {
expect(manifest.contract.input.data['required']).toEqual([]);
const launchProps = Object.keys(manifest.contract.input.data['properties'] as Record<string, unknown>);
expect(launchProps).not.toContain('customerCode');
expect(launchProps).not.toContain('customerName');
expect(launchProps).not.toContain('requestDescription');
expect(launchProps).not.toContain('datasheets');
});
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();
});
/*
* 解封(2026-08-29,Task 9):`landing-confirm` 已随 Task 9 补回 —— `input-supplement`
* (Task 7)/`difference-review`(Task 8)/`landing-confirm`(Task 9)三张全在了,
* `new Set(...)` 的整体相等条件此刻才第一次成立。
*/
it('三张工单视图 + 一张运营侧看板都声明了,剧本每一步引用的 view 都在其中', () => {
expect(new Set(manifest.assets.viewTemplates.map((v) => v.name))).toEqual(new Set([...VIEWS, BOARD_VIEW]));
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 与 P-11 各是主表 + 子表)', () => {
expect(manifest.declarations.ledger.map((l) => `${l.domain}.${l.object}`).sort()).toEqual([
'parts.chip_type', 'parts.parameter', 'parts.profile', 'parts.type_param',
'sub_eval.case', 'sub_eval.part',
'substitution.difference_item', 'substitution.relation', 'validation.record',
].sort());
});
it('类型字典:type_id 是自增主键 —— 人在件上新建类型时留空,由基座续号', () => {
expect(colOf('parts.chip_type', 'type_id')).toMatchObject({ primaryKey: true, type: 'integer', auto: true });
expect(colOf('parts.chip_type', 'type_name')?.required).toBe(true);
});
it('字段字典:复合主键 + 约束类型长在这里(不在每颗料上)', () => {
expect(ledgerOf('parts.type_param')?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['type_id', 'param_name']);
expect(colOf('parts.type_param', 'type_id')?.refersTo).toBe('parts.chip_type.type_id');
expect(colOf('parts.type_param', 'param_desc')?.required).toBe(true);
expect(colOf('parts.type_param', 'constraint_type')).toMatchObject({ required: true, tally: ['hard', 'soft'] });
});
it('P-08:类型必填并引用字典;createOnly 撤掉 —— 在库老料要能补上类型这一格', () => {
expect(colOf('parts.profile', 'chip_type_id')).toMatchObject({ required: true, type: 'integer', refersTo: 'parts.chip_type.type_id' });
expect(ledgerOf('parts.profile')?.createOnly).toBeUndefined();
/* 封装与工艺本来就是「核心参数」—— 它们该以类型字段的身份出现在字典里,不在主记录上另开一列 */
for (const gone of ['package', 'process_technology', 'eol_pcn_status', 'application_scenarios']) {
expect(colOf('parts.profile', gone)).toBeUndefined();
}
});
it('参数子表:约束类型与自指引用墙都退休了', () => {
expect(colOf('parts.parameter', 'constraint_type')).toBeUndefined();
expect(colOf('parts.parameter', 'param_name')?.refersTo).toBeUndefined();
expect(colOf('parts.parameter', 'param_name')?.distinct).toBeUndefined();
expect(colOf('parts.parameter', 'param_value')?.required).toBe(true);
expect(ledgerOf('parts.parameter')?.createOnly).toBeUndefined();
});
it('案卷两张:part 一单两行(配方遍历的唯一来源)、case 一单一行', () => {
expect(ledgerOf('sub_eval.part')?.caseKey).toBe('eval_ref');
expect(ledgerOf('sub_eval.part')?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['eval_ref', 'internal_pn']);
/* 「两颗料是不是同一个类型」于是成了基座数得出来的数:不同取值恰好 1 种 */
expect(colOf('sub_eval.part', 'chip_type_id')).toMatchObject({ required: true, distinct: true });
expect(colOf('sub_eval.part', 'exists_status')?.tally).toEqual(['found', 'absent']);
/* 「在库」是一次 lookup 的结果,不是一句断言:判 found 就必须拿得出一个基座查得到的料号 */
expect(colOf('sub_eval.part', 'found_pn_ref')?.refersTo).toBe('parts.profile.internal_pn');
expect(ledgerOf('sub_eval.case')?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['eval_ref']);
});
it('P-11 明细带合成列 judgement_class —— 推荐等级要数的是「硬约束不符有几条」', () => {
expect(colOf('substitution.difference_item', 'judgement_class')).toMatchObject({
required: true,
tally: ['match', 'soft_mismatch', 'hard_mismatch', 'unknown', 'conditional'],
});
/* 「判断依据」不是文件引用 —— 不写 P-10 了,名字得说实话 */
expect(colOf('substitution.difference_item', 'evidence_note')?.required).toBe(true);
expect(colOf('substitution.difference_item', 'evidence_ref')).toBeUndefined();
expect(colOf('substitution.difference_item', 'unknown_disposition')?.requiredWhen).toEqual({ column: 'difference_status', value: 'unknown' });
});
it('P-11 主记录:退休三列,等级只剩三档', () => {
for (const gone of ['substitution_type', 'confidence_score', 'analysis_report_ref']) {
expect(colOf('substitution.relation', gone)).toBeUndefined();
}
expect(colOf('substitution.relation', 'recommendation_level')?.tally).toEqual(['recommended', 'conditional', 'not_recommended']);
});
it('P-06:关联的是本单案号(P-01 不在本活动里),类型加了 parametric 一档', () => {
expect(colOf('validation.record', 'eval_ref')?.required).toBe(true);
expect(colOf('validation.record', 'request_id')).toBeUndefined();
expect(colOf('validation.record', 'validation_type')?.tally).toContain('parametric');
});
it('这个活动不碰 P-01 / P-10 / 客户档案', () => {
for (const gone of ['demand.request', 'parts.document', 'customer.profile']) {
expect(ledgerOf(gone)).toBeUndefined();
}
});
});
describe('契约', () => {
it('启动两参,都选填 —— 缺了由第一步派工单补', () => {
const props = manifest.contract.input.data['properties'] as Record<string, Record<string, unknown>>;
expect(Object.keys(props)).toEqual(['originalInternalPn', 'candidateInternalPn']);
expect(manifest.contract.input.data['required']).toEqual([]);
/* 运营端启动表单:两格都是「可选可新写」的下拉 */
for (const key of ['originalInternalPn', 'candidateInternalPn']) {
expect(props[key]?.['x-control']).toBe('combo');
expect(props[key]?.['x-options']).toMatchObject({ ledger: 'parts.profile', value: 'internal_pn' });
}
});
it('零工具 —— 这是这次重写的立场,不是疏漏', () => {
expect(manifest.contract.resources.tools).toEqual([]);
expect(manifest.declarations.tools).toEqual([]);
});
it('单一出口,载荷是已落地的事实', () => {
expect(manifest.contract.output.mandatory.map((p) => p.name)).toEqual(['assessment-released']);
expect(manifest.contract.output.selective).toEqual([]);
});
it('判据全读基座数 —— 任何一边都不许是活动自己写的汇总列', () => {
const machine = manifest.contract.acceptanceCriteria.filter((c) => c.level === 'L1' || c.level === 'L2');
expect(machine.length).toBeGreaterThanOrEqual(5);
for (const c of machine) {
expect(c.predicate).toBeDefined();
/* 基座读数的形状:域_对象_rows / _missing / _distinct_列 / tally 计数 */
expect(c.predicate).toMatch(/_(rows|missing|distinct_[a-z_]+|[a-z_]+)\b/);
}
});
it('预算:调人三次(补数据 / 审核 / 放行),模型只出场一次', () => {
expect(manifest.contract.resources.budget.humanCalls).toBe(8);
expect(manifest.contract.authorization.humanCallBudget).toBe(8);
expect(manifest.contract.resources.models).toEqual(['deepseek-v4-flash']);
});
});
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(审核 / 放行),其余两处都给 —— 校验入参与逐项分析全 auto、入库/结论事实一落地就收步', () => {
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();
}
});
/*
* SKIP(2026-08-29,Task 3):`S0d_p08_master` / `S0d_p10_document` 两个计划项已随四步重写撤下 ——
* 「补芯片主数据」与「补原厂技术资料」不再是两张各自的数据工单,统一并入 `S1_inputs`
* (校验入参与补数据)一步之内。Task 3 的 brief 明写这一步不设 `view`(它是可选的,
* Task 7–9 各自补上自己那一张),所以这条「两张数据工单件各自绑自己的 view」眼下没有对应的
* 断言对象可钉 —— 不是「暂时还没做」,是这个二选一视图的设计前提本身已经不存在了。
*/
it.skip('两张数据工单件各自绑自己的 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] ?? '';
/* Task 3 起改用 `manualActivation="true"` 属性直接标在 humanTask 元素上(与 CMMN 解析器的两种认法之一同款),不再嵌套 <manualActivationRule> */
expect(block, id).toContain('manualActivation="true"');
}
});
it('除放行外每一项恰好属于一个阶段;3 段 + 放行 = x/4', () => {
const phases = manifest.assets.businessModel.phases ?? [];
expect(phases).toHaveLength(3);
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);
});
/*
* SKIP(2026-08-29,Task 3):这条测的是「决策卡骨架住在说明书的散文里,模型抄卡骨架时照读」——
* 那正是**零 `guide`**重写要撤掉的机制本身(卡骨架挪进配方 `auto.do[].kind:'card'` 的结构化
* `options` 字段,由引擎按声明发卡,不再靠模型从说明书里抄 JSON 片段)。新版 `NARRATIVE`
* 因此不再嵌任何 `"value":"..."` 这类卡面 JSON —— 不是「还没写全」,是这条断言检查的机制
* 在四步全 auto 的设计里已经不存在了(决策卡的选项由 Task 4–6 的配方声明,不由说明书文本决定)。
*/
it.skip('说明书里只剩审核那一种决策卡(approve/reject/custom:);候选放弃/创建那张已退役 —— 缺库一律自动派数据工单(2026-08-27 用户定)', () => {
const n = manifest.assets.businessModel.narrative;
expect(n).not.toContain('"value":"create"');
expect(n).not.toContain('"value":"abandon"');
expect(n).toContain('"value":"approve"');
expect(n).toContain('"value":"reject"');
expect(n).toContain('custom:');
});
/*
* SKIP(2026-08-29,Task 2 → Task 3 补回,复核后维持 skip):原计划这条断言的是
* 「T_release 剧本里 `judge criterion="..."` 的原文逐字对得上 acceptanceCriteria 的 label」——
* 而 Task 3 的四步重写是**全程零 guide**:`T_release` 的 `guide` 现在是 `''`,
* 「prose guide 里嵌 `judge criterion="..."` 给模型执行」这套机制随零 guide 重写整体撤掉了
* (裁量判据改由人工验收清单 `acceptanceChecklist` 让人亲自核,不再靠模型在 guide 里自判)。
* 这不是「Task 3 会把断言点的字符串换成新的两条」——是断言检查的机制本身不复存在,
* 无法在不改变检查对象的前提下让它变绿,继续保留跳过并记录清楚原因。
*/
it.skip('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('报告模版:编辑面与只读面各就各位', () => {
/*
* SKIP(2026-08-29,Task 2 → Task 7,复核后维持 skip):`p08-master-create` / `p10-document-create` /
* `eval-input-list` 三张局面已被 Task 7 的 `input-supplement` 一张顶掉,但这条测的形状
* 与新设计不同,不是简单换个 view 名字就能救活:①P-10(`parts.document`)整个不在新
* S1 的范围里(本活动不再碰它);②新设计里 `parts.profile` 两行由系统铺、人不加行
* (要建哪颗料是启动参数定死的事),`parts.parameter` 的行集由配方从
* `sub_eval.part × parts.type_param` 派生且 `prune:true`(手加的孤行下一拍就被删)——
* 这两张表都**不给** `data-edit-add`,只有 `parts.type_param`(新建类型的字段清单)才有。
* 新覆盖见下面「件① 评估输入补全」;继续 skip、不改断言内容,留给读的人对照差异。
*/
it.skip('P-08 数据工单件的表 1 + 表 2(标准化参数子表)都可加行可编辑: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-edit="parts.parameter"');
expect(p08).toContain('data-edit-add="parts.parameter"');
expect(p08).toContain('data-col="param_name"');
expect(p08).toContain('data-col="param_value"');
expect(p08).toContain('data-col="constraint_type"');
const p10 = viewHtml('p10-document-create');
expect(p10).toContain('data-edit="parts.document"');
expect(p10).toContain('data-col="doc_seq"');
});
/* SKIP(2026-08-29,Task 2 → Task 7):`eval-input-list` 局面已撤下,`sub_eval.input_gap` / `sub_eval.pn_check` 两张单据也已随重写退休(顶掉它们的是 `sub_eval.part` / `sub_eval.case`) */
it.skip('评估输入清单:缺口表可编辑(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"`);
});
/*
* 2026-08-29 Task 8 改窄:这条原本把 `difference-review` 也算作「只读」,与 `landing-confirm`
* 并列断言零 `data-edit`/`data-col`。而 spec §4.4/§6.2(同一次重写的权威设计,同日写定)
* 明确把 S3 从纯只读改成「人就地改四态,配方重算」—— brief 给的 Step 1 测试(见下面
* 「件② 差异评估结论审核」组)直接要求 `data-edit="substitution.difference_item"` 与
* `data-col="difference_status"` 等必须存在,与这条断言原样矛盾。这条断言写在那处设计
* 定稿之前(或是与 landing-confirm 一起想当然地假设了两者都只读),现改窄成只管
* `landing-confirm`(它确实全只读,spec §6.3)。`difference-review` 的「只读面在哪」
* 改由「件②」组精确覆盖(哪几列可改、`substitution.relation` 那半张不许有 data-edit)。
*/
it('落账确认只读:没有任何 data-edit / data-col(差异审核已按 spec §4.4 变为可就地编辑,断言见「件②」组)', () => {
expect(viewHtml('landing-confirm')).not.toContain('data-edit=');
expect(viewHtml('landing-confirm')).not.toContain('data-col=');
});
it('每个可编辑格子的 data-col 都在对应台账对象的声明里(input-supplement 一屏三张表,三张台账对象的声明并起来看;difference-review 一张表)', () => {
const cases: Array<[string, Array<[string, string]>]> = [
['input-supplement', [['parts', 'profile'], ['parts', 'type_param'], ['parts', 'parameter']]],
['difference-review', [['substitution', 'difference_item']]],
];
for (const [view, objects] of cases) {
const cols = new Set([...viewHtml(view).matchAll(/data-col="([^"]+)"/g)].map((m) => m[1] ?? ''));
const declared = new Set(objects.flatMap(([domain, object]) => byAddress(domain, object)?.fields.map((f) => f.name) ?? []));
for (const c of cols) expect(declared.has(c), `${view}: ${c}`).toBe(true);
}
});
/*
* SKIP(2026-08-29,Task 9 复核后维持 skip):`difference-review` 那半句(第 383-385 行)
* 这次实测确已成立(与「件②」组的等价断言重复,不必再靠这条揭封);但 `landing-confirm`
* 那半句(第 386-390 行)测的 `request_status`/`current_stage` 是 `demand.request` 的列 ——
* 这个活动的清单里从没声明过 `demand.request`(见「台账声明」组「这个活动不碰 P-01 / P-10 /
* 客户档案」那条,已通过),`landing-confirm` 全只读、答的是「进哪几张表各几行」不是需求状态回写,
* 这两个字段**不属于新设计的任何一步**。这不是「还没轮到」,是测的东西这次重写里压根不存在 ——
* 一个 `it` 体绑死两半、拆不开,继续整条跳过,不强救。
*/
it.skip('枚举列只用 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"');
const landing = viewHtml('landing-confirm');
for (const col of ['request_status', 'current_stage', 'analysis_status', 'recommendation_level']) {
expect(landing, col).toContain(`data-class-of="${col}"`);
expect(landing, col).not.toContain(`data-slot="${col}"`);
}
});
it('宿主拒推含脚本的模版', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
});
/* 解封(2026-08-29,Task 9):`landing-confirm` 补齐,三张全能验了 —— 都走同一个 `doc()` 壳。 */
it('三张模版的外壳都是搬自目标件的 page/card,不再含 E04/I01 那套视觉底(.proto-root)的痕迹', () => {
for (const view of VIEWS) {
const html = viewHtml(view);
expect(html, view).toContain('class="card-head"');
expect(html, view).toContain('class="card-foot"');
expect(html, view).not.toContain('proto-root');
}
});
});
/*
* 件① 评估输入补全(2026-08-29,Task 7):S1 的配方缺什么就发一张数据工单,`view: 'input-supplement'`
* 指向它。三块竖排——要评估什么(两颗料的存在性 + 主数据)/ 芯片类型(选型 + 新建类型时的核心参数
* 字段清单)/ 参数值(逐条实测/规格值);顶部一条红带摆配方算好的 `gate_reason`。
* 复用文件顶部已有的 `viewHtml` 短写(与 brief 草稿里的 `viewOf` 同一件事,不重复定义一个)。
*/
describe('件① 评估输入补全', () => {
const html = viewHtml('input-supplement');
it('在 viewTemplates 里,且 S1 指向它', () => {
expect(html).not.toBe('');
const steps = manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; view?: string }>;
expect(steps.find((s) => s.item === 'S1_inputs')?.view).toBe('input-supplement');
});
it('三块都在,行都绑本单草稿(不是整表)', () => {
expect(html).toContain('data-rows="mine:sub_eval.part"');
expect(html).toContain('data-rows="mine:parts.parameter"');
expect(html).toContain('data-rows="mine:parts.type_param"');
});
it('芯片类型是从台账现读的下拉 —— 写死等于「只能选出包那天就有的那几种」', () => {
expect(html).toContain('data-options="parts.chip_type"');
expect(html).toContain('data-option-value="type_id"');
expect(html).toContain('data-option-label="type_name"');
/* data-options 与 data-slot 不能共存(渲染时 data-slot 会被剥掉)—— 当下值由 data-col 定 */
expect(/data-options="[^"]*"[^>]*data-slot=/.test(html)).toBe(false);
});
it('**主键列必挂 `data-col`** —— 它不是编辑面,是行的钥匙(漏了会把人改的行错配到别行上)', () => {
expect(html).toContain('data-col="internal_pn"');
expect(html).toContain('data-col="param_name"');
});
it('可改的三张表各有一道编辑闸', () => {
for (const address of ['parts.profile', 'parts.type_param', 'parts.parameter']) {
expect(html).toContain(`data-edit="${address}"`);
}
});
/*
* 2026-08-29 评审 Minor:brief 原话是「红带显隐用 data-class-of」,但 gate_reason 是
* 中文自由文本 —— classToken 只留 [a-z0-9-],中文字符全被剥空,不论空串还是有内容的
* 中文句子算出来的 token 都是空的,这条路本来就走不通(不是本任务的臆断,评审读了
* view-render.ts 的 classToken 实现确认)。真正管显隐的是 data-blank 出的空文本节点
* 配 CSS :has(:empty) ——源码里不留一个永远不起作用的 data-class-of 属性。
*/
it('模版不算不判:没有内联脚本,红带的显隐交给样式表(:has(:empty),不是 gate_reason 上算不出来的 data-class-of)', () => {
expect(html).not.toContain('<script');
expect(html).not.toContain('data-class-of="gate_reason"');
expect(html).toContain('class="jc-gate-text" data-slot="gate_reason" data-blank');
expect(html).toContain('.jc-gate-bar:has(.jc-gate-text:empty)');
});
it('表宽超出要能横着滑 —— 不许写成 overflow:hidden(E04/I01 抄错过一次)', () => {
expect(html).not.toMatch(/\.jc-tbl-wrap\s*\{[^}]*overflow\s*:\s*hidden/);
});
/*
* 2026-08-29 评审 Important:BIND_COMMON 的序号计数器规则原来只认 `.tbl-wrap`
* (继承自已删除的旧 P08_MASTER_CREATE,同一个 wrapper class,同一个坑),
* 而这张件的三张表全用平台的 `.jc-tbl-wrap` —— 两个 class 对不上,计数器永不触发,
* 只读态与编辑态下「#」列逐行都是空白。钉死 `.jc-tbl-wrap` 现在也接了这五条规则,
* 且这张件确实只用 `.jc-tbl-wrap`(没有表在用没打这条补丁就会数不出来的 `.tbl-wrap`)。
*/
it('三张表的序号列真的会数:.jc-tbl-wrap 也接了 CSS 计数器(不再只认 .tbl-wrap)', () => {
expect(html).toMatch(/\.jc-tbl-wrap\s+table\s*,\s*\.tbl-wrap\s+table\s*\{\s*counter-reset:\s*rn;?\s*\}/);
expect(html).toMatch(/\.jc-tbl-wrap\s+table\s+td\.rn\s*,\s*\.tbl-wrap\s+table\s+td\.rn\s*\{\s*counter-increment:\s*rn;?\s*\}/);
expect(html).toMatch(/\.jc-tbl-wrap\s+table\s+td\.rn\s+\.jc-rn-num::before\s*,\s*\.tbl-wrap\s+table\s+td\.rn\s+\.jc-rn-num::before\s*\{\s*content:\s*counter\(rn\);?\s*\}/);
// 这张件的三张表都走 .jc-tbl-wrap(没有 .tbl-wrap)—— 补丁真的接在会用到的类上
expect(html).not.toContain('class="tbl-wrap"');
const jcTblWrapCount = html.match(/class="jc-tbl-wrap"/g)?.length ?? 0;
expect(jcTblWrapCount).toBe(3);
});
});
/*
* 件② 差异评估结论审核(2026-08-29,Task 8):S3 的验收工单,`view: 'difference-review'`
* 指向它,`cardVariant: 'review'`。绑定契约(spec §6.2):对决头(原 PN → 候选 PN、类型徽)+
* 四态分布带 + 推荐等级大徽章只读;逐条明细可就地改(四态 / 影响说明 / 判断依据 / UNKNOWN 去向);
* 收口四格(推荐理由 / 差异摘要 / 风险 / 验证要求)只读 —— 它们是配方按规则算的,人要改就去改
* 上面那一条四态,系统重算。复用文件顶部已有的 `viewHtml` 短写。
*/
describe('件② 差异评估结论审核', () => {
const html = viewHtml('difference-review');
it('在 viewTemplates 里,S3 指向它,面孔是审核卡', () => {
expect(html).not.toBe('');
const s3 = (manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; view?: string; cardVariant?: string }>)
.find((s) => s.item === 'S3_review');
expect(s3?.view).toBe('difference-review');
expect(s3?.cardVariant).toBe('review');
});
it('逐条明细可改,且**改的是四态**', () => {
expect(html).toContain('data-rows="mine:substitution.difference_item"');
expect(html).toContain('data-edit="substitution.difference_item"');
expect(html).toContain('data-col="difference_status"');
for (const col of ['impact_statement', 'evidence_note', 'unknown_disposition']) {
expect(html).toContain(`data-col="${col}"`);
}
expect(html).toContain('data-col="difference_item_id"');
});
it('**推荐理由 / 摘要 / 风险 / 验证要求只读** —— 让人直接改摘要,等于让他给自己没做的判断盖章', () => {
for (const col of ['recommendation_reason', 'difference_summary', 'verification_requirement']) {
expect(html).toContain(`data-slot="${col}"`);
expect(html).not.toContain(`data-col="${col}"`);
}
expect(html).not.toContain('data-edit="substitution.relation"');
});
it('等级与四态用 data-class-of 上色 —— 判断留在样式表,模版不判', () => {
expect(html).toContain('data-class-of="recommendation_level"');
expect(html).not.toContain('<script');
});
/*
* 评审 fix round 1(变异实测三次,逮到两个活洞):上面「逐条明细可改」只断言了新开放的
* 四列存在,「推荐理由…只读」只断言了 relation 整表不许挂 data-edit —— 两头之间漏了
* 「明细表里其余本该只读的六列 + 收口两枚徽章,不许被顺手多挂一个 data-col」这一段。
* 旧的泛化断言(「data-col 都在台账声明里」)治不了这个:它按名字问「这个字段合法吗」,
* 不是「这一格该不该可改」——`risk_level` 同时是 `substitution.relation` 与
* `substitution.difference_item` 两张表都声明的字段名,两个问题在它身上分岔:挂了
* data-col 依然合法(两张表都真有这一列),泛化规则因此被这个撞车巧合放过。
* 这里改成显式点名,不再依赖任何泛化规则。
*/
it('明细表六列(参数项/说明/约束/原值/候选值/单位)与收口两枚徽章(推荐等级/风险)必须保持只读 —— 显式点名,不靠泛化的「字段已声明」规则', () => {
for (const col of [
'parameter_name', 'parameter_desc', 'constraint_type',
'original_value', 'candidate_value', 'parameter_unit',
'recommendation_level', 'risk_level',
]) {
expect(html, col).not.toContain(`data-col="${col}"`);
}
});
});
/*
* 件③ 落账确认(2026-08-29,Task 9):T_release 的终局验收工单,`view: 'landing-confirm'` 指向它。
* **全只读,答「这一签下去发生什么」(spec §6.3)**:进哪几张表各几行(摆人读名)· 结论卡
* (等级 + 一句话)· 四态分布并把硬约束不符的逐条点名 · 待验项(P-06 的测试条件与后续动作)·
* 发哪一口。复用文件顶部已有的 `viewHtml` 短写。
*/
describe('件③ 落账确认', () => {
const html = viewHtml('landing-confirm');
it('在 viewTemplates 里,T_release 指向它', () => {
expect(html).not.toBe('');
expect((manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; view?: string }>)
.find((s) => s.item === 'T_release')?.view).toBe('landing-confirm');
});
it('**全只读** —— 签之前只核对,不改', () => {
expect(html).not.toContain('data-edit=');
expect(html).not.toContain('data-col=');
expect(html).not.toContain('<script');
});
it('把「进哪几张表各几行」摆出来,用的是基座读数', () => {
for (const reading of ['substitution_relation_rows', 'substitution_difference_item_rows', 'validation_record_rows']) {
expect(html).toContain(`data-slot="${reading}"`);
}
});
it('硬约束不符的逐条点名 —— 签字的人最该看见的就是它', () => {
expect(html).toContain('data-rows="mine:substitution.difference_item"');
/*
* **筛选交给样式表,不是 `data-by`** —— `data-by` 过滤的是「子列 = 外层那一行的父列」,
* 这里没有外层行可依。做法:整表逐行渲出来,行上挂 `data-class-of="judgement_class"`,
* 由 CSS 只显 `.v-hard_mismatch` 那几行(判断留在样式表,模版不判)。
*/
expect(html).toContain('data-class-of="judgement_class"');
});
/*
* `judgement_class` 的 tally 值 `hard_mismatch` 带下划线 —— `classToken`/`vtok` 只留
* `[a-z0-9-]`(**吃掉下划线**),真正生效的选择器是 `.v-hardmismatch` 不是手写直觉的
* `.v-hard_mismatch`(2026-08-27 已在这个包里踩过一次同款事故,`vtok` 是这条回归网)。
*/
it('过滤用的选择器是从值算出来的(vtok),不是手写的 —— 不许踩「带下划线的值显示成待定」那个坑', () => {
expect(html).not.toContain('.v-hard_mismatch');
expect(html).toContain('.v-hardmismatch');
});
});
describe('运营中心看板', () => {
const board = manifest.assets.viewTemplates.find((v) => v.name === 'activity-ledger');
it('取数声明改绑新表,不再引已退役的那几张', () => {
const json = JSON.stringify(board?.data ?? {});
for (const gone of ['demand.request', 'parts.document', 'sub_eval.pn_check', 'sub_eval.input_gap', 'sub_eval.verdict']) {
expect(json).not.toContain(gone);
}
for (const kept of ['sub_eval.case', 'substitution.relation', 'substitution.difference_item']) {
expect(json).toContain(kept);
}
});
});
/*
* 2026-08-27 实测账,两条都真截图 + SQL 复核过:
* ① **带下划线的枚举值一律显示成「待定」**——平台有两套互相打架的消毒规则:
* `data-class-of` 投影出的 class 走 `view-render.ts` 的 `classToken`(只留 `[a-z0-9-]`,
* 吃掉下划线,`pin_to_pin` → `v-pintopin`);基座读数名走 `engines.ts` 的 `slug`
* (只留 `[a-z0-9_]`,吃掉连字符)。手写 `.v-pin_to_pin` 那种选择器永远匹配不上 ——
* 这条测试把 `manifest.declarations.ledger` 里每一个「模版真的摆了徽标」的 tally 列过一遍,
* 用与 `classToken` 一字不差的 `vtok` 算出选择器,断言对应的 CSS 规则确实存在。
* ② **缺口表补充内容每一行都摆红「必填」,而闸只认 3 行**——`requiredWhen` 是按行不按列,
* 静态 `class="req"` 把整列都标了必填。
*/
/*
* SKIP(2026-08-29,Task 9 复核后维持 skip):三张新件与看板全部到位后复查过 ——
* `BADGED` 列表 35 条里没有一条能原样救活:半数指着已彻底撤下的局面(`p08-master-create` /
* `eval-input-list`,Task 7 范围内已不存在)或已退役的字段(`substitution_type` 整列退休、
* `${D}.pn_check` 整个改名 `${D}.part`、`demand.request`/`parts.document` 两域这次重写
* 本活动已不碰),另一半虽然指向真实存在的 view/字段(`difference-review`/`landing-confirm`
* 的 `recommendation_level`/`risk_level`/`difference_status`/`constraint_type` 等),
* 但混在同一个数组里没法只救活这几行 —— 拆开重写等于另起一份测试,超出「回归网」这条 skip
* 本该做的事。这几条真正要钉的口径,「件②」「件③」两组已经用**显式点名**的断言钉过一遍
* (逐条明细六列只读 / judgement_class 的 vtok 选择器),不必靠这条泛化清单再验一遍。
*/
describe.skip('徽标别名与值域相关的坑(2026-08-27 实测账,回归网)', () => {
/** 必须与 `apps/api/src/modules/interaction/internal/domain/view-render.ts` 的 `classToken` 一字不差 */
const vtok = (v: string): string => v.toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 24);
/** 每张模版真的摆了徽标的那几列(带下划线的 tally 值最容易漏,漏了就是「待定」那次事故) */
const BADGED: ReadonlyArray<{ view: string; domain: string; object: string; column: string }> = [
{ view: 'p08-master-create', domain: 'parts', object: 'profile', column: 'lifecycle_status' },
{ view: 'p08-master-create', domain: 'parts', object: 'parameter', column: 'constraint_type' },
{ view: 'p10-document-create', domain: 'parts', object: 'document', column: 'document_type' },
{ view: 'p10-document-create', domain: 'parts', object: 'document', column: 'document_status' },
{ view: 'eval-input-list', domain: D, object: 'pn_check', column: 'exists_status' },
{ view: 'eval-input-list', domain: D, object: 'pn_check', column: 'role' },
{ view: 'eval-input-list', domain: D, object: 'input_gap', column: 'status' },
{ view: 'difference-review', domain: 'substitution', object: 'relation', column: 'substitution_type' },
{ view: 'difference-review', domain: 'substitution', object: 'relation', column: 'recommendation_level' },
{ view: 'difference-review', domain: 'substitution', object: 'relation', column: 'risk_level' },
{ view: 'difference-review', domain: 'substitution', object: 'difference_item', column: 'constraint_type' },
{ view: 'difference-review', domain: 'substitution', object: 'difference_item', column: 'difference_status' },
{ view: 'difference-review', domain: 'substitution', object: 'difference_item', column: 'risk_level' },
{ view: 'landing-confirm', domain: 'substitution', object: 'relation', column: 'substitution_type' },
{ view: 'landing-confirm', domain: 'substitution', object: 'relation', column: 'recommendation_level' },
{ view: 'landing-confirm', domain: 'substitution', object: 'relation', column: 'analysis_status' },
{ view: 'landing-confirm', domain: 'substitution', object: 'relation', column: 'risk_level' },
{ view: 'landing-confirm', domain: 'demand', object: 'request', column: 'request_status' },
/* 运营侧看板把六张表的枚举列全摆成徽标 —— 每一列的每个值都得有文案,漏一个就是一枚「—」 */
{ view: BOARD_VIEW, domain: 'parts', object: 'profile', column: 'lifecycle_status' },
{ view: BOARD_VIEW, domain: 'parts', object: 'document', column: 'document_type' },
{ view: BOARD_VIEW, domain: 'parts', object: 'document', column: 'document_status' },
{ view: BOARD_VIEW, domain: 'demand', object: 'request', column: 'request_status' },
{ view: BOARD_VIEW, domain: 'demand', object: 'request', column: 'risk_level' },
{ view: BOARD_VIEW, domain: 'substitution', object: 'relation', column: 'substitution_type' },
{ view: BOARD_VIEW, domain: 'substitution', object: 'relation', column: 'recommendation_level' },
{ view: BOARD_VIEW, domain: 'substitution', object: 'relation', column: 'analysis_status' },
{ view: BOARD_VIEW, domain: 'substitution', object: 'relation', column: 'risk_level' },
{ view: BOARD_VIEW, domain: 'substitution', object: 'difference_item', column: 'constraint_type' },
{ view: BOARD_VIEW, domain: 'substitution', object: 'difference_item', column: 'difference_status' },
{ view: BOARD_VIEW, domain: 'substitution', object: 'difference_item', column: 'risk_level' },
{ view: BOARD_VIEW, domain: 'validation', object: 'record', column: 'validation_type' },
{ view: BOARD_VIEW, domain: 'validation', object: 'record', column: 'validation_status' },
{ view: BOARD_VIEW, domain: D, object: 'pn_check', column: 'role' },
{ view: BOARD_VIEW, domain: D, object: 'pn_check', column: 'exists_status' },
];
it('每张模版摆了徽标的每个 tally 列,声明里的每个值都有对应的 v-<token> 规则(选择器是从值算出来的,不是手写的)', () => {
for (const { view, domain, object, column } of BADGED) {
const tally = byAddress(domain, object)?.fields.find((f) => f.name === column)?.tally ?? [];
expect(tally.length, `${domain}.${object}.${column} 没有声明 tally`).toBeGreaterThan(0);
const html = viewHtml(view);
for (const value of tally) {
expect(html, `${view}: ${domain}.${object}.${column}=${value} → 应有 .v-${vtok(value)}::after`).toContain(`.v-${vtok(value)}::after`);
}
}
});
it('缺口表补充内容按行投影必填,不是整列静态 req(requiredWhen 是按行的,不是按列的)', () => {
const html = viewHtml('eval-input-list');
expect(html).not.toContain('class="req" data-h="补充内容"');
expect(html).toContain('data-class-of="status"');
expect(html).toContain('.sup.v-gap');
});
});
/*
* 2026-08-27 定向复审逮住的两条 Critical(都是这一轮版面重做新引入的回归,不是原有的坑):
* ① `sub_eval.input_gap` 的「键」(`item_key`)那一格丢了 `data-col` —— 一单 11 行,`item_key`
* 逐行不同,人保存**任何一行**时服务端 `resolveRowKey` 凑不出主键(它既不在件上、
* 又不是「全表一致」的那种案号型列),整格落不下去;
* ② `sub_eval.input_gap.status` 声明 `required: true`,件上又有「+ 加一行」——
* 降成只读徽标后,人新加的行永远没有格子能把这一列填上,`_missing` 恒为正、
* 提交闸永远拦着,这是「墙不配路」的死路形态。
* 两条各配一张回归网,防的是同一类事故再出现在别的字段上。
*/
/*
* 解封(2026-08-29,Task 7):`EDITABLE` 列表已按新设计换成 `input-supplement` 一张件绑的
* 三张台账对象(`parts.profile` / `parts.type_param` / `parts.parameter`)—— `p10-document-create`
* 与 `sub_eval.input_gap` 两张随重写整个退休,不再有对应条目。这两条 Critical 回归网继续钉在
* 新件上:主键(internal_pn / type_id+param_name)都挂了 `data-col`,必填列都有可填的控件。
*/
describe('可编辑件的主键与必填列(2026-08-27 复审:主键丢钥匙 / 必填填不了,两条 Critical 回归)', () => {
/** input-supplement 一屏三张真能编辑的表与它们各自绑定的台账对象 */
const EDITABLE: ReadonlyArray<{ view: string; domain: string; object: string }> = [
{ view: 'input-supplement', domain: 'parts', object: 'profile' },
{ view: 'input-supplement', domain: 'parts', object: 'type_param' },
{ view: 'input-supplement', domain: 'parts', object: 'parameter' },
];
it('每一个主键列都有 data-col —— 它不是编辑面,是行的钥匙,丢了就整行保存不下去', () => {
for (const { view, domain, object } of EDITABLE) {
const table = byAddress(domain, object);
/*
* `caseKey` 声明的那一列(如 `eval_ref`)是例外:它是「一单一号,全表当然一致」的
* 案号型主键(与 I01 的 `round_ref` 同款),件上压根不摆,服务端 `resolveRowKey`
* 按「全表一致」自动补上(`apps/api/src/app/routes/desk.ts` 注释里点名的那条路)——
* 摆出来给人填反而是错的(那会让人以为能按行改案号)。真正逐行不同、必须挂
* `data-col` 的,是主键里除 caseKey 之外的那几列(如 `item_key`)。
*/
const pk = (table?.fields.filter((f) => f.primaryKey).map((f) => f.name) ?? []).filter((name) => name !== table?.caseKey);
expect(pk.length, `${domain}.${object} 没有非 caseKey 的主键列`).toBeGreaterThan(0);
const html = viewHtml(view);
for (const col of pk) {
expect(html, `${view}: 主键列 ${domain}.${object}.${col} 缺 data-col`).toContain(`data-col="${col}"`);
}
}
});
it('可编辑件上,每一个 required:true 的列都有真能填的控件(data-col)—— 必填但填不了是墙不配路', () => {
for (const { view, domain, object } of EDITABLE) {
const table = byAddress(domain, object);
const required = table?.fields.filter((f) => f.required === true).map((f) => f.name) ?? [];
expect(required.length, `${domain}.${object} 没有声明 required 列`).toBeGreaterThan(0);
const html = viewHtml(view);
for (const col of required) {
expect(html, `${view}: 必填列 ${domain}.${object}.${col} 没有可填的控件`).toContain(`data-col="${col}"`);
}
}
});
});
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;
/* 验收件题头是验收绿(2026-08-27 用户定:与对话区验收卡同色,不再是目标件的红) */
describe('两张验收件的主题色', () => {
it('ACC1 / ACC2 的 --ledger 是验收绿 #0aa294,题头渐变不再带红;CONDITIONAL 徽标保留语义红', async () => {
const { ACC1_CSS, ACC2_CSS } = await import('../src/views/target-style.ts');
for (const css of [ACC1_CSS, ACC2_CSS]) {
expect(css).toContain('--ledger:#0aa294');
expect(css).not.toContain('--ledger:#b23a2e');
expect(css).not.toContain('#fdf3f1');
expect(css).toContain('rgba(10,162,148,.25)');
}
// ACC2 的 CONDITIONAL 徽标保留目标件的语义红;ACC1 目标件 2026-08-27 改版把它改成了品牌蓝,照抄
expect(ACC2_CSS).toContain('.st.cond{color:#b23a2e');
expect(ACC1_CSS).toContain('.st.cond{color:var(--brand-deep)');
});
});
/*
* 差异评估结论审核 · 目标件 2026-08-27 改版:「一个候选一张表」(用户:存在多个替代 PN 要对比差异时看不清楚)。
* 外层按 relation 逐个复制 <section class="cand">,里层明细只列该候选名下的行(data-by 父键过滤);
* 汇总表一个候选一条;品牌从 parts.profile 整表按料号挑(取值,不是算)。
*/
/*
* SKIP(2026-08-29,Task 2 → Task 8 补回):`difference-review` 局面已随重写撤下,
* 新版由 Task 8 重做(件② `difference-review` —— 差异评估结论审核)。
*/
describe.skip('difference-review:一个候选一张表', () => {
const html = viewHtml('difference-review');
it('候选块是嵌套行模版:外层 relation、里层明细按 substitution_relation_id 过滤;容器标签不在行模版里再出现', () => {
expect(html).toContain('<article class="cands" data-rows="mine:substitution.relation"><section class="cand">');
expect(html).toContain('data-rows="mine:substitution.difference_item" data-by="substitution_relation_id"');
const inner = html.slice(html.indexOf('<section class="cand">'), html.indexOf('</section></article>'));
expect(inner).not.toContain('<article');
expect(inner).toContain('class="cand-idx"');
expect(inner).toContain('data-slot="original_internal_pn"');
});
it('汇总表一个候选一条:推荐程度 / 风险级别按值上色,差异统计绑 difference_summary,品牌按料号从 parts.profile 挑', () => {
expect(html).toContain('<tbody data-rows="mine:substitution.relation"><tr>');
expect(html).toContain('<span class="rec" data-class-of="recommendation_level"></span>');
expect(html).toContain('class="stat" data-slot="difference_summary"');
expect(html).toContain('data-rows="parts.profile" data-by="internal_pn=candidate_internal_pn"');
expect(html).toContain('data-slot="substitution_relation_rows"');
expect(html).not.toContain('【AI 产出的差异明细】');
});
});
/* 评估输入清单的件上摆「候选参数现值」:一颗料一张、里层按料号过滤、只读(没有 data-edit) */
/*
* SKIP(2026-08-29,Task 2 → Task 7 补回):`eval-input-list` 局面已随重写撤下,
* 「候选参数现值」这块只读参考并入 Task 7 的 `input-supplement`。
*/
describe.skip('eval-input-list:候选参数现值只读参考', () => {
it('外层按核对表逐颗复制,里层 parts.parameter 按 internal_pn 过滤,不是可编辑表', () => {
const html = viewHtml('eval-input-list');
expect(html).toContain(`<article class="prefs" data-rows="mine:${D}.pn_check"><section class="pref">`);
expect(html).toContain('<tbody data-rows="parts.parameter" data-by="internal_pn"><tr>');
const block = html.slice(html.indexOf('<article class="prefs"'), html.indexOf('</section></article>'));
expect(block).not.toContain('data-edit=');
expect(block).toContain('data-slot="param_value"');
expect(html).toContain('【候选参数现值】');
});
});
/*
* 运营中心沙箱看板(2026-08-27 用户定:替换全流程分支图,只摆最近一次已放行实例的关键台账落账快照)。
* 模版归活动,「哪几行是这一单的」也归活动 —— `data` 声明:案号怎么铸、哪张表按哪一列圈;基座只照它查正式表。
*/
describe('activity-ledger:关键台账数据看板', () => {
const board = manifest.assets.viewTemplates.find((v) => v.name === BOARD_VIEW);
const html = board?.html ?? '';
const data = board?.data;
const declaredAddresses = new Set(manifest.declarations.ledger.map((l) => `${l.domain}.${l.object}`));
/*
* 解封(2026-08-29,Task 9「运营中心看板重绑」):`LEDGER_BOARD_DATA` 已换成新九张表 ——
* 案卷两张(`sub_eval.case`/`sub_eval.part`)+ 字段字典两张(`parts.chip_type`/`parts.type_param`)+
* 参数对照两张(`parts.profile`/`parts.parameter`)+ 明细/关系(`substitution.difference_item`/
* `substitution.relation`)+ P-06(`validation.record`,含同料号历史 `validation.history`),
* 这条取数声明的自洽性检查此刻才第一次能验。
*/
it('取数声明:案号台账是 record 且 key 是它的 caseKey、前缀 W05-;每条 rows 的表都已声明;from 只指 case 或前面的 as.列', () => {
expect(data).toBeDefined();
if (data === undefined) return;
const caseLedger = manifest.declarations.ledger.find((l) => `${l.domain}.${l.object}` === data.case.ledger);
expect(caseLedger?.kind).toBe('record');
expect(caseLedger?.caseKey).toBe(data.case.key);
expect(data.case.prefix).toBe('W05-');
const seen = new Set<string>();
for (const entry of data.rows) {
expect(declaredAddresses.has(entry.ledger), entry.ledger).toBe(true);
const columns = new Set(manifest.declarations.ledger.find((l) => `${l.domain}.${l.object}` === entry.ledger)?.fields.map((f) => f.name));
expect(columns.has(entry.by.column), `${entry.as} by ${entry.by.column}`).toBe(true);
for (const ref of [entry.by.from, entry.not?.from]) {
if (ref === undefined || ref === 'case') continue;
const at = ref.lastIndexOf('.');
const as = ref.slice(0, at);
const column = ref.slice(at + 1);
expect(seen.has(as), `${entry.as} 引用了后面 / 不存在的集合 ${as}`).toBe(true);
const source = data.rows.find((r) => r.as === as);
const sourceColumns = new Set(manifest.declarations.ledger.find((l) => `${l.domain}.${l.object}` === source?.ledger)?.fields.map((f) => f.name));
expect(sourceColumns.has(column), `${ref} 不是 ${source?.ledger} 的列`).toBe(true);
}
seen.add(entry.as);
}
// 案卷两张 + 字段字典两张 + 参数对照两张 + 明细/关系 + P-06(含历史)都圈了
expect([...seen]).toEqual(
expect.arrayContaining([`${D}.case`, `${D}.part`, 'parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter', 'substitution.relation', 'substitution.difference_item', 'validation.record', 'validation.history']),
);
});
it('html 里每个 data-rows 键都是声明里的一个 as(行由基座圈过,不是整表);嵌套只用 data-by 父键过滤', () => {
const as = new Set(data?.rows.map((r) => r.as) ?? []);
const keys = [...html.matchAll(/data-rows="([^"]+)"/g)].map((m) => m[1] ?? '');
/*
* 2026-08-29 Task 9:门槛从「> 8」(旧六张主表设计)降到「> 6」——新设计收成 8 处绑定
* (P-08 主表 + 两处嵌套查表 / 字段字典 / 关系 / 明细 / P-06 本单 + 历史),门槛仍要求
* 「不止一两处」这条哨兵语义,只是数字跟着新设计的真实形状改小,不是放水。
*/
expect(keys.length).toBeGreaterThan(6);
for (const key of keys) expect(as.has(key), key).toBe(true);
expect(html).toContain(`<span class="pnrow" data-rows="${D}.part" data-by="internal_pn">`);
expect(html).toContain('<div class="pchips" data-rows="parts.parameter" data-by="internal_pn">');
expect(html).toContain('<tbody class="hist" data-rows="validation.history">');
expect(html).not.toContain('mine:');
});
it('无脚本、无 on*、无外链(过安装静态审);有 </body> 给出口注入报高脚本;三态由 board:state 投影;只读', () => {
expect(html.length).toBeGreaterThan(5000);
expect(html).not.toMatch(/<\s*script\b/i);
expect(html).not.toMatch(/\son[a-z]+\s*=/i);
expect(html).not.toMatch(/(src|href)\s*=\s*["']?\s*(https?:)?\/\//i);
expect(html.toLowerCase()).toContain('</body>');
expect(html).toContain('<div class="page" data-class-of="board:state">');
expect(html).toContain('.page.v-none .notice.none{display:flex}');
expect(html).toContain('.page.v-missing .notice.missing{display:flex}');
expect(html).not.toContain('data-edit=');
expect(html).not.toContain('data-col=');
});
/*
* 2026-08-29 Task 9 改窄:六张表收成新九张(P-01/P-10 两张连同「关联需求」「关联料号」
* 两列一起退休 —— 本活动已不碰 demand.request / parts.document);案号台账从 `${D}.verdict`
* 改名 `${D}.case`;「放弃的候选」这一概念随多候选并行设计一起退役(新设计一次评估恰一对一
* 候选,没有「本轮放弃的候选」这件事)。补一条**已退役地址不再出现**的回归网,同「运营中心看板」组同款纪律。
*/
it('新九张表的表头与页头要素都在;MATCH 行淡显 —— 都是 class 交样式表,模版不判', () => {
for (const header of ['<th>内部料号</th>', '<th>替代关系 ID</th>', '<th>参数项</th>', '<th>验证 ID</th>', '<th>标准化参数集</th>', '<th>参数名</th>']) expect(html).toContain(header);
expect(html).toContain('data-slot="case:instanceNo"');
expect(html).toContain('data-slot="case:ref"');
expect(html).toContain('data-slot="substitution.relation.original_internal_pn"');
expect(html).toContain(`data-slot="${D}.case.summary"`);
expect(html).toContain('<tr data-class-of="difference_status">');
expect(html).toContain('tr.v-match td{opacity:.55}');
expect(html).toContain('data-slot="substitution_difference_item_difference_status_unknown"');
// 已退役的旧域 / 旧字段一律不出现
for (const gone of ['demand.request', 'parts.document', `${D}.pn_check`, `${D}.verdict`, 'substitution_type', 'customer_approval', 'v-abandoned']) {
expect(html, gone).not.toContain(gone);
}
});
});