/** * 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 * 在目标件自己的 `'))).not.toContain('jc-editgate'); /* 笔↔软盘的按面显隐由样式表判 */ expect(html).toContain('.dossiers.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` 同款)两处都写 —— * 外层 `` 一处(给 `ViewContainer.collect()`/`.on` 切换看,它按 * `document.querySelector` 找唯一宿主,要同时收到已有行与种子行两处的输入)、 * 内层 `data-rows` 那个 `` 一处(给 `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` * 从此解析到外层 `
`,而 `addRow()` 接着在这个 host 里找 `tr:last-of-type`—— * CSS `:last-of-type` 是按各自父元素判定的,`` 里孤零零的那个 `` 在它 * 自己的父元素(`thead`)里必然「最后」,若 `` 排在数据行前面,它会被文档序 * 优先命中:克隆出来的是表头文字,零个 `input[data-col]`,`parts.type_param` * 那张(改动前本来是好的)因此被做坏——一次真回归(评审用 jsdom 在 renderView() 真实 * 输出上复现两次坐实)。 * * 修法是把 `` 挪到两个 `` 之后(CSS 的 `table-header-group` 排版规则: * 视觉上永远排最前,不看它在 DOM 里排第几——这条规则本来就是 HTML5 允许 `tfoot` * 写在 `thead` 之后、`tbody` 之前的原理)——`document.querySelector`/`:last-of-type` * 认的是文档序不是视觉序,`` 排在两个 `` 后面,`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 它)—— * 这里补的是**廉价的字符串安全网**,把这份源码与那份镜像测试的假设锁在一起: * 谁若把 `` 挪回两个 `` 之前(哪怕只改一张表),这条测试立刻标红, * 提醒去核对 `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('**回归安全网**:两张新表的 都排在两个 之后(不是最前面)—— 与 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(' { 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(/'); 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) */ /* * 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(`
`); 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}`)); /* * 解封(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(); 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(``); 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='); }); /* * 2026-08-29 Task 9 改窄:六张表收成新九张(P-01/P-10 两张连同「关联需求」「关联料号」 * 两列一起退休 —— 本活动已不碰 demand.request / parts.document);案号台账从 `${D}.verdict` * 改名 `${D}.case`;「放弃的候选」这一概念随多候选并行设计一起退役(新设计一次评估恰一对一 * 候选,没有「本轮放弃的候选」这件事)。补一条**已退役地址不再出现**的回归网,同「运营中心看板」组同款纪律。 */ it('新九张表的表头与页头要素都在;MATCH 行淡显 —— 都是 class 交样式表,模版不判', () => { for (const header of ['
', '', '', '', '', '']) 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(''); 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); } }); });
内部料号替代关系 ID参数项验证 ID标准化参数集参数名