This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-MVP/activities/brand-content-toolkit/__tests__/activity.test.ts
T
xcsandClaude Opus 5 0aa2fc11b0 资料文件真产出来了;E04/I01 补到验收线,顺手填了几个「墙拦住却不给路」的坑
**资料文件全链**(用户:审核资料这一步要真产出 word/excel/md/html,前端能渲、点资料名能开)
接手时这一步一个文件都产不出来 —— S3 剧本白纸黑字写着「file_ref 本 MVP 阶段可留空」。
文件是台账那一行的投影,不另存一份:
· 声明 `file:{titleColumn,bodyColumn,formatColumn}`,它同时是授权面(没声明就点不开)
· 现造真字节 —— docx/xlsx 是自己打的 OOXML 包(zip-write:store+CRC32,零依赖)
· 读回来渲 —— 解的就是造出来那份字节,渲得出来才证明文件是真的
· 件上 `data-file-of` → 工作区二级界面(Word 纸面/Excel 网格/渲好的 md/沙箱 html)+ 下载原件
放行落地前把每一份冻进 `sys.task_asset_file`(迁移 0029):归档件点开的必须是签字那一刻
那一份,而资料落在主数据上、试跑单不落正式表 —— 不存这一份,收工后一个都点不开。
工作成果里「产出的资料」自成一摞。Markdown 富文本渲染顺带销账(仍零依赖)。

**Harness**
· 画面按「这一拍办完之后」在办的那一步挑 —— 从前拿拍首的局面决定拍尾的事:
  收掉 S4、发出 S5 的卡,而屏上还摆着 S4 的只读件,人无处可填(墙要配路第四次现形)
· 验收「重做」真能退回重做,且打回后台账没动就不许再提交(软硬两段,不锁死单子)
· 立监听的授权面是清单声明;发卡的授权面是调人预算,不是计划项
· 读工具同参重调有兜底;空转写身后还有行动就不废串;finish 的 doneWhen 按写完后的草稿算

**界面**
· 翻件与翻页各归各位(从前二选一,多卡多页时页翻不动);没得翻写「—」不写 0/0
· 工作成果详情能翻份;表宽超出能横滑(E04/I01 抄版式时把 overflow-x 抄丢了)
· 查看面灰底滚过一屏变白 —— 两条独立的路都治了(沙箱内不同色 / 弹性子项不长个)
· 测试台「取不到」不再当成「还活着」

**活动**
E04 关系覆盖、I01 品牌内容各自补到验收线:判据全读基座数、判据指向的东西摆上卡面、
样例满载且名字 ≤14 字;提交前要裁的判据搬进说明书(模型会压拍,压过之后只有说明书还在)。

验收:e2e i01 4/4、e04 3/3 全部 released,资料在办中渲得出、放行后存了档点得开;
门禁 typecheck/lint/depcruise/1628 测全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
2026-08-24 10:26:45 +08:00

268 lines
13 KiB
TypeScript

/**
* 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(/<humanTask id="([^"]+)"/g)].map((m) => m[1] ?? '');
expect(stepItems).toEqual(cmmnIds);
});
it('六个计划项:1 执行 + 2 验收(S2 执行工单,S4/T_release 验收工单)', () => {
const cmmnIds = [...manifest.assets.businessModel.cmmnXml.matchAll(/<humanTask id="([^"]+)"/g)].map((m) => 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(`<humanTask id="${id}"[\\s\\S]*?</humanTask>`).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('<span class="jc-tag" data-class-of="priority"></span>');
const draft = manifest.assets.viewTemplates.find((t) => t.name === 'draft-review')?.html ?? '';
expect(draft).toContain('<span class="jc-tag" data-class-of="content_status"></span>');
});
it('宿主拒推含脚本的模版', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).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;