Files
AgentLab/scripts/check-caliber-drafts.mjs
ThearchyHelios 8d9a712eae 业务本体与标准实体:跨源键值对齐、对账共变算子、健康证据链与实体助手
- 新增标准业务实体(CanonicalEntity)模型、确定性跨源编码翻译、后台候选实体扫描与文档驱动实体智能助手
- 新增指标本体图谱(factor / reconciles_with / upstream_driver)、DAG 防环校验以及 ontology_neighbors / source_health 内置工具
- 新增跨源对账(analysis_reconcile)与滞后共变(analysis_cochange)分析算子,打通 [[h:H1]] 数据源健康证据链、报告导出与本体消融评测
- 数据目录新增「标准业务实体」视图与候选审阅工作台,口径页集成指标本体拓扑面板与端到端浏览器检查
2026-11-12 12:57:53 +08:00

2177 lines
139 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// 口径草稿页上的业务规则工作台(K06 + K11 的文档更新 + K12 的一句话改口径):/ledger/drafts 里「找到指标 → 改受支持的
// 统计时间口径 / 退款处理 → 看旧→新和影响指标 → 一次提交 → 看新版本、同步状态、验证与审阅 → 能撤销」这一条路走不走得通,
// 以及增量重导入和一句话改口径这两块。
//
// 守的是这些「看着没坏、其实不对」的地方:
// - 业务用户改的是业务写法,看不到取数 SQL、节点 id、字段路径和 JSON;能力描述为空、认不出唯一口径卡、没有版本号的
// 资产保持只读,并说清为什么;
// - 改完先列「会改到哪几条指标、现在什么写法 → 保存后什么写法」,再把**所有**受影响指标的字段操作放在一次 PATCH
// 里(带基准版本、目标卡、幂等键);入口字段这类查询参数的变化只发试运行,不会发 PATCH、不新增定义版本;
// - 保存回执说清落成哪一版、取数同步到哪一步、旧验证怎么失效;同步不了的说「定义存下来了、取数一个字没动」+ 技术
// 待办,不写「已同步」;
// - 撤销按**保存前**的值构造逆操作(原来没声明的用 remove),带上当前版本和**新的**请求键再存一版,不顺一版整份覆盖;
// - 基准版本过期(409 stale_revision):写不进去,页面上的写法选择一个字不丢、也不自动重发,详情刷新拿到新版本后由人
// 显式再点一次(同内容仍用同一个请求键);
// - 验证那一段按证据说:没有独立期望就是「只记了试运行结果」,样例对得上但没审阅是「还不能算验证通过」,只有独立期望
// 对得上、并且有认证审阅才是验证通过;审阅只调真接口(请求体里没有 actor),拿回来的「谁、覆盖不覆盖」照原样显示;
// - 增量重导入(K11):三方的值(上次被接受的导入值 / 现在的内容 / 这次文档里的说法)和逐格状态照实摆出来;**接受是
// 唯一的落图路径**(一次提交里新增一版图 + 按被接受的格子推进 B),没接受过的格子不推进基线;**提交一律按工作项
// 身份(item_id)指认、把要落的几格明确列出来**(同一条指标上有多条候选时不会指错、不发 auto);抽坏了的(failed)
// 工作项一个接受都点不了、批量也不收它;已接受 / 拒绝过的格子不再被选;漏提取不当删除(不能接受、现在的内容原样
// 留着);没有可比基线时逐格要人工对齐到某条指标,没对齐之前不能接受;同名不同范围也要人来定;被中断的导入由人点
// 「恢复」显式续跑;写不进去时选好的格子一个不丢,且**不拿旧 L 覆盖**(stale_revision 重读详情,import_stale_l 按
// 现在的口径重算三方 + 指名道姓说清是哪几格);有基线不等于整张都有,coverage 是 partial 就写清还差多少;
// - 一句话改口径(K12):只调只读的提案接口,影响只写「哪条指标、哪一项写法、现在什么 → 保存后什么」,不摆 JSON、
// 节点编号和字段路径;含糊的业务条件先显示澄清项、一条操作都不产出、也不给确认按钮;**确认(confirm=true)之前
// 一个字都不写**;确认发的是**预览时那一段原文**(输入改过就必须重新预览,页面显示的和实际落下的不会是两句话),
// 落版之后另比一次服务端落下的操作和预览里的是不是同一批;落版失败时照实报错,页面上的输入和预览原样留着;
// - 结论一律按 data 属性(data-draft-validation 等)核:`还不能算验证通过` 这种否定说法里也有「验证通过」四个字,
// 用 includes 会被骗过(页面没通过、检查却绿了)。
//
// 不连着后端写:/api/ 下的请求几乎全部在浏览器层答掉(草稿详情、试运行、验证记录、字段操作、审阅、重导入、提案),
// 其余 GET 放行给开发服务器(外壳要的),不认得的写请求一律 409 并记账,检查结束时必须是空的。
//
// 跑之前前端得起着(./scripts/dev.sh),默认连 5273:
// AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-caliber-drafts.mjs
// CHECK_ONLY=撤销,复杂 只跑段名里含这些字的段。
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const ONLY = (process.env.CHECK_ONLY ?? '').split(',').map((s) => s.trim()).filter(Boolean)
let failed = 0
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
if (!cond) failed++
}
const opened = new Set()
async function section(name, fn) {
if (ONLY.length && !ONLY.some((k) => name.includes(k))) return
console.log(`\n=== ${name} ===`)
try {
await fn()
} catch (e) {
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
} finally {
for (const c of opened) await c.close().catch(() => {})
opened.clear()
}
}
const until = async (fn, ms = 6000, step = 100) => {
const end = Date.now() + ms
let v = await fn()
while (!v && Date.now() < end) {
await new Promise((r) => setTimeout(r, step))
v = await fn()
}
return v
}
const clone = (x) => JSON.parse(JSON.stringify(x))
const ADMIN = { mode: 'off', username: null, display_name: null, role: 'admin', groups: [], verified: false }
/** 字段操作那一批要落成的东西:两条指标 × 两项业务写法 */
const OLD = { time_role: 'order_time', refund_rule: 'include_all' }
const NEW = { time_role: 'payment_time', refund_rule: 'exclude_full_refund' }
/** 服务端能力描述的 editable_fields(和 app/ledger/bindings.editable_fields 同一份形状) */
const EDITABLE = [
{ field: 'time_role', label: '统计时间口径', values: ['order_time', 'payment_time'],
value_labels: { order_time: '按下单时间', payment_time: '按支付时间' },
help: '这条指标的统计口径按哪个时间点算:下单时间还是支付时间' },
{ field: 'refund_rule', label: '退款处理', values: ['include_all', 'exclude_full_refund'],
value_labels: { include_all: '全部计入', exclude_full_refund: '排除全额退款' },
help: '退款订单算不算进来:全额退款排除、部分退款保留' },
]
const ALIGNED_REASON = '定义和取数 SQL 同步(aligned 只说这两边对得上,不等于业务验证通过)'
const UNSUPPORTED_REASON = '这条 SQL 里没有「统计时间口径」的落点(受控模板里没有 [[time_role]]):'
+ '这条取数不是受控模板写出来的,改不了'
/** 一份已接管的草稿:两条指标都声明着业务写法,图上一张能改的口径卡,客户端要带回 v7 */
function draftOf(over = {}) {
return {
id: 'd-orders', title: '订单口径(单据)', status: 'ready', stage: 'review', metric_count: 2, question_count: 0,
workflow_id: 'wf-orders', workflow_name: '订单口径', created_at: '2026-11-01T02:00:00+00:00', error: null,
card_id: 'card', cards: [{ id: 'card', title: '订单口径' }], revision: 7, version: 7,
metrics: [
{ id: 'm1', name: '订单数', unit: '单', expression: "cell(nodes.fetch, 0, 'orders')", grain: '订单行',
time_role: OLD.time_role, refund_rule: OLD.refund_rule },
{ id: 'm2', name: '订单金额', unit: '元', expression: "cell(nodes.fetch, 0, 'amount')", grain: '订单行',
time_role: OLD.time_role, refund_rule: OLD.refund_rule },
],
entry_fields: [{ name: 'start', type: 'string', required: true }, { name: 'end', type: 'string', required: true }],
trial: { run_id: 'run-1', status: 'succeeded', version: 7 },
binding: bindingOf({ status: 'aligned', reason: ALIGNED_REASON, fix: 'none', synced: false, ...(over.binding ?? {}) }),
field_baseline: { status: 'known', artifact_id: 'bl-1', version: 1, content_hash: 'bl-1', card_id: 'card',
workflow_id: 'wf-orders', accepted_revision: 1, accepted_at: '2026-11-01T02:00:00+00:00',
metric_ids: ['m1', 'm2'], field_count: 4 },
...over.draft,
}
}
function bindingOf(over = {}) {
const values = over.metric_values ?? { m1: clone(OLD), m2: clone(OLD) }
return {
editable_fields: EDITABLE,
current: over.current ?? clone(OLD),
status: over.status ?? 'aligned',
reason: over.reason ?? ALIGNED_REASON,
fix: over.fix ?? 'none',
metrics: over.metrics ?? ['m1', 'm2'],
checked: true,
metric_values: values,
...(over.status === 'unsupported' ? { unsupported_reason: over.reason ?? UNSUPPORTED_REASON } : {}),
binding_fingerprints: { fetch: 'fp-1' },
nodes: [{ node_id: 'fetch', status: over.status ?? 'aligned', reason: over.reason ?? ALIGNED_REASON,
fix: over.fix ?? 'none', declared: clone(OLD), values: clone(OLD), metrics: ['m1', 'm2'],
binding_fingerprint: 'fp-1' }],
synced: !!over.synced,
}
}
/** 一次 PATCH 的变更回执(和 app/ledger/changes.summary 同一份形状) */
function receiptOf({ revision, metrics = ['m1', 'm2'], operations = [], binding, applied = true }) {
return {
applied, classification: applied ? 'definition' : 'none', card: 'card', metrics: applied ? metrics : [],
operations, revision,
definition_hash: { before: 'dh-1', after: applied ? 'dh-2' : 'dh-1', changed: applied },
validation: {
invalidated: applied, scope: 'card', metrics: applied ? metrics : [],
reason: applied
? '口径卡的内容变了(图的执行指纹跟着变):旧试跑和旧验证不再算覆盖这一版,按卡片保守失效(4.8)'
: '这次操作没有改动任何内容:图没换版本,原来的验证仍然对着这一版',
next: applied ? '重新试运行,按新定义取一次独立样例' : '不用重跑',
},
binding,
}
}
/** 验证记录的一条:和 app/ledger/validation.as_dict 同一份形状 */
function recordOf({ id = 'vr-1', status = 'observed', expected = null, ok = null, covers = true, review = null,
metric_ids = ['m1', 'm2'], actual = null } = {}) {
return {
id, draft_id: 'd-orders', workflow_id: 'wf-orders', version: 7, card_id: 'card', metric_ids,
definition_hash: 'dh-1', binding_fingerprints: { fetch: 'fp-1' }, semantic_hash: 'sh-1', execution_hash: 'eh-1',
method: { version: 1, note: '原值比较、不做隐式舍入;逐条比较卡上全部指标;声明成标量的指标必须恰好一行' },
sample: { id: '', version: '' }, inputs: { start: '2026-10-01', end: '2026-11-01' }, inputs_hash: 'ih-1',
expected,
actual: actual ?? {
m1: { value: 2, rendered: '2', rows: 1, masked: false, shape: 'scalar', shape_ok: true, findings: [], ok,
...(expected ? { expected: expected.metrics.m1 } : {}) },
m2: { value: 500, rendered: '500', rows: 1, masked: false, shape: 'scalar', shape_ok: true, findings: [], ok,
...(expected ? { expected: expected.metrics.m2 } : {}) },
},
status, problems: status === 'observed' ? ['这次试跑没有独立期望:只记 observed,不能算验证通过'] : [],
run_id: 'run-1', trial_id: 'tr-1', actor: 'alice', origin: 'trial', created_at: '2026-11-01T03:00:00+00:00',
covers_current: covers, review,
}
}
function summaryOf({ verdict, status, record = null, covers = true }) {
return {
verdict, status, covers_current: covers, record_id: record?.id ?? null, review: record?.review ?? null,
counts: { total: record ? 1 : 0, covering: covers && record ? 1 : 0, matched: record?.status === 'matched' ? 1 : 0,
mismatched: 0, observed: record?.status === 'observed' ? 1 : 0, unverified: 0, failed: 0 },
reason: REASONS[verdict] ?? verdict,
semantic_gaps: [], method: { version: 1, note: '原值比较、不做隐式舍入' },
}
}
const REASONS = {
none: '这份草稿还没有试跑留下的验证记录',
stale: '现有的验证记录都不是对着当前定义/绑定跑的(定义、取数或绑定改过,或者现在读不到这份取数图):'
+ '旧记录留着可追,但不能算覆盖这一版,请重新试跑',
observed: '试跑跑了、也取了数,但没有独立期望:不能算验证通过',
unverified: '有独立期望且对得上,但还没有认证审阅:样例匹配不等于正式批准',
verified: '有独立期望的记录覆盖当前版本,并且有认证主体确认过',
mismatched: '有独立期望,但这一次没对上',
failed: '这一次试跑没有成功',
rejected: '有覆盖当前版本的记录,但它被审阅驳回了',
}
const BASELINE = { status: 'known', artifact_id: 'bl-1', version: 1, content_hash: 'bl-1', card_id: 'card',
workflow_id: 'wf-orders', accepted_revision: 1, accepted_at: '2026-11-01T02:00:00+00:00',
metric_ids: ['m1', 'm2'], field_count: 4,
coverage: { status: 'complete', metrics: {}, uncovered_metric_ids: [] } }
// ---------------------------------------------------------------- K11 / K12 的伪造:一次增量重导入、一份提案
const ACCEPT_STATES = ['update', 'conflict', 'new', 'manual']
/** 一格的三方(和 app/api/caliber_import._field_view 同一份形状):b 是上次被接受的值、l 是现在的内容、r 是这次候选 */
function importField(path, state, over = {}) {
const b = over.b ?? null
const l = over.l ?? null
const r = over.r ?? null
return {
path, state,
b, l, r,
b_present: over.bPresent ?? b != null, l_present: over.lPresent ?? l != null, r_present: over.rPresent ?? r != null,
decided: over.decided ?? null,
acceptable: ACCEPT_STATES.includes(state),
}
}
/** 一条工作项(一条候选指标) */
function importItem(over = {}) {
const metricId = over.metric_id ?? 'm1'
return {
item_id: over.item_id ?? 'it-1', seq: over.seq ?? 1, metric_id: metricId, candidate_id: over.candidate_id ?? 'c1',
align_key: over.align_key ?? `k-${over.item_id ?? 'it-1'}`,
name: over.name ?? '订单数', status: over.status ?? 'update',
failed: over.failed ?? (over.status === 'failed'),
align: over.align ?? { metric_id: metricId, mode: 'metric_id', evidence: ['ch-1'],
reason: '候选带着和图上一致的稳定 metric_id,来源证据也对得上' },
error: over.error ?? '', accepted: over.accepted ?? [], rejected: over.rejected ?? [],
applied_revision: over.applied_revision ?? null, fields: over.fields ?? [],
}
}
/** 一次导入的现状(GET …/imports、GET …/imports/{run_id}):冲突那一列由逐字段的状态现算(和 _run_view 同一套) */
function importRun(over = {}) {
const items = over.items ?? []
const conflicts = over.conflicts ?? items.flatMap((item) => item.fields
.filter((cell) => ['conflict', 'manual', 'missing'].includes(cell.state))
.map((cell) => ({ metric_id: item.metric_id, item_id: item.item_id, seq: item.seq, name: item.name,
path: cell.path, state: cell.state, b: cell.b, l: cell.l, r: cell.r })))
return {
run_id: over.run_id ?? 'im-1', draft_id: 'd-orders', workflow_id: 'wf-orders',
status: over.status ?? 'succeeded', error: over.error ?? '', actor: 'alice', request_id: 'req-1',
created_at: '2026-11-09T02:00:00+00:00',
items_total: over.items_total ?? items.length, items_decided: over.items_decided ?? 0,
source_snapshot: {}, visible_chunk_ids: [], items, conflicts,
}
}
/** 接受之后的回:一次落到哪一版(和 accept_fields 的返回同一份形状) */
const acceptOut = (run, revision, over = {}) => ({ ...run, ...over, applied: true, replayed: false, revision })
/** 接受 / 拒绝要拦下来的写请求:每次记进 state.calls,检查随后核对请求体 */
const importCalls = (state, method, kind) =>
state.calls.filter((call) => call.method === method && new RegExp(`/imports/[^/]+${kind}$`).test(call.path))
/** 一份 proposal(和 app/ledger/nlchange.Proposal.as_dict 同一份形状) */
function proposalOf(over = {}) {
return {
card: 'card', ready: over.ready ?? true,
operations: over.operations ?? [
{ op: 'set', metric_id: 'm1', path: '/time_role', value: 'payment_time' },
{ op: 'set', metric_id: 'm2', path: '/time_role', value: 'payment_time' },
],
metrics: over.metrics ?? ['m1', 'm2'],
clarifications: over.clarifications ?? [],
preview: over.preview ?? {
applied: true, classification: 'definition', card: 'card', metrics: ['m1', 'm2'],
operations: [], revision: null,
definition_hash: { before: 'dh-1', after: 'dh-2', changed: true },
validation: { invalidated: true, scope: 'card', metrics: ['m1', 'm2'], reason: '口径卡的内容变了', next: '重新试运行' },
binding: bindingOf({ status: 'aligned', reason: ALIGNED_REASON, fix: 'none', synced: true }),
preview: true, persisted: false,
},
ignored_claims: [], notes: [],
}
}
// ---------------------------------------------------------------- 浏览器
const browser = await chromium.launch({ executablePath: CHROME })
/**
* 开一页 /ledger/drafts:/api/ 下认得的请求按 state 答,别的 GET 放行给开发服务器,不认得的写请求一律 409 并记进
* stray(检查结束时必须是空的)。每次写请求都记进 state.calls,检查随后核对请求体。
*/
async function open(state, { me = ADMIN, w = 1280, h = 1100 } = {}) {
const ctx = await browser.newContext({ viewport: { width: w, height: h } })
opened.add(ctx)
// 帧和接口都在本机,但整套检查是并行跑的:给宽一点,机器的抖动不算这一项没过
ctx.setDefaultTimeout(15_000)
const page = await ctx.newPage()
const errors = []
const dialogs = []
page.on('pageerror', (e) => errors.push(e.message))
page.on('dialog', (d) => { dialogs.push(d.message()); void d.dismiss() })
await page.route((u) => new URL(u).pathname.startsWith('/api/'), async (route) => {
const req = route.request()
const url = new URL(req.url())
const p = decodeURIComponent(url.pathname.replace(/^\/api/, ''))
let body = null
try { body = req.postDataJSON() } catch { body = null }
if (p === '/auth/me') return route.fulfill({ json: me })
const out = await state.reply(req.method(), p, body, url)
if (out) return route.fulfill({ status: out.status ?? 200, json: out.json ?? {} })
if (req.method() === 'GET') return route.continue()
state.stray.push(`${req.method()} ${p}`)
return route.fulfill({ status: 409, json: { detail: '检查脚本不写库' } })
})
await page.goto(`${WEB}/ledger/drafts`, { waitUntil: 'domcontentloaded' })
return { ctx, page, errors, dialogs }
}
/** 展开那份草稿的详情 */
async function expand(page, draft) {
const toggle = page.locator(`[data-draft="${draft.id}"] [data-draft-toggle]`)
await toggle.waitFor({ state: 'visible' })
await toggle.click()
await page.locator('[data-draft-detail]').waitFor({ state: 'visible' })
}
/** 页面上的业务块:规则、预览、回执、验证 */
const rulesPanel = (page) => page.locator('[data-draft-rules]')
const validationPanel = (page) => page.locator('[data-draft-validation]')
const textOf = async (locator) => (await locator.innerText().catch(() => '')).replace(/\s+/g, ' ')
/**
* 这段文字有没有把结论说成「验证通过」。只看短语本身不够:`还不能算验证通过`、`不能算整卡验证通过` 都是**否定**的
* 说法,用 includes('验证通过') 会被它们骗过(页面明明没通过,检查却绿了)。所以按「前面不是否定词」判;
* 结论本身一律另按 data-draft-validation 的取值核(verified / unverified / observed…)。
*/
const claimsVerified = (text) => /(?<!(不能算|不算|不是|没有|未))验证通过/.test(text)
function makeState({ draft, validations, writes = {} }) {
const state = { draft, validations, stray: [], calls: [], trial: null, ...writes }
state.reply = async (method, p, body) => {
state.calls.push({ method, path: p, body })
if (method === 'GET' && p === '/ledger/drafts') return { json: { drafts: [state.draft] } }
if (method === 'GET' && p === `/ledger/drafts/${state.draft.id}`) return { json: clone(state.draft) }
if (method === 'GET' && p === `/ledger/drafts/${state.draft.id}/trial`) {
return state.trial ? { json: state.trial } : { status: 404, json: { detail: '这份草稿还没有试运行' } }
}
if (method === 'GET' && p === `/ledger/drafts/${state.draft.id}/validations`) {
return { json: { draft_id: state.draft.id, field_baseline: state.baseline ?? BASELINE,
records: state.validations.records,
summary: state.validations.summary, method: { version: 1, note: '原值比较' } } }
}
if (method === 'PATCH' && p === `/ledger/drafts/${state.draft.id}`) return state.patch(body)
if (method === 'POST' && p === `/ledger/drafts/${state.draft.id}/trial`) return state.trial_(body)
const review = p.match(new RegExp(`^/ledger/drafts/${state.draft.id}/validations/([^/]+)/review$`))
if (method === 'POST' && review) return state.review(review[1], body)
// K11 增量重导入:列表 / 建一次 / 一次导入的现状 / 人工对齐 / 接受 / 拒绝 / 恢复
if (method === 'GET' && p === `/ledger/drafts/${state.draft.id}/imports`) return { json: { runs: state.imports } }
if (method === 'POST' && p === `/ledger/drafts/${state.draft.id}/imports`) return state.createImport_(body)
const importRun = p.match(/^\/ledger\/imports\/([^/]+)$/)
if (method === 'GET' && importRun) return state.getImport(importRun[1])
const alignPath = p.match(/^\/ledger\/imports\/([^/]+)\/items\/([^/]+)\/align$/)
if (method === 'POST' && alignPath) return state.align(alignPath[1], alignPath[2], body)
const acceptPath = p.match(/^\/ledger\/imports\/([^/]+)\/accept$/)
if (method === 'POST' && acceptPath) return state.accept(acceptPath[1], body)
const rejectPath = p.match(/^\/ledger\/imports\/([^/]+)\/reject$/)
if (method === 'POST' && rejectPath) return state.reject(rejectPath[1], body)
const resumePath = p.match(/^\/ledger\/imports\/([^/]+)\/resume$/)
if (method === 'POST' && resumePath) return state.resume(resumePath[1])
const refreshPath = p.match(/^\/ledger\/imports\/([^/]+)\/refresh$/)
if (method === 'POST' && refreshPath) return state.refresh(refreshPath[1])
// K12 一句话改口径:只读提案 / 确认落版
if (method === 'POST' && p === `/ledger/drafts/${state.draft.id}/change-proposal`) return state.proposal(body)
if (method === 'POST' && p === `/ledger/drafts/${state.draft.id}/nl-change`) return state.nl(body)
if (method === 'GET' && p === '/kb/documents') return { json: [] }
if (method === 'GET' && p === '/datasources/names') return { json: [] }
if (method === 'GET' && p.startsWith('/runs')) return { json: [] }
return null
}
// 缺省的写答复:不认得就 409(检查里显式给出各段要的答复)
state.patch = () => ({ status: 409, json: { detail: '这一段没有安排字段操作的答复' } })
state.trial_ = () => ({ status: 409, json: { detail: '这一段没有安排试运行的答复' } })
state.review = () => ({ status: 409, json: { detail: '这一段没有安排审阅的答复' } })
state.imports = []
state.createImport_ = () => ({ status: 409, json: { detail: '这一段没有安排重新读取的答复' } })
state.getImport = () => ({ status: 409, json: { detail: '这一段没有安排导入现状的答复' } })
state.align = () => ({ status: 409, json: { detail: '这一段没有安排人工对齐的答复' } })
state.accept = () => ({ status: 409, json: { detail: '这一段没有安排接受的答复' } })
state.reject = () => ({ status: 409, json: { detail: '这一段没有安排拒绝的答复' } })
state.resume = () => ({ status: 409, json: { detail: '这一段没有安排恢复的答复' } })
state.refresh = () => ({ status: 409, json: { detail: '这一段没有安排重算三方的答复' } })
state.proposal = () => ({ status: 409, json: { detail: '这一段没有安排提案的答复' } })
state.nl = () => ({ status: 409, json: { detail: '这一段没有安排一句话落版的答复' } })
return state
}
const patchBodies = (state) => state.calls.filter((c) => c.method === 'PATCH').map((c) => c.body)
/** 每个用过的 state(收尾时核对:兜底拦下的写请求必须是空的)和全局的原生对话框 */
const states = []
const dialogs = []
async function pageOn(state, opts) {
states.push(state)
const made = await open(state, opts)
for (const d of made.dialogs) dialogs.push(d)
return made
}
// ================================================================ 1 正向:改两项、一次提交
await section('正向:业务用户改统计时间口径和退款处理,看旧→新和影响指标,一次提交所有受影响指标', async () => {
const state = makeState({
draft: draftOf(),
validations: { records: [recordOf({ status: 'observed' })], summary: summaryOf({ verdict: 'observed', status: 'observed', record: recordOf({ status: 'observed' }) }) },
})
state.trial = { run_id: 'run-1', status: 'succeeded', metrics: [
{ id: 'm1', name: '订单数', value: 3, rendered: '3' }, { id: 'm2', name: '订单金额', value: 900, rendered: '900' }],
queries: [] }
state.patch = (body) => {
state.saved = body
const live = draftOf()
live.revision = 8
live.version = 8
live.binding = bindingOf({ status: 'aligned', reason: ALIGNED_REASON, fix: 'none', synced: true, metric_values:
{ m1: clone(NEW), m2: clone(NEW) }, current: clone(NEW) })
live.metrics = live.metrics.map((metric) => ({ ...metric, ...NEW }))
live.change = receiptOf({ revision: 8, operations: body.operations.map((op) => ({ ...op, changed: true })),
binding: live.binding })
return { json: live }
}
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
const panel = rulesPanel(page)
check('业务规则一块画出来了', await panel.count() === 1)
check('服务端说的同步状态照实写出来', await panel.getAttribute('data-draft-rules') === 'aligned',
await panel.getAttribute('data-draft-rules'))
const text = await textOf(panel)
check('只写业务说法:不出现取数语句、节点编号、字段路径和 JSON',
!/SELECT|\[\[|nodes\.|time_role|refund_rule|\{|\}/.test(text), text.slice(0, 200))
check('两项业务写法都在,带给人看的说法', await panel.locator('[data-draft-rule-select="time_role"] option').count() === 3
&& (await textOf(panel.locator('[data-draft-rule-select="time_role"]'))).includes('按下单时间'),
await textOf(panel.locator('[data-draft-rule-select="time_role"]')))
check('还没改时预览说「还没有改动」',
(await textOf(panel.locator('[data-draft-rule-affected]'))).includes('还没有改动'))
check('保存按钮在没改动时是禁用的', await panel.locator('[data-draft-rule-save]').isDisabled())
// 指标自己身上也写清按什么口径算:业务用户在这一页找得到、看得懂,不用去翻字段路径
const metricCard = page.locator('[data-draft-metric="订单数"]')
await metricCard.locator('summary').click()
const business = await textOf(metricCard.locator('[data-draft-metric-business]'))
check('指标自己写清按什么口径算,不出现内部字段名',
business.includes('统计时间口径:按下单时间') && business.includes('退款处理:全部计入')
&& !/time_role|refund_rule/.test(business), business)
// 改两项业务写法
await panel.locator('[data-draft-rule-select="time_role"]').selectOption('payment_time')
await panel.locator('[data-draft-rule-select="refund_rule"]').selectOption('exclude_full_refund')
await until(async () => (await panel.locator('[data-draft-rule-change]').count()) === 4)
check('预览列出会改到哪几条指标(2 条指标 × 2 项写法)',
await panel.locator('[data-draft-rule-change]').count() === 4,
String(await panel.locator('[data-draft-rule-change]').count()))
check('预览写清条数', (await textOf(panel.locator('[data-draft-rule-affected]'))).includes('会改到 4 条指标'),
await textOf(panel.locator('[data-draft-rule-affected]')))
const preview = await textOf(panel.locator('[data-draft-rule-preview]'))
check('预览写清「现在什么写法 → 保存后什么写法」',
preview.includes('按下单时间') && preview.includes('按支付时间') && preview.includes('全部计入')
&& preview.includes('排除全额退款'), preview)
check('预览里有两条指标的名字', preview.includes('订单数') && preview.includes('订单金额'), preview)
await panel.locator('[data-draft-rule-save]').click()
await page.locator('[data-draft-rule-result]').waitFor({ state: 'visible' })
const saved = state.saved
check('一次 PATCH 把两条指标的字段操作都提交了', saved?.operations?.length === 4,
JSON.stringify(saved?.operations))
check('全是 set,按指标身份和业务字段定位,值是新写法',
saved.operations.every((op) => op.op === 'set')
&& new Set(saved.operations.map((op) => `${op.metric_id}${op.path}`)).size === 4
&& saved.operations.filter((op) => op.path === '/time_role').every((op) => op.value === 'payment_time'),
JSON.stringify(saved.operations))
check('带上读到的基准版本', saved.expected_revision === 7, String(saved.expected_revision))
check('指到这张口径卡(不给「第一张」留余地)', saved.target?.card_id === 'card' && saved.target?.workflow_id === 'wf-orders',
JSON.stringify(saved.target))
check('带上幂等键', typeof saved.request_id === 'string' && saved.request_id.length > 0, String(saved.request_id))
check('不走整份换指标那条路(没有 metrics)', saved.metrics === undefined)
const result = await textOf(page.locator('[data-draft-rule-result]'))
check('回执写清新版本', result.includes('已保存成 v8'), result)
check('回执写清定义和取数已经在同一个版本里改过来了', result.includes('定义和取数已经在同一个版本里改过来了'), result)
check('回执写清旧验证不再覆盖这一版、下一步重新试运行',
result.includes('旧的试运行和验证不再算覆盖这一版') && result.includes('重新试运行'), result)
check('回执按服务端的答复标成真写进去了',
await page.locator('[data-draft-rule-result]').getAttribute('data-draft-rule-result') === 'applied')
check('保存后表单回到「没有改动」', (await textOf(panel.locator('[data-draft-rule-affected]'))).includes('还没有改动'))
check('保存后出现「撤销这次修改」', await panel.locator('[data-draft-rule-undo]').count() === 1)
check('全程页面没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
})
// ================================================================ 2 撤销
await section('撤销:按保存前的值构造逆操作,带当前版本和新请求键再存一版,不顺一版整份覆盖', async () => {
const state = makeState({
draft: draftOf(),
validations: { records: [], summary: summaryOf({ verdict: 'none', status: 'none' }) },
})
state.trial = { run_id: 'run-1', status: 'succeeded', metrics: [], queries: [] }
let revision = 7
const seen = []
state.patch = (body) => {
seen.push(body)
revision += 1
const live = draftOf()
live.revision = revision
live.version = revision
const values = seen.length === 1 ? { m1: { ...clone(OLD), time_role: 'payment_time' }, m2: { ...clone(OLD), time_role: 'payment_time' } } : { m1: clone(OLD), m2: clone(OLD) }
live.metrics = live.metrics.map((metric) => ({ ...metric, time_role: values[metric.id].time_role }))
live.binding = bindingOf({ status: 'aligned', reason: ALIGNED_REASON, fix: 'none', synced: true,
metric_values: values, current: { time_role: values.m1.time_role, refund_rule: 'include_all' } })
live.change = receiptOf({ revision, metrics: ['m1', 'm2'],
operations: body.operations.map((op) => ({ ...op, changed: true })), binding: live.binding })
return { json: live }
}
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
const panel = rulesPanel(page)
await panel.locator('[data-draft-rule-select="time_role"]').selectOption('payment_time')
await until(async () => (await panel.locator('[data-draft-rule-change]').count()) === 2)
await panel.locator('[data-draft-rule-save]').click()
await until(async () => (await panel.locator('[data-draft-rule-undo]').count()) === 1)
check('保存后出现撤销按钮,写清撤销几处改动',
(await textOf(panel.locator('[data-draft-rule-undo]'))).includes('撤销这次修改(回到 2 处改动之前的值)'),
await textOf(panel.locator('[data-draft-rule-undo]')))
check('撤销按钮上写明撤销也是新增一版',
(await panel.locator('[data-draft-rule-undo]').getAttribute('title') ?? '').includes('新增一版'))
await panel.locator('[data-draft-rule-undo]').click()
await until(async () => seen.length === 2)
const undo = seen[1]
check('撤销用保存前的值构造逆操作(两条指标都设回按下单时间)',
undo.operations.length === 2 && undo.operations.every((op) => op.op === 'set' && op.path === '/time_role'
&& op.value === 'order_time'), JSON.stringify(undo.operations))
check('撤销以当前版本为基准(保存后已经是 v8)', undo.expected_revision === 8, String(undo.expected_revision))
check('撤销用一个**新的**请求键', typeof undo.request_id === 'string' && undo.request_id
&& undo.request_id !== seen[0].request_id, `${seen[0].request_id} / ${undo.request_id}`)
check('撤销不走整份覆盖(没有 metrics)', undo.metrics === undefined)
// 收到 PATCH 不等于响应已渲染:等新版本的回执,避免页面较忙时读到上一版。
await until(async () => (await textOf(page.locator('[data-draft-rule-result]'))).includes('已保存成 v9'))
const after = await textOf(page.locator('[data-draft-rule-result]'))
check('撤销后回执写清新版本', after.includes('已保存成 v9'), after)
check('撤销记在这次会话里(提示已撤销)', await page.locator('[data-draft-rule-undo]').count() === 0)
check('全程页面没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
})
// ================================================================ 3 冲突:基准版本过期
await section('冲突:基准版本过期时保留写法选择、不自动重发,刷新详情后由人显式再点一次', async () => {
const state = makeState({
draft: draftOf(),
validations: { records: [], summary: summaryOf({ verdict: 'none', status: 'none' }) },
})
state.trial = { run_id: 'run-1', status: 'succeeded', metrics: [], queries: [] }
const seen = []
state.patch = (body) => {
seen.push(body)
if (seen.length === 1) {
// 另一个页面刚存过一版:服务端现在已经是 v8,这次写不进去
state.draft.revision = 8
state.draft.version = 8
return { status: 409, json: { detail: '这份内容基于 v7,现在已经是 v8:有人先改过。你刚才的修改还留着,'
+ '没有替你重发,确认后可以基于最新的一版再提交一次', code: 'stale_revision', current_version: 8 } }
}
const live = draftOf()
live.revision = 9
live.version = 9
live.binding = bindingOf({ status: 'aligned', reason: ALIGNED_REASON, fix: 'none', synced: true,
metric_values: { m1: clone(NEW), m2: clone(NEW) }, current: clone(NEW) })
live.metrics = live.metrics.map((metric) => ({ ...metric, ...NEW }))
live.change = receiptOf({ revision: 9, operations: body.operations.map((op) => ({ ...op, changed: true })),
binding: live.binding })
return { json: live }
}
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
const panel = rulesPanel(page)
await panel.locator('[data-draft-rule-select="time_role"]').selectOption('payment_time')
await panel.locator('[data-draft-rule-select="refund_rule"]').selectOption('exclude_full_refund')
await until(async () => (await panel.locator('[data-draft-rule-change]').count()) === 4)
await page.locator('[data-draft-rule-save]').click()
await page.locator('[data-draft-rule-stale]').waitFor({ state: 'visible' })
const stale = await textOf(page.locator('[data-draft-rule-stale]'))
check('写清你基于哪一版、服务端现在是哪一版',
stale.includes('你基于 v7') && stale.includes('服务端现在是 v8'), stale)
check('说清没有替你重发、选择还在', stale.includes('选择还在') || stale.includes('写法还在'), stale)
check('没有自动重发(只发了那一次 PATCH)', seen.length === 1, `${seen.length} 次`)
check('详情被重新读了一次(拿到新的基准版本)',
state.calls.filter((c) => c.method === 'GET' && c.path === '/ledger/drafts/d-orders').length >= 2)
check('页面上选好的写法一个字没丢',
await panel.locator('[data-draft-rule-select="time_role"]').inputValue() === 'payment_time'
&& await panel.locator('[data-draft-rule-select="refund_rule"]').inputValue() === 'exclude_full_refund')
check('预览还在(选择没被清掉)', await panel.locator('[data-draft-rule-change]').count() === 4,
String(await panel.locator('[data-draft-rule-change]').count()))
await page.locator('[data-draft-rule-save]').click()
await until(async () => seen.length === 2)
check('第二次用的是刷新后的版本(v8)', seen[1].expected_revision === 8, String(seen[1].expected_revision))
check('同内容重试用同一个请求键(服务端认得出是同一件事)',
seen[0].request_id === seen[1].request_id, `${seen[0].request_id} / ${seen[1].request_id}`)
await page.locator('[data-draft-rule-result]').waitFor({ state: 'visible' })
check('第二次存进去了(回执写清新版本)',
(await textOf(page.locator('[data-draft-rule-result]'))).includes('已保存成 v9'),
await textOf(page.locator('[data-draft-rule-result]')))
check('全程页面没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
})
// ================================================================ 4 复杂:同步不了
await section('复杂:同步不了的改动照实说原因和技术待办,不写「已同步」', async () => {
const state = makeState({
draft: draftOf({ binding: { status: 'unsupported', reason: UNSUPPORTED_REASON, fix: 'author' } }),
validations: { records: [], summary: summaryOf({ verdict: 'stale', status: 'observed', covers: false }) },
})
state.trial = { run_id: 'run-1', status: 'succeeded', metrics: [], queries: [] }
state.patch = (body) => {
const live = draftOf({ binding: { status: 'unsupported', reason: UNSUPPORTED_REASON, fix: 'author' } })
live.revision = 8
live.version = 8
live.metrics = live.metrics.map((metric) => ({ ...metric, time_role: 'payment_time' }))
live.binding = bindingOf({ status: 'unsupported', reason: UNSUPPORTED_REASON, fix: 'author', synced: false,
metric_values: { m1: { ...clone(OLD), time_role: 'payment_time' }, m2: { ...clone(OLD), time_role: 'payment_time' } } })
live.change = receiptOf({ revision: 8, metrics: ['m1', 'm2'],
operations: body.operations.map((op) => ({ ...op, changed: true })), binding: live.binding })
return { json: live }
}
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
const panel = rulesPanel(page)
check('同步不了的状态照实写在面板上', await panel.getAttribute('data-draft-rules') === 'unsupported',
await panel.getAttribute('data-draft-rules'))
const reason = await textOf(panel)
check('给出具体原因(这条取数没有这个业务字段的落点)', reason.includes('没有「统计时间口径」的落点'), reason.slice(0, 240))
check('给出技术待办', reason.includes('技术待办') && reason.includes('要作者侧补或改受控绑定'), reason.slice(0, 240))
await panel.locator('[data-draft-rule-select="time_role"]').selectOption('payment_time')
await until(async () => (await panel.locator('[data-draft-rule-change]').count()) === 2)
check('复杂资产照样能提交(定义照常保存,取数不动)',
!(await panel.locator('[data-draft-rule-save]').isDisabled()))
await panel.locator('[data-draft-rule-save]').click()
await page.locator('[data-draft-rule-result]').waitFor({ state: 'visible' })
const result = await textOf(page.locator('[data-draft-rule-result]'))
check('回执说「定义已经存下来了、取数一个字没动」', result.includes('定义已经存下来了,取数一个字没动'), result)
check('回执不说「已同步」', !result.includes('定义和取数已经在同一个版本里改过来了'), result)
check('回执带上技术待办', await page.locator('[data-draft-rule-fix]').count() === 1,
await textOf(page.locator('[data-draft-rule-fix]')))
check('回执里绑定状态照实是 unsupported',
await page.locator('[data-draft-rule-binding]').getAttribute('data-draft-rule-binding') === 'unsupported',
await page.locator('[data-draft-rule-binding]').getAttribute('data-draft-rule-binding'))
check('全程页面没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
})
// ================================================================ 5 独立样例、审阅与「不算通过」
await section('验证:独立期望试运行、样例对得上、审阅确认,且没有期望时如实说只记了试跑结果', async () => {
const observed = recordOf({ status: 'observed' })
const state = makeState({
draft: draftOf(),
validations: { records: [observed], summary: summaryOf({ verdict: 'observed', status: 'observed', record: observed }) },
})
state.trial = { run_id: 'run-1', status: 'succeeded', metrics: [
{ id: 'm1', name: '订单数', value: 3, rendered: '3' }, { id: 'm2', name: '订单金额', value: 900, rendered: '900' }],
queries: [] }
let matched = null
state.trial_ = (body) => {
state.trialBody = body
matched = recordOf({ id: 'vr-2', status: 'matched', ok: true,
expected: { source: 'fixture', metrics: { m1: 2, m2: 500 }, hash: 'xh-1', note: null } })
state.draft = { ...draftOf(), trial: { run_id: 'run-2', status: 'succeeded', version: 7 } }
state.validations = { records: [matched], summary: summaryOf({ verdict: 'unverified', status: 'matched', record: matched }) }
return { json: { run_id: 'run-2', status: 'succeeded', metrics: [], queries: [] } }
}
state.review = (recordId, body) => {
state.reviewBody = { recordId, body }
const review = { id: 'rv-1', draft_id: 'd-orders', record_id: recordId, workflow_id: 'wf-orders',
decision: body.decision, actor: 'alice', verified: true, scope: ['m1', 'm2'],
basis: { version: 7, definition_hash: 'dh-1' }, note: body.note ?? null,
created_at: '2026-11-01T04:00:00+00:00', covers_current: true }
matched = { ...matched, review }
state.validations = { records: [matched],
summary: { ...summaryOf({ verdict: body.decision === 'confirmed' ? 'verified' : 'rejected', status: 'matched', record: matched }),
review,
// 服务端按确认范围算出来的那两份:确认到了全部指标,所以是整卡通过
confirmed: body.decision === 'confirmed' ? ['m1', 'm2'] : [],
unconfirmed: body.decision === 'confirmed' ? [] : ['m1', 'm2'] } }
state.draft = { ...state.draft, validation: state.validations.summary }
return { json: { ok: true, draft_id: 'd-orders', record: matched } }
}
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
const panel = validationPanel(page)
await panel.waitFor({ state: 'visible' })
check('验证一块画出来了', await panel.count() === 1)
check('没有独立期望的记录如实标成「只记了试运行结果」',
await panel.getAttribute('data-draft-validation') === 'observed',
await panel.getAttribute('data-draft-validation'))
const first = page.locator('[data-draft-validation-record="vr-1"]')
check('那条记录写清没有独立期望、不能算验证通过',
(await textOf(first)).includes('这条没有独立期望:只记试运行结果,不能算验证通过'), await textOf(first))
check('那一条不写「对得上」', !(await textOf(first.locator('[data-record-actual]').first())).includes('对得上'),
await textOf(first.locator('[data-record-actual]').first()))
check('总结论不是「验证通过」',
await panel.getAttribute('data-draft-validation') === 'observed'
&& !claimsVerified(await textOf(panel.locator('[data-draft-validation-verdict]'))),
await panel.getAttribute('data-draft-validation'))
check('字段基线照实写', (await textOf(panel.locator('[data-draft-baseline]'))).includes('有可还原的导入基线'),
await textOf(panel.locator('[data-draft-baseline]')))
// 带独立期望试运行
await panel.locator('[data-draft-expect] summary').click()
check('要逐条给全卡指标才让提交(还没填时禁用 + 写明)',
await panel.locator('[data-draft-expect-submit]').isDisabled()
&& await panel.locator('[data-draft-expect-needall]').count() === 1)
await panel.locator('[data-draft-expect-input="m1"]').fill('2')
await panel.locator('[data-draft-expect-input="m2"]').fill('500')
await panel.locator('[data-draft-expect-source]').selectOption('fixture')
await until(async () => !(await panel.locator('[data-draft-expect-submit]').isDisabled()))
check('期望填全了才让提交', !(await panel.locator('[data-draft-expect-submit]').isDisabled()))
// 入口字段是必填:先填上再试(缺参数的服务端不会发查询)
await page.locator('[data-draft-entry-input="start"]').fill('2026-10-01')
await page.locator('[data-draft-entry-input="end"]').fill('2026-11-01')
await panel.locator('[data-draft-expect-submit]').click()
await until(async () => !!state.trialBody)
check('试运行带上独立期望(逐条给全卡指标)',
state.trialBody?.expect?.metrics?.m1 === 2 && state.trialBody?.expect?.metrics?.m2 === 500
&& state.trialBody?.expect?.source === 'fixture', JSON.stringify(state.trialBody?.expect))
check('试运行只发试运行请求,入口字段的写法不创建定义版本(没有 PATCH)',
patchBodies(state).length === 0, patchBodies(state).length + ' 次 PATCH')
await page.locator('[data-draft-validation-record="vr-2"]').waitFor({ state: 'visible' })
const second = page.locator('[data-draft-validation-record="vr-2"]')
check('新记录是「和独立期望对得上」', await second.getAttribute('data-record-status') === 'matched',
await second.getAttribute('data-record-status'))
const secondText = await textOf(second)
check('记录里写清期望来自哪里、期望值和实际值',
secondText.includes('固定夹具') && secondText.includes('2') && secondText.includes('500'), secondText)
check('实际值那一条判「对得上」',
await second.locator('[data-record-actual="m1"]').getAttribute('data-record-actual-ok') === 'true',
await second.locator('[data-record-actual="m1"]').getAttribute('data-record-actual-ok'))
check('样例对得上但还没审阅:总结论是「还不能算验证通过」(按 data 属性核,不算通过)',
await panel.getAttribute('data-draft-validation') === 'unverified'
&& (await textOf(panel.locator('[data-draft-validation-verdict]'))) === '还不能算验证通过',
await panel.getAttribute('data-draft-validation'))
check('并写明样例匹配不等于正式批准',
(await textOf(panel.locator('[data-draft-validation-reason]'))).includes('样例匹配不等于正式批准'),
await textOf(panel.locator('[data-draft-validation-reason]')))
// 审阅:只调真接口,请求体里没有 actor
await panel.locator('[data-draft-review-note="vr-2"]').fill('对过业务口径和样例来源')
await second.locator('[data-draft-review="confirmed"]').click()
await until(async () => !!state.reviewBody)
check('审阅调的是那份记录的审阅接口',
state.reviewBody?.recordId === 'vr-2' && state.reviewBody?.body?.decision === 'confirmed',
JSON.stringify(state.reviewBody))
check('请求体里没有审批人(代写不了)', !('actor' in (state.reviewBody?.body ?? {})),
JSON.stringify(state.reviewBody?.body))
await until(async () => (await textOf(second.locator('[data-record-review-line]'))).includes('alice'))
const reviewLine = await textOf(second.locator('[data-record-review-line]'))
check('审阅结果照服务端交回的说(谁、认证状态、范围)',
reviewLine.includes('确认') && reviewLine.includes('由 alice') && reviewLine.includes('订单数'), reviewLine)
// 结论一律按 data-draft-validation 核:文本里出现「验证通过」四个字不算数(否定说法里也有这四个字)
await until(async () => (await panel.getAttribute('data-draft-validation')) === 'verified')
check('有独立期望 + 认证审阅之后结论才是「验证通过」',
await panel.getAttribute('data-draft-validation') === 'verified'
&& (await textOf(panel.locator('[data-draft-validation-verdict]'))) === '验证通过',
await panel.getAttribute('data-draft-validation'))
check('验证一块里也不出现取数语句和节点编号',
!/SELECT|\[\[|nodes\.|data\.config/.test(await textOf(panel)), (await textOf(panel)).slice(0, 200))
check('全程页面没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
})
// ================================================================ 6 只读资产
await section('只读:能力描述为空、认不出唯一口径卡、没有版本号的资产不开表单', async () => {
const unbound = draftOf({ binding: { status: 'unbound', reason: '这张卡没有指标声明统计时间口径和退款规则:'
+ '按旧资产处理,不判绑定同步', fix: 'none', metric_values: {}, current: {} } })
const state = makeState({
draft: unbound,
validations: { records: [], summary: summaryOf({ verdict: 'none', status: 'none' }) },
})
state.trial = { run_id: 'run-1', status: 'succeeded', metrics: [], queries: [] }
const { page, errors } = await pageOn(state)
await expand(page, unbound)
const panel = rulesPanel(page)
check('旧资产照实标 unbound', await panel.getAttribute('data-draft-rules') === 'unbound',
await panel.getAttribute('data-draft-rules'))
check('不开表单(一个下拉都没有)', await panel.locator('[data-draft-rule-select]').count() === 0)
check('说清为什么不能改',
(await textOf(panel.locator('[data-draft-rules-readonly]'))).includes('没有声明统计时间口径和退款处理'),
await textOf(panel.locator('[data-draft-rules-readonly]')))
check('旧资产不会因为少一个字段就报错', errors.length === 0, errors.slice(0, 2).join(';'))
})
await section('只读:没有取数图的老草稿不画业务规则一块', async () => {
const bare = { id: 'd-bare', title: '老草稿', status: 'path_pending', stage: 'review', metric_count: 1,
question_count: 0, workflow_id: null, workflow_name: null, created_at: '2026-10-01T02:00:00+00:00', error: null,
metrics: [{ id: 'm1', name: '订单数' }], revision: null, card_id: null, cards: [] }
const state = makeState({
draft: bare,
validations: { records: [], summary: summaryOf({ verdict: 'none', status: 'none' }) },
})
state.trial = { run_id: '', status: 'needs_input', metrics: [], queries: [] }
const { page, errors } = await pageOn(state)
await expand(page, bare)
check('老草稿上没有业务规则一块', await rulesPanel(page).count() === 0)
check('验证一块照常给出(还没有记录)',
(await textOf(validationPanel(page))).includes('还没有验证记录'), await textOf(validationPanel(page)))
check('全程页面没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
})
// ================================================================ 7 查询参数
await section('查询参数:入口字段的变化只发试运行,不发 PATCH、不新增定义版本', async () => {
const state = makeState({
draft: draftOf(), validations: { records: [], summary: summaryOf({ verdict: 'none', status: 'none' }) },
})
state.trial = { run_id: 'run-1', status: 'succeeded', metrics: [], queries: [] }
state.trial_ = (body) => {
state.trialBody = body
return { json: { run_id: 'run-2', status: 'succeeded', metrics: [], queries: [] } }
}
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
await page.locator('[data-draft-entry-input="start"]').fill('2026-10-01')
await page.locator('[data-draft-entry-input="end"]').fill('2026-11-01')
await page.locator('[data-draft-trial-run-button]').click()
await until(async () => !!state.trialBody)
check('只发试运行,带填好的入口字段',
state.trialBody?.inputs?.start === '2026-10-01' && state.trialBody?.inputs?.end === '2026-11-01',
JSON.stringify(state.trialBody?.inputs))
check('入口字段变化不触发字段操作(没有 PATCH)', patchBodies(state).length === 0,
JSON.stringify(patchBodies(state)))
check('试运行请求里没有期望(这一条只记试运行结果)', state.trialBody?.expect === undefined)
check('全程页面没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
})
// ================================================================ 8 契约防御
await section('契约防御:试跑响应里的 validation 只有记录那一段时,不当成质量通过,覆盖判断未知照实写', async () => {
const own = recordOf({ id: 'vr-9', status: 'matched', ok: true,
expected: { source: 'human_confirmed', metrics: { m1: 2, m2: 500 }, hash: 'xh-9' } })
own.covers_current = null // 读数缺这一段:判不了,不许当成覆盖
const state = makeState({
draft: draftOf(),
// 总结论照着这条记录说;覆盖判断也是未知
validations: { records: [own],
summary: { ...summaryOf({ verdict: 'unverified', status: 'matched', record: own }), covers_current: null } },
})
// 成功分支、但 validation 里只有记录那一段:没有 status、没有 checks
state.trial = { run_id: 'run-9', status: 'succeeded', metrics: [{ id: 'm1', name: '订单数', value: 2, rendered: '2' }],
queries: [], validation: { record: own, covers_current: null,
note: '试跑结果只说明这一次跑成什么样;它还算不算覆盖当前定义/绑定看 covers_current' } }
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
const trialBlock = page.locator('[data-draft-trial="succeeded"]')
const trialText = await textOf(trialBlock)
check('读不到质量状态时不写「质量检查通过」', !trialText.includes('质量检查通过'), trialText)
check('照实说质量结论还没有', !trialText.includes('质量检查未通过') && trialText.includes('尚未完成质量验证'), trialText)
await trialBlock.locator('[data-draft-trial-record]').waitFor({ state: 'visible' })
check('试运行块里只画这一次的那条记录',
await trialBlock.locator('[data-draft-trial-record="matched"]').count() === 1,
await trialBlock.locator('[data-draft-trial-record]').count() + ' 条')
check('覆盖判断未知就写未知,不当成覆盖',
await trialBlock.locator('[data-draft-trial-record]').getAttribute('data-trial-record-covers') === 'unknown'
&& (await textOf(trialBlock.locator('[data-draft-trial-record]'))).includes('覆盖判断未知'),
await textOf(trialBlock.locator('[data-draft-trial-record]')))
check('试运行块里不写「验证通过」', !claimsVerified(trialText), trialText)
check('那一条写明独立期望来自哪里',
(await textOf(trialBlock.locator('[data-draft-trial-record]'))).includes('独立期望:人工确认'),
await textOf(trialBlock.locator('[data-draft-trial-record]')))
const panel = validationPanel(page)
check('面板上的覆盖判断也写未知(只有明确说覆盖才算覆盖)',
await page.locator('[data-record-covers]').first().getAttribute('data-record-covers') === 'unknown')
check('这条记录不写「覆盖当前版本」(按 data 属性核,不看否定说法里的四个字)',
await panel.locator('[data-draft-validation-record="vr-9"] [data-record-covers]')
.getAttribute('data-record-covers') === 'unknown',
await panel.locator('[data-draft-validation-record="vr-9"] [data-record-covers]').getAttribute('data-record-covers'))
check('结论说清是按哪一条记录下的',
(await textOf(panel.locator('[data-draft-validation-basis]'))).includes('结论按')
&& (await textOf(panel.locator('[data-draft-validation-basis]'))).includes('和独立期望对得上'),
await textOf(panel.locator('[data-draft-validation-basis]')))
check('全程页面没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
})
await section('契约防御:试跑还在跑又没有本次记录时只写等本次验证,不拿旧记录充当本次', async () => {
const old = recordOf({ id: 'vr-old', status: 'observed', covers: false })
const state = makeState({
draft: draftOf({ draft: { trial: { run_id: 'run-10', status: 'running', version: 7 } } }),
validations: { records: [old], summary: summaryOf({ verdict: 'stale', status: 'observed', record: old, covers: false }) },
})
state.trial = { run_id: 'run-10', status: 'running', metrics: [], queries: [],
validation: { record: null, covers_current: false,
note: '这次试跑还没有留下验证记录:缺证据不能算验证通过' } }
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
const trialBlock = page.locator('[data-draft-trial="running"]')
await trialBlock.locator('[data-draft-trial-waiting]').waitFor({ state: 'visible' })
check('写明正在等本次验证记录',
(await textOf(trialBlock.locator('[data-draft-trial-waiting]'))).includes('正在等本次的验证记录'),
await textOf(trialBlock.locator('[data-draft-trial-waiting]')))
check('试运行块里没有画出任何验证记录(旧记录不充当本次)',
await trialBlock.locator('[data-draft-trial-record]').count() === 0)
const trialText = await textOf(trialBlock)
check('试运行块里不写旧记录的状态、也不写通过',
!trialText.includes('只记了试运行结果') && !claimsVerified(trialText), trialText)
const panel = validationPanel(page)
check('旧记录仍然只摆在自己的位置(面板里,标不覆盖当前版本)',
await panel.locator('[data-draft-validation-record="vr-old"]').count() === 1
&& await page.locator('[data-record-covers="no"]').count() >= 1)
check('结论按旧记录说是过期,不写成通过',
await panel.getAttribute('data-draft-validation') === 'stale'
&& (await textOf(panel.locator('[data-draft-validation-verdict]'))) === '现有记录不覆盖这一版',
await panel.getAttribute('data-draft-validation'))
check('全程页面没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
})
await section('契约防御:部分确认不能写成整卡通过(按范围收回结论)', async () => {
const partialReview = { id: 'rv-3', draft_id: 'd-orders', record_id: 'vr-3', workflow_id: 'wf-orders',
decision: 'confirmed', actor: 'alice', verified: true, scope: ['m1'], basis: { version: 7 },
note: '只确认了订单数这一条', created_at: '2026-11-01T05:00:00+00:00', covers_current: true }
const record = recordOf({ id: 'vr-3', status: 'matched', ok: true,
expected: { source: 'fixture', metrics: { m1: 2, m2: 500 }, hash: 'xh-3' }, review: partialReview })
const state = makeState({
draft: draftOf(),
// 服务端(还没改好之前)照样回 verified:页面要按范围把它收回来
validations: { records: [record], summary: summaryOf({ verdict: 'verified', status: 'matched', record }) },
})
state.trial = { run_id: 'run-1', status: 'succeeded', metrics: [], queries: [] }
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
const panel = validationPanel(page)
check('部分确认时面板不标成验证通过', await panel.getAttribute('data-draft-validation') === 'unverified',
await panel.getAttribute('data-draft-validation'))
check('按范围收回了结论', await panel.getAttribute('data-validation-partial') === 'recalled',
await panel.getAttribute('data-validation-partial'))
const verdict = await textOf(panel.locator('[data-draft-validation-verdict]'))
check('结论写「还不能算验证通过」(data 属性也不是 verified)',
verdict === '还不能算验证通过' && await panel.getAttribute('data-draft-validation') === 'unverified', verdict)
const note = await textOf(panel.locator('[data-draft-validation-partial]'))
check('写清盖住了哪条、没盖住哪条、一共几条',
note.includes('订单数') && note.includes('订单金额') && note.includes('2 条指标')
&& note.includes('不能算整卡验证通过'), note)
const recordText = await textOf(page.locator('[data-draft-validation-record="vr-3"]'))
check('记录上写明确认范围(订单数)并指出没盖住全部指标',
recordText.includes('确认范围:订单数') && recordText.includes('没有盖住这条记录的全部指标'), recordText)
check('审阅自己的覆盖判断照实写',
await page.locator('[data-record-review-covers]').first().getAttribute('data-record-review-covers') === 'yes')
check('全程页面没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
// 服务端已经按确认范围收回结论(现在这一版就是这样):名字用服务端列出来的那份,页面上也不写通过
const fixedSummary = { ...summaryOf({ verdict: 'unverified', status: 'matched', record }), confirmed: ['m1'],
unconfirmed: ['m2'] }
const second = makeState({ draft: draftOf(), validations: { records: [record], summary: fixedSummary } })
second.trial = { run_id: 'run-1', status: 'succeeded', metrics: [], queries: [] }
const secondPage = await pageOn(second)
await expand(secondPage.page, second.draft)
const fixedPanel = validationPanel(secondPage.page)
check('服务端按范围收回时页面上也不是验证通过(标成范围没盖住)',
await fixedPanel.getAttribute('data-validation-partial') === 'scope',
await fixedPanel.getAttribute('data-validation-partial'))
check('这一页也不写成整卡通过',
await fixedPanel.getAttribute('data-draft-validation') === 'unverified'
&& (await textOf(fixedPanel.locator('[data-draft-validation-verdict]'))) === '还不能算验证通过',
await fixedPanel.getAttribute('data-draft-validation'))
const fixedNote = await textOf(fixedPanel.locator('[data-draft-validation-partial]'))
check('按服务端列出的未确认指标说话',
fixedNote.includes('订单数') && fixedNote.includes('订单金额'), fixedNote)
check('这一页没有报错', secondPage.errors.length === 0, secondPage.errors.slice(0, 2).join(';'))
})
await section('契约防御:审阅的覆盖判断读不到时不算覆盖', async () => {
const review = { id: 'rv-4', draft_id: 'd-orders', record_id: 'vr-4', workflow_id: 'wf-orders',
decision: 'confirmed', actor: 'alice', verified: true, scope: ['m1', 'm2'], basis: { version: 7 },
note: null, created_at: '2026-11-01T06:00:00+00:00', covers_current: null }
const record = recordOf({ id: 'vr-4', status: 'matched', ok: true,
expected: { source: 'fixture', metrics: { m1: 2, m2: 500 }, hash: 'xh-4' }, review })
const state = makeState({
draft: draftOf(),
validations: { records: [record], summary: { ...summaryOf({ verdict: 'unverified', status: 'matched', record }), review } },
})
state.trial = { run_id: 'run-1', status: 'succeeded', metrics: [], queries: [] }
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
check('审阅的覆盖判断未知就写未知',
await page.locator('[data-record-review-covers]').first().getAttribute('data-record-review-covers') === 'unknown'
&& (await textOf(page.locator('[data-record-review-covers]').first())).includes('覆盖判断未知'),
await textOf(page.locator('[data-record-review-covers]').first()))
check('不写成「这次审阅覆盖当前版本」',
await page.locator('[data-draft-validation-record="vr-4"] [data-record-review-covers]')
.getAttribute('data-record-review-covers') !== 'yes',
await page.locator('[data-draft-validation-record="vr-4"] [data-record-review-covers]').getAttribute('data-record-review-covers'))
check('全程页面没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
})
// ================================================================ 9 增量重导入:三方、接受、漏项
await section('重导入:三方的值逐格摆出来,接受是无冲突更新里唯一能接受的格子,漏提取不当删除也不推进基线', async () => {
const item1 = importItem({
fields: [
importField('/definition/text', 'update', { b: '按已付款订单统计', l: '按已付款订单统计', r: '按已付款订单统计(退款另计)' }),
importField('/unit', 'missing', { b: '元', l: '元' }),
],
})
const run = importRun({ items: [item1] })
const state = makeState({
draft: draftOf(),
validations: { records: [], summary: summaryOf({ verdict: 'none', status: 'none' }) },
})
state.trial = { run_id: 'run-1', status: 'succeeded', metrics: [], queries: [] }
state.baseline = { ...BASELINE, coverage: { status: 'partial', metrics: { m1: { covered: 1, expected: 2, missing: ['/unit'] } }, uncovered_metric_ids: ['m2'] } }
state.imports = [run]
state.createImport_ = () => ({ json: importRun({ run_id: 'im-2', status: 'queued', items: [] }) })
const accepts = []
state.accept = (runId, body) => {
accepts.push({ runId, body })
const done = importItem({
accepted: ['/definition/text'], status: 'accepted', applied_revision: 8,
fields: [
importField('/definition/text', 'unchanged', { b: '按已付款订单统计(退款另计)', l: '按已付款订单统计(退款另计)',
r: '按已付款订单统计(退款另计)', decided: 'accepted' }),
importField('/unit', 'missing', { b: '元', l: '元' }),
],
})
return { json: acceptOut(importRun({ items: [done], items_decided: 1 }), 8) }
}
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
const panel = page.locator('[data-draft-imports]')
await until(async () => (await panel.getAttribute('data-draft-imports')) === 'succeeded')
check('文档更新一块画出来了,最近那一次导入读完了就摆出明细',
await panel.getAttribute('data-draft-imports') === 'succeeded', await panel.getAttribute('data-draft-imports'))
check('没接着后端写:导入现状是按列表接口答的',
state.calls.filter((c) => c.method === 'GET' && c.path === '/ledger/drafts/d-orders/imports').length === 1)
// 打开这一条记录看明细
await page.locator('[data-draft-import-run="im-1"]').click()
await page.locator('[data-draft-import-detail]').waitFor({ state: 'visible' })
const field = page.locator('[data-draft-import-field="it-1:/definition/text"]')
check('逐格写出三方的值(上次被接受的、现在的内容、这次文档里的说法)',
await field.locator('[data-draft-import-value="b"]').count() === 1
&& await field.locator('[data-draft-import-value="l"]').count() === 1
&& await field.locator('[data-draft-import-value="r"]').count() === 1,
await textOf(field))
const fieldText = await textOf(field)
check('三方各带给人看的说法,B / L / R 不写成缩写',
fieldText.includes('上次被接受的导入值') && fieldText.includes('现在的内容') && fieldText.includes('这次文档里的说法'),
fieldText)
check('无冲突更新写清状态和能不能接受',
await field.getAttribute('data-import-field-state') === 'update' && fieldText.includes('无冲突更新')
&& fieldText.includes('上次接受之后没人动过'), fieldText)
// 漏提取的那一格:明确说不当删除,而且接受是禁用的
const missing = page.locator('[data-draft-import-field="it-1:/unit"]')
const missingText = await textOf(missing)
check('漏提取的那一格不当删除:写清这次没提取到、现在的内容原样留着',
await missing.getAttribute('data-import-field-state') === 'missing'
&& missingText.includes('这次没提取到') && missingText.includes('漏提取不当删除')
&& !missingText.includes('删除这一格'), missingText)
check('漏提取的格子不能接受(接受是禁用的)',
await missing.locator('[data-draft-import-pick-accept="it-1:/unit"]').isDisabled())
check('这一条上只有 1 格能接受,一次收掉的按钮按它算',
(await textOf(page.locator('[data-draft-import-accept-clean="it-1"]'))).includes('无冲突的 1 格'),
await textOf(page.locator('[data-draft-import-accept-clean="it-1"]')))
// 没选格子时接受按钮是禁用的
check('没选格子时「接受选中的 0 格」是禁用的', await page.locator('[data-draft-import-accept]').isDisabled())
// 选那一格接受,提交
await field.locator('[data-draft-import-pick-accept="it-1:/definition/text"]').check()
await until(async () => !(await page.locator('[data-draft-import-accept]').isDisabled()))
check('选中之后按钮写清接受几格',
(await textOf(page.locator('[data-draft-import-accept]'))).includes('接受选中的 1 格'),
await textOf(page.locator('[data-draft-import-accept]')))
await page.locator('[data-draft-import-accept]').click()
await until(async () => accepts.length === 1)
const acceptBody = accepts[0].body
check('接受按工作项身份(item_id)指认,只提交勾中的那一格、明确列出字段路径',
accepts[0].runId === 'im-1' && acceptBody.items?.length === 1
&& acceptBody.items[0].item_id === 'it-1' && acceptBody.items[0].metric_id === 'm1'
&& JSON.stringify(acceptBody.items[0].fields) === JSON.stringify(['/definition/text'])
&& acceptBody.items[0].auto === undefined,
JSON.stringify(acceptBody.items))
check('接受带上读到的基准版本(v7)', acceptBody.expected_revision === 7, String(acceptBody.expected_revision))
check('接受带上幂等键', typeof acceptBody.request_id === 'string' && acceptBody.request_id.length > 0,
String(acceptBody.request_id))
const result = page.locator('[data-draft-import-result]')
await result.waitFor({ state: 'visible' })
check('接受之后回执写清落到哪一版', (await textOf(result)).includes('落到 v8'), await textOf(result))
check('回执写清图和基线是同一次提交里推进的、没接受的格子不推进',
(await textOf(result)).includes('没有接受的格子不会推进基线'), await textOf(result))
check('接受之后刷新验证状态(字段基线跟着再取一次)',
state.calls.filter((c) => c.method === 'GET' && c.path === '/ledger/drafts/d-orders/validations').length >= 2)
check('接受之后详情重新取了一次(拿到新的基准版本)',
state.calls.filter((c) => c.method === 'GET' && c.path === '/ledger/drafts/d-orders').length >= 2)
check('接受之后这次导入的现状也刷新了(格子标成已接受)',
(await textOf(page.locator('[data-draft-import-item="m1"]'))).includes('已接受'))
check('页面没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
// 第一次读出来的覆盖:有基线不等于整张都有,partial 要说出来
const coverage = page.locator('[data-draft-import-coverage]')
await until(async () => await coverage.getAttribute('data-draft-import-coverage') === 'partial')
check('覆盖状态照服务端说是 partial', await coverage.getAttribute('data-draft-import-coverage') === 'partial',
await coverage.getAttribute('data-draft-import-coverage'))
check('写清还有几格、几条指标没有基线', (await textOf(coverage)).includes('还有 1 格没有基线')
&& (await textOf(coverage)).includes('1 条指标整条没有基线'), await textOf(coverage))
})
// ================================================================ 10 重新读取一次
await section('重新读取:按文档重读是显式动作、带幂等键,抽的过程中跟着轮询这一次的现状', async () => {
const state = makeState({
draft: draftOf(),
validations: { records: [], summary: summaryOf({ verdict: 'none', status: 'none' }) },
})
state.trial = { run_id: 'run-1', status: 'succeeded', metrics: [], queries: [] }
state.imports = []
const creates = []
state.createImport_ = (body) => {
creates.push(body)
return { status: 201, json: importRun({ run_id: 'im-9', status: 'running', items: [] }) }
}
const polls = []
state.getImport = (runId) => {
polls.push(runId)
return { json: importRun({ run_id: runId, status: 'succeeded',
items: [importItem({ fields: [importField('/unit', 'update', { b: '元', l: '元', r: '万元' })] })] }) }
}
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
check('还没有记录时说清还没有重导入记录',
(await textOf(page.locator('[data-draft-imports]'))).includes('还没有重导入记录'),
await textOf(page.locator('[data-draft-imports]')))
check('没有记录时不摆明细块', await page.locator('[data-draft-import-detail]').count() === 0)
check('重读之前没有写过任何东西(只有读接口)',
state.calls.every((call) => call.method === 'GET'), JSON.stringify(state.calls.filter((c) => c.method !== 'GET')))
await page.locator('[data-draft-import-create]').click()
await until(async () => creates.length === 1)
check('重读带幂等键(同一份草稿 + 同一个键只建一次)',
typeof creates[0].request_id === 'string' && creates[0].request_id.length > 0, JSON.stringify(creates[0]))
check('重读只发一次(没重复建)', creates.length === 1, `${creates.length} 次`)
await until(async () => polls.length >= 1, 8000)
check('抽的过程中跟着轮询这一次导入的现状', polls.length >= 1 && polls[0] === 'im-9', JSON.stringify(polls))
check('跑到终态时状态照服务端说',
await until(async () => (await textOf(page.locator('[data-draft-import-status]'))).includes('读完了'), 8000),
await textOf(page.locator('[data-draft-import-status]')))
check('新的这一次出现在记录列表里', await page.locator('[data-draft-import-run="im-9"]').count() === 1)
check('页面没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
})
// ================================================================ 11 人工冲突 / 同名不对齐 / 拒绝
await section('冲突:同一格人工改过时按状态说清,接受取文档的、拒绝留现在的;同名不同范围要人来定对齐', async () => {
const conflict = importItem({
item_id: 'it-9', name: '订单金额', metric_id: 'm2',
fields: [
importField('/unit', 'conflict', { b: '元', l: '万元', r: '元' }),
importField('/grain', 'update', { b: '订单行', l: '订单行', r: '订单行(明细)' }),
],
})
const manual = importItem({
item_id: 'it-8', seq: 2, name: '订单数', metric_id: 'm1',
align: { metric_id: 'm1', mode: 'manual', evidence: ['ch-2'], live_evidence: ['ch-1'],
reason: '候选带着和图上一样的 metric_id,但引的原文不是同一段(同名不同范围):请人工确认' },
fields: [importField('/unit', 'manual', { l: '单' })],
})
const state = makeState({
draft: draftOf(),
validations: { records: [], summary: summaryOf({ verdict: 'none', status: 'none' }) },
})
state.trial = { run_id: 'run-1', status: 'succeeded', metrics: [], queries: [] }
state.imports = [importRun({ items: [conflict, manual] })]
const rejects = []
state.reject = (runId, body) => {
rejects.push({ runId, body })
const after = importItem({ item_id: 'it-9', name: '订单金额', metric_id: 'm2',
status: 'partial', rejected: ['/grain'],
fields: [
importField('/unit', 'conflict', { b: '元', l: '万元', r: '元' }),
importField('/grain', 'update', { b: '订单行', l: '订单行', r: '订单行(明细)', decided: 'rejected' }),
] })
return { json: importRun({ items: [after, manual] }) }
}
const aligns = []
state.align = (runId, itemId, body) => {
aligns.push({ runId, itemId, body })
const fixed = importItem({ item_id: 'it-8', seq: 2, name: '订单数', metric_id: '',
align: { metric_id: '', mode: 'new', reason: '没有对得上的指标:请人工确认' },
fields: [importField('/unit', 'new', { r: '单' })] })
return { json: importRun({ items: [conflict, fixed] }) }
}
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
await page.locator('[data-draft-import-run="im-1"]').click()
await page.locator('[data-draft-import-detail]').waitFor({ state: 'visible' })
const conflictCell = page.locator('[data-draft-import-field="it-9:/unit"]')
const conflictText = await textOf(conflictCell)
check('人工修改冲突照状态写出来', await conflictCell.getAttribute('data-import-field-state') === 'conflict'
&& conflictText.includes('人工修改冲突'), conflictText)
check('写清接受取文档的、拒绝留现在的内容',
conflictText.includes('接受取文档的,拒绝就留你现在的内容'), conflictText)
check('三方的值都在(元 / 万元 / 元)',
(await textOf(conflictCell.locator('[data-draft-import-value="l"]'))).includes('万元')
&& (await textOf(conflictCell.locator('[data-draft-import-value="r"]'))).includes('元'), conflictText)
check('冲突那一格能接受(要人点名,不自动收)',
!(await conflictCell.locator('[data-draft-import-pick-accept="it-9:/unit"]').isDisabled()))
// 同名不同范围:身份对得上但引的原文不是同一段 → 要人来定,页面上给得出对齐入口
const manualItem = page.locator('[data-draft-import-item="m1"]')
const manualText = await textOf(manualItem)
check('同名不同范围照实说是要人来定', await page.locator('[data-draft-import-reason="manual"]').count() === 1
&& manualText.includes('同名不同范围'), manualText)
check('这种情形也给得出人工对齐的入口',
await manualItem.locator('[data-draft-import-align-select="it-8"]').count() === 1)
await manualItem.locator('[data-draft-import-align-select="it-8"]').selectOption('m2')
await manualItem.locator('[data-draft-import-align-submit="it-8"]').click()
await until(async () => aligns.length === 1)
check('对齐把人工选的那条指标发给服务端',
aligns[0].runId === 'im-1' && aligns[0].itemId === 'it-8' && aligns[0].body.metric_id === 'm2',
JSON.stringify(aligns[0]))
// 拒绝:只拒勾中的那一格,保持现在的内容不动
await page.locator('[data-draft-import-pick-reject="it-9:/grain"]').check()
await until(async () => !(await page.locator('[data-draft-import-reject]').isDisabled()))
await page.locator('[data-draft-import-reject]').click()
await until(async () => rejects.length === 1)
check('拒绝按工作项身份(item_id)指认,只提交勾中的那一格',
rejects[0].runId === 'im-1' && rejects[0].body.items?.length === 1
&& rejects[0].body.items[0].item_id === 'it-9' && rejects[0].body.items[0].metric_id === 'm2'
&& JSON.stringify(rejects[0].body.items[0].fields) === JSON.stringify(['/grain']),
JSON.stringify(rejects[0].body.items))
check('拒绝之后写清现在的内容和基线都没动',
(await textOf(page.locator('[data-draft-import-result]'))).includes('现在的内容和字段基线都没动'),
await textOf(page.locator('[data-draft-import-result]')))
check('页面没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
})
// ================================================================ 12 没有基线:每格都要人工对齐
await section('没有基线:baseline_unknown 不假装有基线,逐格要人工对齐到现有指标,覆盖照实说读不出来', async () => {
const item = importItem({
item_id: 'it-3', metric_id: '', candidate_id: 'c3', name: '客单价', status: 'manual',
align: { metric_id: '', mode: 'manual', evidence: ['ch-3'],
reason: '这条指标没有可比的基线:请人工对齐' },
fields: [importField('/expression', 'manual', { r: 'cell(nodes.fetch, 0, \'avg\')' })],
})
const state = makeState({
draft: draftOf(),
validations: { records: [], summary: summaryOf({ verdict: 'none', status: 'none' }) },
})
state.trial = { run_id: 'run-1', status: 'succeeded', metrics: [], queries: [] }
state.baseline = { status: 'baseline_unknown', readable: false,
reason: '这份草稿没有字段基线:当时被接受的导入值无法还原,不能拿当前内容当作 B',
coverage: { status: 'unknown', metrics: {}, uncovered_metric_ids: [] } }
state.imports = [importRun({ items: [item] })]
const aligns = []
state.align = (runId, itemId, body) => {
aligns.push({ runId, itemId, body })
return { json: importRun({ items: [importItem({
item_id: 'it-3', metric_id: body.metric_id, name: '客单价', status: 'update',
fields: [importField('/expression', 'update', { b: "cell(nodes.fetch, 0, 'avg')", l: "cell(nodes.fetch, 0, 'avg')",
r: "cell(nodes.fetch, 0, 'avg_2')" })],
})] }) }
}
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
await page.locator('[data-draft-import-run="im-1"]').click()
await page.locator('[data-draft-import-detail]').waitFor({ state: 'visible' })
const coverage = page.locator('[data-draft-import-coverage]')
check('没有基线时覆盖照实说读不出来(不是 complete,也不是 partial)',
await coverage.getAttribute('data-draft-import-coverage') === 'unknown', await coverage.getAttribute('data-draft-import-coverage'))
check('写清不能拿现在的内容当作基线', (await textOf(coverage)).includes('不能拿现在的内容当作它'), await textOf(coverage))
const cell = page.locator('[data-draft-import-field="it-3:/expression"]')
check('没有可比基线的格子标成需要人工对齐',
await cell.getAttribute('data-import-field-state') === 'manual'
&& (await textOf(cell)).includes('需要人工对齐'), await textOf(cell))
check('没对齐之前不能接受(先由人指定对到哪条指标)',
await cell.locator('[data-draft-import-pick-accept="it-3:/expression"]').isDisabled())
// 逐格人工对齐:这条候选取值没有对上的指标,选一条现有的指标把它对上去
const alignSelect = page.locator('[data-draft-import-align-select="it-3"]')
check('没对齐的工作项给得出对齐到哪条指标的入口', await alignSelect.count() === 1)
check('可选的指标就是这张卡上的指标', await alignSelect.locator('option').count() === 3,
String(await alignSelect.locator('option').count()))
await alignSelect.selectOption('m2')
await page.locator('[data-draft-import-align-submit="it-3"]').click()
await until(async () => aligns.length === 1)
check('人工对齐把选中的指标发出去', aligns[0].itemId === 'it-3' && aligns[0].body.metric_id === 'm2',
JSON.stringify(aligns[0]))
check('对齐之后这一格变成有基线可比的更新',
await page.locator('[data-draft-import-field="it-3:/expression"]').getAttribute('data-import-field-state') === 'update',
await page.locator('[data-draft-import-field="it-3:/expression"]').getAttribute('data-import-field-state'))
check('对齐之后才谈得上接受',
!(await page.locator('[data-draft-import-pick-accept="it-3:/expression"]').isDisabled()))
check('页面没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
})
// ================================================================ 13 中断与恢复
await section('恢复:被中断的那一次由人点「恢复」显式续跑,已处置的不动、不重复建版', async () => {
const done = importItem({ item_id: 'it-1', status: 'accepted', accepted: ['/unit'], applied_revision: 8,
fields: [importField('/unit', 'unchanged', { b: '元', l: '元', r: '元', decided: 'accepted' })] })
const pending = importItem({ item_id: 'it-2', seq: 2, name: '订单金额', metric_id: 'm2', status: 'pending',
fields: [importField('/unit', 'update', { b: '元', l: '元', r: '万元' })] })
const state = makeState({
draft: draftOf(),
validations: { records: [], summary: summaryOf({ verdict: 'none', status: 'none' }) },
})
state.trial = { run_id: 'run-1', status: 'succeeded', metrics: [], queries: [] }
state.imports = [importRun({ status: 'interrupted', error: '服务中断,已保留导入进度,可恢复',
items: [done, pending], items_decided: 1 })]
const resumes = []
state.resume = (runId) => {
resumes.push(runId)
return { json: importRun({ status: 'queued', items: [done, pending], items_decided: 1 }) }
}
state.getImport = (runId) => ({ json: importRun({ run_id: runId, status: 'succeeded',
items: [done, pending], items_decided: 1 }) })
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
const panel = page.locator('[data-draft-imports]')
check('被中断的那一次照实说是中断了、可以恢复',
(await textOf(page.locator('[data-draft-import-status]'))).includes('可以恢复'),
await textOf(page.locator('[data-draft-import-status]')))
check('说清还有几条没做完', await page.locator('[data-draft-import-unfinished]').count() === 1
&& (await textOf(page.locator('[data-draft-import-unfinished]'))).includes('还有 1 条没做完'),
await textOf(page.locator('[data-draft-import-unfinished]')))
check('已经接受的格子标成已接受,未完成的照旧是没处置',
(await textOf(page.locator('[data-draft-import-item="m1"]'))).includes('已接受')
&& (await textOf(page.locator('[data-draft-import-item="m2"]'))).includes('还没处置'))
await page.locator('[data-draft-import-resume]').click()
await until(async () => resumes.length === 1)
check('恢复只发一次恢复请求(不重复建版、也不替人接受)',
resumes.length === 1 && resumes[0] === 'im-1'
&& importCalls(state, 'POST', '/accept').length === 0, JSON.stringify(resumes))
await until(async () => await panel.getAttribute('data-draft-imports') === 'succeeded')
check('恢复之后跟着轮询现状,直到它离开运行中', (await textOf(page.locator('[data-draft-import-status]'))).includes('读完了'),
await textOf(page.locator('[data-draft-import-status]')))
check('页面没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
})
// ================================================================ 14 基准版本过期:选择保留
await section('冲突:接受时基准版本过期,选好的格子一个不丢、不自动重发,刷新详情后由人再点一次', async () => {
const item = importItem({ fields: [importField('/unit', 'update', { b: '元', l: '元', r: '万元' })] })
const state = makeState({
draft: draftOf(),
validations: { records: [], summary: summaryOf({ verdict: 'none', status: 'none' }) },
})
state.trial = { run_id: 'run-1', status: 'succeeded', metrics: [], queries: [] }
state.imports = [importRun({ items: [item] })]
// stale 之后页面会重新读这一次导入的现状(逐字段三方按服务端现在的内容重算)
state.getImport = () => ({ json: importRun({ items: [item] }) })
const accepts = []
state.accept = (runId, body) => {
accepts.push({ runId, body })
if (accepts.length === 1) {
state.draft.revision = 8
state.draft.version = 8
return { status: 409, json: { detail: '口径已经改到 v8,你的版本是 v7:请刷新后重新核对再接受',
code: 'stale_revision', current_version: 8 } }
}
return { json: acceptOut(importRun({ items: [importItem({
fields: [importField('/unit', 'unchanged', { b: '万元', l: '万元', r: '万元', decided: 'accepted' })],
status: 'accepted', accepted: ['/unit'], applied_revision: 9 })], items_decided: 1 }), 9) }
}
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
await page.locator('[data-draft-import-run="im-1"]').click()
await page.locator('[data-draft-import-detail]').waitFor({ state: 'visible' })
await page.locator('[data-draft-import-pick-accept="it-1:/unit"]').check()
await page.locator('[data-draft-import-accept]').click()
await page.locator('[data-draft-import-stale]').waitFor({ state: 'visible' })
const stale = await textOf(page.locator('[data-draft-import-stale]'))
check('写清你基于哪一版、服务端现在是哪一版',
stale.includes('你基于 v7') && stale.includes('服务端现在是 v8'), stale)
check('说清选好的格子还在、没有替你重发', stale.includes('你选好的格子还在'), stale)
check('没有自动重发(只发了那一次接受)', accepts.length === 1, `${accepts.length} 次`)
check('详情被重新读了一次(拿到新的基准版本)',
state.calls.filter((c) => c.method === 'GET' && c.path === '/ledger/drafts/d-orders').length >= 2)
check('选好的那一格一个字没丢',
await page.locator('[data-draft-import-pick-accept="it-1:/unit"]').isChecked())
check('按钮还在、还写着接受 1 格',
!(await page.locator('[data-draft-import-accept]').isDisabled())
&& (await textOf(page.locator('[data-draft-import-accept]'))).includes('接受选中的 1 格'))
await page.locator('[data-draft-import-accept]').click()
await until(async () => accepts.length === 2)
check('第二次用的是刷新后的版本(v8)', accepts[1].body.expected_revision === 8,
String(accepts[1].body.expected_revision))
check('第二次还是按同一条工作项身份提交',
accepts[1].body.items?.[0]?.item_id === 'it-1', JSON.stringify(accepts[1].body.items))
check('同内容重试用同一个请求键(服务端认得出是同一件事)',
accepts[0].body.request_id === accepts[1].body.request_id,
`${accepts[0].body.request_id} / ${accepts[1].body.request_id}`)
await until(async () => (await page.locator('[data-draft-import-result]').count()) === 1)
check('第二次接受落到了 v9',
(await textOf(page.locator('[data-draft-import-result]'))).includes('落到 v9'),
await textOf(page.locator('[data-draft-import-result]')))
check('页面没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
})
// ================================================================ 15 一句话改口径:预览 / 澄清 / 确认
await section('一句话:先看人读的影响(不摆 JSON 和节点编号),含糊先澄清,确认之后才落一版', async () => {
const state = makeState({
draft: draftOf(),
validations: { records: [], summary: summaryOf({ verdict: 'none', status: 'none' }) },
})
state.trial = { run_id: 'run-1', status: 'succeeded', metrics: [], queries: [] }
const proposals = []
state.proposal = (body) => {
proposals.push(body)
return { json: { draft_id: 'd-orders', workflow_id: 'wf-orders', revision: 7, card: 'card',
proposal: proposalOf(), applied: false, persisted: false } }
}
const writes = []
state.nl = (body) => {
writes.push(body)
const live = draftOf()
live.revision = 8
live.version = 8
live.metrics = live.metrics.map((metric) => ({ ...metric, time_role: 'payment_time' }))
live.change = receiptOf({ revision: 8, operations: [], binding: live.binding })
return { json: live }
}
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
const panel = page.locator('[data-draft-nl]')
check('一句话改口径一块画出来了', await panel.count() === 1)
check('没预览之前没有「确认并应用」(没有确认就不写)',
await page.locator('[data-draft-nl-confirm]').count() === 0)
check('面板上写清预览不写任何东西',
(await textOf(panel)).includes('预览本身不写任何东西'), await textOf(panel))
await page.locator('[data-draft-nl-input]').fill('订单金额按支付时间算')
await page.locator('[data-draft-nl-preview]').click()
await until(async () => proposals.length === 1)
check('预览只调只读的提案接口,带上目标卡',
proposals[0].instruction === '订单金额按支付时间算' && proposals[0].target?.card_id === 'card'
&& proposals[0].target?.workflow_id === 'wf-orders', JSON.stringify(proposals[0]))
await page.locator('[data-draft-nl-changes]').waitFor({ state: 'visible' })
const changes = await textOf(page.locator('[data-draft-nl-changes]'))
check('影响按人读的说法写:哪条指标、哪一项写法、现在什么 → 保存后什么',
changes.includes('订单数') && changes.includes('订单金额') && changes.includes('统计时间口径')
&& changes.includes('现在 按下单时间') && changes.includes('保存后 按支付时间'), changes)
check('不摆 JSON、节点编号和字段路径',
!/[\{\}\[\]"]|time_role|nodes\.|metric_id|\/definition/.test(changes), changes)
check('写清会改到几处', (await textOf(page.locator('[data-draft-nl-count]'))).includes('会改到 2 处'),
await textOf(page.locator('[data-draft-nl-count]')))
check('预览不落版(没有发过一句话写请求)', writes.length === 0 && patchBodies(state).length === 0)
// 确认之后才写一版
await page.locator('[data-draft-nl-confirm]').click()
await until(async () => writes.length === 1)
check('确认之后才调 nl-change,带 confirm=true',
writes[0].confirm === true && writes[0].instruction === '订单金额按支付时间算', JSON.stringify(writes[0]))
check('确认带上读到的基准版本', writes[0].expected_revision === 7, String(writes[0].expected_revision))
check('确认带上幂等键', typeof writes[0].request_id === 'string' && writes[0].request_id.length > 0,
String(writes[0].request_id))
await page.locator('[data-draft-nl-result]').waitFor({ state: 'visible' })
check('落版之后写清落到哪一版',
(await textOf(page.locator('[data-draft-nl-result]'))).includes('落到 v8'),
await textOf(page.locator('[data-draft-nl-result]')))
check('落版之后写清旧验证不再覆盖这一版',
(await textOf(page.locator('[data-draft-nl-result]'))).includes('旧的试运行和验证不再覆盖这一版'),
await textOf(page.locator('[data-draft-nl-result]')))
check('一句话只走这一条写路径(没有 PATCH)', patchBodies(state).length === 0)
check('页面没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
})
// ================================================================ 16 一句话:先澄清、失败不擅自改
await section('一句话:含糊的业务条件先显示澄清,一条操作都不产出、也不给确认按钮;落版失败时什么都不改', async () => {
const state = makeState({
draft: draftOf(),
validations: { records: [], summary: summaryOf({ verdict: 'none', status: 'none' }) },
})
state.trial = { run_id: 'run-1', status: 'succeeded', metrics: [], queries: [] }
const writes = []
state.nl = (body) => {
writes.push(body)
return { status: 409, json: { detail: '这句话提到「退款处理」,但没说清取哪个值:请指定全部计入、排除全额退款',
code: 'change_proposal_ambiguous' } }
}
state.proposal = (body) => ({ json: { draft_id: 'd-orders', workflow_id: 'wf-orders', revision: 7, card: 'card',
applied: false, persisted: false,
proposal: proposalOf({ ready: false, operations: [], clarifications: [{ kind: 'ambiguous', field: 'refund_rule',
question: '这句话提到「退款处理」,但没说清取哪个值:请指定全部计入、排除全额退款',
options: [{ value: 'include_all', label: '全部计入' }, { value: 'exclude_full_refund', label: '排除全额退款' }] }] }) } })
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
await page.locator('[data-draft-nl-input]').fill('排除退款')
await page.locator('[data-draft-nl-preview]').click()
await page.locator('[data-draft-nl-clarify]').waitFor({ state: 'visible' })
const clarify = await textOf(page.locator('[data-draft-nl-clarify]'))
check('含糊的条件照服务端的问题写出来', clarify.includes('没说清取哪个值'), clarify)
check('把允许的取值原样列出来(不替人猜)',
clarify.includes('全部计入') && clarify.includes('排除全额退款'), clarify)
check('澄清时不产出任何修改(没有影响列表)', await page.locator('[data-draft-nl-changes]').count() === 0)
check('澄清时没有确认按钮(先问清)', await page.locator('[data-draft-nl-confirm]').count() === 0)
check('澄清之前也没写过任何东西', writes.length === 0)
// 就算硬发一次确认(服务端仍然说不清):失败照实说,页面上什么都不改
const state2 = makeState({
draft: draftOf(),
validations: { records: [], summary: summaryOf({ verdict: 'none', status: 'none' }) },
})
state2.trial = { run_id: 'run-1', status: 'succeeded', metrics: [], queries: [] }
state2.proposal = () => ({ json: { draft_id: 'd-orders', workflow_id: 'wf-orders', revision: 7, card: 'card',
applied: false, persisted: false, proposal: proposalOf() } })
const failures = []
state2.nl = (body) => {
failures.push(body)
return { status: 409, json: { detail: '口径已经改到 v8,你的版本是 v7:请刷新后重新核对再改',
code: 'stale_revision', current_version: 8 } }
}
const second = await pageOn(state2)
await expand(second.page, state2.draft)
await second.page.locator('[data-draft-nl-input]').fill('订单金额按支付时间算')
await second.page.locator('[data-draft-nl-preview]').click()
await second.page.locator('[data-draft-nl-changes]').waitFor({ state: 'visible' })
await second.page.locator('[data-draft-nl-confirm]').click()
await until(async () => failures.length === 1)
const failure = await textOf(second.page.locator('[data-draft-nl] [role="alert"]'))
check('落版失败时照实报错(服务端说不清就一个字都不改)',
failures[0].confirm === true && failure.includes('口径已经改到 v8'), failure)
check('失败之后输入和预览原样留着(人再决定)',
await second.page.locator('[data-draft-nl-input]').inputValue() === '订单金额按支付时间算'
&& await second.page.locator('[data-draft-nl-changes]').count() === 1)
check('失败时没有落成任何一版(没有成功回执)',
await second.page.locator('[data-draft-nl-result]').count() === 0)
check('这一页没有报错', second.errors.length === 0, second.errors.slice(0, 2).join(';'))
check('这一页没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
})
// ================================================================ 17 拒绝之后批量、重复工作项、抽坏了的项
await section('批量:拒绝过的格子不再被收进来,一次收掉的按钮把剩下的几格明确列出来(不发 auto)', async () => {
const clean = () => importItem({ item_id: 'it-1', fields: [
importField('/unit', 'update', { b: '元', l: '元', r: '万元' }),
importField('/grain', 'update', { b: '订单行', l: '订单行', r: '订单行(明细)' }),
importField('/definition/text', 'update', { b: '按已付款订单统计', l: '按已付款订单统计', r: '按已付款订单统计(退款另计)' }),
] })
const state = makeState({
draft: draftOf(),
validations: { records: [], summary: summaryOf({ verdict: 'none', status: 'none' }) },
})
state.trial = { run_id: 'run-1', status: 'succeeded', metrics: [], queries: [] }
state.imports = [importRun({ items: [clean()] })]
state.getImport = () => ({ json: importRun({ items: [state.imports[0].items[0]] }) })
const rejects = []
state.reject = (runId, body) => {
rejects.push({ runId, body })
const after = importItem({ item_id: 'it-1', status: 'partial', rejected: ['/unit'], fields: [
importField('/unit', 'update', { b: '元', l: '元', r: '万元', decided: 'rejected' }),
importField('/grain', 'update', { b: '订单行', l: '订单行', r: '订单行(明细)' }),
importField('/definition/text', 'update', { b: '按已付款订单统计', l: '按已付款订单统计', r: '按已付款订单统计(退款另计)' }),
] })
return { json: importRun({ items: [after] }) }
}
const accepts = []
state.accept = (runId, body) => {
accepts.push({ runId, body })
return { json: acceptOut(importRun({ items: [importItem({ item_id: 'it-1', status: 'accepted',
accepted: ['/grain', '/definition/text'], applied_revision: 8, fields: [
importField('/grain', 'unchanged', { b: '订单行(明细)', l: '订单行(明细)', r: '订单行(明细)', decided: 'accepted' }),
importField('/definition/text', 'unchanged', { b: '按已付款订单统计(退款另计)', l: '按已付款订单统计(退款另计)',
r: '按已付款订单统计(退款另计)', decided: 'accepted' }),
] })], items_decided: 1 }), 8) }
}
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
check('三格都能收时,按钮写清 3 格',
(await textOf(page.locator('[data-draft-import-accept-clean="it-1"]'))).includes('无冲突的 3 格'),
await textOf(page.locator('[data-draft-import-accept-clean="it-1"]')))
// 先拒掉一格:那一格不该再被批量收进去
await page.locator('[data-draft-import-pick-reject="it-1:/unit"]').check()
await page.locator('[data-draft-import-reject]').click()
await until(async () => rejects.length === 1)
check('拒绝把勾中的那一格指成工作项身份 + 字段路径',
rejects[0].body.items?.[0]?.item_id === 'it-1'
&& JSON.stringify(rejects[0].body.items[0].fields) === JSON.stringify(['/unit']),
JSON.stringify(rejects[0].body.items))
const rejectedCell = page.locator('[data-draft-import-field="it-1:/unit"]')
check('拒绝过的那一格不再摆出选择(已经决定过了)',
await rejectedCell.locator('[data-draft-import-pick-accept="it-1:/unit"]').count() === 0
&& await rejectedCell.locator('[data-draft-import-pick-reject="it-1:/unit"]').count() === 0)
check('批量按钮只剩 2 格(拒绝过的不再被收)',
(await textOf(page.locator('[data-draft-import-accept-clean="it-1"]'))).includes('无冲突的 2 格'),
await textOf(page.locator('[data-draft-import-accept-clean="it-1"]')))
await page.locator('[data-draft-import-accept-clean="it-1"]').click()
await until(async () => accepts.length === 1)
const body = accepts[0].body
check('一次收掉发的是**明确列出来的**剩下那几格(不是 auto)',
body.items?.length === 1 && body.items[0].auto === undefined
&& JSON.stringify(body.items[0].fields) === JSON.stringify(['/grain', '/definition/text']),
JSON.stringify(body.items))
check('这条也带着工作项身份和落点指标',
body.items[0].item_id === 'it-1' && body.items[0].metric_id === 'm1', JSON.stringify(body.items[0]))
check('页面没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
})
await section('重复工作项:同一条指标上有两条候选时,接受 / 拒绝按工作项身份指认,不指错', async () => {
const first = importItem({ item_id: 'it-a', seq: 1, candidate_id: 'ca', name: '订单数',
fields: [importField('/unit', 'update', { b: '元', l: '元', r: '万元' })] })
const second = importItem({ item_id: 'it-b', seq: 2, candidate_id: 'cb', name: '订单数(另一种说法)',
fields: [importField('/grain', 'update', { b: '订单行', l: '订单行', r: '订单行(明细)' })] })
const state = makeState({
draft: draftOf(),
validations: { records: [], summary: summaryOf({ verdict: 'none', status: 'none' }) },
})
state.trial = { run_id: 'run-1', status: 'succeeded', metrics: [], queries: [] }
state.imports = [importRun({ items: [first, second] })]
const accepts = []
state.accept = (runId, body) => {
accepts.push({ runId, body })
return { json: acceptOut(importRun({ items: [first, second], items_decided: 1 }), 8) }
}
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
await until(async () => await page.locator('[data-draft-import-item="m1"]').count() === 2)
check('两条候选都摆出来了(同一条指标)', await page.locator('[data-draft-import-item="m1"]').count() === 2,
String(await page.locator('[data-draft-import-item="m1"]').count()))
// 只对第二条候选的那一格点接受
await page.locator('[data-draft-import-pick-accept="it-b:/grain"]').check()
await page.locator('[data-draft-import-accept]').click()
await until(async () => accepts.length === 1)
check('按工作项身份指认,没有指到同一条指标上的另一条候选',
accepts[0].body.items?.length === 1 && accepts[0].body.items[0].item_id === 'it-b'
&& JSON.stringify(accepts[0].body.items[0].fields) === JSON.stringify(['/grain']),
JSON.stringify(accepts[0].body.items))
check('批量按钮也按各自的工作项身份发',
(await textOf(page.locator('[data-draft-import-accept-clean="it-a"]'))).includes('无冲突的 1 格'))
check('页面没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
})
await section('抽坏了的工作项:所有接受都禁用,批量里也不收它', async () => {
const broken = importItem({ item_id: 'it-x', seq: 1, name: '客单价', metric_id: 'm2', status: 'failed',
error: '候选的引用指向的段落没有提供给模型:不能当出处,请重新抽取或人工补依据',
fields: [importField('/expression', 'update', { b: "cell(nodes.fetch, 0, 'avg')", l: "cell(nodes.fetch, 0, 'avg')",
r: "cell(nodes.fetch, 0, 'avg_2')" })] })
const good = importItem({ item_id: 'it-y', seq: 2, name: '订单数', metric_id: 'm1', status: 'update',
fields: [importField('/unit', 'update', { b: '元', l: '元', r: '万元' })] })
const state = makeState({
draft: draftOf(),
validations: { records: [], summary: summaryOf({ verdict: 'none', status: 'none' }) },
})
state.trial = { run_id: 'run-1', status: 'succeeded', metrics: [], queries: [] }
state.imports = [importRun({ items: [broken, good] })]
const accepts = []
state.accept = (runId, body) => {
accepts.push({ runId, body })
return { json: acceptOut(importRun({ items: [broken, good], items_decided: 1 }), 8) }
}
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
const brokenCell = page.locator('[data-draft-import-field="it-x:/expression"]')
check('抽坏了的工作项标成这条没抽好', (await textOf(page.locator('[data-draft-import-item="m2"]')))
.includes('这条没抽好'), await textOf(page.locator('[data-draft-import-item="m2"]')))
check('抽坏了的那条:接受是禁用的', await brokenCell.locator('[data-draft-import-pick-accept="it-x:/expression"]').isDisabled())
check('抽坏了的那条不摆「一次收掉」的按钮',
await page.locator('[data-draft-import-accept-clean="it-x"]').count() === 0)
check('抽坏了的那条仍然可以拒绝(不是口径的就拒掉)',
!(await brokenCell.locator('[data-draft-import-pick-reject="it-x:/expression"]').isDisabled()))
check('批量只算能接受的那一条(1 格)',
(await textOf(page.locator('[data-draft-import-accept]'))).includes('接受选中的 0 格')
&& await page.locator('[data-draft-import-accept]').isDisabled())
await page.locator('[data-draft-import-pick-accept="it-y:/unit"]').check()
await until(async () => !(await page.locator('[data-draft-import-accept]').isDisabled()))
await page.locator('[data-draft-import-accept]').click()
await until(async () => accepts.length === 1)
check('提交里只有能接受的那一条(抽坏的不混进去)',
accepts[0].body.items?.length === 1 && accepts[0].body.items[0].item_id === 'it-y',
JSON.stringify(accepts[0].body.items))
check('页面没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
})
// ================================================================ 18 这次导入比的还是旧的 L
await section('冲突:服务端说这次导入比的还是旧的 L(import_stale_l),重新读回现状、选择留着、不拿旧 L 覆盖', async () => {
const before = importItem({ item_id: 'it-1', fields: [importField('/unit', 'update', { b: '元', l: '元', r: '万元' })] })
// 别处把这张卡改过了:重新读回来时 L 已经不是「元」,这一格成了人工冲突
const after = importItem({ item_id: 'it-1', status: 'conflict',
fields: [importField('/unit', 'conflict', { b: '元', l: '万元', r: '元' })] })
const state = makeState({
draft: draftOf(),
validations: { records: [], summary: summaryOf({ verdict: 'none', status: 'none' }) },
})
state.trial = { run_id: 'run-1', status: 'succeeded', metrics: [], queries: [] }
state.imports = [importRun({ items: [before] })]
// 服务端说「三方算的是旧的 L」之后,页面要按现在的 L / B 重算一次(POST …/refresh),把新三方读回来
let refreshes = 0
state.refresh = (runId) => {
refreshes += 1
return { json: importRun({ run_id: runId, items: [after] }) }
}
const state2Reads = () => refreshes
const accepts = []
state.accept = (runId, body) => {
accepts.push({ runId, body })
if (accepts.length === 1) {
return { status: 409, json: { detail: '这一格的当前内容和这次导入算候选时的那一版不一样了:请刷新这次导入的三方、'
+ '按现在的口径重新确认后再接受', code: 'import_stale_l',
fields: [{ item_id: 'it-1', metric_id: 'm1', path: '/unit' }] } }
}
return { json: acceptOut(importRun({ items: [importItem({ item_id: 'it-1', status: 'accepted',
accepted: ['/unit'], applied_revision: 9,
fields: [importField('/unit', 'unchanged', { b: '元', l: '元', r: '元', decided: 'accepted' })] })], items_decided: 1 }), 9) }
}
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
await page.locator('[data-draft-import-pick-accept="it-1:/unit"]').check()
await page.locator('[data-draft-import-accept]').click()
await page.locator('[data-draft-import-stale]').waitFor({ state: 'visible' })
check('照服务端的码标出来(不是笼统的过期)',
await page.locator('[data-draft-import-stale]').getAttribute('data-draft-import-stale') === 'import_stale_l',
await page.locator('[data-draft-import-stale]').getAttribute('data-draft-import-stale'))
const stale = await textOf(page.locator('[data-draft-import-stale]'))
check('指名道姓说清是哪一格对不上了、已经按现在的口径重算了三方',
stale.includes('单位') && stale.includes('已经按现在的口径把这次导入的三方重算了一遍'), stale)
check('没有自动重发(只发了那一次接受)', accepts.length === 1, `${accepts.length} 次`)
check('走的是「按现在的 L / B 重算三方」那条路(POST …/refresh),不是只更新版本号',
state2Reads() >= 1, String(state2Reads()))
const cell = page.locator('[data-draft-import-field="it-1:/unit"]')
await until(async () => (await cell.getAttribute('data-import-field-state')) === 'conflict')
check('三方按**重新读回来**的内容写:现在的内容变成 万元,状态是人工修改冲突',
await cell.getAttribute('data-import-field-state') === 'conflict'
&& (await textOf(cell.locator('[data-draft-import-value="l"]'))).includes('万元'),
await textOf(cell))
check('选好的那一格还留着(没有拿旧 L 直接覆盖,也没清空选择)',
await page.locator('[data-draft-import-pick-accept="it-1:/unit"]').isChecked())
check('详情也重新读了一次(拿到新的基准版本)',
state.calls.filter((call) => call.method === 'GET' && call.path === '/ledger/drafts/d-orders').length >= 2)
await page.locator('[data-draft-import-accept]').click()
await until(async () => accepts.length === 2)
check('第二次按重新读回来的现状提交(同一格、同一身份)',
accepts[1].body.items?.[0]?.item_id === 'it-1'
&& JSON.stringify(accepts[1].body.items[0].fields) === JSON.stringify(['/unit']),
JSON.stringify(accepts[1].body.items))
check('页面没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
})
// ================================================================ 19 一句话:输入改过必须先重新预览、落下的要对得上
await section('一句话:输入改过之后必须重新预览,确认发的就是预览时那一段原文', async () => {
const state = makeState({
draft: draftOf(),
validations: { records: [], summary: summaryOf({ verdict: 'none', status: 'none' }) },
})
state.trial = { run_id: 'run-1', status: 'succeeded', metrics: [], queries: [] }
const proposals = []
state.proposal = (body) => {
proposals.push(body)
return { json: { draft_id: 'd-orders', workflow_id: 'wf-orders', revision: 7, card: 'card',
applied: false, persisted: false,
proposal: proposalOf({ operations: [{ op: 'set', metric_id: 'm2', path: '/refund_rule', value: 'exclude_full_refund' }],
metrics: ['m2'] }) } }
}
const writes = []
state.nl = (body) => {
writes.push(body)
const live = draftOf()
live.revision = 8
live.version = 8
live.change = receiptOf({ revision: 8, operations: [{ op: 'set', metric_id: 'm2', path: '/refund_rule', changed: true }],
binding: live.binding })
return { json: live }
}
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
await page.locator('[data-draft-nl-input]').fill('订单金额排除全额退款')
await page.locator('[data-draft-nl-preview]').click()
await page.locator('[data-draft-nl-changes]').waitFor({ state: 'visible' })
check('预览写明这份影响是按哪一段原文算的',
(await textOf(page.locator('[data-draft-nl-of]'))).includes('这份影响是按这一段原文算的:「订单金额排除全额退款」'),
await textOf(page.locator('[data-draft-nl-of]')))
check('没改输入时有确认按钮', await page.locator('[data-draft-nl-confirm]').count() === 1)
// 改了输入:这份提案就不作数了,确认按钮收起来、也不许写
await page.locator('[data-draft-nl-input]').fill('订单数量按支付时间算')
await until(async () => (await page.locator('[data-draft-nl]').getAttribute('data-draft-nl-touched')) === 'true')
check('输入改过就标成「这句话改过了」,确认按钮收起来(必须重新预览)',
await page.locator('[data-draft-nl-confirm]').count() === 0
&& (await textOf(page.locator('[data-draft-nl-repreview]'))).includes('要先重新预览'),
await textOf(page.locator('[data-draft-nl-repreview]')))
check('改过之后一个字都没写', writes.length === 0)
check('影响列表还标着是按上一次那段原文算的(显示的是 A 就说 A)',
(await textOf(page.locator('[data-draft-nl-of]'))).includes('输入改过了')
&& (await textOf(page.locator('[data-draft-nl-of]'))).includes('请重新预览'),
await textOf(page.locator('[data-draft-nl-of]')))
// 重新预览之后才谈得上确认,且确认发的是**重新预览那一段**
await page.locator('[data-draft-nl-preview]').click()
await until(async () => proposals.length === 2)
check('重新预览用的是输入框现在的内容', proposals[1].instruction === '订单数量按支付时间算',
JSON.stringify(proposals[1]))
await page.locator('[data-draft-nl-confirm]').waitFor({ state: 'visible' })
await page.locator('[data-draft-nl-confirm]').click()
await until(async () => writes.length === 1)
check('确认发的是预览那一段原文(不是别的)', writes[0].instruction === '订单数量按支付时间算',
JSON.stringify(writes[0]))
check('落下的操作和预览一致时不提示不一致',
await page.locator('[data-draft-nl-mismatch]').count() === 0)
check('页面没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
})
await section('一句话:服务端落下的操作和预览里的不是同一批时,照实提示,不让显示 A 实际落 B', async () => {
const state = makeState({
draft: draftOf(),
validations: { records: [], summary: summaryOf({ verdict: 'none', status: 'none' }) },
})
state.trial = { run_id: 'run-1', status: 'succeeded', metrics: [], queries: [] }
state.proposal = () => ({ json: { draft_id: 'd-orders', workflow_id: 'wf-orders', revision: 7, card: 'card',
applied: false, persisted: false, proposal: proposalOf() } })
// 服务端这次落下的只有 m1 那一条(预览里是 m1、m2 两条)
state.nl = () => {
const live = draftOf()
live.revision = 8
live.version = 8
live.change = receiptOf({ revision: 8,
operations: [{ op: 'set', metric_id: 'm1', path: '/time_role', changed: true }], binding: live.binding })
return { json: live }
}
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
await page.locator('[data-draft-nl-input]').fill('订单按支付时间算')
await page.locator('[data-draft-nl-preview]').click()
await page.locator('[data-draft-nl-changes]').waitFor({ state: 'visible' })
await page.locator('[data-draft-nl-confirm]').click()
await page.locator('[data-draft-nl-mismatch]').waitFor({ state: 'visible' })
check('写清落下的和预览里的不是同一批、要人到画布上核对',
(await textOf(page.locator('[data-draft-nl-mismatch]'))).includes('和预览里的不是同一批'),
await textOf(page.locator('[data-draft-nl-mismatch]')))
check('页面没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
})
// ================================================================ 20 结论一律按 data 属性核(否定说法反例)
await section('验证反例:页面写的是「还不能算验证通过」,按 data 属性核,不被这四个字骗过', async () => {
const review = { id: 'rv-5', draft_id: 'd-orders', record_id: 'vr-5', workflow_id: 'wf-orders',
decision: 'confirmed', actor: 'alice', verified: true, scope: ['m1'], basis: { version: 7 },
note: null, created_at: '2026-11-01T07:00:00+00:00', covers_current: true }
const record = recordOf({ id: 'vr-5', status: 'matched', ok: true,
expected: { source: 'fixture', metrics: { m1: 2, m2: 500 }, hash: 'xh-5' }, review })
const state = makeState({
draft: draftOf(),
// 服务端自己已经按范围收回成 unverified:页面上的说法里带「验证通过」四个字,但不是通过
validations: { records: [record], summary: { ...summaryOf({ verdict: 'unverified', status: 'matched', record }),
confirmed: ['m1'], unconfirmed: ['m2'] } },
})
state.trial = { run_id: 'run-1', status: 'succeeded', metrics: [], queries: [] }
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
const panel = validationPanel(page)
const verdict = await textOf(panel.locator('[data-draft-validation-verdict]'))
check('页面写的确实是否定说法「还不能算验证通过」', verdict === '还不能算验证通过', verdict)
check('那句里确实有「验证通过」四个字(只看这四个字就会被骗)', verdict.includes('验证通过'), verdict)
check('短语判定认得这是否定说法(naive includes 会在这里误报成通过)', claimsVerified(verdict) === false, verdict)
check('结论按 data 属性核:不是 verified', await panel.getAttribute('data-draft-validation') === 'unverified',
await panel.getAttribute('data-draft-validation'))
check('页面没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
})
await section('语义问数:常用叫法可输入多个,对象日期绑定按版本保存', async () => {
const state = makeState({ draft: draftOf({ draft: {
metrics: [{ id: 'm1', name: '日 OEE', grain: '工厂、日单值' }], metric_count: 1,
entry_fields: ['start', 'end', 'plant'].map(name => ({ name, type: 'string', required: true })),
} }), validations: { records: [], summary: summaryOf({ verdict: 'none', status: null }) } })
state.patch = body => {
state.draft = { ...state.draft, revision: 8, metrics: [{ ...state.draft.metrics[0], semantic: body.operations[0].value }] }
return { json: clone(state.draft) }
}
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
const panel = page.locator('[data-query-semantics]')
await panel.locator('summary').click()
await panel.getByLabel('常用叫法(逗号分隔)').fill('OEE,设备综合效率')
await panel.getByLabel('汇总与缺失规则').fill('按实际开班时长加权,缺失不写零')
await panel.getByLabel('按日期查询').check()
await panel.getByLabel('开始(含)').selectOption('start')
await panel.getByLabel('结束(不含)').selectOption('end')
await panel.getByLabel('日期规则说明').fill('按已确认的生产日归属')
await panel.getByRole('button', { name: '添加对象类型', exact: true }).click()
await panel.getByLabel('对象类型', { exact: true }).fill('工厂')
await panel.getByLabel('绑定参数').selectOption('plant')
await panel.getByRole('button', { name: '添加对象', exact: true }).click()
await panel.getByLabel('工厂 value', { exact: true }).fill('1050')
await panel.getByLabel('工厂 label', { exact: true }).fill('工厂甲')
await panel.getByLabel('已确认上述定义与取数实现一致,发布后启用直接问数').check()
// 普通 HTTP 部署中没有 randomUUID,仍要能保存;这不是生成安全凭据。
await page.evaluate(() => Object.defineProperty(window.crypto, 'randomUUID', { value: undefined, configurable: true }))
await panel.getByRole('button', { name: '保存问数定义' }).click()
await until(() => patchBodies(state).length)
const body = patchBodies(state)[0]
check('单次字段变更带基准版本和目标卡', body?.expected_revision === 7 && body?.target?.card_id === 'card' && !!body?.request_id)
check('多个常用叫法均保留', JSON.stringify(body?.operations[0]?.value?.aliases) === JSON.stringify(['OEE', '设备综合效率']))
check('实体编号绑定到工厂参数', body?.operations[0]?.value?.entities[0]?.field === 'plant'
&& body.operations[0].value.entities[0].members[0].value === '1050')
check('默认只允许单日,未暗中启用月汇总', JSON.stringify(body?.operations[0]?.value?.time?.periods) === '["day"]')
check('页面没有报错', errors.length === 0, errors.join(';'))
})
await section('语义编辑恢复:保存其他指标保留未保存内容并使用新版本', async () => {
const state = makeState({ draft: draftOf({ draft: {
metrics: [{ id: 'm1', name: '指标甲' }, { id: 'm2', name: '指标乙' }], metric_count: 2,
} }), validations: { records: [], summary: summaryOf({ verdict: 'none', status: null }) } })
state.patch = body => {
const op = body.operations[0]
state.draft = { ...state.draft, revision: state.draft.revision + 1,
metrics: state.draft.metrics.map(metric => metric.id === op.metric_id ? { ...metric, semantic: op.value } : metric) }
return { json: clone(state.draft) }
}
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
const first = page.locator('[data-query-semantics]').nth(0)
const second = page.locator('[data-query-semantics]').nth(1)
await first.locator('summary').click()
await second.locator('summary').click()
await first.getByLabel('汇总与缺失规则').fill('甲的汇总规则')
await second.getByLabel('汇总与缺失规则').fill('乙尚未保存的规则')
await first.getByRole('button', { name: '保存问数定义' }).click()
await until(() => state.draft.revision === 8)
await until(async () => !await first.getByText('有未保存的修改;保存其他指标不会覆盖这里的内容。').count())
check('保存甲后乙仍展开且保留输入', await second.getByLabel('汇总与缺失规则').isVisible()
&& await second.getByLabel('汇总与缺失规则').inputValue() === '乙尚未保存的规则')
await second.getByRole('button', { name: '保存问数定义' }).click()
await until(() => patchBodies(state).length === 2)
check('乙按新版本保存原编辑内容', patchBodies(state)[1]?.expected_revision === 8
&& patchBodies(state)[1]?.operations[0]?.value?.aggregation === '乙尚未保存的规则')
check('页面没有报错', errors.length === 0, errors.join(';'))
})
await section('语义编辑恢复:同指标外部更新阻止覆盖并可载入最新版', async () => {
const state = makeState({ draft: draftOf({ draft: {
metrics: [{ id: 'm1', name: '指标甲' }, { id: 'm2', name: '指标乙' }], metric_count: 2,
} }), validations: { records: [], summary: summaryOf({ verdict: 'none', status: null }) } })
state.patch = body => {
state.draft = { ...state.draft, revision: 9, metrics: [
{ ...state.draft.metrics[0], semantic: body.operations[0].value },
{ ...state.draft.metrics[1], semantic: { version: 1, enabled: false, aliases: [], grain: '',
aggregation: '其他人更新的规则', time: null, entities: [] } },
] }
return { json: clone(state.draft) }
}
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
const first = page.locator('[data-query-semantics]').nth(0)
const second = page.locator('[data-query-semantics]').nth(1)
await first.locator('summary').click()
await second.locator('summary').click()
await second.getByLabel('汇总与缺失规则').fill('乙本地未保存的规则')
await first.getByRole('button', { name: '保存问数定义' }).click()
const reload = second.getByRole('button', { name: '载入最新配置(放弃本地修改)' })
await reload.waitFor({ state: 'visible' })
check('同指标发生冲突时保留输入并禁止保存', await second.getByRole('button', { name: '保存问数定义' }).isDisabled()
&& await second.getByLabel('汇总与缺失规则').inputValue() === '乙本地未保存的规则')
await reload.click()
check('明确放弃后载入服务端配置并可继续保存', await second.getByLabel('汇总与缺失规则').inputValue() === '其他人更新的规则'
&& await second.getByRole('button', { name: '保存问数定义' }).isEnabled())
check('页面没有报错', errors.length === 0, errors.join(';'))
})
await section('语义编辑恢复:关闭重开后旧保存响应不覆盖新详情', async () => {
const state = makeState({ draft: draftOf({ draft: {
metrics: [{ id: 'm1', name: '指标甲' }], metric_count: 1,
} }), validations: { records: [], summary: summaryOf({ verdict: 'none', status: null }) } })
let release
state.patch = body => new Promise(resolve => { release = () => resolve({ json: {
...clone(state.draft), revision: 8,
metrics: [{ ...state.draft.metrics[0], semantic: body.operations[0].value }],
} }) })
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
const panel = page.locator('[data-query-semantics]')
await panel.locator('summary').click()
await panel.getByLabel('汇总与缺失规则').fill('旧保存内容')
await panel.getByRole('button', { name: '保存问数定义' }).click()
await until(() => release)
await page.locator(`[data-draft="${state.draft.id}"] [data-draft-toggle]`).click()
state.draft = { ...state.draft, revision: 9, metrics: [{ id: 'm1', name: '新版本指标' }] }
await expand(page, state.draft)
release()
await page.waitForTimeout(200)
check('旧响应没有覆盖重开的新版本', (await panel.innerText()).includes('新版本指标'))
check('页面没有报错', errors.length === 0, errors.join(';'))
})
await section('语义编辑恢复:慢网络起草轮询不并发堆积', async () => {
const state = makeState({ draft: draftOf({ draft: { status: 'drafting' } }),
validations: { records: [], summary: summaryOf({ verdict: 'none', status: null }) } })
const original = state.reply
let requests = 0, active = 0, peak = 0
state.reply = async (method, path, body, url) => {
if (method === 'GET' && path === `/ledger/drafts/${state.draft.id}` && ++requests > 1) {
active++; peak = Math.max(peak, active)
await new Promise(resolve => setTimeout(resolve, 3000))
active--
}
return original(method, path, body, url)
}
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
await page.waitForTimeout(8500)
check('两轮以上慢请求仍最多一个在途', requests >= 3 && peak === 1, `requests=${requests}, peak=${peak}`)
check('页面没有报错', errors.length === 0, errors.join(';'))
})
await section('语义编辑恢复:补充缺失指标候选可合并回原稿', async () => {
const item = id => ({ id, name: id, kind: 'metric', status: 'definition_only', fields: {},
dependencies: [], source: { document_id: 'doc', line: 1, quote: id } })
const originalDraft = draftOf({ draft: { status: 'questions_open',
metrics: [{ id: 'm1', name: '已有指标', source_definition_id: 'def1' }], metric_count: 1,
selected_definition_ids: ['def1', 'def2'],
semantic_inventory: { version: 1, items: [item('def1'), item('def2')], rules: [], warnings: [] },
} })
const state = makeState({ draft: clone(originalDraft),
validations: { records: [], summary: summaryOf({ verdict: 'none', status: null }) } })
const originalReply = state.reply
let continued, merged, mergeAttempts = 0
state.reply = async (method, path, body, url) => {
if (method === 'POST' && path === `/ledger/drafts/${originalDraft.id}/continue`) {
continued = body
state.draft = { ...clone(originalDraft), id: 'd-candidate', title: '补充指标候选', revision: 3,
metrics: [{ id: 'm2', name: '补充指标', source_definition_id: 'def2' }],
origin: { continuation_from: { draft_id: originalDraft.id, revision: 7, merge_required: true } } }
return { json: { draft_id: state.draft.id, status: state.draft.status, original_draft_id: originalDraft.id, merge_required: true } }
}
if (method === 'GET' && path === `/ledger/drafts/${originalDraft.id}` && state.draft.id !== originalDraft.id)
return { json: { ...clone(originalDraft), revision: 8 } }
if (method === 'POST' && path === '/ledger/drafts/d-candidate/merge') {
mergeAttempts++
if (mergeAttempts === 1) return { status: 409, json: { detail: '原稿已变化,请核对后重试' } }
merged = body
state.draft = { ...clone(originalDraft), revision: 9, metrics: [...originalDraft.metrics, ...state.draft.metrics] }
return { json: { ...clone(state.draft), merged_candidate_id: 'd-candidate' } }
}
return originalReply(method, path, body, url)
}
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
let panel = page.locator('[data-draft-continuation]')
await panel.getByLabel('补充说明').fill('使用已核实的产量字段')
await panel.getByRole('button', { name: '继续处理未完成指标' }).click()
await until(() => continued)
check('继续请求包含基准版本和补充说明', continued?.expected_revision === 7 && continued?.guidance === '使用已核实的产量字段')
await panel.getByRole('button', { name: '合并补充指标到原稿' }).waitFor({ state: 'visible' })
await panel.getByRole('button', { name: '合并补充指标到原稿' }).click()
await panel.getByText('原稿已变化,请核对后重试', { exact: false }).first().waitFor({ state: 'visible' })
check('版本冲突保留候选和重试入口', state.draft.id === 'd-candidate'
&& await panel.getByRole('button', { name: '合并补充指标到原稿' }).isEnabled())
await panel.getByRole('button', { name: '合并补充指标到原稿' }).click()
await until(() => merged)
check('合并采用原稿最新版本及候选版本', merged?.expected_revision === 8 && merged?.candidate_revision === 3)
await until(async () => await page.locator('[data-query-semantics]').count() === 2)
check('合并后回到保留已有指标的原稿', await page.locator('[data-query-semantics]').count() === 2
&& state.draft.metrics[0].name === '已有指标')
check('页面没有报错', errors.length === 0, errors.join(';'))
})
await section('语义问数:定义清单保留未实现项,无数据源指标不能被选择', async () => {
const item = (id, name, status) => ({ id, name, kind: 'metric', status, section: 'OEE 系列', formula: '原文公式',
policy: '缺失不写零', fields: { 指标: name, 公式: '原文公式' }, dependencies: [],
source: { document_id: 'doc', line: 8, quote: '| 原文公式 |' } })
const state = makeState({ draft: draftOf({ draft: {
status: 'path_pending', workflow_id: null, revision: null, card_id: null, metrics: [], entry_fields: [],
semantic_inventory: { version: 1, note: '尚未绑定数据', rules: [], items: [
item('day', '日 OEE', 'definition_only'), item('missing', '人均效率', 'unavailable'),
] },
} }), validations: { records: [], summary: summaryOf({ verdict: 'none', status: null }) } })
const original = state.reply
let implementation
state.reply = async (method, p, body, url) => {
if (p === '/datasources/names') return { json: [{ id: 'source', name: '合成测试库', enabled: true }] }
if (method === 'POST' && p.endsWith('/implement')) {
implementation = body
return { status: 202, json: { draft_id: state.draft.id, status: 'drafting' } }
}
return original(method, p, body, url)
}
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
const panel = page.locator('[data-semantic-inventory]')
check('默认不要求逐项勾选', await panel.locator('input[type="checkbox"]').count() === 0)
await panel.getByRole('button', { name: '仅处理部分指标', exact: true }).click()
check('无数据源定义保留但禁选', await panel.getByLabel('选择 人均效率', { exact: true }).isDisabled())
await panel.getByLabel('选择 日 OEE', { exact: true }).check()
await panel.getByLabel('用于实现的数据源').selectOption('source')
await panel.getByRole('button', { name: '开始处理选中的 1 项' }).click()
await until(() => implementation)
check('只提交选中的定义和数据源', JSON.stringify(implementation) === JSON.stringify({ selected: ['day'], datasource_id: 'source' }))
check('页面没有报错', errors.length === 0, errors.join(';'))
})
await section('自动处理:默认全部、无需手动分批,持续展示进度和缺口', async () => {
const items = Array.from({ length: 13 }, (_, i) => ({ id: `metric-${i}`, name: `指标${i}`, kind: 'metric',
status: 'definition_only', fields: {}, dependencies: [], source: { document_id: 'doc', line: i + 1, quote: '原文' } }))
items.push({ ...items[0], id: 'missing', name: '无来源指标', status: 'unavailable' })
const state = makeState({ draft: draftOf({ draft: {
status: 'path_pending', datasource_id: null, workflow_id: null, revision: null, card_id: null,
metrics: [], entry_fields: [], semantic_inventory: { version: 1, rules: [], items },
} }), validations: { records: [], summary: summaryOf({ verdict: 'none', status: null }) } })
const original = state.reply
let implementation
state.reply = async (method, p, body, url) => {
if (p === '/datasources/names') return { json: [{ id: 'source', name: '唯一已授权测试库', enabled: true }] }
if (method === 'POST' && p.endsWith('/implement')) {
implementation = body
state.draft = { ...state.draft, status: 'drafting', selected_definition_ids: items.slice(0, 13).map(i => i.id),
implementation_mode: 'all', implementation_progress: { total: 13, completed: 6, pending: 7, blocked: 0, active_names: ['指标6'] },
definition_issues: [{ definition_id: 'missing', name: '无来源指标', reason: '原文注明无数据源' }] }
return { status: 202, json: { draft_id: state.draft.id, status: 'drafting' } }
}
return original(method, p, body, url)
}
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
const panel = page.locator('[data-semantic-inventory]')
check('默认清单没有勾选框', await panel.locator('input[type="checkbox"]').count() === 0)
check('预告全部可实现项,排除无来源项', (await panel.innerText()).includes('将自动处理 13 项'))
await panel.getByRole('button', { name: '仅处理部分指标', exact: true }).click()
for (let i = 0; i < 7; i++) await panel.getByLabel(`选择 指标${i}`, { exact: true }).check()
check('可选范围不受内部六项批次限制', await panel.getByRole('button', { name: '开始处理选中的 7 项' }).isEnabled())
await panel.getByRole('button', { name: '恢复处理全部指标', exact: true }).click()
await panel.getByRole('button', { name: '自动处理全部可实现指标', exact: true }).click()
await until(() => implementation)
check('默认提交全部模式,没有偷偷沿用手选范围', JSON.stringify(implementation) === '{"datasource_id":"source"}')
await panel.locator('[data-implementation-progress]').waitFor()
const text = await panel.innerText()
check('持续进度说明已生成和待处理数', text.includes('已生成 6 项,待处理 7 项') && text.includes('正在处理:指标6'))
check('单独说明待补充来源的指标', text.includes('另有 1 项'))
check('不用用户再添加下一批', !text.includes('下一批') && !text.includes('最多选择 6'))
check('页面没有报错', errors.length === 0, errors.join(';'))
})
await section('旧口径迁移:显示粒度缺口,按检查版本准备独立草稿', async () => {
const state = makeState({ draft: draftOf(), validations: { records: [], summary: summaryOf({ verdict: 'none', status: null }) } })
const original = state.reply
let migration
state.reply = async (method, p, body, url) => {
if (p.endsWith('/migration') && method === 'GET') return { json: {
revision: 7, metrics_total: 1, metrics_needing_work: 1, can_rebuild: true,
note: '这是静态迁移检查,不代表查询结果已验证',
metrics: [{ card_id: 'card', metric_id: 'm1', name: '日 OEE', query_count: 1,
findings: [{ code: 'scalar_unproven', message: '最终取数未收敛为单行' }], guidance: '比率不能简单平均' }],
} }
if (p.endsWith('/migration') && method === 'POST') {
migration = body
return { status: 201, json: { draft_id: state.draft.id, status: 'path_pending' } }
}
return original(method, p, body, url)
}
const { page, errors } = await pageOn(state)
await expand(page, state.draft)
const panel = page.locator('[data-migration-panel]')
await panel.getByRole('button', { name: '检查问数与取数粒度' }).click()
await panel.getByText('共 1 项,1 项需处理').waitFor()
await panel.locator('details summary').click()
check('逐项显示粒度问题', await panel.getByText('最终取数未收敛为单行', { exact: true }).isVisible())
check('说明静态检查不等于验证通过', (await panel.innerText()).includes('不代表查询结果已验证'))
await panel.getByRole('button', { name: '从原文准备独立迁移草稿' }).click()
await until(() => migration)
check('迁移基于刚检查的版本,未整份覆盖旧图', JSON.stringify(migration) === '{"expected_revision":7}')
check('页面没有报错', errors.length === 0, errors.join(';'))
})
check('整个检查没有弹出原生对话框', dialogs.length === 0, dialogs.slice(0, 2).join(';'))
check('检查没有写库(兜底拦下的写请求)', states.every((s) => s.stray.length === 0),
states.flatMap((s) => s.stray).slice(0, 4).join('、'))
await browser.close()
console.log(`\n${failed ? `✗ ${failed} 项没过` : '✓ 全部通过'}`)
process.exit(failed ? 1 : 0)