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