Archived
CI / ci (push) Failing after 10s
契约:launchOption 加 group/groupLabel(x-options.group 从字典表现读人读名)、 launchParam 加 linkGroup(x-link 结组);api-report 已随 --update 入库。 前端 launch-link.ts 纯函数管三条口径:锚摆全量、其余按锚的分组键收窄 (未标类型殿后一组,新增照走);锚粘着 —— 清空只解除收窄不换锚,人改得了主意。 服务端不据此立墙:combo 本就收候选之外的值,类型一致归 S1 收步判据 sub_eval_part_distinct_chip_type_id = 1(数得出来的门)。 W05 清单两格挂上 x-link: chip-type;文档同步。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
1348 lines
86 KiB
TypeScript
1348 lines
86 KiB
TypeScript
/**
|
||
* 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' });
|
||
}
|
||
});
|
||
|
||
/*
|
||
* 按芯片类型联动(2026-08-31 用户定):候选带上分组键与人读名,两格结成一组。
|
||
* 分组键指的那一列必须是候选表**真有**的列(`parts.profile.chip_type_id`),
|
||
* 字典指的那一张表也得是本活动声明过的 —— 写错地址不会炸,只会静默不联动(服务端 warn 一句),
|
||
* 而「静默不联动」正是没人会发现的那种坏。
|
||
*/
|
||
it('两格按芯片类型联动:候选带分组键 + 字典换人读名,x-link 同名结成一组', () => {
|
||
const props = manifest.contract.input.data['properties'] as Record<string, Record<string, unknown>>;
|
||
const ledgerOf = (address: string) => manifest.declarations.ledger.find((l) => `${l.domain}.${l.object}` === address);
|
||
for (const key of ['originalInternalPn', 'candidateInternalPn']) {
|
||
expect(props[key]?.['x-link']).toBe('chip-type');
|
||
const options = props[key]?.['x-options'] as { group?: { column?: string; ledger?: string; value?: string; label?: string } };
|
||
expect(options.group).toEqual({ column: 'chip_type_id', ledger: 'parts.chip_type', value: 'type_id', label: 'type_name' });
|
||
// 分组键那一列在候选表上真的存在
|
||
expect(ledgerOf('parts.profile')?.fields.some((f) => f.name === options.group?.column)).toBe(true);
|
||
// 换名字典的两列也真的存在
|
||
const dict = ledgerOf('parts.chip_type');
|
||
expect(dict?.fields.some((f) => f.name === options.group?.value)).toBe(true);
|
||
expect(dict?.fields.some((f) => f.name === options.group?.label)).toBe(true);
|
||
}
|
||
});
|
||
|
||
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', 'parameter']]],
|
||
/* 件② 2026-08-31 起多一张:收口四格可就地改,写向 substitution.relation */
|
||
['difference-review', [['substitution', 'difference_item'], ['substitution', 'relation']]],
|
||
];
|
||
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):三张全走同一个 `doc()` 壳。2026-08-31 视图层重设计后,
|
||
* 壳不再「搬自目标件」而是活动自己的设计系统(style.ts),但骨架类名不变:
|
||
* card-head 是电路板夜景 hero,card-foot 是丝印条;E04/I01 那套视觉底(.proto-root)仍不许出现。
|
||
*/
|
||
it('三张模版共用同一个 page/card 外壳(card-head hero + card-foot),不含 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).toContain('class="hero-title"');
|
||
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('三块都在,行都绑本单草稿(不是整表);档案卡行源是 parts.profile 本身(类型字典只读整表,不再有 mine: 镜像)', () => {
|
||
expect(html).toContain('data-rows="mine:sub_eval.part"');
|
||
expect(html).toContain('data-rows="mine:parts.profile"');
|
||
expect(html).toContain('data-rows="mine:parts.parameter"');
|
||
expect(html).not.toContain('data-rows="mine:parts.chip_type"');
|
||
expect(html).not.toContain('data-rows="mine:parts.type_param"');
|
||
});
|
||
|
||
/*
|
||
* 2026-08-31 用户实测三连的回归网(视图侧两条,配方侧第三条在 steps.test.ts):
|
||
* ① 「品牌…点保存根本不会保存」—— 病根:块① 行源曾是 `mine:sub_eval.part`,那张案卷表
|
||
* 没有品牌这些列,renderTemplate 只认行作用域,显示与孪生件的值永远空白。行源必须是
|
||
* `mine:parts.profile` 本身(S1 第④笔 prune:true 保证恒有两行)。
|
||
* ② 「明明没选类型,界面自己给上了」—— data-by 行里缺键落回外层同名字段(debt I3),
|
||
* 查名 pill 摆出这一行没有的类型。守卫认 selectCurrent 按这一行自己的值写下的
|
||
* option[selected]:真选上才摆名,没选摆「待选择」。
|
||
*/
|
||
it('档案卡:行源 = mine:parts.profile、一个宿主一道闸;嵌套的角色/在库查表全只读(嵌套行里有 data-col 会被 collect 并成一条)', () => {
|
||
expect((html.match(/data-edit="parts\.profile"/g) ?? []).length).toBe(1);
|
||
expect(html).toContain('data-rows="mine:parts.profile" data-edit="parts.profile"');
|
||
expect(html).not.toContain('data-rows="mine:sub_eval.part" data-edit="parts.profile"');
|
||
const nested = [...html.matchAll(/<span data-rows="mine:sub_eval\.part" data-by="internal_pn=internal_pn">((?:(?!<\/span>)[\s\S])*?)<\/span>/g)];
|
||
expect(nested.length).toBeGreaterThanOrEqual(1);
|
||
for (const m of nested) expect(m[1] ?? '').not.toContain('data-col');
|
||
});
|
||
|
||
it('类型名带守卫:这一行的下拉真有选中项才摆名,没选摆「待选择」—— data-by 外层回落的谎话进不了屏(单层 :has,嵌套 :has 是非法选择器会被整条丢弃)', () => {
|
||
expect(html).toContain('.dz-type:not(:has(option[selected])) .tsel > span{display:none}');
|
||
expect(html).toContain('待选择');
|
||
/* 回归网:不许再写回嵌套 :has 的形 —— 那条选择器非法,浏览器静默丢弃,守卫等于没有 */
|
||
expect(html).not.toContain(':has(+ select.jc-cell:not(:has(');
|
||
});
|
||
|
||
/*
|
||
* 2026-08-31 用户第二轮:「芯片类型列表和核心参数字段,不能合并成一个卡片吗…两个芯片都
|
||
* 选一样的,就应该只显示一种」。合并 = 比对基准卡(类型名 + 字段清单一张只读卡);
|
||
* 「只显示一种、跟手」= 不列 mine: 草稿镜像(会积旧选择),按案卷锚(case.chip_type_id,
|
||
* 配方每拍现算)嵌套查字典 —— quiet 拍 project 照跑,保存即换脸。
|
||
* 新建类型收进 details 抽屉(零脚本),两张编辑表原样在抽屉里(种子行/加行机制不动)。
|
||
*/
|
||
it('比对基准卡:case 锚嵌套查字典,类型与字段合一、全只读;编辑表收进抽屉', () => {
|
||
expect(html).toContain('data-rows="mine:sub_eval.case"');
|
||
const basis = /<article class="basis"[\s\S]*?<\/article>/.exec(html)?.[0] ?? '';
|
||
expect(basis).not.toBe('');
|
||
expect(basis).toContain('data-rows="parts.chip_type" data-by="type_id=chip_type_id"');
|
||
expect(basis).toContain('data-rows="parts.type_param" data-by="type_id=chip_type_id"');
|
||
/* 全只读:选类型在档案卡,建类型在抽屉 —— 基准卡上一个编辑面都不许有 */
|
||
expect(basis).not.toContain('data-col');
|
||
expect(basis).not.toContain('data-edit');
|
||
/* 基准卡不再有表格:类型 + 字段全是瓷贴;说明藏进悬浮气泡(降噪,2026-08-31) */
|
||
expect(basis).not.toContain('<table');
|
||
expect(basis).toContain('class="ftiles"');
|
||
expect(html).toContain('.ftile:hover .ft-desc');
|
||
/* 新建类型抽屉已随「只选不建」退役(2026-08-31 用户定)—— 只剩说明折叠那一只 details */
|
||
expect(html).not.toContain('<details class="lab">');
|
||
expect(html).not.toContain('data-edit="parts.chip_type"');
|
||
expect(html).not.toContain('data-edit="parts.type_param"');
|
||
});
|
||
|
||
/*
|
||
* 2026-08-31 用户第三轮:「芯片卡片需要和该芯片的核心参数值合并…点击后卡片翻转,背面写
|
||
* 核心参数,悬浮要看得出可以翻;这个界面都不再需要表格来展示数据」。
|
||
* 背面就地可改 = 两张卡背各住一个 parts.parameter 宿主 —— 多宿主收值是同日的平台扩面
|
||
* (ViewContainer hostsOf + 格子只归最近的 data-edit 祖先,ui-kit multihost 测试钉着)。
|
||
*/
|
||
it('翻面卡:背面住着参数宿主、按料过滤(渲染时随行复制成每卡一个 —— 多宿主收值由 ui-kit multihost 测试钉);翻转是无 data-col 的 checkbox;编辑态锁面', () => {
|
||
expect((html.match(/data-edit="parts\.parameter"/g) ?? []).length).toBe(1);
|
||
expect(html).toContain('data-rows="mine:parts.parameter" data-by="internal_pn=internal_pn" data-edit="parts.parameter"');
|
||
const flip = /<input class="flip-tg"[^>]*>/.exec(html)?.[0] ?? '';
|
||
expect(flip).not.toBe('');
|
||
expect(flip).toContain('type="checkbox"');
|
||
expect(flip).not.toContain('data-col');
|
||
expect(html).toContain('class="face front"');
|
||
expect(html).toContain('class="face back"');
|
||
/* 编辑锁面:主数据编辑停正面,参数编辑翻背面 —— 判断全在样式表 */
|
||
/* 按卡锁面(2026-08-31「不要做联动」):.on 由 scopedTargets 落在这张卡的行上 */
|
||
expect(html).toContain('.dossier.on .flip-in{transform:rotateY(0deg)}');
|
||
expect(html).toContain('.dossier:has(.pback.on) .flip-in{transform:rotateY(180deg)}');
|
||
/* 悬浮提示丸:看得出点击可翻 */
|
||
expect(html).toContain('点击翻面');
|
||
/* 「可替换」动画与卡同级(2026-08-31 第二稿:不做背景板 —— 会被卡盖住):
|
||
三列网格中间留实体动画道,⇄ 徽 + 双点轨道对转 */
|
||
expect(html).toContain('class="swapfx"');
|
||
expect(html).toContain('class="sw-orbit"');
|
||
expect(html).toContain('@keyframes sworbit');
|
||
expect(html).toContain('grid-template-columns:minmax(0,1fr) 76px minmax(0,1fr)');
|
||
expect(html).toContain('.dossiers > .dossier:nth-of-type(even){grid-column:3}');
|
||
});
|
||
|
||
it('整件零表格(2026-08-31 用户:「这个界面都不再需要表格来展示数据了」;抽屉连表带功能一并退役)', () => {
|
||
expect(html).not.toContain('<table');
|
||
expect(html).not.toContain('class="jc-tbl-wrap"');
|
||
});
|
||
|
||
it('占位口径:正面必填红/建议橘;卡背值必填红,说明空着**什么都不摆**(2026-08-31 用户:「没写就什么都不要展示」)', () => {
|
||
expect(html).toContain('.fcell.req .jc-vcell:empty::after{content:"必填"');
|
||
expect(html).toContain('.fcell.sug .jc-vcell:empty::after{content:"建议"');
|
||
expect(html).toContain('.ptile .pv .jc-vcell:empty::after{content:"必填"');
|
||
expect(html).toContain('.ptile .sg .jc-vcell:empty{display:none}');
|
||
expect(html).not.toContain('.ptile .sg .jc-vcell:empty::after');
|
||
});
|
||
|
||
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);
|
||
/* 档案卡正面绑 chip_type_id 下拉 —— 只选不建(2026-08-31 用户定),type_id 的写入下拉已随抽屉退役 */
|
||
expect(html).toContain('data-col="chip_type_id" required data-options="parts.chip_type"');
|
||
expect(html).not.toContain('data-col="type_id"');
|
||
});
|
||
|
||
/*
|
||
* Task 7b:件① 新建类型(`parts.chip_type`)与新建字段(`parts.type_param`)两张表开局都是
|
||
* 零行,`ViewContainer.addRow` 靠克隆 `tr:last-of-type` 添行,零行时找不到样子可克隆
|
||
* (查证见 Task 7b 报告)。补一条**不受 `data-rows` 管辖的静态种子行**——两张表各一条,
|
||
* 字面量手写 `data-col` 输入格,不经 `cell()`/`data-slot`,行数再少也在。
|
||
*/
|
||
/*
|
||
* SKIP(2026-08-31 用户定「把添加芯片类型的功能从这个活动中移除,只能选择已有类型」):
|
||
* 新建类型抽屉连同两张编辑表(静态种子行 / type_id 禁用 data-col 的 Critical-1 修法 /
|
||
* thead 后置回归网 / data-edit 双写)整体退役 —— 这些钉的是已移除的创建路径,不是被放松的
|
||
* 断言。平台侧机制钉测(view-render-addrow-thead / view-table-edit)不随本活动退役。
|
||
*/
|
||
it.skip('**零行也加得出第一行**:两张表各带一条静态种子行(class="jc-row-static"),不靠 data-rows 生成', () => {
|
||
expect(true).toBe(true);
|
||
});
|
||
|
||
/*
|
||
* `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`,且必须是禁用的 —— 人看得见「由系统生成」
|
||
* 这句占位,但怎么点都改不了它的值。
|
||
*/
|
||
/*
|
||
* SKIP(2026-08-31 用户定「把添加芯片类型的功能从这个活动中移除,只能选择已有类型」):
|
||
* 新建类型抽屉连同两张编辑表(静态种子行 / type_id 禁用 data-col 的 Critical-1 修法 /
|
||
* thead 后置回归网 / data-edit 双写)整体退役 —— 这些钉的是已移除的创建路径,不是被放松的
|
||
* 断言。平台侧机制钉测(view-render-addrow-thead / view-table-edit)不随本活动退役。
|
||
*/
|
||
it.skip('**新建类型的种子行:type_id 挂 data-col、但禁用恒空**(2026-08-30 修 Critical 1 —— 漏挂会让 resolveRowKey 的"全表一致"把新建的类型误判成既有那一行,静默覆盖它)', () => {
|
||
expect(true).toBe(true);
|
||
});
|
||
|
||
it('**主键列必挂 `data-col`** —— 它不是编辑面,是行的钥匙(漏了会把人改的行错配到别行上)', () => {
|
||
expect(html).toContain('data-col="internal_pn"');
|
||
expect(html).toContain('data-col="param_name"');
|
||
});
|
||
|
||
/*
|
||
* 2026-08-31 用户定:两行编辑闸退役,换成卡右上角的笔↔软盘 —— 笔开当前这一面的编辑
|
||
* (正面=主数据 / 背面=参数值),软盘保存,叉放弃。按钮就是平台认的 data-edit-open /
|
||
* data-edit-save / data-edit-cancel 触发器,快照与取消机制原样。
|
||
*/
|
||
it('编辑收进卡角:笔(open)/软盘(save)/叉(cancel)正反两面各一套;地址只剩 profile 与 parameter', () => {
|
||
for (const address of ['parts.profile', 'parts.parameter']) {
|
||
expect(html).toContain(`data-edit-open="${address}"`);
|
||
expect(html).toContain(`data-edit-save="${address}"`);
|
||
expect(html).toContain(`data-edit-cancel="${address}"`);
|
||
}
|
||
/* 老的闸行不再出现在正文里(样式表里的基座规则给 ② 用,不算) */
|
||
expect(html.slice(html.indexOf('</style>'))).not.toContain('jc-editgate');
|
||
/* 笔↔软盘的按面显隐由样式表判 */
|
||
expect(html).toContain('.dossier.on .disk-f{display:grid}');
|
||
expect(html).toContain(".dossier:has(.pback.on) .disk-b{display:grid}");
|
||
/* 参数瓷贴的输入孪生件带占位提示(渲染器认 data-ph) */
|
||
expect(html).toContain('data-ph="规格书上的值 · 查不到留空"');
|
||
expect(html).toContain('data-ph="补充说明 · 选填"');
|
||
/* 题头回浅蓝渐变(2026-08-31 用户:「标题背景还是改成之前的那种蓝色渐变」) */
|
||
expect(html).toContain('class="card hd-light"');
|
||
expect(html).toContain('linear-gradient(120deg,#f0f5ff 0%,#fff 55%,#eef4ff 100%)');
|
||
});
|
||
|
||
/*
|
||
* Task 7b:`data-edit="parts.chip_type"`(与 `parts.type_param` 同款)两处都写 ——
|
||
* 外层 `<table>` 一处(给 `ViewContainer.collect()`/`.on` 切换看,它按
|
||
* `document.querySelector` 找唯一宿主,要同时收到已有行与种子行两处的输入)、
|
||
* 内层 `data-rows` 那个 `<tbody>` 一处(给 `renderRows` 的 `editable` 检测看,
|
||
* 它只认与 `data-rows` 同一个标签自带的属性,不看祖先)。
|
||
*/
|
||
/*
|
||
* SKIP(2026-08-31 用户定「把添加芯片类型的功能从这个活动中移除,只能选择已有类型」):
|
||
* 新建类型抽屉连同两张编辑表(静态种子行 / type_id 禁用 data-col 的 Critical-1 修法 /
|
||
* thead 后置回归网 / data-edit 双写)整体退役 —— 这些钉的是已移除的创建路径,不是被放松的
|
||
* 断言。平台侧机制钉测(view-render-addrow-thead / view-table-edit)不随本活动退役。
|
||
*/
|
||
it.skip('新建类型 / 新建字段两张表的 data-edit 各出现两次(外层 table + 内层 data-rows tbody)', () => {
|
||
expect(true).toBe(true);
|
||
});
|
||
|
||
/*
|
||
* **评审 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` 是否也需要同步。
|
||
*/
|
||
/*
|
||
* SKIP(2026-08-31 用户定「把添加芯片类型的功能从这个活动中移除,只能选择已有类型」):
|
||
* 新建类型抽屉连同两张编辑表(静态种子行 / type_id 禁用 data-col 的 Critical-1 修法 /
|
||
* thead 后置回归网 / data-edit 双写)整体退役 —— 这些钉的是已移除的创建路径,不是被放松的
|
||
* 断言。平台侧机制钉测(view-render-addrow-thead / view-table-edit)不随本活动退役。
|
||
*/
|
||
it.skip('**回归安全网**:两张新表的 <thead> 都排在两个 <tbody> 之后(不是最前面)—— 与 view-render-addrow-thead.test.ts 的假设锁在一起', () => {
|
||
expect(true).toBe(true);
|
||
});
|
||
|
||
/*
|
||
* 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)');
|
||
});
|
||
|
||
/*
|
||
* 2026-08-31 用户:「页面上所有的问题都合并到最上面的还差什么,不要在某一个板块显示
|
||
* 类似还差 1 行必填没填这种提示」—— 逐块 gapbar 撤下,点名并进顶条(同一批基座读数,
|
||
* 数值 0 的那行由样式表自己收起来)。
|
||
*/
|
||
it('都齐了红条整条收起:开关是案卷真数据 gate_state(v-clear),不看那句话空不空', () => {
|
||
expect(html).toContain('class="jc-gate-bar" data-class-of="gate_state"');
|
||
expect(html).toContain('.jc-gate-bar.v-clear{display:none}');
|
||
expect(colOf('sub_eval.case', 'gate_state')?.tally).toEqual(['open', 'clear']);
|
||
});
|
||
|
||
it('缺项点名全并进顶条「还差什么」,正文不再有逐块红带', () => {
|
||
expect(html).toContain('class="jc-gate-sub" data-class-of="parts_profile_missing"');
|
||
expect(html).toContain('data-slot="parts_profile_missing_keys"');
|
||
expect(html).toContain('data-slot="parts_parameter_missing_keys"');
|
||
expect(html).toContain('html.editable .jc-gate-sub.v-0{display:none}');
|
||
expect(html.slice(html.indexOf('</style>'))).not.toContain('jc-gapbar');
|
||
});
|
||
|
||
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`)。
|
||
*/
|
||
/*
|
||
* SKIP(2026-08-31 用户定「把添加芯片类型的功能从这个活动中移除,只能选择已有类型」):
|
||
* 新建类型抽屉连同两张编辑表(静态种子行 / type_id 禁用 data-col 的 Critical-1 修法 /
|
||
* thead 后置回归网 / data-edit 双写)整体退役 —— 这些钉的是已移除的创建路径,不是被放松的
|
||
* 断言。平台侧机制钉测(view-render-addrow-thead / view-table-edit)不随本活动退役。
|
||
*/
|
||
it.skip('抽屉里两张表的序号列真的会数:.jc-tbl-wrap 也接了 CSS 计数器(不再只认 .tbl-wrap)', () => {
|
||
expect(true).toBe(true);
|
||
});
|
||
});
|
||
|
||
/*
|
||
* 件② 差异评估结论审核(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"');
|
||
});
|
||
|
||
/**
|
||
* **收口四格可就地改**(2026-08-31 用户:「差异评估结论审核这个界面的收口结论下面这 4 个
|
||
* 字段也需要可以编辑」)。此前是只读的,理由是「让人直接改,等于给自己没做的判断盖章」——
|
||
* 用户改了这个口径:审核人对结论有最终发言权。
|
||
*
|
||
* **配方那一侧必须一起改,否则等于没开**:`RELATION_PROJECTION` 每跑一次就把这四列按规则
|
||
* 重写一遍,而人在件上按保存会触发 quiet 拍、quiet 恰恰跑 project —— 人改完一保存就被冲掉。
|
||
* S3 因此换成只重算判定的 `RELATION_RECOMPUTE`(那一组断言在「S3 只重算判定」里)。
|
||
*/
|
||
it('**收口四格可就地改**:三句话走 cell(只读份 + 孪生输入)、风险走徽标 + select,整块一个宿主一道闸', () => {
|
||
for (const col of ['recommendation_reason', 'difference_summary', 'verification_requirement']) {
|
||
expect(html, col).toContain(`data-slot="${col}"`);
|
||
expect(html, col).toContain(`data-col="${col}"`);
|
||
}
|
||
/* 风险是枚举:只读态摆徽标(不裸显 low/medium/high),编辑态换 select */
|
||
expect(html).toContain('class="risk-badge" data-class-of="risk_level"');
|
||
expect(html).toMatch(/<select class="jc-cell" data-col="risk_level" required>/);
|
||
/* 一个地址一个宿主(bodyOf 是单 querySelector),且必须配一道闸 —— 闸不在就打不开(Task 7 那颗按不下去的钮) */
|
||
expect((html.match(/data-edit="substitution\.relation"/g) ?? []).length).toBe(1);
|
||
expect(html).toContain('data-edit-for="substitution.relation"');
|
||
expect(html).toContain('data-edit-open="substitution.relation"');
|
||
/* 主键钥匙在场且藏起来 —— 漏了钥匙这一行凑不出主键(2026-08-02 栽过) */
|
||
expect(html).toContain('data-col="substitution_relation_id"');
|
||
expect(html).toContain('.summary-box .rowkey{display:none}');
|
||
});
|
||
|
||
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',
|
||
/* 推荐等级**仍然只读**:它是规则按四态分布算出来的判据核心,人改四态即改它,不容手改。
|
||
风险(risk_level)2026-08-31 起交给人,已从这份名单里移出 —— 见「收口四格可就地改」。 */
|
||
'recommendation_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) */
|
||
/* 2026-08-31「只选不建」:chip_type / type_param 不再是可编辑面,从名单撤下 */
|
||
const EDITABLE: ReadonlyArray<{ view: string; domain: string; object: string }> = [
|
||
{ view: 'input-supplement', domain: 'parts', object: 'profile' },
|
||
{ 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 用户定:与对话区验收卡同色,不再是目标件的红)。
|
||
* 2026-08-31 视图层重设计后,样式真源从 target-style.ts(逐字节抽自目标件,随重设计退役)
|
||
* 换成 style.ts 的强调色令牌 —— 这条钉的用户口径不变:验收两件 #0aa294、数据工单数据蓝、
|
||
* CONDITIONAL 保持独立语义色(第四态,不与 MATCH 绿 / MISMATCH 红 / UNKNOWN 琥珀混用)、
|
||
* 目标件时代的题头红 #b23a2e 不再出现。
|
||
*/
|
||
describe('三张件的主题锚', () => {
|
||
it('验收两件强调色 #0aa294;数据工单数据蓝;CONDITIONAL 独立语义色;目标件的红不再出现', () => {
|
||
const review = viewHtml('difference-review');
|
||
const landing = viewHtml('landing-confirm');
|
||
for (const html of [review, landing]) {
|
||
expect(html).toContain('--acc:#0aa294');
|
||
expect(html).toContain('--ledger:#0aa294');
|
||
expect(html).not.toContain('#b23a2e');
|
||
}
|
||
expect(viewHtml('input-supplement')).toContain('--acc:#2e6bff');
|
||
expect(review).toContain('--cond:#5661f0');
|
||
expect(review).toContain('.st.v-conditional');
|
||
});
|
||
});
|
||
|
||
/*
|
||
* 差异评估结论审核 · 目标件 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);
|
||
}
|
||
});
|
||
});
|