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
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

288 lines
14 KiB
TypeScript

/**
* 步骤剧本与呈现面 —— **这段错了不报错,只是活动开始转圈 / 界面开始撒谎**
* (与 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');
});
});