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 45cec82984 fix(w05): 终审两条 Critical + 地基缺陷的另一半 + 文档与欠账收口
**C1 新建芯片类型会改写既有字典行(共享主数据被静默污染)**
链条逐行复核成立:件上种子行漏挂 data-col → barred 与 isSeq 双保护同时失效
(isSeq 那条是因为 Task 7b 把 type_id 从 integer 改成了 text)→ 落进「全表一致」,
而 H35 的自投影恰好保证这张表只有一行 → 人新建的类型被认成既有 type_id=1 →
确认时随范围放行把既有字典行改名并落进正式表,屏上只说「改了 1 行」。
三条各自成立的裁定叠出来的静默污染。
修法:种子行改成 disabled 的真实 <input data-col=type_id>(desk.ts 零改动),
格子里摆「由系统生成」——不能一声不吭地拿掉,那样人会以为这张档少了一格。
测试不止断言结果:另加两条用**真实 resolveRowKey** 重放病灶/修法两种 cells 形状,
直接证明因果机制。平台根治(全表一致应排除 auto 列)记 debt。

**C2 两个启动参数都选填 → 留空则整单永久无解**
gate_reason 说「在下面把两格补上」,而块① 没有加一行、也不在 gateLedgers ——
一句指向空气的指路。改为必填;第一档改口为唯一真无解的那条路径(两格填了同一个号)。

**I1 地基缺陷只修了一半:recipe.let 同样按对象键序求值**
与 fields 是一模一样的假设,同样住 jsonb 列、同样被 PG 重排;今天不炸只因 W05 那五条
互不引用,而 ADR-014 是给后续所有活动用的。新增 orderLets/letReferences
(把 {in:X} 视为依赖 —— 语义与 fields 不同,故单独实现),auto-runner 与静态审同步接入。

**I2 审核卡按「重做」照样收步推进** —— 坐实为真实平台缺陷(「卡答过了就收步」快道不看裁决),
并证明活动侧加选项救不了、chosenGuidance 那条也救不了(那是喂模型的提示词,
而这条快道存在的目的就是跳过模型)。按裁定只留复现测试,平台未改。

**I4** 同一堵墙撞第二次即发兜底执行工单交人 + 立监听(头一次软化留痕),
不再空转到 exhausted 而人无处可改。

debt +4(C1 根治 / modules→app / thead-after-tbody / data-by 落回外层),
并补写此前被两处引用却从不存在的「refersTo 传递闭包」条目。
文档:ADR-014:201「按声明顺序求值」改对(照它写配方的人会重犯本轮的地基缺陷)、
陈旧注释四处、spec 四处分叉逐条判断处置、端到端测试记录标为历史、
CLAUDE.md 纠正「新建类型端到端通了」那句(它没通过真人路)并更新版本与文档表。

包版本 2.1.9(已装,SQL 直查库内 version/required/gate_reason/视图 HTML 四项核实)。
pnpm gates 退出码 0(2476 通过 / 34 跳过);E04 92/92、I01 55/55 无回归。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-30 16:09:18 +08:00

1180 lines
75 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);
/**
* 从一段 CSS 文本里摘出**选择器里提到过的所有 class token**(2026-08-30 task-12,缺陷③用)。
* 只扫选择器,不扫规则体(`content:"…"` 这类属性值里的文字不算);`@media`/`@supports`
* 这类带嵌套一层规则体的 at-rule 会往里再走一遍(本仓三份件的 `@media(max-width:600px)` 与
* `@media (prefers-reduced-motion: reduce)` 都嵌着真正的选择器,不能漏)。
* 这是**存在性**扫描(选择器里出没出现过这个 class),不判断它是不是复合选择器的一部分 ——
* `.zic` 完全不存在是这份扫描要抓的那种漏(缺陷③的两个真实事故都是这一种:某个 class
* 在目标件自己的 `<style>` 里一次都没被提到)。
*/
function definedClassesOf(css: string): Set<string> {
const defined = new Set<string>();
const noComments = css.replace(/\/\*[\s\S]*?\*\//g, '');
const walk = (text: string): void => {
let i = 0;
while (i < text.length) {
const openIdx = text.indexOf('{', i);
if (openIdx === -1) break;
const selector = text.slice(i, openIdx);
for (const m of selector.matchAll(/\.([a-zA-Z0-9_-]+)/g)) defined.add(m[1] ?? '');
let depth = 1;
let j = openIdx + 1;
while (j < text.length && depth > 0) {
if (text[j] === '{') depth += 1;
else if (text[j] === '}') depth -= 1;
j += 1;
}
const body = text.slice(openIdx + 1, j - 1);
if (/^\s*@media/.test(selector) || /^\s*@supports/.test(selector)) walk(body);
i = j;
}
};
walk(noComments);
return defined;
}
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) */
/*
* 2026-08-30:两条 L4 从「裸的(无 predicate)」改成「带谓词」——真跑 e2e 撞见的结构性
* 发现:T_release 全程 auto 驱动,`submit` 是五个声明式动词之一,没有第六个 `judge`;
* 裸 L4 只能靠 `judgement`(Agent 的 judge 行动)结算,而这个活动没有 Agent 会走到这一步,
* `submittable()` 于是永远读到 pass:false,`raiseAcceptance` 每一拍都拒、不算错误、
* 下一拍原样重来,稳定烧光步数预算到 exhausted。谓词只覆盖客观前提(有没有值、
* 值哪来的),主观那一半已经由 S3 人工验收清单接住(`engines.ts` 的 `criterionCheck`:
* 「裁量项带谓词时谓词说了算」,本仓 2026-08-02 就立的纪律,W05 这两条当时没跟上)。
*/
it('L1/L2/L4 判据都给了谓词(L4 走「谓词为真即达标」那条路,主观部分交人工验收清单接住);L5 恰一条', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level === 'L1' || c.level === 'L2' || c.level === 'L4') expect(c.predicate, c.key).toBeDefined();
if (c.level === 'L5') 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 需求主记录一起撤下(本活动不再碰它)。逐条断言见「契约 > 启动两参」。
*
* **2026-08-30 终审 Critical 2 改判**:两格从「都选填」改成**都必填**——旧断言
* `toEqual([])` 钉的正是那条留空即永久卡死在 S1 的死路(gate_reason 指向一条不存在的
* 「加一行」路径),详见「契约 > 启动两参」那条测试与 `manifest.ts`/`case-model.ts`
* 里的大注释。「客户与需求两类字段已随重写撤下」这半句结论不变。
*/
it('起单两格都必填(2026-08-30 修 Critical 2 改判);客户与需求两类字段已随重写从启动表单撤下', () => {
expect(manifest.contract.input.data['required']).toEqual(['originalInternalPn', 'candidateInternalPn']);
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');
});
/*
* 2026-08-29 Task 11:随新样例集换血(单数 `candidateInternalPn`,起单只剩两格)——
* 旧断言查的是已撤下的 `candidateInternalPns`(数组)/ `customerName`,这里同步核新形状:
* 第一条是「候选缺库」那条路(S1 会派数据工单),每条样例的入参恰是启动契约那两格,不多不少。
*/
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 { originalInternalPn?: string; candidateInternalPn?: string };
expect(first.originalInternalPn).toBe('LM2596S-5.0');
expect(first.candidateInternalPn).toBe('XL2596S-5.0');
for (const s of manifest.samples) {
expect(Object.keys(s.input as Record<string, unknown>).sort()).toEqual(['candidateInternalPn', 'originalInternalPn']);
}
});
/*
* 解封(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());
});
/*
* Task 7b(2026-08-29):`type_id` 声明是 `text` 不是 `integer` —— 它是本仓唯一
* 「auto:true 又是整数型」的主键,integer 会踩两个平台坑(仍未修,见 docs/debt.md):
* ① `resolveRowKey` 的续号步骤只认 declared type 是不是 integer/number(`isSeq`),
* 不看 `auto`,抢在 `autoKeys()` 前面把它按草稿行数续成 1、撞种子的 `type_id=1`;
* ② `autoKeys()` 补零的字符串 `'03'` 经引用列的 `coerceToColumn(_, 'integer')` 会被
* `Number()` 削掉前导零,`refersTo`/查表按字符串比对全部失配。
* **改 text 不是因为「台账没有物理列类型」**——`master`/`record` 是真实物理列
* (`ledger-store.ts` 的 `PG_TYPE` 映射,`integer → bigint`);只有任务级草稿层
* `ledger.__draft` 才是 JSONB。安全的真正原因是 `schema-merge.ts` 的单调加宽 lattice
* 把 `integer → text` 判定成合法加宽(不是类型冲突),`widen-column` 那支生成的是
* `alter column … type text using col::text`——bigint 转 text 沿 PG 的转型没有失败路径。
* 查证过程见 Task 7b 报告。
*/
it('类型字典:type_id 是自增主键 —— 人在件上新建类型时留空,由基座续号', () => {
expect(colOf('parts.chip_type', 'type_id')).toMatchObject({ primaryKey: true, type: 'text', 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: 'text', 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('契约', () => {
/*
* **2026-08-30 终审 Critical 2**:两颗料号是这件事的身份,不是可以事后补的细节 ——
* 都选填时留空,`sub_eval.part` 的两行会因为同一个空主键 `internal_pn=''` 撞成一行,
* `gate_reason` 判「还没填全」而块① 没有「加一行」的路,doneWhen 永不成立,整单
* 永久停在 S1(链条见 `manifest.ts`/`case-model.ts` 的大注释)。改必填不是把人堵死——
* 平台既有的「受理归一」机制(`task-service.ts` 的 `missingRequired`/`askCompletion`)
* 接住它:起单缺这两格,任务落成 `awaiting-human`,一张补全卡问负责人要,答完才开跑,
* 不是门口拒单。主数据(品牌/类型/参数)仍然缺了才由第一步派数据工单补。
*/
it('启动两参,都必填 —— 两颗料号是身份不是细节,留空会让整单永久卡死在 S1', () => {
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(['originalInternalPn', 'candidateInternalPn']);
/* 运营端启动表单:两格仍是「可选可新写」的下拉 —— 必填只挡空值,不挡「库里没有就自己写一个新的」 */
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=');
/* 编辑闸的外壳也不许有 —— 理由见下面「全只读」那条的注释(`data-edit=` 这个子串照不到它们) */
expect(viewHtml('landing-confirm')).not.toContain('data-edit-open=');
expect(viewHtml('landing-confirm')).not.toContain('data-edit-add=');
});
it('每个可编辑格子的 data-col 都在对应台账对象的声明里(input-supplement 一屏四张表,四张台账对象的声明并起来看;difference-review 一张表)', () => {
const cases: Array<[string, Array<[string, string]>]> = [
['input-supplement', [['parts', 'profile'], ['parts', 'chip_type'], ['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-30 task-12,浏览器真人走查逮到,阻塞人):差异评估结论审核件里三个
* `<svg class="zic">` 因为 `ACC1_CSS` 里没有 `.zic` 而撑到 946×946 像素、把整份文档
* 顶到 4625px 高,正文全被挤出可视区,人打开只看见三块灰色巨块加大片空白。
* 根因是 `ACC1_CSS`/`ACC2_CSS`(target-style.ts)虽然叫「机械抽自目标件」,但
* report.ts 的 `zone()`/`note()` 助手把 P08 目标件「视觉补充段 v3」的图标与强调色
* 跨文档复用到了这两张验收件上,原验收工单目标件自己的 `<style>` 从没定义过它们 ——
* 同一批复查还揪出 `.note .done`/`.note .need`(两张验收件的提示文案同样失色)与
* `.kv-box`(落账确认件的「待验项」盒子没有边框底色)。
* 这条测试把「三份件各自用到的 class,都能在它自己那段 `<style>` 里找到定义」钉成机检 ——
* 拿掉本轮任何一条修复,这里就会变红(变异验证见 task-12 报告)。
*/
it('三份件各自用到的 class,都能在它自己那段 <style> 里找到定义(不许再有 .zic 946×946 那种漏)', () => {
for (const view of VIEWS) {
const html = viewHtml(view);
const styleMatch = /<style>([\s\S]*?)<\/style>/.exec(html);
expect(styleMatch, view).not.toBeNull();
const css = styleMatch?.[1] ?? '';
const body = html.slice((styleMatch?.[0] ?? '').length);
const used = new Set<string>();
for (const m of body.matchAll(/\sclass="([^"]*)"/g)) {
for (const c of (m[1] ?? '').split(/\s+/)) if (c !== '') used.add(c);
}
const defined = definedClassesOf(css);
for (const cls of used) {
expect(defined.has(cls), `${view}: class="${cls}" 用到了,但它自己的 <style> 里没有任何选择器提到 .${cls}`).toBe(true);
}
}
});
});
/*
* 件① 评估输入补全(2026-08-29,Task 7;Task 7b 补②a 块):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.chip_type"');
expect(html).toContain('data-rows="mine:parts.type_param"');
});
it('芯片类型是从台账现读的下拉 —— 写死等于「只能选出包那天就有的那几种」(块①的 chip_type_id 与块②b 的 type_id 都是)', () => {
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);
/* 块①(sub_eval.part 行模版,只有一份行模版,被 data-rows 迭代)绑 chip_type_id 下拉 */
expect(html).toContain('data-col="chip_type_id" required data-options="parts.chip_type"');
/*
* 块②b(type_param)绑 type_id 下拉的地方**该出现两次**——已有行的行模版(data-rows
* 迭代)一份、种子行(字面量静态行,不受行数所限)一份;只数子串存在会被「只剩其中一份」
* 这种退化蒙混过去(已用变异验证逮到:只把动态行模版那份换回裸文本输入,这条子串断言
* 仍然为真,因为种子行那份还在——按出现次数数才钉得住两处都在)。
*/
const typeIdSelectCount = (html.match(/data-col="type_id" required data-options="parts\.chip_type"/g) ?? []).length;
expect(typeIdSelectCount).toBe(2);
});
/*
* Task 7b:件① 新建类型(`parts.chip_type`)与新建字段(`parts.type_param`)两张表开局都是
* 零行,`ViewContainer.addRow` 靠克隆 `tr:last-of-type` 添行,零行时找不到样子可克隆
* (查证见 Task 7b 报告)。补一条**不受 `data-rows` 管辖的静态种子行**——两张表各一条,
* 字面量手写 `data-col` 输入格,不经 `cell()`/`data-slot`,行数再少也在。
*/
it('**零行也加得出第一行**:两张表各带一条静态种子行(class="jc-row-static"),不靠 data-rows 生成', () => {
const staticRows = html.match(/<tr class="jc-row-static">/g) ?? [];
expect(staticRows).toHaveLength(2);
/* 种子行里的输入格是手写的,不经 twinInputs —— 直接断言 data-col 存在即可 */
expect(html).toContain('<input class="jc-cell" data-col="type_name" placeholder="新类型名称">');
expect(html).toContain('<input class="jc-cell" data-col="description"');
expect(html).toContain('<input class="jc-cell" data-col="param_name" placeholder="参数名">');
expect(html).toContain('<input class="jc-cell" data-col="param_desc"');
expect(html).toContain('<select class="jc-cell" data-col="constraint_type">');
expect(html).toContain('<input class="jc-cell" data-col="param_unit"');
/* 种子行只在编辑态露面 —— 读的时候不该看见一行空壳子(界面不撒谎) */
expect(html).toContain('table:not(.on) tr.jc-row-static { display: none; }');
});
/*
* `auto:true` 的主键(`type_id`)**人不填它**——自增列不问人(与联系人编码
* `contact_code` 同一条纪律)。种子行只有 `type_name`/`description` 两个可填输入格,
* `type_id` 自己那一格**必须**是一个真的、禁用的 `data-col` 输入格(值恒空),
* **不能干脆不给** `data-col`。
*
* **2026-08-30 终审 Critical 1 的回归钉**(反着来的教训:这条测试原来断言
* `not.toContain('data-col="type_id"')`,而正是那个「不给 data-col」的形状让
* `desk.ts` 的 `resolveRowKey` 认不出「这一列人碰过没有」,退回「全表一致」把
* 新建的类型误判成种子字典里唯一那一行、静默改名覆盖它——详见 `views/report.ts`
* `CHIP_TYPE_TABLE` 头上的大注释与 `apps/api/src/app/__tests__/view-table-edit.test.ts`
* 里对真实 `resolveRowKey` 的因果验证)。这条测试当年"证明"的是病灶本身,现在改成
* 钉住修法:`type_id` 必须挂 `data-col`,且必须是禁用的 —— 人看得见「由系统生成」
* 这句占位,但怎么点都改不了它的值。
*/
it('**新建类型的种子行:type_id 挂 data-col、但禁用恒空**(2026-08-30 修 Critical 1 —— 漏挂会让 resolveRowKey 的"全表一致"把新建的类型误判成既有那一行,静默覆盖它)', () => {
expect(html).toContain('由系统生成');
// 「由系统生成」只出现在 chip_type 那条种子行里 —— 精确抓出那一行
const chipTypeStaticRow = /<tr class="jc-row-static">(?:(?!<\/tr>)[\s\S])*?由系统生成(?:(?!<\/tr>)[\s\S])*?<\/tr>/.exec(html)?.[0] ?? '';
expect(chipTypeStaticRow).not.toBe('');
/* 必须是一个真的 <input data-col>(collect() 只认 input/select/textarea[data-col]),
不能是纯文本 <span> —— 否则这一格永远不会出现在回传的 cells 里,barred 的保护无从谈起 */
expect(chipTypeStaticRow).toMatch(/<input[^>]*data-col="type_id"[^>]*disabled/);
/* disabled 意味着人怎么点都改不了它的值 —— 保存时这一格恒是空串,才谈得上「barred」 */
expect(chipTypeStaticRow).toContain('data-col="type_name"');
expect(chipTypeStaticRow).toContain('data-col="description"');
});
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.chip_type', 'parts.type_param', 'parts.parameter']) {
expect(html).toContain(`data-edit="${address}"`);
}
});
/*
* Task 7b:`data-edit="parts.chip_type"`(与 `parts.type_param` 同款)两处都写 ——
* 外层 `<table>` 一处(给 `ViewContainer.collect()`/`.on` 切换看,它按
* `document.querySelector` 找唯一宿主,要同时收到已有行与种子行两处的输入)、
* 内层 `data-rows` 那个 `<tbody>` 一处(给 `renderRows` 的 `editable` 检测看,
* 它只认与 `data-rows` 同一个标签自带的属性,不看祖先)。
*/
it('新建类型 / 新建字段两张表的 data-edit 各出现两次(外层 table + 内层 data-rows tbody)', () => {
for (const address of ['parts.chip_type', 'parts.type_param']) {
const count = (html.match(new RegExp(`data-edit="${address}"`, 'g')) ?? []).length;
expect(count, address).toBe(2);
}
});
/*
* **评审 Critical 回归修复的结构安全网**(2026-08-30):`data-edit` 两处写法
* (上面那条)有个副作用——`ViewContainer.bodyOf()` 的 `document.querySelector`
* 从此解析到外层 `<table>`,而 `addRow()` 接着在这个 host 里找 `tr:last-of-type`——
* CSS `:last-of-type` 是按各自父元素判定的,`<thead>` 里孤零零的那个 `<tr>` 在它
* 自己的父元素(`thead`)里必然「最后」,若 `<thead>` 排在数据行前面,它会被文档序
* 优先命中:克隆出来的是表头文字,零个 `input[data-col]`,`parts.type_param`
* 那张(改动前本来是好的)因此被做坏——一次真回归(评审用 jsdom 在 renderView() 真实
* 输出上复现两次坐实)。
*
* 修法是把 `<thead>` 挪到两个 `<tbody>` 之后(CSS 的 `table-header-group` 排版规则:
* 视觉上永远排最前,不看它在 DOM 里排第几——这条规则本来就是 HTML5 允许 `tfoot`
* 写在 `thead` 之后、`tbody` 之前的原理)——`document.querySelector`/`:last-of-type`
* 认的是文档序不是视觉序,`<thead>` 排在两个 `<tbody>` 后面,`tr:last-of-type` 的
* 搜索就再也碰不到表头行。
*
* 走 DOM 选择器的那部分行为(渲染后 `addRow`/`bodyOf` 真的会不会命中表头)由
* `apps/api/src/modules/interaction/__tests__/view-render-addrow-thead.test.ts`
* 覆盖(activities 包按 ADR-004 只准 import @sdk/@contracts/@sfeel,够不着
* `apps/api` 的 `renderView`,所以那份测试镜像了这份源码的结构,不是 import 它)——
* 这里补的是**廉价的字符串安全网**,把这份源码与那份镜像测试的假设锁在一起:
* 谁若把 `<thead>` 挪回两个 `<tbody>` 之前(哪怕只改一张表),这条测试立刻标红,
* 提醒去核对 `view-render-addrow-thead.test.ts` 是否也需要同步。
*/
it('**回归安全网**:两张新表的 <thead> 都排在两个 <tbody> 之后(不是最前面)—— 与 view-render-addrow-thead.test.ts 的假设锁在一起', () => {
for (const address of ['parts.chip_type', 'parts.type_param']) {
const tableStart = html.indexOf(`<table data-edit="${address}">`);
expect(tableStart, address).toBeGreaterThanOrEqual(0);
const tableEnd = html.indexOf('</table>', tableStart);
const tableHtml = html.slice(tableStart, tableEnd);
const theadAt = tableHtml.indexOf('<thead>');
const tbodyPositions = [...tableHtml.matchAll(/<tbody/g)].map((m) => m.index ?? -1);
expect(theadAt, `${address}: 找不到 <thead>`).toBeGreaterThanOrEqual(0);
expect(tbodyPositions.length, `${address}: 应有两个 <tbody>`).toBe(2);
// <thead> 必须排在两个 <tbody> 的开标签**之后**(源码序;视觉呈现由 CSS 的
// table-header-group 保证仍在最前,与源码序无关)
for (const pos of tbodyPositions) expect(theadAt, address).toBeGreaterThan(pos);
}
});
/*
* 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)—— 补丁真的接在会用到的类上(Task 7b 新增 parts.chip_type 这一张,3→4)
expect(html).not.toContain('class="tbl-wrap"');
const jcTblWrapCount = html.match(/class="jc-tbl-wrap"/g)?.length ?? 0;
expect(jcTblWrapCount).toBe(4);
});
});
/*
* 件② 差异评估结论审核(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');
});
/*
* **`data-edit=` 这个子串照不到编辑闸的外壳**(2026-08-29 评审自拟变异逮到的盲点):
* `editGate()` / `addrow()` 产出的是 `data-edit-open=` / `-cancel=` / `-save=` / `-for=` /
* `-add=` / `-del=` —— `edit` 后面跟的是连字符,**永远凑不出 `data-edit=`**。
* 实测:往这份件里插一个孤立的 `<button data-edit-open="…">`,上面三条断言原样全绿。
* 眼下没触发(这份件零处调用那两个 helper),但「全只读」得连外壳一起说死 ——
* 光有闸没有 `data-col` 孪生格虽然写不进数据,可屏上会冒出一枚按不出结果的「编辑」钮,
* 那是**界面撒谎**。
*/
it('**全只读** —— 签之前只核对,不改(连编辑闸的外壳一起,别只挡 data-edit=)', () => {
expect(html).not.toContain('data-edit=');
expect(html).not.toContain('data-col=');
expect(html).not.toContain('<script');
for (const shell of ['data-edit-open=', 'data-edit-cancel=', 'data-edit-save=', 'data-edit-for=', 'data-edit-add=', 'data-edit-del=']) {
expect(html, shell).not.toContain(shell);
}
});
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 回归;Task 7b 补 parts.chip_type)', () => {
/** input-supplement 一屏四张真能编辑的表与它们各自绑定的台账对象(Task 7b 加入 parts.chip_type) */
const EDITABLE: ReadonlyArray<{ view: string; domain: string; object: string }> = [
{ view: 'input-supplement', domain: 'parts', object: 'profile' },
{ view: 'input-supplement', domain: 'parts', object: 'chip_type' },
{ 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);
/*
* 两类主键列豁免,不必挂 `data-col`:
* ① `caseKey` 声明的那一列(如 `eval_ref`)—— 「一单一号,全表当然一致」的案号型
* 主键(与 I01 的 `round_ref` 同款),件上压根不摆,服务端 `resolveRowKey` 按
* 「全表一致」自动补上(`apps/api/src/app/routes/desk.ts` 注释里点名的那条路)——
* 摆出来给人填反而是错的(那会让人以为能按行改案号)。
* ② `auto:true` 的那一列(如 `parts.chip_type.type_id`,Task 7b 补)—— 自增列不问人
* (与联系人编码 `contact_code` 同一条纪律),值由 `writeEditedRows` 的
* `autoKeys()` 在写入那一刻续号,件上给它挂 `data-col` 反而是在问人一个他答不上
* 来的号(该表种子行只带 `type_name`/`description` 两格,不带 `type_id`)。
* 真正逐行不同、必须挂 `data-col` 的,是主键里除这两类之外的那几列。
*/
const pk = (table?.fields.filter((f) => f.primaryKey).map((f) => f.name) ?? []).filter(
(name) => name !== table?.caseKey && table?.fields.find((f) => f.name === name)?.auto !== true,
);
const html = viewHtml(view);
for (const col of pk) {
expect(html, `${view}: 主键列 ${domain}.${object}.${col} 缺 data-col`).toContain(`data-col="${col}"`);
}
// 至少一类主键真存在(caseKey/auto 全豁免的表,如 chip_type,pk 允许为空数组——上面循环体自然跳过)
const allPk = table?.fields.filter((f) => f.primaryKey).map((f) => f.name) ?? [];
expect(allPk.length, `${domain}.${object} 没有任何主键列`).toBeGreaterThan(0);
}
});
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);
}
});
});