/** * I01 品牌形象与销售工具包管理的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。 * 案例活动没有 `run`,自测不跑回合;测的是清单本身与四处对齐 * (判据谓词 / 台账声明的三级占位 / 决策卡循环的骨架 / 报告模版的读写面), * 方法照抄 E04(`crm-relationship-coverage`)已验证过的测试结构。 */ import { describe, expect, it } from 'vitest'; import { activityManifestSchema } from '@contracts'; import { createMockHost } from '@sdk'; import { manifest } from '../src/manifest.ts'; import activity from '../src/index.ts'; describe('清单本身合规', () => { it('过 @contracts 的 schema', () => { expect(() => activityManifestSchema.parse(manifest)).not.toThrow(); }); it('没有悬空监听(I01 本轮没有静态监听声明)', () => { expect(manifest.declarations.listeners).toEqual([]); }); it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => { for (const c of manifest.contract.acceptanceCriteria) { if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef, c.key).toBeDefined(); } }); it('L4 判据不给谓词 —— 是裁量项,不是机检项', () => { const l4 = manifest.contract.acceptanceCriteria.filter((c) => c.level === 'L4'); expect(l4.length).toBe(3); for (const c of l4) expect(c.predicate, c.key).toBeUndefined(); }); it('禁区短而硬,授权边界给了调人预算', () => { expect(manifest.contract.prohibitions.length).toBeGreaterThan(0); expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(0); }); it('每件必交付物都有台账地址', () => { for (const d of manifest.contract.requiredDeliverables) { expect(d.ledger, d.key).toBeDefined(); } expect(manifest.contract.requiredDeliverables).toHaveLength(3); }); /* * **人环预算必须严格大于「最后一次拍板」时的实际调人次数**(照抄 E04 2026-08-20 实测栽过 * 那条纪律):基线三次(S2 / S4 通过 / T_release)+ 两轮决策卡循环(每轮 +2)= 7,留够余量。 */ it('人环预算留够余量:基线三次 + 两轮决策卡循环仍在 cap 之内', () => { const baseline = 3; const twoRejectRounds = baseline + 4; expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(twoRejectRounds); expect(manifest.contract.resources.budget.humanCalls).toBeGreaterThan(twoRejectRounds); }); it('起单只必填 trigger,reason/requester/targetCustomer/note 都可选 —— 两级下拉的形状', () => { expect(manifest.contract.input.data['required']).toEqual(['trigger']); const properties = manifest.contract.input.data['properties'] as Record< string, { enum?: unknown[]; showWhen?: { field?: string; equals?: string } } >; for (const key of ['trigger', 'reason', 'requester', 'targetCustomer', 'note']) { expect(properties[key], `入参少了 ${key}`).toBeDefined(); } expect(properties['reason']?.showWhen).toEqual({ field: 'trigger', equals: 'proactive' }); expect(properties['requester']?.showWhen).toEqual({ field: 'trigger', equals: 'reactive' }); }); it('四条样例形状真不同(触发路径各不相同),各自的入参都能通过对应 showWhen 校验', () => { // 第四条是「资料文件」那一环的常备案例(一封需求要三种形态,格式分得开) expect(manifest.samples).toHaveLength(4); const shapes = manifest.samples.map((s) => JSON.stringify(s.input)); expect(new Set(shapes).size).toBe(4); const proactive = manifest.samples.find((s) => (s.input as { trigger?: string }).trigger === 'proactive'); expect(proactive).toBeDefined(); const reactive = manifest.samples.find((s) => (s.input as { trigger?: string }).trigger === 'reactive'); expect(reactive).toBeDefined(); expect((reactive?.input as { requester?: string }).requester).toBe('E04'); expect((reactive?.input as { targetCustomer?: string }).targetCustomer).toBe('CU-0142'); }); it('三张视图模版都声明了,案例模型每一步引用的 view 都在其中', () => { const names = new Set(manifest.assets.viewTemplates.map((v) => v.name)); expect(names).toEqual(new Set(['requirement-list', 'draft-review', 'publish-confirm'])); for (const step of manifest.assets.businessModel.steps as ReadonlyArray<{ view?: string }>) { if (step.view !== undefined) expect(names.has(step.view), step.view).toBe(true); } }); }); describe('台账声明:三级占位与自有台账', () => { const byAddress = (domain: string, object: string) => manifest.declarations.ledger.find((l) => l.domain === domain && l.object === object); it('L-01 市场信号只读:本活动不写它,字段不带 required/expect', () => { const signal = byAddress('market', 'signal'); expect(signal).toBeDefined(); for (const f of signal?.fields ?? []) { expect(f.required, f.name).not.toBe(true); expect(f.expect, f.name).not.toBe(true); } }); it('L-05 内容资产:I01 唯一读写者,必填列齐全(content_type/title/language/version/content_status)', () => { const asset = byAddress('content', 'asset'); expect(asset).toBeDefined(); const required = new Set( (asset?.fields ?? []).filter((f) => f.required === true).map((f) => f.name), ); for (const name of ['content_type', 'title', 'language', 'version', 'content_status']) { expect(required.has(name), name).toBe(true); } const status = asset?.fields.find((f) => f.name === 'content_status'); expect(status?.tally).toEqual(['draft', 'reviewed', 'published', 'deprecated']); }); it('内容需求清单主键是 round_ref + req_seq(没有天然外部键,照抄 E04 ledger_update.update_seq 的处置)', () => { const requirement = byAddress('content_brand', 'requirement'); const keys = (requirement?.fields ?? []).filter((f) => f.primaryKey).map((f) => f.name); expect(keys).toEqual(['round_ref', 'req_seq']); }); it('优先级是 ASCII tally(high/medium/low),报告靠 data-class-of 投影,不靠模型写中文', () => { const requirement = byAddress('content_brand', 'requirement'); const priority = requirement?.fields.find((f) => f.name === 'priority'); expect(priority?.tally).toEqual(['high', 'medium', 'low']); }); }); describe('案例模型与清单的四处对齐', () => { it('步骤剧本的 item 名与 CMMN humanTask id 一一对应', () => { const stepItems = (manifest.assets.businessModel.steps as ReadonlyArray<{ item: string }>).map((s) => s.item); const cmmnIds = [...manifest.assets.businessModel.cmmnXml.matchAll(/ m[1] ?? ''); expect(stepItems).toEqual(cmmnIds); }); it('六个计划项:1 执行 + 2 验收(S2 执行工单,S4/T_release 验收工单)', () => { const cmmnIds = [...manifest.assets.businessModel.cmmnXml.matchAll(/ m[1] ?? ''); expect(cmmnIds).toEqual([ 'S1_identify_requirement', 'S2_requirement_confirm', 'S3_gather_and_draft', 'S4_draft_review', 'S5_publish_prep', 'T_release', ]); }); it('三处不给 doneWhen(交人核对/要人拍板/签下去触达世界),其余三处都给了', () => { const steps = manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; doneWhen?: string }>; const noDoneWhen = new Set(['S2_requirement_confirm', 'S4_draft_review', 'T_release']); for (const s of steps) { if (noDoneWhen.has(s.item)) expect(s.doneWhen, s.item).toBeUndefined(); else expect(s.doneWhen, s.item).toBeDefined(); } }); it('S2/S4/T_release 三项在 CMMN 里都标了人工激活(manualActivationRule)', () => { for (const id of ['S2_requirement_confirm', 'S4_draft_review', 'T_release']) { const block = new RegExp(``).exec(manifest.assets.businessModel.cmmnXml)?.[0] ?? ''; expect(block, id).toContain('manualActivationRule'); } }); it('除放行外每一项恰好属于一个阶段(进度条刻度不重不漏)', () => { const stepItems = (manifest.assets.businessModel.steps as ReadonlyArray<{ item: string }>) .map((s) => s.item) .filter((item) => item !== 'T_release'); const phaseItems = (manifest.assets.businessModel.phases ?? []).flatMap((p) => p.items); expect([...phaseItems].sort()).toEqual([...stepItems].sort()); expect(new Set(phaseItems).size).toBe(phaseItems.length); }); it('说明书点名的计划项都在案例模型里真实存在(没有臆造的 S-id)', () => { const stepItems = new Set((manifest.assets.businessModel.steps as ReadonlyArray<{ item: string }>).map((s) => s.item)); // 只认「S数字_字母」这种完整计划项形状 —— 裸的「S1」「S1/S2/S3」是自然语言里的口语简写 // (如「见下方 S1 的 guide」),不是在点名一个具体的计划项 id,不该被当成引用校验。 const mentioned = [...manifest.assets.businessModel.narrative.matchAll(/\b(S\d_[a-zA-Z_]+|T_release)\b/g)].map((m) => m[1] ?? ''); for (const name of mentioned) { expect(stepItems.has(name), `说明书提到但不存在的计划项: ${name}`).toBe(true); } }); it('决策卡骨架(S4_draft_review)三种 value 与说明书一致:approve/reject/custom:', () => { expect(manifest.assets.businessModel.narrative).toContain('"kind":"decide","itemId":"S4_draft_review"'); expect(manifest.assets.businessModel.narrative).toContain('"approve"'); expect(manifest.assets.businessModel.narrative).toContain('"reject"'); expect(manifest.assets.businessModel.narrative).toContain('"custom:"'); }); /* * **T_release 的 judge criterion 必须逐字抄 acceptanceCriteria 的 label**(2026-08-21 * 实测逼出):剧本写了缩写版"版本号与状态是否正确",清单的 label 是 * "版本号与状态是否正确:版本号已更新、状态流转无误"——两边对不上,模型照剧本裁的那句 * 匹配不到清单上的判据,提交放行永远差一条"[L4] 还没裁过",连拒五拍熔断。 * 这条测试钉死:T_release 的 guide 里每一句 `judge criterion="..."` 都要能在 * acceptanceCriteria 的 label 集合里原样找到。 */ 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.length).toBeGreaterThan(0); 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('内容需求清单可编辑:data-edit 与 data-col 都在', () => { const html = manifest.assets.viewTemplates.find((t) => t.name === 'requirement-list')?.html ?? ''; expect(html).toContain('data-edit="content_brand.requirement"'); expect(html).toContain('data-col="content_type"'); }); it('内容初稿审核只读:没有任何 data-edit / data-col —— 人只审不改', () => { const html = manifest.assets.viewTemplates.find((t) => t.name === 'draft-review')?.html ?? ''; expect(html).not.toContain('data-edit='); expect(html).not.toContain('data-col='); }); it('发布确认只读:没有任何 data-edit / data-col —— 归档件不给改', () => { const html = manifest.assets.viewTemplates.find((t) => t.name === 'publish-confirm')?.html ?? ''; expect(html).not.toContain('data-edit='); expect(html).not.toContain('data-col='); }); it('每个可编辑格子的 data-col 都在对应台账对象的声明里 —— 编辑面不能超出声明的字段', () => { const html = manifest.assets.viewTemplates.find((t) => t.name === 'requirement-list')?.html ?? ''; const cols = new Set([...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1] ?? '')); const declared = new Set( manifest.declarations.ledger.find((l) => l.domain === 'content_brand' && l.object === 'requirement')?.fields.map((f) => f.name), ); for (const c of cols) expect(declared.has(c), c).toBe(true); }); it('状态徽(优先级/内容状态)只用 data-class-of 投影,不写 data-slot 重复暴露原值', () => { const req = manifest.assets.viewTemplates.find((t) => t.name === 'requirement-list')?.html ?? ''; expect(req).toContain(''); const draft = manifest.assets.viewTemplates.find((t) => t.name === 'draft-review')?.html ?? ''; expect(draft).toContain(''); }); it('宿主拒推含脚本的模版', async () => { const host = createMockHost({ contract: CONTRACT }); await expect(host.base.interaction.pushHtml('
')).rejects.toThrow(/无脚本执行权/); }); }); describe('活动入口', () => { it('没有 run —— 行为全在清单里', () => { expect(activity.run).toBeUndefined(); }); }); const CONTRACT = { activityId: 'brand-content-toolkit', version: '1.0.0', data: {}, message: undefined, outputPorts: { mandatory: ['content-published'], selective: [] }, prohibitions: [], discretion: [], } as const;