资料文件真产出来了;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
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* 步骤剧本与呈现面 —— **这段错了不报错,只是活动开始转圈 / 界面开始撒谎**
|
||||
* (与 quote-* / E04 各包同一套惯例)。
|
||||
*
|
||||
* 这一份是 2026-08-23 按 E04 的标准审 I01 时补的:每一条都对应审出来的一处真问题。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { evaluateCondition } from '@sfeel';
|
||||
import { CMMN_XML, CONTENT_PHASES, CONTENT_STEPS, NARRATIVE } from '../src/case-model.ts';
|
||||
import { DRAFT_REVIEW, PUBLISH_CONFIRM, REQUIREMENT_LIST } from '../src/views/report.ts';
|
||||
import { PRD_CSS } from '../src/views/prd-style.ts';
|
||||
import { manifest } from '../src/manifest.ts';
|
||||
|
||||
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map(
|
||||
(m) => m[1] ?? '',
|
||||
);
|
||||
|
||||
describe('剧本与案例模型对得上', () => {
|
||||
it('每一条剧本都指向一个真实存在的计划项,图上每一步也都有剧本', () => {
|
||||
for (const step of CONTENT_STEPS) expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
|
||||
for (const id of ITEM_IDS) expect(CONTENT_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
|
||||
});
|
||||
|
||||
it('清单里挂的就是这份剧本与这份阶段(不是另抄一份)', () => {
|
||||
expect(manifest.assets.businessModel.steps).toBe(CONTENT_STEPS);
|
||||
expect(manifest.assets.businessModel.phases).toBe(CONTENT_PHASES);
|
||||
});
|
||||
});
|
||||
|
||||
describe('剧本声明的读写面 ⊆ 清单声明 —— 越出去的地址运行时才炸', () => {
|
||||
const declaredAddresses = new Set(manifest.declarations.ledger.map((d) => `${d.domain}.${d.object}`));
|
||||
const declaredTools = new Set(manifest.declarations.tools.map((t) => t.name));
|
||||
|
||||
it('steps[].writes / steps[].reads(台账那几个)全落在声明过的对象上', () => {
|
||||
for (const step of CONTENT_STEPS) {
|
||||
for (const address of step.writes) {
|
||||
expect(declaredAddresses.has(address), `${step.item} 要写 ${address},清单没声明`).toBe(true);
|
||||
}
|
||||
for (const address of step.reads.filter((r) => r.includes('.'))) {
|
||||
expect(declaredAddresses.has(address), `${step.item} 要读 ${address},清单没声明`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('steps[].tools 全部是声明过的工具(本活动一个工具都不用,那就都得是空)', () => {
|
||||
for (const step of CONTENT_STEPS) {
|
||||
for (const tool of step.tools) expect(declaredTools.has(tool), `${step.item} 要调 ${tool}`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('doneWhen —— 开局不许真,干完必须真', () => {
|
||||
const fresh = (input: Record<string, unknown>): Record<string, unknown> => ({
|
||||
...input,
|
||||
content_brand_requirement_rows: 0,
|
||||
content_asset_rows: 0,
|
||||
content_asset_missing: 0,
|
||||
content_asset_content_status_published: 0,
|
||||
});
|
||||
|
||||
it('**开局一步都不该被自动收掉** —— 三条样例的入参形状逐一试', () => {
|
||||
for (const sample of manifest.samples) {
|
||||
for (const step of CONTENT_STEPS) {
|
||||
if (step.doneWhen === undefined) continue;
|
||||
const ripe = safeEval(step.doneWhen, fresh(sample.input as Record<string, unknown>));
|
||||
expect(ripe, `样例「${sample.name}」下 ${step.item} 的 doneWhen 开局就为真`).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('需求写下了 → S1 收得掉;初稿有行且必填齐 → S3 收得掉,缺一头都收不掉', () => {
|
||||
expect(safeEval(doneOf('S1_identify_requirement'), { content_brand_requirement_rows: 2 })).toBe(true);
|
||||
expect(safeEval(doneOf('S3_gather_and_draft'), { content_asset_rows: 1, content_asset_missing: 0 })).toBe(true);
|
||||
expect(safeEval(doneOf('S3_gather_and_draft'), { content_asset_rows: 1, content_asset_missing: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it('拟发布:记账旗 + 已发布行数 + 必填齐,三样缺一不可', () => {
|
||||
const done = doneOf('S5_publish_prep');
|
||||
expect(
|
||||
safeEval(done, { publish_prepared_total: 1, content_asset_content_status_published: 1, content_asset_missing: 0 }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
safeEval(done, { publish_prepared_total: 1, content_asset_content_status_published: 0, content_asset_missing: 0 }),
|
||||
).toBe(false);
|
||||
// 场景/BT 标签是「发布态必填」—— 缺了会进 _missing,这一步就收不掉
|
||||
expect(
|
||||
safeEval(done, { publish_prepared_total: 1, content_asset_content_status_published: 1, content_asset_missing: 1 }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('这一轮审出来的口径 —— 改口要连测试一起改', () => {
|
||||
/*
|
||||
* **两条 L1 各钉一半基座数**:旗是模型写的,单独成谓词就是「机检只读基座数」的伪装形
|
||||
* (E04 那单抢跑写旗、活单熔断,正是踩着这半边过的门)。
|
||||
*/
|
||||
it('两条 L1 都不只读记账旗,各自还钉着基座数', () => {
|
||||
for (const key of ['requirement-confirmed', 'draft-approved']) {
|
||||
const found = manifest.contract.acceptanceCriteria.find((c) => c.key === key);
|
||||
expect(found?.predicate, `${key} 没有谓词`).toBeDefined();
|
||||
expect(found?.predicate).toMatch(/_rows > 0/);
|
||||
expect(found?.predicate).toMatch(/_missing = 0/);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **判据指向的东西必须摆在卡面上**(E04 那一课):L4「场景与 BT 标签是否登记完整」
|
||||
* 从前让人核一个 `content.asset` 里压根不存在的列。
|
||||
*/
|
||||
it('BT 标签有列、发布态必填、两份验收件上都摆得出来', () => {
|
||||
const asset = manifest.declarations.ledger.find((d) => d.domain === 'content' && d.object === 'asset');
|
||||
const bt = asset?.fields.find((f) => f.name === 'bt_tag');
|
||||
expect(bt, 'content.asset 缺 bt_tag').toBeDefined();
|
||||
expect(bt?.requiredWhen).toEqual({ column: 'content_status', value: 'published' });
|
||||
// 不带 tally —— 件上是普通格子、没有翻译层,存 ASCII 人看到的就是 ASCII(scene 踩过)
|
||||
expect(bt?.tally).toBeUndefined();
|
||||
expect(DRAFT_REVIEW).toContain('data-slot="bt_tag"');
|
||||
expect(PUBLISH_CONFIRM).toContain('data-slot="bt_tag"');
|
||||
});
|
||||
|
||||
it('场景也是发布态必填 —— S5 剧本说「发布前必须登记」,声明就得钉得住', () => {
|
||||
const asset = manifest.declarations.ledger.find((d) => d.domain === 'content' && d.object === 'asset');
|
||||
expect(asset?.fields.find((f) => f.name === 'scene')?.requiredWhen).toEqual({
|
||||
column: 'content_status',
|
||||
value: 'published',
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* **主键列必挂 `data-col`** —— 它不是编辑面,是**行的钥匙**(仓里立过的纪律,A1 从头就带)。
|
||||
* 这一条是浏览器实测逼出来的:一度把序号列的 `data-col` 拿掉「让它只读」,
|
||||
* 结果人改的老行也没了钥匙 —— 服务端只好去猜,把它当成新增行、又拿「全表一致」
|
||||
* 猜出个 1,**当场覆盖掉原来那一行**。钥匙必须随行回传;
|
||||
* 「人加的新行序号空着」那一半由基座续号(`resolveRowKey`)接住。
|
||||
*/
|
||||
it('需求清单的序号列挂着 data-col(行的钥匙),内容类型是可改的必填格', () => {
|
||||
expect(REQUIREMENT_LIST).toContain('data-slot="req_seq"');
|
||||
expect(REQUIREMENT_LIST).toContain('data-col="req_seq"');
|
||||
expect(REQUIREMENT_LIST).toContain('data-col="content_type"');
|
||||
});
|
||||
|
||||
it('放行剧本带「被打回重做」那一支,且写面覆盖要改的那几张表', () => {
|
||||
const release = CONTENT_STEPS.find((s) => s.item === 'T_release');
|
||||
expect(release?.guide).toContain('验收被打回重做');
|
||||
for (const address of ['content.asset', 'content_brand.verdict']) {
|
||||
expect(release?.writes, `T_release 改不了 ${address}`).toContain(address);
|
||||
}
|
||||
expect(release?.guide).toContain('覆盖写');
|
||||
});
|
||||
});
|
||||
|
||||
describe('三份工单模版照说明书 §6.2 的共用动态位', () => {
|
||||
const ALL = [REQUIREMENT_LIST, DRAFT_REVIEW, PUBLISH_CONFIRM];
|
||||
|
||||
it('卡头:收件人 + 关联任务;side:步骤行;页脚:单号 —— 三份都有', () => {
|
||||
for (const view of ALL) {
|
||||
expect(view).toContain('data-slot="case:owner"');
|
||||
expect(view).toContain('data-slot="case:taskId"');
|
||||
expect(view).toContain('jc-rp-step');
|
||||
expect(view).toContain('data-slot="round_ref"');
|
||||
}
|
||||
});
|
||||
|
||||
it('执行工单的编辑闸照目标件措辞:「AI 已识别 N 个内容需求」,N 是基座读数', () => {
|
||||
expect(REQUIREMENT_LIST).toContain('AI 已识别');
|
||||
expect(REQUIREMENT_LIST).toContain('data-slot="content_brand_requirement_rows"');
|
||||
// 触发口径并进【做什么】,不再单开一段(照目标件的 note 结构)
|
||||
expect(REQUIREMENT_LIST).not.toContain('【本轮触发】');
|
||||
});
|
||||
|
||||
it('样例 0 是满载的那条,且每条样例名 ≤14 字(超了测试台只显示「样例 N」)', () => {
|
||||
const first = manifest.samples[0]?.input as Record<string, unknown>;
|
||||
for (const key of ['trigger', 'requester', 'targetCustomer', 'note']) {
|
||||
expect(first[key], `样例 0 缺 ${key}`).toBeDefined();
|
||||
}
|
||||
for (const s of manifest.samples) {
|
||||
expect(s.name.length, `样例名「${s.name}」超过 14 字`).toBeLessThanOrEqual(14);
|
||||
expect((s.input as { note?: string }).note, `样例「${s.name}」没有 note`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function doneOf(item: string): string {
|
||||
const found = CONTENT_STEPS.find((s) => s.item === item)?.doneWhen;
|
||||
if (found === undefined) throw new Error(`${item} 没有 doneWhen`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** 谓词写坏了引擎当作「没算出来」——测里也照这个口径,断言才对得上运行时 */
|
||||
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
|
||||
try {
|
||||
return evaluateCondition(expression, scope);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* **审核资料这一步要真产出资料文件**(2026-08-23 用户定:「格式可以是 word/excel/md/html,
|
||||
* 文件需要能在前端渲染,在表格中点击资料名可以跳转到一个新的页面来渲染这些文件」)。
|
||||
*
|
||||
* 接手时这一步只写下一行标题 —— S3 的剧本白纸黑字写着「file_ref 选填,本 MVP 阶段可留空」:
|
||||
* 人要审的那份初稿根本不在系统里,点不开也下不来。下面每一条都钉着这一轮补上的一环。
|
||||
*/
|
||||
describe('资料文件:声明 → 正文 → 格式 → 点得开', () => {
|
||||
const asset = manifest.declarations.ledger.find((d) => d.domain === 'content' && d.object === 'asset');
|
||||
|
||||
it('台账声明了 file 段(它就是授权面 —— 不声明,件上点不开)', () => {
|
||||
expect(asset?.file).toEqual({ titleColumn: 'title', bodyColumn: 'body_md', formatColumn: 'file_format' });
|
||||
});
|
||||
|
||||
it('file 段点名的三列都真在 fields 里', () => {
|
||||
const names = (asset?.fields ?? []).map((f) => f.name);
|
||||
for (const col of Object.values(asset?.file ?? {})) expect(names, `声明的 ${col} 不在 fields 里`).toContain(col);
|
||||
});
|
||||
|
||||
it('正文与格式都是**必填** —— 少一样这一份就不是文件,只是一行标题', () => {
|
||||
for (const col of ['body_md', 'file_format']) {
|
||||
expect(asset?.fields.find((f) => f.name === col)?.required, `${col} 不是必填`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('格式列带 tally 四档(基座据此现数,判据两边才都是基座数)', () => {
|
||||
expect(asset?.fields.find((f) => f.name === 'file_format')?.tally).toEqual(['md', 'html', 'docx', 'xlsx']);
|
||||
});
|
||||
|
||||
it('L2 判据「资料文件已产出」两边都是基座数:四档之和 = 行数', () => {
|
||||
const found = manifest.contract.acceptanceCriteria.find((c) => c.key === 'asset-file-ready');
|
||||
expect(found?.level).toBe('L2');
|
||||
for (const fmt of ['md', 'html', 'docx', 'xlsx']) {
|
||||
expect(found?.predicate, `谓词漏了 ${fmt}`).toContain(`content_asset_file_format_${fmt}`);
|
||||
}
|
||||
expect(found?.predicate).toContain('= content_asset_rows');
|
||||
// 自报列一个都不许出现在机检谓词里
|
||||
expect(found?.predicate).not.toMatch(/file_ref|reviewer/);
|
||||
});
|
||||
|
||||
it('S3 的剧本教它写正文与挑格式,且不再说「可留空」', () => {
|
||||
const s3 = CONTENT_STEPS.find((s) => s.item === 'S3_gather_and_draft');
|
||||
expect(s3?.writes).toContain('content.asset');
|
||||
expect(s3?.guide).toContain('body_md');
|
||||
expect(s3?.guide).toContain('file_format');
|
||||
for (const fmt of ['xlsx', 'docx', 'html', 'md']) expect(s3?.guide, `剧本没讲 ${fmt} 什么时候用`).toContain(fmt);
|
||||
// 那句话正是「这一步不产出文件」的出处 —— 它在,行为就回去了
|
||||
expect(s3?.guide).not.toContain('本 MVP 阶段可留空');
|
||||
expect(s3?.guide).not.toContain('待补充');
|
||||
});
|
||||
|
||||
it('S3 的 doneWhen 覆盖得住新的必填(必填齐才收步)', () => {
|
||||
const s3 = CONTENT_STEPS.find((s) => s.item === 'S3_gather_and_draft');
|
||||
expect(s3?.doneWhen).toContain('content_asset_missing = 0');
|
||||
// 正文/格式没写 → missing 不为 0 → 收不掉
|
||||
expect(evaluateCondition(s3?.doneWhen ?? 'false', { content_asset_rows: 2, content_asset_missing: 1 })).toBe(false);
|
||||
expect(evaluateCondition(s3?.doneWhen ?? 'false', { content_asset_rows: 2, content_asset_missing: 0 })).toBe(true);
|
||||
});
|
||||
|
||||
it('审核那一屏:资料名挂 data-file-of,旁边摆格式徽', () => {
|
||||
expect(DRAFT_REVIEW).toContain('data-file-of="content_id"');
|
||||
expect(DRAFT_REVIEW).toContain('data-class-of="file_format"');
|
||||
// 归档那一屏同样点得开 —— 办结之后回看资料是常事
|
||||
expect(PUBLISH_CONFIRM).toContain('data-file-of="content_id"');
|
||||
});
|
||||
|
||||
it('资料名那一格看得出能点(样式表里有它,不是一格普通文字)', () => {
|
||||
expect(DRAFT_REVIEW).toContain('jc-fileln');
|
||||
expect(PRD_CSS).toContain('.jc-fileln');
|
||||
expect(PRD_CSS).toContain('cursor: pointer');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **提交前要裁的那三条,写在说明书里**(2026-08-23 实测:S5 与 submit 被压成一拍,
|
||||
* 那一拍摊开的是 S5 的剧本,T_release 的「先 judge 再 submit」看不见 → 每单白吃一次被拒)。
|
||||
* 与 A1 的卡骨架、A6 的决策卡同一课:**模型会压拍,压过之后只有说明书还在**。
|
||||
*/
|
||||
describe('裁量判据的规矩住在说明书里,不只住在 T_release 的剧本里', () => {
|
||||
it('三条 L4 的整句都在 NARRATIVE 里(criterion 要照抄,抄不到就对不上名)', () => {
|
||||
for (const c of manifest.contract.acceptanceCriteria.filter((x) => x.level === 'L4')) {
|
||||
expect(NARRATIVE, `说明书里没有「${c.label}」`).toContain(c.label);
|
||||
}
|
||||
});
|
||||
|
||||
it('说明书说清了顺序:同一串先 judge 再 submit', () => {
|
||||
expect(NARRATIVE).toContain('judge');
|
||||
expect(NARRATIVE).toContain('submit');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user