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 59afcbfdbd feat(w05): 件② 差异结论审核 —— 可改的是四态,摘要与等级只读
推荐理由 / 摘要 / 风险 / 验证要求都是规则算出来的:让人直接改它们,
等于让他给自己没做的判断盖章。要改就改上面那一条四态,系统重算。

同批修一条与本次设计矛盾的既有断言:「差异审核与落账确认只读」原把
difference-review 也算只读,而 spec §4.4/§6.2 已把 S3 改成可就地编辑
——改窄成只管 landing-confirm,differerence-review 的只读面改由新增的
「件②」测试组精确覆盖。

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

828 lines
51 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();
});
/*
* SKIP(2026-08-29,Task 2 → Task 7 部分交付):`input-supplement` 已随 Task 7 补回
* `assets.viewTemplates`(`VIEWS` 已更新),但 `difference-review` / `landing-confirm`
* 两张还在等 Task 8 / 9 —— `new Set(...)` 的整体相等要三张都在才成立,继续跳过。
*/
it.skip('三张工单视图 + 一张运营侧看板都声明了,剧本每一步引用的 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 2 → Task 8/9):`difference-review` / `landing-confirm` 两张局面已撤下(新的两张由 Task 8/9 重做);它们绑的字段(recommendation_level 已只剩三档、analysis_status 已不带 tally)到时也会一并对齐 */
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(/无脚本执行权/);
});
/*
* SKIP(2026-08-29,Task 2 → Task 7 部分交付):VIEWS 现在是三张新局面的名单,`input-supplement`
* 已随 Task 7 补回、这一条本身可以对它验(外壳确实是 page/card),但 `difference-review` /
* `landing-confirm` 两张还没有(viewHtml 返回空串,`toContain('class="card-head"')` 会假),
* 循环体没法只验一部分 —— 等 Task 8/9 补齐三张再一起解封。
*/
it.skip('三张模版的外壳都是搬自目标件的 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');
});
});
/*
* 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 2 → Task 7-9 补回):`BADGED` 列表里的每一项都指着已撤下的五张局面
* (p08-master-create 等)或已改动的字段(recommendation_level 掉了 alternate 档、
* analysis_status 不再带 tally、`sub_eval.pn_check` 整个改名 `sub_eval.part`)。
* 这整组「回归网」要等 Task 7–9 把新三张件补回来、字段清单也重新对齐后才谈得上继续钉。
*/
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}`));
/*
* SKIP(2026-08-29,Task 2 → Task 9 补回):看板的 `data` 声明(`views/ledger-board.ts` 的
* `LEDGER_BOARD_DATA`)还圈着已退休的旧台账(`sub_eval.pn_check` / `sub_eval.verdict` /
* `demand.request` / `customer.profile` / `parts.document`)—— Task 9「运营中心看板重绑」
* 会把它换成新九张表;这条取数声明的自洽性检查到那时再验。html 自身的结构测试(下面三条)
* 不依赖 `declarations.ledger`,仍然有效、继续跑。
*/
it.skip('取数声明:案号台账是 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);
}
// 六张主表 + 案号两张 + 同料号历史都圈了
expect([...seen]).toEqual(
expect.arrayContaining([`${D}.verdict`, `${D}.pn_check`, 'demand.request', 'customer.profile', 'parts.profile', 'parts.parameter', 'parts.document', '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] ?? '');
expect(keys.length).toBeGreaterThan(8);
for (const key of keys) expect(as.has(key), key).toBe(true);
expect(html).toContain('<span class="pnrow" data-rows="sub_eval.pn_check" 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=');
});
it('目标件六张表的表头与页头要素都在;MATCH 行淡显、放弃的候选只在有 abandoned 时露面 —— 都是 class 交样式表,模版不判', () => {
for (const header of ['<th>内部料号</th>', '<th>替代关系 ID</th>', '<th>参数项</th>', '<th>关联料号</th>', '<th>验证 ID</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="${D}.verdict.original_internal_pn"`);
expect(html).toContain('data-slot="demand.request.request_id"');
expect(html).toContain('<tr data-class-of="difference_status">');
expect(html).toContain('tr.v-match td{opacity:.55}');
expect(html).toContain('.aband:has(i.v-abandoned){display:block}');
expect(html).toContain('data-slot="substitution_difference_item_difference_status_unknown"');
});
});