/** * 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, RULE_VERSION } from '../src/manifest.ts'; import activity from '../src/index.ts'; const ITEMS = ['S0_bind', 'S0d_p08_master', 'S0d_p10_document', 'S1_input_confirm', 'S2_normalize', 'S3_assess', 'S4_review', 'S5_prepare_landing', 'T_release']; /* * 2026-08-27 晚:两张数据工单改成**给 doneWhen**(只读入库事实)—— 引擎只在人答过卡之后求它, * 不再靠模型记得 finish。仍不给的三处:输入确认(交人补缺口)/ 审核循环 / 放行。 */ const NO_DONE_WHEN = new Set(['S1_input_confirm', 'S4_review', 'T_release']); const VIEWS = ['p08-master-create', 'p10-document-create', 'eval-input-list', '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 ?? ''; describe('清单本身合规', () => { it('过 @contracts 的 schema', () => { expect(() => activityManifestSchema.parse(manifest)).not.toThrow(); }); it('没有静态监听(等外部回音一律落在执行工单上,listen 在本仓不可用)', () => { expect(manifest.declarations.listeners).toEqual([]); }); it('L1/L2 判据都给了谓词;L4 三条不给(裁量项);L5 恰一条', () => { for (const c of manifest.contract.acceptanceCriteria) { if (c.level === 'L1' || c.level === 'L2') expect(c.predicate, c.key).toBeDefined(); if (c.level === 'L4') expect(c.predicate, c.key).toBeUndefined(); } expect(manifest.contract.acceptanceCriteria.filter((c) => c.level === 'L4')).toHaveLength(3); expect(manifest.contract.acceptanceCriteria.filter((c) => c.level === 'L5')).toHaveLength(1); }); it('机检谓词只读基座读数与记账旗:不出现活动自报的汇总列名', () => { const forbidden = ['relation_total', 'candidate_total', 'recommended_total', 'not_recommended_total']; for (const c of manifest.contract.acceptanceCriteria) { for (const name of forbidden) expect(c.predicate ?? '', `${c.key} 读了自报数 ${name}`).not.toContain(name); } }); it('三件必交付物都有台账地址;人环预算严格大于「基线 8 + 两轮审核循环 4」', () => { expect(manifest.contract.requiredDeliverables).toHaveLength(3); for (const d of manifest.contract.requiredDeliverables) expect(d.ledger, d.key).toBeDefined(); expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(12); expect(manifest.contract.resources.budget.humanCalls).toBeGreaterThan(12); }); it('起单一格必填都不设(2026-08-28 用户定);客户两格同日从启动表单撤下;datasheet 是选填文件', () => { expect(manifest.contract.input.data['required']).toEqual([]); const launchProps = Object.keys(manifest.contract.input.data['properties'] as Record); expect(launchProps).not.toContain('customerCode'); expect(launchProps).not.toContain('customerName'); const properties = manifest.contract.input.data['properties'] as Record; expect(properties['candidateInternalPns']?.type).toBe('array'); expect(properties['datasheets']?.format).toBe('file'); }); it('五条样例:第一条满载走数据工单那条路,名字 ≤14 字,形状各不相同', () => { expect(manifest.samples).toHaveLength(5); for (const s of manifest.samples) expect(s.name.length, s.name).toBeLessThanOrEqual(14); expect(new Set(manifest.samples.map((s) => JSON.stringify(s.input))).size).toBe(5); const first = manifest.samples[0]?.input as { candidateInternalPns?: string[] }; expect(first.candidateInternalPns).toContain('XL2596S-5.0'); const newCustomer = manifest.samples.find((s) => (s.input as { customerName?: string }).customerName !== undefined); expect(newCustomer).toBeDefined(); }); it('五张工单视图 + 一张运营侧看板都声明了,剧本每一步引用的 view 都在其中', () => { expect(new Set(manifest.assets.viewTemplates.map((v) => v.name))).toEqual(new Set([...VIEWS, 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 主键 internal_pn,标准化参数集拆成独立子表 parts.parameter(2026-08-27 结构改:与目标件一致)', () => { const p08 = byAddress('parts', 'profile'); expect(p08?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['internal_pn']); expect(p08?.fields.find((f) => f.name === 'normalized_parameter_set')).toBeUndefined(); const param = byAddress('parts', 'parameter'); expect(param?.kind).toBe('master'); expect(param?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['internal_pn', 'param_name']); expect(param?.fields.find((f) => f.name === 'internal_pn')?.refersTo).toBe('parts.profile.internal_pn'); /* * 参数名自指引用:只能用台账里已经在用的那几个 —— 2026-08-27 实测三次, * 模型给新料写英文 key,剧本写过两遍 + 一条乘法对账都拦不住「只建一颗」那一路。 */ const pn = param?.fields.find((f) => f.name === 'param_name'); expect(pn?.refersTo).toBe('parts.parameter.param_name'); expect(pn?.distinct).toBe(true); expect(param?.fields.find((f) => f.name === 'param_value')?.required).toBe(true); expect(param?.fields.find((f) => f.name === 'constraint_type')?.tally).toEqual(['hard', 'soft']); expect(param?.fields.find((f) => f.name === 'constraint_type')?.required).toBe(true); }); it('P-11 主记录的原 PN / 候选 PN 都是引用墙(查无此料当场拒 —— 存在性检查的硬门)', () => { const rel = byAddress('substitution', 'relation'); for (const name of ['original_internal_pn', 'candidate_internal_pn']) { expect(rel?.fields.find((f) => f.name === name)?.refersTo, name).toBe('parts.profile.internal_pn'); } }); it('客户批准状态**不在声明里**:本活动没有它的写权(只读边界)', () => { const rel = byAddress('substitution', 'relation'); expect(rel?.fields.map((f) => f.name)).not.toContain('customer_approval_status'); }); it('UNKNOWN 必有去向:unknown_disposition 钉在 difference_status = unknown 的行上;四态是 ASCII tally', () => { const item = byAddress('substitution', 'difference_item'); expect(item?.fields.find((f) => f.name === 'unknown_disposition')?.requiredWhen).toEqual({ column: 'difference_status', value: 'unknown' }); expect(item?.fields.find((f) => f.name === 'difference_status')?.tally).toEqual(['match', 'mismatch', 'unknown', 'conditional']); expect(item?.fields.find((f) => f.name === 'substitution_relation_id')?.distinct).toBe(true); }); it('P-01 回写五项钉在 current_stage = gate03 那一刻(放行前不拦,放行门 L2 数得出来)', () => { const p01 = byAddress('demand', 'request'); for (const name of ['risk_level', 'verification_requirement', 'output_ref', 'next_action', 'current_activity']) { expect(p01?.fields.find((f) => f.name === name)?.requiredWhen, name).toEqual({ column: 'current_stage', value: 'gate03' }); } }); it('自持单据都带 caseKey eval_ref 且在主键里;放弃候选必须写原因', () => { for (const object of ['pn_check', 'input_gap', 'verdict']) { const t = byAddress(D, object); expect(t?.kind, object).toBe('record'); expect(t?.caseKey, object).toBe('eval_ref'); expect(t?.fields.find((f) => f.name === 'eval_ref')?.primaryKey, object).toBe(true); } expect(byAddress(D, 'pn_check')?.fields.find((f) => f.name === 'abandon_reason')?.requiredWhen).toEqual({ column: 'exists_status', value: 'abandoned' }); }); it('三张主数据表标 createOnly:数据工单只建库里没有的料,在库的写下来当场拒(2026-08-27 实测:模型在数据工单里多铺了一行不该建的料号,件上多出一行要人填)', () => { for (const obj of ['profile', 'parameter', 'document'] as const) expect(byAddress('parts', obj)?.createOnly, obj).toBe(true); // 凭空拼出来的料号(实测 XL2596S-ADJ)由这一道管:只许建核对表里核过的 expect(byAddress('parts', 'profile')?.fields.find((f) => f.name === 'internal_pn')?.refersTo).toBe(`${D}.pn_check.internal_pn`); }); it('判「在库」必须填得出引用依据,基座拿它去主数据里查(2026-08-27 实测:原 PN 不在库而模型把两颗都写成 found,硬阻塞那条路整个被跳过)', () => { const ref = byAddress(D, 'pn_check')?.fields.find((f) => f.name === 'found_pn_ref'); expect(ref?.refersTo).toBe('parts.profile.internal_pn'); expect(ref?.requiredWhen).toEqual({ column: 'exists_status', value: 'found' }); expect(ref?.primaryKey ?? false).toBe(false); }); it('规则版本常量随包冻结', () => { expect(RULE_VERSION).toBe('R-2026.07'); expect(manifest.assets.businessModel.dmn.tables.map((t) => t.id)).toEqual(['d01-difference-status', 'd02-recommendation']); }); }); describe('案例模型与清单的对齐', () => { it('九个计划项,剧本 item 与 CMMN humanTask id 一一对应', () => { const cmmnIds = [...manifest.assets.businessModel.cmmnXml.matchAll(/ m[1] ?? ''); expect(cmmnIds).toEqual(ITEMS); expect((manifest.assets.businessModel.steps as ReadonlyArray<{ item: string }>).map((s) => s.item)).toEqual(ITEMS); }); it('三处不给 doneWhen(输入确认 / 审核 / 放行),其余六处都给 —— 两张数据工单的只读入库事实', () => { for (const s of manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; doneWhen?: string }>) { if (NO_DONE_WHEN.has(s.item)) expect(s.doneWhen, s.item).toBeUndefined(); else expect(s.doneWhen, s.item).toBeDefined(); } }); it('两张数据工单件各自绑自己的 view(P-08 与 P-10 不共用同一份画面)', () => { const steps = manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; view?: string }>; expect(steps.find((s) => s.item === 'S0d_p08_master')?.view).toBe('p08-master-create'); expect(steps.find((s) => s.item === 'S0d_p10_document')?.view).toBe('p10-document-create'); }); it('那五项在 CMMN 里都标了人工激活', () => { for (const id of NO_DONE_WHEN) { const block = new RegExp(``).exec(manifest.assets.businessModel.cmmnXml)?.[0] ?? ''; expect(block, id).toContain('manualActivationRule'); } }); it('除放行外每一项恰好属于一个阶段;5 段 + 放行 = x/6,与文档 0.5/1/2/3/4/5 六步对齐', () => { const phases = manifest.assets.businessModel.phases ?? []; expect(phases).toHaveLength(5); const phaseItems = phases.flatMap((p) => p.items); expect([...phaseItems].sort()).toEqual([...ITEMS.filter((i) => i !== 'T_release')].sort()); expect(new Set(phaseItems).size).toBe(phaseItems.length); }); it('说明书点名的计划项都真实存在', () => { const mentioned = [...manifest.assets.businessModel.narrative.matchAll(/\b(S\d[a-z]?_[a-zA-Z_]+|T_release)\b/g)].map((m) => m[1] ?? ''); for (const name of mentioned) expect(ITEMS, `说明书提到但不存在的计划项: ${name}`).toContain(name); }); it('说明书里只剩审核那一种决策卡(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:'); }); it('T_release 剧本里 judge criterion 的原文逐字对得上 acceptanceCriteria 的 label', () => { const tRelease = (manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; guide?: string }>).find((s) => s.item === 'T_release'); const quoted = [...(tRelease?.guide ?? '').matchAll(/judge criterion="([^"]+)"/g)].map((m) => m[1] ?? ''); expect(quoted).toHaveLength(3); const labels = new Set(manifest.contract.acceptanceCriteria.map((c) => c.label)); for (const q of quoted) expect(labels.has(q), q).toBe(true); }); }); describe('报告模版:编辑面与只读面各就各位', () => { it('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"'); }); it('评估输入清单:缺口表可编辑(supplement),绑定区只读', () => { const html = viewHtml('eval-input-list'); expect(html).toContain(`data-edit="${D}.input_gap"`); expect(html).toContain('data-col="supplement"'); expect(html).toContain(`data-rows="mine:${D}.pn_check"`); }); it('差异审核与落账确认只读:没有任何 data-edit / data-col', () => { for (const name of ['difference-review', 'landing-confirm']) { expect(viewHtml(name), name).not.toContain('data-edit='); expect(viewHtml(name), name).not.toContain('data-col='); } }); it('每个可编辑格子的 data-col 都在对应台账对象的声明里(p08-master-create 一屏两张表,两张台账对象的声明并起来看)', () => { const cases: Array<[string, Array<[string, string]>]> = [ ['p08-master-create', [['parts', 'profile'], ['parts', 'parameter']]], ['p10-document-create', [['parts', 'document']]], ['eval-input-list', [[D, 'input_gap']]], ]; 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); } }); it('枚举列只用 data-class-of 投影,不摆 ASCII 原值', () => { expect(viewHtml('difference-review')).toContain('data-class-of="difference_status"'); expect(viewHtml('difference-review')).toContain('data-class-of="recommendation_level"'); expect(viewHtml('difference-review')).not.toContain('data-slot="difference_status"'); 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('
')).rejects.toThrow(/无脚本执行权/); }); 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-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"` 把整列都标了必填。 */ describe('徽标别名与值域相关的坑(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- 规则(选择器是从值算出来的,不是手写的)', () => { 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` 恒为正、 * 提交闸永远拦着,这是「墙不配路」的死路形态。 * 两条各配一张回归网,防的是同一类事故再出现在别的字段上。 */ describe('可编辑件的主键与必填列(2026-08-27 复审:主键丢钥匙 / 必填填不了,两条 Critical 回归)', () => { /** 三张真能编辑的件与它们各自绑定的台账对象 */ const EDITABLE: ReadonlyArray<{ view: string; domain: string; object: string }> = [ { view: 'p08-master-create', domain: 'parts', object: 'profile' }, { view: 'p08-master-create', domain: 'parts', object: 'parameter' }, { view: 'p10-document-create', domain: 'parts', object: 'document' }, { view: 'eval-input-list', domain: D, object: 'input_gap' }, ]; it('每一个主键列都有 data-col —— 它不是编辑面,是行的钥匙,丢了就整行保存不下去', () => { for (const { view, domain, object } of EDITABLE) { const table = byAddress(domain, object); /* * `caseKey` 声明的那一列(如 `eval_ref`)是例外:它是「一单一号,全表当然一致」的 * 案号型主键(与 I01 的 `round_ref` 同款),件上压根不摆,服务端 `resolveRowKey` * 按「全表一致」自动补上(`apps/api/src/app/routes/desk.ts` 注释里点名的那条路)—— * 摆出来给人填反而是错的(那会让人以为能按行改案号)。真正逐行不同、必须挂 * `data-col` 的,是主键里除 caseKey 之外的那几列(如 `item_key`)。 */ const pk = (table?.fields.filter((f) => f.primaryKey).map((f) => f.name) ?? []).filter((name) => name !== table?.caseKey); expect(pk.length, `${domain}.${object} 没有非 caseKey 的主键列`).toBeGreaterThan(0); const html = viewHtml(view); for (const col of pk) { expect(html, `${view}: 主键列 ${domain}.${object}.${col} 缺 data-col`).toContain(`data-col="${col}"`); } } }); it('可编辑件上,每一个 required:true 的列都有真能填的控件(data-col)—— 必填但填不了是墙不配路', () => { for (const { view, domain, object } of EDITABLE) { const table = byAddress(domain, object); const required = table?.fields.filter((f) => f.required === true).map((f) => f.name) ?? []; expect(required.length, `${domain}.${object} 没有声明 required 列`).toBeGreaterThan(0); const html = viewHtml(view); for (const col of required) { expect(html, `${view}: 必填列 ${domain}.${object}.${col} 没有可填的控件`).toContain(`data-col="${col}"`); } } }); }); describe('活动入口', () => { it('没有 run —— 行为全在清单里', () => { expect(activity.run).toBeUndefined(); }); }); const CONTRACT = { activityId: 'part-substitution-assessment', version: '1.0.0', data: {}, message: undefined, outputPorts: { mandatory: ['assessment-released'], selective: [] }, prohibitions: [], discretion: [], } as const; /* 验收件题头是验收绿(2026-08-27 用户定:与对话区验收卡同色,不再是目标件的红) */ describe('两张验收件的主题色', () => { it('ACC1 / ACC2 的 --ledger 是验收绿 #0aa294,题头渐变不再带红;CONDITIONAL 徽标保留语义红', async () => { const { ACC1_CSS, ACC2_CSS } = await import('../src/views/target-style.ts'); for (const css of [ACC1_CSS, ACC2_CSS]) { expect(css).toContain('--ledger:#0aa294'); expect(css).not.toContain('--ledger:#b23a2e'); expect(css).not.toContain('#fdf3f1'); expect(css).toContain('rgba(10,162,148,.25)'); } // ACC2 的 CONDITIONAL 徽标保留目标件的语义红;ACC1 目标件 2026-08-27 改版把它改成了品牌蓝,照抄 expect(ACC2_CSS).toContain('.st.cond{color:#b23a2e'); expect(ACC1_CSS).toContain('.st.cond{color:var(--brand-deep)'); }); }); /* * 差异评估结论审核 · 目标件 2026-08-27 改版:「一个候选一张表」(用户:存在多个替代 PN 要对比差异时看不清楚)。 * 外层按 relation 逐个复制
,里层明细只列该候选名下的行(data-by 父键过滤); * 汇总表一个候选一条;品牌从 parts.profile 整表按料号挑(取值,不是算)。 */ describe('difference-review:一个候选一张表', () => { const html = viewHtml('difference-review'); it('候选块是嵌套行模版:外层 relation、里层明细按 substitution_relation_id 过滤;容器标签不在行模版里再出现', () => { expect(html).toContain('
'); expect(html).toContain('data-rows="mine:substitution.difference_item" data-by="substitution_relation_id"'); const inner = html.slice(html.indexOf('
'), html.indexOf('
')); expect(inner).not.toContain(' { expect(html).toContain(''); expect(html).toContain(''); 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) */ describe('eval-input-list:候选参数现值只读参考', () => { it('外层按核对表逐颗复制,里层 parts.parameter 按 internal_pn 过滤,不是可编辑表', () => { const html = viewHtml('eval-input-list'); expect(html).toContain(`
`); expect(html).toContain(''); const block = html.slice(html.indexOf('
')); 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}`)); 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(); for (const entry of data.rows) { expect(declaredAddresses.has(entry.ledger), entry.ledger).toBe(true); const columns = new Set(manifest.declarations.ledger.find((l) => `${l.domain}.${l.object}` === entry.ledger)?.fields.map((f) => f.name)); expect(columns.has(entry.by.column), `${entry.as} by ${entry.by.column}`).toBe(true); for (const ref of [entry.by.from, entry.not?.from]) { if (ref === undefined || ref === 'case') continue; const at = ref.lastIndexOf('.'); const as = ref.slice(0, at); const column = ref.slice(at + 1); expect(seen.has(as), `${entry.as} 引用了后面 / 不存在的集合 ${as}`).toBe(true); const source = data.rows.find((r) => r.as === as); const sourceColumns = new Set(manifest.declarations.ledger.find((l) => `${l.domain}.${l.object}` === source?.ledger)?.fields.map((f) => f.name)); expect(sourceColumns.has(column), `${ref} 不是 ${source?.ledger} 的列`).toBe(true); } seen.add(entry.as); } // 六张主表 + 案号两张 + 同料号历史都圈了 expect([...seen]).toEqual( expect.arrayContaining([`${D}.verdict`, `${D}.pn_check`, 'demand.request', 'customer.profile', 'parts.profile', 'parts.parameter', 'parts.document', 'substitution.relation', 'substitution.difference_item', 'validation.record', 'validation.history']), ); }); it('html 里每个 data-rows 键都是声明里的一个 as(行由基座圈过,不是整表);嵌套只用 data-by 父键过滤', () => { const as = new Set(data?.rows.map((r) => r.as) ?? []); const keys = [...html.matchAll(/data-rows="([^"]+)"/g)].map((m) => m[1] ?? ''); expect(keys.length).toBeGreaterThan(8); for (const key of keys) expect(as.has(key), key).toBe(true); expect(html).toContain(''); expect(html).toContain('
'); expect(html).toContain(''); expect(html).not.toContain('mine:'); }); it('无脚本、无 on*、无外链(过安装静态审);有 给出口注入报高脚本;三态由 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(''); expect(html).toContain('
'); expect(html).toContain('.page.v-none .notice.none{display:flex}'); expect(html).toContain('.page.v-missing .notice.missing{display:flex}'); expect(html).not.toContain('data-edit='); expect(html).not.toContain('data-col='); }); it('目标件六张表的表头与页头要素都在;MATCH 行淡显、放弃的候选只在有 abandoned 时露面 —— 都是 class 交样式表,模版不判', () => { for (const header of ['内部料号', '替代关系 ID', '参数项', '关联料号', '验证 ID', '标准化参数集']) expect(html).toContain(header); expect(html).toContain('data-slot="case:instanceNo"'); expect(html).toContain('data-slot="case:ref"'); expect(html).toContain(`data-slot="${D}.verdict.original_internal_pn"`); expect(html).toContain('data-slot="demand.request.request_id"'); expect(html).toContain(''); expect(html).toContain('tr.v-match td{opacity:.55}'); expect(html).toContain('.aband:has(i.v-abandoned){display:block}'); expect(html).toContain('data-slot="substitution_difference_item_difference_status_unknown"'); }); });