Files
AgentLab/scripts/check-verify.mjs
T
ThearchyHelios 7a9dc93dde 双盲复算前端(三):检查器字段、节点卡片摘要,以及检查器与节点卡片的钩子
- nodeDefs:照约定一字不差地加 FieldDef.render 与 FieldRenderProps;NodeDef 加可选的 summary(ctx) 钩子
  (ctx:id、config、画布上的 nodes 与 edges,只返回字符串);dual_check 换成真字段:要复算的口径卡、复算的指标、
  口径说明、启用、审核人不采用原流程的结果时(基本),盲算模型的接入与模型、允许的相对误差(高级)
- Inspector:照约定一字不差地在 FieldInput 的 switch 前交给 field.render
- canvas/DualCheckFields.tsx(新):口径卡下拉分上游、其余两组,不在上游或不在画布上照实提醒;指标逐个勾,
  全勾回到缺省不写、最后一个不让取消;口径说明逐个指标写,被取消的指标上已写的说明列出来可删;盲算模型照「结论句
  裁判」的写法(只填模型时写明按模型名用哪个接入);相对误差的占位写出设置里的值;启用三态,跟随设置不留 null。
  每个控件只写自己的键,留空写回 undefined
- NodeCard:用只返回字符串的 studio 选择器调摘要钩子,有就用、没有照旧 summarize;摘要写「复算「卡名」 · N 个指标」,
  关闭时标出;去掉 dual_check 的占位 case
- 预览用例加检查器(小画布用真的节点卡片 + 360px 检查器),check-verify 加检查器写配置、摘要跟着变、窄屏和对比度
- 去掉 nodeDefs 里已经不成立的「下面两种还是占位」注释
2026-10-05 14:23:17 +08:00

529 lines
38 KiB
JavaScript
Raw 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.
// 双盲复算与反方审稿的回归检查(期 3)。
//
// 双盲复算这一半守的是这些「看着没坏、其实用不了」的地方:
// - 审批卡:两路对不上时,卡上要先按摘要画出来(完整对照还在路上时不能一片空白),再按工件补全;具体差异逐条列,
// 只有兜底说明时写明「未能归纳成具体差异」并默认展开对照;对照两条 SQL 时左右并排(窄栏上下排),规范化后不同的
// 行高亮;三个选择缺一个都不能提交,提交的回复是 {choices, note};卡上不能再冒出通用的输入框和「提交 / 驳回并终止」
// (mode 是 input,通用那套会把一个字符串当 value 发出去);后果按运行类别说,探索运行不提出具;
// - 检查器:口径卡、指标、口径说明、盲算模型、相对误差、不采用原流程时、启用,每一项都真的写进节点配置;
// 节点卡片的摘要写出复算哪张口径卡、几个指标;
// - 设置页「核查」一节:读回各项,改了之后整组写 verify(不限存 null),叫法和服务端 VERIFY_SETTING_LABEL 一字不差;
// - 390px 窄屏不横向溢出,深浅色都读得清,键盘能操作。
//
// 页面都跑在预览页(/preview.html?syn=…,用例在 frontend/src/dev/fixtures/dual_check.tsx,夹具 dual_check.json 两边
// 共用)和设置页上,接口一律 page.route 伪造:完整对照的工件、审批回复、设置的读写都在浏览器里答,别的非 GET 拦掉,
// 不碰真实的写接口。文案对照那一节不开浏览器:读 backend/app/api/settings.py,terms.ts 借 vite 转译后直接 import。
//
// node scripts/check-verify.mjs
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-verify.mjs
// VERIFY_SHOTS=<目录> 时亮暗两套各截几张,供人眼复核。
import { mkdirSync, readFileSync } from 'node:fs'
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 SHOTS = process.env.VERIFY_SHOTS ?? ''
const root = new URL('..', import.meta.url).pathname
const fx = JSON.parse(readFileSync(`${root}frontend/src/dev/fixtures/dual_check.json`, 'utf8'))
const AP = fx.approval
const CARD_ID = AP.payload.card_artifact
let failed = 0
const check = (name, cond, detail = '') => {
const d = String(detail ?? '').replace(/\s+/g, ' ').slice(0, 160)
console.log(` ${cond ? '✓' : '✗'} ${name}${d ? ` — ${d}` : ''}`)
if (!cond) failed++
}
/** 一节一节地跑:某一节里等待超时、元素找不到,只记成这一节失败,收掉它开的页面,接着跑下一节 */
const opened = new Set()
async function section(name, fn) {
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()
}
}
// terms.ts 借 vite 转译成 data: URL 再 import(同 check-decode):依赖也各自转好替换进去
const loaded = new Map()
async function load(path) {
if (loaded.has(path)) return loaded.get(path)
const res = await fetch(`${WEB}${path}?t=${Date.now()}`)
if (!res.ok) throw new Error(`${path} → HTTP ${res.status}`)
let code = await res.text()
const deps = new Set([...code.matchAll(/from\s+["'](\/src\/[^"'?]+)(?:\?[^"']*)?["']/g)].map((m) => m[1]))
for (const dep of deps) {
const url = await load(dep)
const pattern = new RegExp(`(["'])${dep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\?[^"']*)?\\1`, 'g')
code = code.replace(pattern, JSON.stringify(url))
}
const url = 'data:text/javascript;base64,' + Buffer.from(code).toString('base64')
loaded.set(path, url)
return url
}
const shot = async (page, name) => {
if (!SHOTS) return
mkdirSync(SHOTS, { recursive: true })
await page.screenshot({ path: `${SHOTS}/${name}.png`, fullPage: true })
}
const text = async (loc) => (await loc.first().innerText().catch(() => '')).replace(/\s+/g, ' ').trim()
/** 两个颜色的对比度(WCAG):颜色是 getComputedStyle 给的 rgb / rgba / color(srgb …),半透明的叠在底色上 */
function contrast(fg, bg, base) {
const parse = (c) => {
if (typeof c !== 'string') return null
const m = c.match(/color\(srgb ([\d.]+) ([\d.]+) ([\d.]+)(?: \/ ([\d.]+))?\)/)
if (m) return [m[1] * 255, m[2] * 255, m[3] * 255, m[4] == null ? 1 : Number(m[4])]
const n = c.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/)
return n ? [Number(n[1]), Number(n[2]), Number(n[3]), n[4] == null ? 1 : Number(n[4])] : null
}
const over = (top, under) => top.slice(0, 3).map((v, i) => v * top[3] + under[i] * (1 - top[3]))
const lum = ([r, g, b]) => {
const f = (v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 }
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b)
}
const b0 = parse(base); const b1 = parse(bg); const f0 = parse(fg)
if (!b0 || !b1 || !f0) return 0
const under = over(b1, b0.slice(0, 3))
const a = lum(over(f0, under)); const b = lum(under)
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
}
// ---------------------------------------------------------------- 文案对照(不开浏览器)
await section('核查设置的叫法和服务端一字不差', async () => {
// 设置页保存时 422 的报错写的是 VERIFY_SETTING_LABEL 里的叫法:界面上的字段名得是同一套,人才找得到是哪一项
const py = readFileSync(`${root}backend/app/api/settings.py`, 'utf8')
const block = py.match(/^VERIFY_SETTING_LABEL = \{([\s\S]*?)^\}/m)?.[1] ?? ''
const labels = Object.fromEntries([...block.matchAll(/"([^"]+)":\s*"([^"]+)"/g)].map((m) => [m[1], m[2]]))
check('从 settings.py 读出了 VERIFY_SETTING_LABEL', Object.keys(labels).length >= 10, `${Object.keys(labels).length} 项`)
const { VERIFY_SETTING_TEXT: T } = await import(await load('/src/lib/terms.ts'))
const keys = Object.keys(T).filter((k) => k !== 'section')
const missing = Object.keys(labels).filter((k) => !(k in T))
const extra = keys.filter((k) => !(k in labels))
check('VERIFY_SETTING_TEXT 顶层的键和服务端完全相同(section 除外)', !missing.length && !extra.length,
`缺 ${missing.join('、') || '无'};多 ${extra.join('、') || '无'}`)
const wrong = Object.entries(labels).filter(([k, v]) => T[k] !== v)
check('……每一项的叫法一字不差', !wrong.length, wrong.map(([k, v]) => `${k}:${T[k]} ≠ ${v}`).join(';'))
check('……这一节其余的文案放在 section 里', !!T.section?.title && typeof T.section.spend === 'function')
})
// ---------------------------------------------------------------- 页面
const browser = await chromium.launch({ executablePath: CHROME })
/**
* 开一个预览用例。完整对照的工件按 artifact 答(默认夹具里的那份;给函数可以延迟、出错),审批回复记下请求体、
* 回一个伪造的运行;审批列表回空;别的非 GET 一律拦掉,别的 GET 放行
*/
async function preview(syn, { width = 1280, height = 900, theme = 'dark', artifact = null, extra = null } = {}) {
const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, timezoneId: 'Asia/Shanghai' })
opened.add(ctx)
ctx.setDefaultTimeout(6000)
const page = await ctx.newPage()
const sent = []
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
page.on('dialog', (d) => { errors.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())
if (extra) {
const out = await extra(req, url)
if (out) return route.fulfill({ status: out.status ?? 200, json: out.json })
}
if (req.method() === 'POST' && url.pathname === `/api/approvals/${AP.id}/decide`) {
sent.push({ path: url.pathname, body: req.postDataJSON() })
return route.fulfill({ json: { id: AP.run_id, status: 'running', run_class: AP.run_class, input: {}, output: {}, usage: {} } })
}
if (req.method() !== 'GET') {
sent.push({ path: url.pathname, blocked: true, method: req.method() })
return route.abort()
}
if (url.pathname === `/api/artifacts/${CARD_ID}`) {
const out = artifact ? await artifact() : { json: { id: CARD_ID, content: fx.card } }
return route.fulfill({ status: out.status ?? 200, json: out.json })
}
if (url.pathname === '/api/approvals') return route.fulfill({ json: [] })
return route.continue()
})
await page.goto(`${WEB}/preview.html?syn=${syn}&theme=${theme}&actor=张工`, { waitUntil: 'domcontentloaded' })
await page.waitForSelector(`[data-fixture="${syn}"]`)
return { ctx, page, sent, errors }
}
const metric = (page, id) => page.locator(`[data-dual-metric="${id}"]`)
await section('审批卡:先按摘要画,再取完整对照', async () => {
// 完整对照晚一点到:这段时间里卡上照样有两个值、相差多少、摘要里的差异,选择也能先做
let release
const gate = new Promise((r) => { release = r })
const { page, sent, errors } = await preview('dual-check-approval', {
artifact: async () => { await gate; return { json: { id: CARD_ID, content: fx.card } } },
})
await page.waitForSelector('[data-dual-check]')
const card = page.locator('[data-approval]')
check('卡头是审批标题', (await text(card)).includes('双盲复算:2 个指标不一致'))
check('开头一句说几个指标不一致、要逐个选', (await text(page.locator('[data-dual-intro]'))) === '盲算与原流程有 2 个指标不一致,请逐个选择采用哪一边的结果')
check('每个不一致的指标一块,按摘要的顺序', (await page.locator('[data-dual-metric]').evaluateAll((els) => els.map((e) => e.dataset.dualMetric))).join(',')
=== 'gmv,refund_rate')
const gmv = metric(page, 'gmv')
check('指标名、原流程值、盲算值', (await text(gmv.locator('[data-dual-name]'))) === '销售额'
&& (await text(gmv.locator('[data-dual-value="original"]'))) === '45,678.5元' && (await text(gmv.locator('[data-dual-value="blind"]'))) === '51,388.3元')
check('……两个值各标是哪一边', (await text(gmv.locator('[data-dual-values]'))).includes('原流程') && (await text(gmv.locator('[data-dual-values]'))).includes('盲算'))
check('相差百分比带正负号', (await text(gmv.locator('[data-dual-delta]'))) === '相差 +12.5%' && (await text(metric(page, 'refund_rate').locator('[data-dual-delta]'))) === '相差 +31.91%',
`${await text(gmv.locator('[data-dual-delta]'))} / ${await text(metric(page, 'refund_rate').locator('[data-dual-delta]'))}`)
check('完整对照还没到:先列摘要里的具体差异', (await text(gmv.locator('[data-dual-diff-item]'))) === AP.payload.summary[0].diff[0])
check('……只有兜底的那个指标写明未能归纳', (await text(metric(page, 'refund_rate').locator('[data-dual-fallback]'))) === '两条 SQL 的写法不同,未能归纳成具体差异')
check('……说明正在加载完整对照', (await page.locator('[data-dual-loading]').count()) > 0)
check('卡上没有通用的输入框和备注', await card.locator('textarea[aria-label="你的输入"], input[aria-label="备注"]').count() === 0)
check('……也没有通用的「提交」「驳回并终止」', await card.getByRole('button', { name: '提交', exact: true }).count() === 0
&& await card.getByRole('button', { name: /驳回/ }).count() === 0)
check('……通用那句「提交:以你填写的内容继续运行」也不在', !(await text(card)).includes('以你填写的内容继续运行'))
check('署名那一行还在', (await text(card)).includes('将以「张工」签批'))
release()
await page.waitForSelector('[data-dual-check][data-state="ready"]')
check('完整对照到了:写出盲算模型', (await text(page.locator('[data-dual-model]'))) === '盲算模型:blind-model-x')
check('……具体差异按工件逐条列', (await gmv.locator('[data-dual-diff-item]').count()) === 1
&& (await text(gmv.locator('[data-dual-diff-item]'))) === fx.card.metrics[0].diff.items[0].text)
check('……具体差异的那个指标默认收着对照,给「对照两条 SQL」', (await gmv.locator('[data-dual-compare-toggle]').getAttribute('aria-expanded')) === 'false'
&& (await text(gmv.locator('[data-dual-compare-toggle]'))) === '对照两条 SQL' && await gmv.locator('[data-dual-compare]').count() === 0)
const rr = metric(page, 'refund_rate')
check('……只有兜底的那个指标默认展开对照', (await rr.locator('[data-dual-compare-toggle]').getAttribute('aria-expanded')) === 'true'
&& await rr.locator('[data-dual-compare]').count() === 1)
check('……兜底的原话不再列成一条「具体差异」', await rr.locator('[data-dual-diff-item]').count() === 0)
check('只差一个方面的指标:说明选择会记入数据目录', (await text(gmv.locator('[data-dual-catalog]'))).includes('记入数据目录')
&& await rr.locator('[data-dual-catalog]').count() === 0)
const after = await text(page.locator('[data-dual-consequence]'))
check('正式运行:后果写明报告里的数仍取自原流程、采用盲算或都不对会让出具降档', after.includes('报告中的数仍取自原流程') && after.includes('出具降档'), after)
check('没有发出任何写请求', sent.length === 0, JSON.stringify(sent))
check('页面没有报错', errors.length === 0, errors.join(';'))
await shot(page, 'approval-dark')
})
await section('审批卡:并排对照与高亮', async () => {
const { page, errors } = await preview('dual-check-approval')
await page.waitForSelector('[data-dual-check][data-state="ready"]')
const gmv = metric(page, 'gmv')
const toggle = gmv.locator('[data-dual-compare-toggle]')
// 键盘:Tab 到按钮上回车展开
await toggle.focus()
await page.keyboard.press('Enter')
check('键盘回车展开对照,按钮标为展开、指向对照区', (await toggle.getAttribute('aria-expanded')) === 'true'
&& await page.locator(`#${await toggle.getAttribute('aria-controls')}[data-dual-compare]`).count() === 1)
check('……按钮改说「收起对照」', (await text(toggle)) === '收起对照')
const orig = gmv.locator('[data-dual-sql="original"]')
const blind = gmv.locator('[data-dual-sql="blind"]')
check('两条 SQL 都在,各标是哪一边', (await text(orig)).includes('paid_at') && (await text(blind)).includes('created_at')
&& (await text(gmv.locator('[data-dual-pair]'))).includes('原流程的 SQL') && (await text(gmv.locator('[data-dual-pair]'))).includes('盲算的 SQL'))
const marks = await gmv.evaluate((el) => ({
del: [...el.querySelectorAll('[data-dual-sql="original"] [data-diff-line="del"]')].map((m) => m.textContent.trim()),
add: [...el.querySelectorAll('[data-dual-sql="blind"] [data-diff-line="add"]')].map((m) => m.textContent.trim()),
}))
check('text_diff 的行在原流程那边高亮', marks.del.join('|') === "WHERE paid_at >= '2026-09-01'|AND paid_at < '2026-09-08'", marks.del.join('|'))
check('……在盲算那边也高亮', marks.add.join('|') === "WHERE created_at >= '2026-09-01'|AND created_at < '2026-09-08'", marks.add.join('|'))
check('……相同的行不高亮', !(await orig.locator('[data-diff-line]').allInnerTexts()).some((t) => t.includes('FROM orders')))
const o = await orig.boundingBox()
const b = await blind.boundingBox()
check('宽栏里左右并排:同一行、盲算在右边', !!o && !!b && Math.abs(o.y - b.y) < 2 && b.x >= o.x + o.width - 1, JSON.stringify({ o, b }))
check('盲算的说明', (await text(gmv.locator('[data-dual-rationale]'))).includes('销售额按下单时间统计本周全部订单金额之和'))
check('输入取值:路径、两边的值', (await text(gmv.locator('[data-dual-input="vars.kpi.gmv"]'))).includes('45,678.5')
&& (await text(gmv.locator('[data-dual-input="vars.kpi.gmv"]'))).includes('51,388.3'))
const lines = await gmv.locator('[data-dual-text-diff] [data-diff-side]').evaluateAll((els) => els.map((e) => e.dataset.diffSide))
check('规范化后不同的行另列一份,每行标是哪一边', lines.join(',') === 'original,original,blind,blind', lines.join(','))
const rr = metric(page, 'refund_rate')
check('两个输入、只有一个不同:只对照不同的那一个', (await rr.locator('[data-dual-pair]').evaluateAll((els) => els.map((e) => e.dataset.dualPair))).join(',')
=== 'vars.kpi.refunds')
check('……输入取值两项都列,不同的那项标出来', await rr.locator('[data-dual-input]').count() === 2
&& await rr.locator('[data-dual-input="vars.kpi.refunds"][data-differs]').count() === 1
&& await rr.locator('[data-dual-input="vars.kpi.orders"][data-differs]').count() === 0)
// 原流程那条规范化后多了 AS,和原文对不上:不乱标;盲算那条规范化后和原文一字不差:照样高亮
check('……一行写完的 SQL:规范化后对不上原文的不乱标,对得上的照样高亮', await rr.locator('[data-dual-sql="original"] [data-diff-line]').count() === 0
&& await rr.locator('[data-dual-sql="blind"] [data-diff-line="add"]').count() === 1)
check('……规范化后不同的行照样另列', await rr.locator('[data-dual-text-diff] [data-diff-side]').count() === 2)
await page.keyboard.press('Enter')
check('再按一次回车收起', (await toggle.getAttribute('aria-expanded')) === 'false' && await gmv.locator('[data-dual-compare]').count() === 0)
check('页面没有报错', errors.length === 0, errors.join(';'))
})
await section('审批卡:不选全不能提交,提交的回复是 {choices, note}', async () => {
const { page, sent, errors } = await preview('dual-check-approval')
await page.waitForSelector('[data-dual-check][data-state="ready"]')
const submit = page.locator('[data-dual-submit]')
check('三个选项必选:一个都没选时不能提交', await submit.isDisabled() && (await text(page.locator('[data-dual-pending]'))) === '还有 2 个指标未选择')
const gmv = metric(page, 'gmv')
const opts = await gmv.locator('[data-dual-choice] label').allInnerTexts()
check('三个选项的说法', opts.map((t) => t.trim()).join('|') === '采用原流程的结果|采用盲算的结果|两边都不对', opts.join('|'))
check('……每组有读屏名:哪个指标采用哪一边', (await text(gmv.locator('[data-dual-choice] legend'))) === '「销售额」采用哪一边的结果')
await gmv.locator('label', { hasText: '采用原流程的结果' }).click()
check('选了一个:还差一个,仍不能提交', await submit.isDisabled() && (await text(page.locator('[data-dual-pending]'))) === '还有 1 个指标未选择')
// 键盘:方向键在一组里换选项
const rr = metric(page, 'refund_rate')
await rr.locator('input[value="a"]').focus()
await page.keyboard.press('ArrowRight')
await page.keyboard.press('ArrowRight')
check('键盘方向键在一组里换选项', await rr.locator('input[value="neither"]').isChecked() && !(await rr.locator('input[value="b"]').isChecked()))
check('全选了:可以提交、不再提示未选', !(await submit.isDisabled()) && await page.locator('[data-dual-pending]').count() === 0)
const note = '销售额以支付时间为准;退款率两边都没有剔除部分退款'
await page.locator('[data-dual-note]').fill(note)
await submit.click()
await page.waitForFunction(() => window.__dualResolved != null)
const posts = sent.filter((s) => s.path === `/api/approvals/${AP.id}/decide`)
check('走审批回复接口,只发一次', posts.length === 1, String(posts.length))
const body = posts[0]?.body ?? {}
check('请求体:value 是 {choices, note}', JSON.stringify(body.value) === JSON.stringify({ choices: { gmv: 'a', refund_rate: 'neither' }, note }),
JSON.stringify(body.value))
check('……照 input 模式的写法:approved 为真、备注同时放在外层', body.approved === true && body.note === note, JSON.stringify(body))
check('提交后交给调用方重新接上运行', await page.evaluate(() => window.__dualResolved) === AP.run_id)
check('……提示已提交、运行继续', (await text(page.locator('body'))).includes('已提交裁决,运行继续'))
check('没有别的写请求', !sent.some((s) => s.blocked), JSON.stringify(sent.filter((s) => s.blocked)))
check('页面没有报错', errors.length === 0, errors.join(';'))
})
await section('审批卡:完整对照取不到时,按摘要照样能裁决', async () => {
const { page, sent } = await preview('dual-check-approval', {
artifact: async () => ({ status: 404, json: { detail: '工件不存在' } }),
})
await page.waitForSelector('[data-dual-load-failed]')
check('写明无法加载完整对照、以下仅为摘要', (await text(page.locator('[data-dual-load-failed]'))) === '无法加载完整对照,以下仅为摘要')
check('……摘要照样在:值、相差、差异', (await text(metric(page, 'gmv').locator('[data-dual-value="blind"]'))) === '51,388.3元'
&& await metric(page, 'gmv').locator('[data-dual-diff-item]').count() === 1)
check('……没有可对照的 SQL,不给「对照两条 SQL」', await page.locator('[data-dual-compare-toggle]').count() === 0)
for (const id of ['gmv', 'refund_rate']) await metric(page, id).locator('label', { hasText: '采用盲算的结果' }).click()
await page.locator('[data-dual-submit]').click()
await page.waitForFunction(() => window.__dualResolved != null)
const body = sent.find((s) => s.path === `/api/approvals/${AP.id}/decide`)?.body
check('……照样能提交', JSON.stringify(body?.value?.choices) === JSON.stringify({ gmv: 'b', refund_rate: 'b' }), JSON.stringify(body))
})
await section('审批卡:探索运行不提出具', async () => {
const { page } = await preview('dual-check-approval-exploratory')
await page.waitForSelector('[data-dual-check][data-state="ready"]')
const said = await text(page.locator('[data-dual-consequence]'))
check('后果只说记为缺口,不写任何出具相关的话', said.includes('记为缺口') && !said.includes('出具'), said)
})
await section('审批卡:390px 不横向溢出、窄栏里上下排、深浅色可读', async () => {
for (const theme of ['dark', 'light']) {
const { page, errors } = await preview('dual-check-approval', { width: 390, height: 844, theme })
await page.waitForSelector('[data-dual-check][data-state="ready"]')
await metric(page, 'gmv').locator('[data-dual-compare-toggle]').click()
const w = await page.evaluate(() => ({
page: document.documentElement.scrollWidth - document.documentElement.clientWidth,
card: (() => { const c = document.querySelector('[data-dual-check]'); return c.scrollWidth - c.clientWidth })(),
}))
check(`390px(${theme}):两边都展开后不横向溢出`, w.page <= 0 && w.card <= 1, JSON.stringify(w))
const o = await metric(page, 'gmv').locator('[data-dual-sql="original"]').boundingBox()
const b = await metric(page, 'gmv').locator('[data-dual-sql="blind"]').boundingBox()
check(`390px(${theme}):两条 SQL 上下排`, !!o && !!b && b.y >= o.y + o.height - 1, JSON.stringify({ o, b }))
const c = await page.evaluate(() => {
const card = document.querySelector('[data-approval]')
const base = getComputedStyle(document.body).backgroundColor
const pick = (sel) => {
const el = document.querySelector(sel)
if (!el) return null
// 往上找第一层不透明(或半透明)的底色
let bg = 'rgba(0, 0, 0, 0)'
for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
const v = getComputedStyle(n).backgroundColor
if (v !== 'rgba(0, 0, 0, 0)' && v !== 'transparent') { bg = v; break }
}
return { fg: getComputedStyle(el).color, bg }
}
return { base, card: getComputedStyle(card).backgroundColor, delta: pick('[data-dual-delta]'),
consequence: pick('[data-dual-consequence]'), mark: pick('[data-diff-line="del"]'), fallback: pick('[data-dual-fallback]') }
})
for (const key of ['delta', 'consequence', 'mark', 'fallback']) {
const v = c[key]
const ratio = v ? contrast(v.fg, v.bg, c.base) : 0
check(`${theme}:${({ delta: '相差比例', consequence: '后果说明', mark: '高亮的行', fallback: '兜底说明' })[key]}对比度 ≥ 4.5`, ratio >= 4.5,
`${ratio.toFixed(2)}(${v?.fg} on ${v?.bg})`)
}
await shot(page, `approval-narrow-${theme}`)
check(`390px(${theme}):页面没有报错`, errors.length === 0, errors.join(';'))
}
const rail = await preview('dual-check-approval-rail', { width: 1280, height: 900 })
await rail.page.waitForSelector('[data-dual-check][data-state="ready"]')
await metric(rail.page, 'gmv').locator('[data-dual-compare-toggle]').click()
const r = await rail.page.evaluate(() => {
const box = document.querySelector('[data-fixture="dual-check-approval-rail"]')
return box.scrollWidth - box.clientWidth
})
check('360px 画布右栏:展开对照也不撑宽右栏', r <= 0, `${r}px`)
const o = await metric(rail.page, 'gmv').locator('[data-dual-sql="original"]').boundingBox()
const b = await metric(rail.page, 'gmv').locator('[data-dual-sql="blind"]').boundingBox()
check('360px 画布右栏:宽屏下也按卡片的宽度上下排', !!o && !!b && b.y >= o.y + o.height - 1, JSON.stringify({ o, b }))
await shot(rail.page, 'approval-rail-dark')
})
// ---------------------------------------------------------------- 检查器与节点卡片
/**
* 检查器用例:设置(相对误差、启用的「跟随设置」要写出眼下的值)按夹具答,改配置触发的校验、变量分析回空,
* 节点配置直接从 studio store 读(window.__studio,开发服务器才挂)
*/
async function inspector(opts = {}) {
const out = await preview('dual-check-inspector', {
...opts,
extra: (req, url) => {
if (req.method() === 'GET' && url.pathname === '/api/settings') return { json: { run: {}, verify: fx.verify } }
if (req.method() === 'POST' && url.pathname === '/api/workflows/validate') return { json: { issues: [] } }
if (req.method() === 'POST' && url.pathname === '/api/workflows/variables') return { json: { variables: [], issues: [] } }
return null
},
})
await out.page.waitForSelector('[data-field="card"] select')
return out
}
const config = (page) => page.evaluate(() => window.__studio.getState().nodes.find((n) => n.id === 'dc').data.config)
const summaryOf = (page) => text(page.locator('.react-flow__node[data-id="dc"] .nc-summary'))
/** 画布上的节点是 React Flow 在下一拍才按新配置重画的:等摘要变成想要的那句(最多 3 秒),返回最后看到的 */
async function summaryBecomes(page, want) {
const ok = await page.waitForFunction((w) => {
const el = document.querySelector('.react-flow__node[data-id="dc"] .nc-summary')
return !!el && (typeof w === 'string' ? el.textContent.trim() === w : el.textContent.trim().endsWith(w.suffix))
}, want, { timeout: 3000 }).then(() => true, () => false)
return { ok, seen: await summaryOf(page) }
}
await section('检查器:每一项都写进节点配置,节点卡片的摘要跟着变', async () => {
const { page, errors, sent } = await inspector()
const panel = page.locator('[data-dual-inspector]')
check('说明里不再写「功能开发中」', !(await text(panel)).includes('功能开发中') && (await text(panel)).includes('另起一路独立计算'))
check('节点卡片的摘要:复算哪张口径卡、全部几个指标', await summaryOf(page) === '复算「周报口径卡」 · 全部 3 个指标', await summaryOf(page))
// 口径卡:下拉选画布上的口径卡,上游的一组、其余的一组;留空取上游的那张
const card = page.locator('[data-field="card"] select')
const groups = await card.evaluate((el) => [...el.querySelectorAll('optgroup')].map((g) => `${g.label}:${[...g.querySelectorAll('option')].map((o) => o.value).join('+')}`))
check('口径卡:上游的一组、其余的一组', groups.join('|') === '上游的口径卡:card|其他口径卡(需连接到本节点上游):card2', groups.join('|'))
check('……留空那一项写明默认取上游的哪一张', (await card.locator('option').first().innerText()) === '默认:上游的口径卡「周报口径卡」')
check('……标签指向这个下拉', await page.locator('[data-field="card"] label').getAttribute('for') === await card.getAttribute('id'))
await card.selectOption('card2')
check('选另一张:写进 card', (await config(page)).card === 'card2', JSON.stringify(await config(page)))
check('……不在上游的照实提醒', (await text(page.locator('[data-dual-card-warn]'))).includes('不在本节点上游'))
const swapped = await summaryBecomes(page, '复算「月报口径卡」 · 全部 1 个指标')
check('……摘要换成这张卡', swapped.ok, swapped.seen)
await card.selectOption('')
check('选回默认:card 去掉,不留空串', !('card' in (await config(page))) || (await config(page)).card === undefined, JSON.stringify(await config(page)))
// 指标:逐个勾;全部勾着是缺省,不写;最后一个不让取消
const opts = page.locator('[data-dual-metric-option]')
check('指标:列出这张卡的三个指标,默认全勾、说明是缺省', await opts.count() === 3
&& await page.locator('[data-dual-metric-option] input:checked').count() === 3
&& (await text(page.locator('[data-dual-metrics-state]'))) === '未指定:复算全部输入来自查询的指标')
check('……一组有读屏名', await page.locator('[data-dual-metrics="list"]').getAttribute('aria-label') === '复算的指标')
await page.locator('[data-dual-metric-option="aov"] input').uncheck()
check('取消一个:写进 metrics(按卡里的顺序)', JSON.stringify((await config(page)).metrics) === '["gmv","orders"]', JSON.stringify((await config(page)).metrics))
const two = await summaryBecomes(page, '复算「周报口径卡」 · 2 个指标')
check('……摘要写几个指标', two.ok, two.seen)
check('……说明写已选几个', (await text(page.locator('[data-dual-metrics-state]'))) === '已选 2 / 3 个指标')
await page.locator('[data-dual-metric-option="orders"] input').uncheck()
check('只剩一个:不让取消(一个都不勾和缺省分不清)', await page.locator('[data-dual-metric-option="gmv"] input').isDisabled()
&& JSON.stringify((await config(page)).metrics) === '["gmv"]')
// 口径说明:只列要复算的指标;写了的存成 {指标 id: 说明}
const defs = page.locator('[data-dual-definition]')
check('口径说明:只列要复算的指标', (await defs.evaluateAll((els) => els.map((e) => e.dataset.dualDefinition))).join(',') === 'gmv')
const words = '销售额按支付时间统计,不含退款'
await page.locator('[data-dual-definition="gmv"] textarea').fill(words)
check('写一段:存成 {指标 id: 说明}', JSON.stringify((await config(page)).definitions) === JSON.stringify({ gmv: words }),
JSON.stringify((await config(page)).definitions))
check('……每段有自己的标签(指标名)', (await text(page.locator('[data-dual-definition="gmv"] label'))) === '销售额(gmv)')
for (const id of ['orders', 'aov']) await page.locator(`[data-dual-metric-option="${id}"] input`).check()
check('全部勾回来:metrics 去掉(缺省)', (await config(page)).metrics === undefined, JSON.stringify((await config(page)).metrics))
await page.locator('[data-dual-definition="orders"] textarea').fill('订单数按支付成功的订单计')
await page.locator('[data-dual-metric-option="orders"] input').uncheck()
check('写了说明的指标被取消:照实提醒、能删掉', (await text(page.locator('[data-dual-definition-gone]'))).includes('「orders」'))
await page.locator('[data-dual-definition-gone] button').click()
check('……删掉后只剩还在复算的那段', JSON.stringify((await config(page)).definitions) === JSON.stringify({ gmv: words }))
await page.locator('[data-dual-definition="gmv"] textarea').fill('')
check('清空最后一段:definitions 去掉', (await config(page)).definitions === undefined)
await page.locator('[data-dual-metric-option="orders"] input').check()
// 启用:跟随设置 / 开启 / 关闭
const enabled = page.locator('[data-field="enabled"] [role="radiogroup"]')
check('启用:三态,跟随设置写出设置里眼下是开着的', (await enabled.locator('[role="radio"]').allInnerTexts()).join('|') === '跟随设置(当前开启)|开启|关闭'
&& await enabled.locator('[role="radio"][aria-checked="true"]').getAttribute('data-value') === 'follow')
await enabled.locator('[data-value="off"]').click()
const off = await summaryBecomes(page, { suffix: ' · 已关闭' })
check('关闭:enabled 为 false,摘要标「已关闭」', (await config(page)).enabled === false && off.ok, off.seen)
await enabled.locator('[data-value="off"]').focus()
await page.keyboard.press('ArrowLeft')
check('键盘方向键换选项:开启是 true', (await config(page)).enabled === true
&& await page.evaluate(() => document.activeElement?.getAttribute('data-value')) === 'on')
await enabled.locator('[data-value="follow"]').click()
check('跟随设置:enabled 去掉,不留 null', (await config(page)).enabled === undefined, JSON.stringify(await config(page)))
// 不采用原流程时
const reject = page.locator('[data-field="on_reject"] select')
check('不采用原流程时:默认落在「记为出具缺口」上', (await reject.locator('option:checked').innerText()) === '记为出具缺口,出具降档(默认)')
await reject.selectOption('fail')
check('……选判为失败:on_reject 为 fail', (await config(page)).on_reject === 'fail')
// 高级:盲算模型、相对误差
await page.locator('button', { hasText: '高级选项' }).click()
const provider = page.locator('[data-field="provider"] select')
check('盲算模型 · 接入:留空跟随设置,停用的接入照实标出', (await provider.locator('option').allInnerTexts()).join('|') === '跟随设置|云端接入|备用接入(已停用)',
(await provider.locator('option').allInnerTexts()).join('|'))
await provider.selectOption('云端接入')
const model = page.locator('[data-field="model"] input')
check('选了接入:provider 写进去,模型的占位换成接入的默认模型', (await config(page)).provider === '云端接入'
&& await model.getAttribute('placeholder') === '留空则使用该接入的默认模型')
await model.fill('model-b')
check('模型:写进 model', (await config(page)).model === 'model-b')
await provider.selectOption('')
check('只填了模型:接入留空那一项改叫「按模型名找接入」,写明按模型名用哪个接入', (await provider.locator('option').first().innerText()) === '按模型名找接入'
&& (await text(page.locator('[data-dual-by-model]'))).includes('将按模型名使用接入「云端接入」') && (await config(page)).provider === undefined)
const tol = page.locator('[data-field="tolerance"] input')
check('允许的相对误差:占位写出设置里的值', await tol.getAttribute('placeholder') === '跟随设置(0.000001)', await tol.getAttribute('placeholder'))
await tol.fill('0.001')
check('……填了写成数', (await config(page)).tolerance === 0.001)
await tol.fill('-1')
check('……负数标出来', (await text(page.locator('[data-field="tolerance"]'))).includes('请填写不小于 0 的数') && await tol.getAttribute('aria-invalid') === 'true')
await tol.fill('')
check('……清空回到跟随设置', (await config(page)).tolerance === undefined)
const KEYS = ['card', 'metrics', 'definitions', 'provider', 'model', 'tolerance', 'on_reject', 'enabled']
const keys = Object.entries(await config(page)).filter(([, v]) => v !== undefined).map(([k]) => k)
check('配置里只有约定的键,留空的项不留空值', keys.every((k) => KEYS.includes(k)) && keys.sort().join(',') === 'model,on_reject', keys.join(','))
check('没有写库的请求', !sent.some((s) => s.blocked), JSON.stringify(sent.filter((s) => s.blocked)))
check('页面没有报错', errors.length === 0, errors.join(';'))
await shot(page, 'inspector-dark')
})
await section('检查器:360px 栏、390px 窄屏不横向溢出,深浅色可读', async () => {
for (const [width, theme] of [[1280, 'dark'], [390, 'light']]) {
const { page, errors } = await inspector({ width, height: 900, theme })
await page.locator('button', { hasText: '高级选项' }).click()
await page.locator('[data-field="model"] input').fill('model-zzz')
const w = await page.evaluate(() => {
const panel = document.querySelector('[data-dual-inspector]')
const body = panel.querySelector('fieldset')
return { page: document.documentElement.scrollWidth - document.documentElement.clientWidth,
panel: panel.scrollWidth - panel.clientWidth, body: body ? body.scrollWidth - body.clientWidth : 0 }
})
check(`${width}px(${theme}):检查器不横向溢出`, w.page <= 0 && w.panel <= 0 && w.body <= 0, JSON.stringify(w))
const c = await page.evaluate(() => {
const base = getComputedStyle(document.querySelector('[data-dual-inspector]')).backgroundColor
const el = document.querySelector('[data-dual-by-model]')
const radio = document.querySelector('[data-field="enabled"] [role="radio"][aria-checked="true"]')
return { base, hint: el && getComputedStyle(el).color, radio: radio && getComputedStyle(radio).color,
radioBg: radio && getComputedStyle(radio).backgroundColor }
})
const hint = contrast(c.hint, c.base, c.base)
check(`${theme}:只填了模型的提醒对比度 ≥ 4.5`, hint >= 4.5, `${hint.toFixed(2)}(${c.hint} on ${c.base})`)
const radio = contrast(c.radio, c.radioBg, c.base)
check(`${theme}:启用里选中那一项对比度 ≥ 4.5`, radio >= 4.5, `${radio.toFixed(2)}(${c.radio} on ${c.radioBg})`)
await shot(page, `inspector-${width}-${theme}`)
check(`${width}px(${theme}):页面没有报错`, errors.length === 0, errors.join(';'))
}
})
await browser.close()
console.log(failed ? `\n✗ 双盲复算与反方审稿:${failed} 项未通过` : '\n✓ 双盲复算与反方审稿:全部通过')
process.exit(failed ? 1 : 0)