W05 在途修订:差异评估结论审核件一个候选一张表 · 关键台账数据看板件(ledger-board)· S1 清单不再把人刚填的参数摆成缺口

活动包 `part-substitution-assessment`(case-model / manifest / report / target-style / badge / ledger-board 与两份包内测试)、
目标件「验收工单-01-差异评估结论审核」、`docs/ss02-act-05` 关键台账数据两份、端到端测试记录、e2e 脚本。
逐条口径见同批 CLAUDE.md 快照。2026-08-27 另一会话做的,一直留在工作树没入库;与同批另两条提交互相依赖。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
This commit is contained in:
xcs
2026-08-27 23:20:15 +08:00
co-authored by Claude Fable 5
parent 0923fbfe4d
commit 909b1fe4b7
13 changed files with 1943 additions and 208 deletions
@@ -13,8 +13,14 @@ 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'];
const NO_DONE_WHEN = new Set(['S0d_p08_master', 'S0d_p10_document', 'S1_input_confirm', 'S4_review', '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 ?? '';
@@ -67,8 +73,8 @@ describe('清单本身合规', () => {
expect(newCustomer).toBeDefined();
});
it('五张视图模版都声明了,剧本每一步引用的 view 都在其中', () => {
expect(new Set(manifest.assets.viewTemplates.map((v) => v.name))).toEqual(new Set(VIEWS));
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);
}
@@ -158,7 +164,7 @@ describe('案例模型与清单的对齐', () => {
expect((manifest.assets.businessModel.steps as ReadonlyArray<{ item: string }>).map((s) => s.item)).toEqual(ITEMS);
});
it('五处不给 doneWhen(两张数据工单 / 输入确认 / 审核 / 放行),其余四处都给', () => {
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();
@@ -313,6 +319,23 @@ describe('徽标别名与值域相关的坑(2026-08-27 实测账,回归网)', ()
{ 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> 规则(选择器是从值算出来的,不是手写的)', () => {
@@ -400,3 +423,134 @@ const CONTRACT = {
prohibitions: [],
discretion: [],
} as const;
/* 验收件题头是验收绿(2026-08-27 用户定:与对话区验收卡同色,不再是目标件的红) */
describe('两张验收件的主题色', () => {
it('ACC1 / ACC2 的 --ledger 是验收绿 #0aa294,题头渐变不再带红;CONDITIONAL 徽标保留语义红', async () => {
const { ACC1_CSS, ACC2_CSS } = await import('../src/views/target-style.ts');
for (const css of [ACC1_CSS, ACC2_CSS]) {
expect(css).toContain('--ledger:#0aa294');
expect(css).not.toContain('--ledger:#b23a2e');
expect(css).not.toContain('#fdf3f1');
expect(css).toContain('rgba(10,162,148,.25)');
}
// ACC2 的 CONDITIONAL 徽标保留目标件的语义红;ACC1 目标件 2026-08-27 改版把它改成了品牌蓝,照抄
expect(ACC2_CSS).toContain('.st.cond{color:#b23a2e');
expect(ACC1_CSS).toContain('.st.cond{color:var(--brand-deep)');
});
});
/*
* 差异评估结论审核 · 目标件 2026-08-27 改版:「一个候选一张表」(用户:存在多个替代 PN 要对比差异时看不清楚)。
* 外层按 relation 逐个复制 <section class="cand">,里层明细只列该候选名下的行(data-by 父键过滤);
* 汇总表一个候选一条;品牌从 parts.profile 整表按料号挑(取值,不是算)。
*/
describe('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) */
describe('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}`));
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);
}
// 六张主表 + 案号两张 + 同料号历史都圈了
expect([...seen]).toEqual(
expect.arrayContaining([`${D}.verdict`, `${D}.pn_check`, 'demand.request', 'customer.profile', 'parts.profile', 'parts.parameter', 'parts.document', 'substitution.relation', 'substitution.difference_item', 'validation.record', 'validation.history']),
);
});
it('html 里每个 data-rows 键都是声明里的一个 as(行由基座圈过,不是整表);嵌套只用 data-by 父键过滤', () => {
const as = new Set(data?.rows.map((r) => r.as) ?? []);
const keys = [...html.matchAll(/data-rows="([^"]+)"/g)].map((m) => m[1] ?? '');
expect(keys.length).toBeGreaterThan(8);
for (const key of keys) expect(as.has(key), key).toBe(true);
expect(html).toContain('<span class="pnrow" data-rows="sub_eval.pn_check" data-by="internal_pn">');
expect(html).toContain('<div class="pchips" data-rows="parts.parameter" data-by="internal_pn">');
expect(html).toContain('<tbody class="hist" data-rows="validation.history">');
expect(html).not.toContain('mine:');
});
it('无脚本、无 on*、无外链(过安装静态审);有 </body> 给出口注入报高脚本;三态由 board:state 投影;只读', () => {
expect(html.length).toBeGreaterThan(5000);
expect(html).not.toMatch(/<\s*script\b/i);
expect(html).not.toMatch(/\son[a-z]+\s*=/i);
expect(html).not.toMatch(/(src|href)\s*=\s*["']?\s*(https?:)?\/\//i);
expect(html.toLowerCase()).toContain('</body>');
expect(html).toContain('<div class="page" data-class-of="board:state">');
expect(html).toContain('.page.v-none .notice.none{display:flex}');
expect(html).toContain('.page.v-missing .notice.missing{display:flex}');
expect(html).not.toContain('data-edit=');
expect(html).not.toContain('data-col=');
});
it('目标件六张表的表头与页头要素都在;MATCH 行淡显、放弃的候选只在有 abandoned 时露面 —— 都是 class 交样式表,模版不判', () => {
for (const header of ['<th>内部料号</th>', '<th>替代关系 ID</th>', '<th>参数项</th>', '<th>关联料号</th>', '<th>验证 ID</th>', '<th>标准化参数集</th>']) expect(html).toContain(header);
expect(html).toContain('data-slot="case:instanceNo"');
expect(html).toContain('data-slot="case:ref"');
expect(html).toContain(`data-slot="${D}.verdict.original_internal_pn"`);
expect(html).toContain('data-slot="demand.request.request_id"');
expect(html).toContain('<tr data-class-of="difference_status">');
expect(html).toContain('tr.v-match td{opacity:.55}');
expect(html).toContain('.aband:has(i.v-abandoned){display:block}');
expect(html).toContain('data-slot="substitution_difference_item_difference_status_unknown"');
});
});