Files
AgentLab/scripts/check-verify.mjs
T
ThearchyHelios 52baa0d65c 双盲复算前端复核修正(第 1 轮):高亮按服务端真实的差异输出做,「记入数据目录」只在服务端真会写时有条件地说,后果和提示按节点的处置说
审批卡
- 对照两条 SQL:text_diff 是规范化之后的行(列名带表名、条件并成一行),和原文逐行对不上,原来按「两边逐行相等」高亮在真实输出上一处都标不出。改成两段原文按行做一次 LCS(不看空白、大小写和行尾逗号分号)高亮不同的行,一行都对不上时不乱标;text_diff 分原流程、盲算两栏,每行高亮(窄栏上下排)。
- 「只差一个方面、会记入数据目录」:照服务端 dual_check_facts 的门槛收紧——不等价、没用兜底、差异恰好一条(几个输入都对不上时合并后至少两条,所以也就是只有一个输入对不上),且是时间列、筛选条件或聚合;按这三种分别有条件地说会记成哪一项,选「两边都不对」不记、已有人工确认的不覆盖、以时间线为准。时间范围、两条筛选、关联、解析失败、多个输入合并都不再说。
- 后果和提交后的提示:payload 不带 on_reject,从这次运行的图快照里按节点 id 读「未采用原流程时」;判为失败时写明采用盲算或都不对会终止运行、提示也照实说,缺省时不再挂那句,读不到时有条件地说、提示不下结论。
- 只有解析失败时和摘要阶段一样按兜底说(固定那句 + 解析的原话),默认展开对照;兜底的原话另有原因(比较超时)时附在下面,一边没有 SQL 时不说「两条 SQL 的写法不同」。
- 认不出任何指标时留一个「驳回并终止」(approved 为假),运行不再一直挂着。

时间线
- dual_check_catalog 的 item 是目录项的键(business_date 等),按数据目录的叫法写成「「orders」(shop)的业务日期」,认不出的键不显示。
- dual_check_limit 的 metric 是指标 ID,不再当名字加引号。
- challenge_skipped 的 no_report 是报告文档读不出来(报告节点找不到时节点直接报错),说法和下一步改成去查报告节点有没有执行成功、在不在执行到的分支上。

检查器
- 字段名和服务端校验报错的叫法(schema.py 的 DUAL_CHECK_FIELD_LABEL)对齐,处置选项以服务端的短说法开头;check-verify 在服务端合入后逐项比对。
- 盲算模型移出高级选项:时间线「模型相同」的定位要找得到它。
- 上游有几张口径卡时写明必须选(服务端校验会挡住发起运行),不再说「建议指定」。
- 「跟随设置」的当前值每次打开检查器都重取,改了设置回到画布不再是旧值。

测试
- 夹具里的差异说明换成 explain_sql_diff 对同一对 SQL 的真实输出,新增几对 SQL 的变体;backend/tests/test_dual_check_fixture.py 核对夹具和真实输出一致,并可重新生成。
- check-verify 补真实形状下的高亮、各种差异下「记入数据目录」说不说、只有解析失败和兜底原因、按处置说后果和提示、认不出指标时的驳回、叫法比对、多张口径卡、设置重取;check-decode 改用服务端真实的日志形状。check-verify 的耗时估计按实测改成 45 秒。
2026-10-05 16:10:55 +08:00

993 lines
74 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 时左右并排(窄栏上下排),
// 原文按行比出的不同行高亮,规范化后不同的行(text_diff)分两栏高亮——夹具里的差异说明是服务端的真实输出
// (backend/tests/test_dual_check_fixture.py 核对),规范化的行和原文对不上,高亮不能指望两边逐行相等;
// 「只差一个方面、会记入数据目录」只在服务端真会写目录的形状下说(恰好一条具体差异,且是时间列、筛选条件或聚合);
// 三个选择缺一个都不能提交,提交的回复是 {choices, note};卡上不能再冒出通用的输入框和「提交 / 驳回并终止」
// (mode 是 input,通用那套会把一个字符串当 value 发出去),认不出指标时另给「驳回并终止」;后果按运行类别和节点的
// 「未采用原流程时」说(从运行的图快照里读),探索运行不提出具,读不到节点设置时有条件地说;
// - 检查器:口径卡、指标、口径说明、盲算模型、相对误差、未采用原流程时、启用,每一项都真的写进节点配置;字段的
// 叫法和服务端校验报错里的(schema.py 的 DUAL_CHECK_FIELD_LABEL,合入后)一字不差;盲算模型不藏在高级选项里
// (时间线「模型相同」那一行要能定位过来);上游有好几张口径卡时写明必须选;「跟随设置」的当前值每次打开都重取;
// 节点卡片的摘要写出复算哪张口径卡、几个指标;
// - 设置页「核查」一节:读回各项,改了之后整组写 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')
})
await section('双盲复算检查器的叫法和服务端校验报错一致', async () => {
// 校验问题挂在字段下方、出现在问题面板里时,报错引用的是 schema.py 的 DUAL_CHECK_FIELD_LABEL:检查器上得是同一个名字,
// 人才找得到是哪一项。处置的取值:报错里用短说法,下拉的选项以它开头、后面可以带一句用途(labels.py 的约定)
const { DUAL_CHECK_TEXT: D } = await import(await load('/src/lib/terms.ts'))
const KEYS = ['card', 'metrics', 'definitions', 'provider', 'model', 'tolerance', 'on_reject', 'enabled']
check('检查器的字段名按配置键放在 DUAL_CHECK_TEXT.field', KEYS.every((k) => typeof D.field?.[k] === 'string' && D.field[k]),
KEYS.filter((k) => !D.field?.[k]).join('、'))
check('……模型的两项照「裁判模型 · 接入」「裁判模型」的叫法', D.field.provider === '盲算模型 · 接入' && D.field.model === '盲算模型',
`${D.field.provider} / ${D.field.model}`)
const py = readFileSync(`${root}backend/app/engine/schema.py`, 'utf8')
const block = py.match(/^DUAL_CHECK_FIELD_LABEL = \{([\s\S]*?)^\}/m)?.[1]
if (block == null) {
// 双盲复算的服务端在另一个工作包里,合入之前 schema.py 还没有这一组:合入后这一节就逐项比对
check('schema.py 还没有 DUAL_CHECK_FIELD_LABEL(双盲复算服务端合入后逐项比对)', true)
return
}
const labels = Object.fromEntries([...block.matchAll(/"([^"]+)":\s*"([^"]+)"/g)].map((m) => [m[1], m[2]]))
const wrong = KEYS.filter((k) => labels[k] !== D.field[k])
check('每个字段的叫法和 DUAL_CHECK_FIELD_LABEL 一字不差', !wrong.length && Object.keys(labels).length === KEYS.length,
wrong.map((k) => `${k}:${D.field[k]} ≠ ${labels[k]}`).join(';'))
const opts = py.match(/^DUAL_CHECK_ON_REJECT_LABEL = \{([^}]*)\}/m)?.[1] ?? ''
const short = Object.fromEntries([...opts.matchAll(/"([^"]+)":\s*"([^"]+)"/g)].map((m) => [m[1], m[2]]))
check('「未采用原流程时」的选项以服务端的短说法开头', Object.keys(short).length === 2
&& Object.entries(short).every(([k, v]) => D.onReject?.[k]?.startsWith(v)), JSON.stringify(short))
})
// ---------------------------------------------------------------- 页面
const browser = await chromium.launch({ executablePath: CHROME })
/**
* 开一个预览用例。完整对照的工件按 artifact 答(默认夹具里的那份;给函数可以延迟、出错),这次运行的图快照按 graph
* 答(默认夹具里的图:双盲复算节点没写「未采用原流程时」,按缺省的计入缺口),审批回复记下请求体、回一个伪造的运行;
* 审批列表回空;别的非 GET 一律拦掉,别的 GET 放行
*/
async function preview(syn, { width = 1280, height = 900, theme = 'dark', artifact = null, graph = 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/runs/${AP.run_id}/graph`) {
const out = graph ? await graph() : { json: { graph: fx.graph, workflow_id: null, version: null } }
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}"]`)
/** 弹出的提示(toast 的播报区)里的文字:卡上的后果说明里也有「运行继续」,不能拿整页的文字比 */
const toastOf = (page) => text(page.locator('[role="status"][aria-live="polite"]'))
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
&& await rr.locator('[data-dual-fallback-note]').count() === 0)
// 销售额只差按哪一列筛时间(一条 time):服务端会把采用的一边记成该表的业务日期,卡上有条件地说
const note = await text(gmv.locator('[data-dual-catalog]'))
check('只差在时间列的指标:说明采用一边后会记成该表的业务日期,有条件地说', note.includes('业务日期') && note.includes('记入数据目录')
&& note.includes('两边都不对') && note.includes('不覆盖') && await rr.locator('[data-dual-catalog]').count() === 0, note)
await page.waitForSelector('[data-dual-consequence][data-on-reject="degrade"]')
const after = await text(page.locator('[data-dual-consequence]'))
check('正式运行:后果写明报告里的数仍取自原流程、采用盲算或都不对会让出具降档', after.includes('报告中的数仍取自原流程') && after.includes('出具降档'), after)
check('……节点按缺省(计入缺口)处置:不再挂「判为失败会终止」那句', !after.includes('判为失败') && !after.includes('终止'), after)
check('没有发出任何写请求', sent.length === 0, JSON.stringify(sent))
check('页面没有报错', errors.length === 0, errors.join(';'))
await shot(page, 'approval-dark')
})
/** 一块里高亮的行:原文两栏(data-dual-sql)和规范化后的两栏(data-dual-norm)各自的 <mark> 文字 */
const marksIn = (loc) => loc.evaluate((el) => {
const pick = (sel) => [...el.querySelectorAll(sel)].map((m) => m.textContent.trim())
return {
del: pick('[data-dual-sql="original"] [data-diff-line="del"]'), add: pick('[data-dual-sql="blind"] [data-diff-line="add"]'),
normDel: pick('[data-dual-norm="original"] [data-diff-line="del"]'), normAdd: pick('[data-dual-norm="blind"] [data-diff-line="add"]'),
}
})
/** text_diff 里两边各有哪几行(去掉「- 」「+ 」和首尾空白) */
const sidesOf = (diff) => ({
del: diff.text_diff.filter((l) => l.startsWith('- ')).map((l) => l.slice(2).trim()),
add: diff.text_diff.filter((l) => l.startsWith('+ ')).map((l) => l.slice(2).trim()),
})
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'))
// 夹具的 text_diff 是 explain_sql_diff 的真实输出:规范化后列名带表名、两个条件并成一行,和原文的任何一行都对不上
const real = sidesOf(fx.card.metrics[0].diff)
const rawLines = new Set(fx.card.metrics[0].original.inputs[0].sql.split('\n').map((l) => l.trim()))
check('(夹具)text_diff 是规范化后的行,和原文逐行对不上', real.del.length > 0 && !real.del.some((l) => rawLines.has(l)), real.del.join(' | '))
const marks = await marksIn(gmv)
check('原文按行比:不同的行在原流程那边高亮', 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')))
check('text_diff 的行分两栏高亮:原流程一栏是「- 」行', marks.normDel.join('|') === real.del.join('|'), marks.normDel.join('|'))
check('……盲算一栏是「+ 」行', marks.normAdd.join('|') === real.add.join('|'), marks.normAdd.join('|'))
const hl = await gmv.locator('[data-dual-norm] [data-diff-line]').first().evaluate((el) => getComputedStyle(el).backgroundColor)
check('……高亮行有底色', !!hl && hl !== 'rgba(0, 0, 0, 0)' && hl !== 'transparent', hl)
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 }))
const no = await gmv.locator('[data-dual-norm="original"]').boundingBox()
const nb = await gmv.locator('[data-dual-norm="blind"]').boundingBox()
check('……规范化后的两栏也左右并排', !!no && !!nb && Math.abs(no.y - nb.y) < 2 && nb.x >= no.x + no.width - 1, JSON.stringify({ no, nb }))
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 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)
// 退款这一项两边写法差得多(EXISTS 对 IN):原文只有前两行相同,其余各自高亮;规范化后的行照样分两栏
const rm = await marksIn(rr)
const rreal = sidesOf(fx.card.metrics[1].diff)
check('……原文按行比:相同的前两行不标,其余各自高亮', rm.del.length === 4 && rm.add.join('|') === 'WHERE r.order_id IN (SELECT id FROM orders)'
&& !rm.del.concat(rm.add).some((t) => t.startsWith('SELECT COUNT') || t.startsWith('FROM refunds')), `${rm.del.join('|')} ‖ ${rm.add.join('|')}`)
check('……规范化后的行两栏各自高亮(「- 」「+ 」交错给的也分得开)', rm.normDel.join('|') === rreal.del.join('|') && rm.normAdd.join('|') === rreal.add.join('|'),
`${rm.normDel.join('|')} ‖ ${rm.normAdd.join('|')}`)
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(';'))
})
/**
* 把「销售额」换成 variants 里的一对 SQL 及其真实的差异说明(取值照旧不同),别的指标原样;diff 给了就用给的
* (拼几个输入的、兜底的原话)
*/
function cardWith(name, patch = {}) {
const v = fx.variants[name]
const card = JSON.parse(JSON.stringify(fx.card))
const gmv = card.metrics[0]
gmv.original.inputs[0].sql = v?.sql[0] ?? gmv.original.inputs[0].sql
gmv.blind.inputs[0].sql = v?.sql[1] ?? gmv.blind.inputs[0].sql
gmv.diff = JSON.parse(JSON.stringify(v?.diff ?? gmv.diff))
Object.assign(gmv, patch.metric ?? {})
if (patch.diff) gmv.diff = patch.diff
return async () => ({ json: { id: CARD_ID, content: card } })
}
await section('审批卡:「记入数据目录」只在服务端真会写的形状下说', async () => {
// dual_check_facts.derive 的门槛:只有一个输入对不上、恰好一条具体差异、没有兜底,而且这一条是时间列、筛选条件或聚合
const cases = [
['time_range', null, '只差时间范围(不是换了时间列)'],
['two_filters', null, '差两个筛选条件'],
['join', null, '差在关联方式'],
['parse', null, '一边解析失败'],
['filter_extra', '有效记录条件', '只差一个筛选条件'],
['dedup', '去重规则', '只差去不去重'],
]
for (const [name, want, what] of cases) {
const { page, errors } = await preview('dual-check-approval', { artifact: cardWith(name) })
await page.waitForSelector('[data-dual-check][data-state="ready"]')
const gmv = metric(page, 'gmv')
const note = await text(gmv.locator('[data-dual-catalog]'))
check(want ? `${what}:说明会记成该表的${want}` : `${what}:不说会记入数据目录`, want ? note.includes(want) && note.includes('记入数据目录') : !note,
note || '(无)')
if (name === 'filter_extra') check('……筛选条件只在条件只有一边有、且采用了有它的一边时才记,照实说', note.includes('只有一边有') && note.includes('采用有它的一边'), note)
if (name === 'two_filters') {
check('……两条差异都列出来', await gmv.locator('[data-dual-diff-item]').count() === 2)
}
const diff = fx.variants[name].diff
if (diff.text_diff.length) {
await gmv.locator('[data-dual-compare-toggle]').click()
const m = await marksIn(gmv)
const real = sidesOf(diff)
check(`……规范化后的行按真实输出分两栏高亮(${real.del.length} + ${real.add.length} 行)`,
m.normDel.join('|') === real.del.join('|') && m.normAdd.join('|') === real.add.join('|'), `${m.normDel.join('|')} ‖ ${m.normAdd.join('|')}`)
if (!real.add.length) {
check('……一边没有不同的行时照实说', (await text(gmv.locator('[data-dual-norm="blind"]'))).includes('没有不同的行'))
}
}
check(`${what}:页面没有报错`, errors.length === 0, errors.join(';'))
}
// 几个输入都差在时间列:节点把两处的差异合成一份(每条前面写明是哪个输入),不止一条,不写目录
const t = fx.variants.time_range.diff.items[0]
const merged = {
equivalent: false, fallback: false, text_diff: fx.card.metrics[0].diff.text_diff,
items: [{ aspect: 'time', text: `输入「gmv_web」:${fx.card.metrics[0].diff.items[0].text}` }, { aspect: 'time', text: `输入「gmv_app」:${t.text}` }],
}
const two = await preview('dual-check-approval', { artifact: cardWith(null, { diff: merged }) })
await two.page.waitForSelector('[data-dual-check][data-state="ready"]')
check('几个输入都差在同一方面(合并后两条):不说会记入数据目录', await metric(two.page, 'gmv').locator('[data-dual-catalog]').count() === 0)
})
await section('审批卡:只有解析失败、兜底原话另有原因时', async () => {
// 只有解析失败:和摘要阶段(服务端把它算作兜底)说法一致,默认展开对照,解析的原话附在下面
const { page } = await preview('dual-check-approval', { artifact: cardWith('parse') })
await page.waitForSelector('[data-dual-check][data-state="ready"]')
const gmv = metric(page, 'gmv')
check('只有解析失败:写那句固定的兜底说明,不当成一条具体差异', (await text(gmv.locator('[data-dual-fallback]'))) === '两条 SQL 的写法不同,未能归纳成具体差异'
&& await gmv.locator('[data-dual-diff-item]').count() === 0)
check('……解析的原话附在下面', (await text(gmv.locator('[data-dual-fallback-note]'))).includes('盲算:SQL 无法解析'))
check('……默认展开对照', (await gmv.locator('[data-dual-compare-toggle]').getAttribute('aria-expanded')) === 'true')
// 有一边没有 SQL(节点的原话「有一边没有可比较的 SQL」):不能再说「两条 SQL 的写法不同」
const noSql = { equivalent: false, fallback: true, text_diff: [], items: [{ aspect: 'other', text: '有一边没有可比较的 SQL,请对照原文' }] }
const card = cardWith(null, { diff: noSql })
const lone = await preview('dual-check-approval', {
artifact: async () => {
const out = await card()
out.json.content.metrics[0].blind.inputs[0].sql = null
return out
},
})
await lone.page.waitForSelector('[data-dual-check][data-state="ready"]')
const g2 = metric(lone.page, 'gmv')
const said = await text(g2.locator('[data-dual-fallback]'))
check('一边没有 SQL:照节点的原话说,不写「两条 SQL 的写法不同」', said === '有一边没有可比较的 SQL' && !(await text(g2)).includes('写法不同'), said)
check('……盲算那一栏写没有记录 SQL', (await text(g2.locator('[data-dual-sql="blind"]'))) === '没有记录 SQL')
// 比较超时:固定那句之外附上原因
const slow = { equivalent: false, fallback: true, text_diff: fx.card.metrics[0].diff.text_diff,
items: [{ aspect: 'other', text: '两条 SQL 比较超时,未能归纳成具体差异,请对照原文' }] }
const late = await preview('dual-check-approval', { artifact: cardWith(null, { diff: slow }) })
await late.page.waitForSelector('[data-dual-check][data-state="ready"]')
const g3 = metric(late.page, 'gmv')
check('比较超时:固定那句之外附上原因(去掉「请对照原文」)', (await text(g3.locator('[data-dual-fallback]'))) === '两条 SQL 的写法不同,未能归纳成具体差异'
&& (await text(g3.locator('[data-dual-fallback-note]'))) === '两条 SQL 比较超时,未能归纳成具体差异', await text(g3.locator('[data-dual-fallback-note]')))
})
/** 运行的图快照里双盲复算节点的「未采用原流程时」写成 value(undefined 是没写) */
const graphWith = (value) => async () => {
const graph = JSON.parse(JSON.stringify(fx.graph))
const dc = graph.nodes.find((n) => n.id === 'dc')
dc.data.config = { ...dc.data.config, ...(value === undefined ? {} : { on_reject: value }) }
return { json: { graph, workflow_id: null, version: null } }
}
await section('审批卡:后果和提交后的提示按节点的「未采用原流程时」说', async () => {
// 判为失败:采用盲算或都不对会让运行终止,卡上只说这一种,提交后的提示也照实说
const fail = await preview('dual-check-approval', { graph: graphWith('fail') })
await fail.page.waitForSelector('[data-dual-consequence][data-on-reject="fail"]')
const said = await text(fail.page.locator('[data-dual-consequence]'))
check('判为失败:后果写明采用盲算或都不对时运行将终止,不再说出具降档', said.includes('运行将终止') && !said.includes('降档') && !said.includes('如节点'), said)
for (const id of ['gmv', 'refund_rate']) await metric(fail.page, id).locator('label', { hasText: '采用原流程的结果' }).click()
await metric(fail.page, 'gmv').locator('label', { hasText: '采用盲算的结果' }).click()
await fail.page.locator('[data-dual-submit]').click()
await fail.page.waitForFunction(() => window.__dualResolved != null)
await fail.page.waitForFunction(() => document.querySelector('[role="status"][aria-live="polite"]')?.textContent?.includes('已提交裁决'))
const failToast = await toastOf(fail.page)
check('……提交后的提示:运行将终止,不说「运行继续」', failToast.includes('已提交裁决,运行将按节点设置终止') && !failToast.includes('运行继续'), failToast)
// 判为失败但全选原流程:照常继续
const ok = await preview('dual-check-approval', { graph: graphWith('fail') })
await ok.page.waitForSelector('[data-dual-consequence][data-on-reject="fail"]')
for (const id of ['gmv', 'refund_rate']) await metric(ok.page, id).locator('label', { hasText: '采用原流程的结果' }).click()
await ok.page.locator('[data-dual-submit]').click()
await ok.page.waitForFunction(() => window.__dualResolved != null)
await ok.page.waitForFunction(() => document.querySelector('[role="status"][aria-live="polite"]')?.textContent?.includes('已提交裁决'))
check('……全部采用原流程:提示运行继续', (await toastOf(ok.page)) === '已提交裁决,运行继续', await toastOf(ok.page))
// 图快照取不到(老运行、接口出错):两种处置都可能,有条件地说;提交后的提示不说继续还是终止
const lost = await preview('dual-check-approval', { graph: async () => ({ status: 404, json: { detail: '运行不存在' } }) })
await lost.page.waitForSelector('[data-dual-consequence][data-on-reject="unknown"]')
const maybe = await text(lost.page.locator('[data-dual-consequence]'))
check('取不到节点设置:降档那句之外,有条件地说判为失败会终止', maybe.includes('出具降档') && maybe.includes('如节点的「未采用原流程时」设为「判为失败」'), maybe)
for (const id of ['gmv', 'refund_rate']) await metric(lost.page, id).locator('label', { hasText: '两边都不对' }).click()
await lost.page.locator('[data-dual-submit]').click()
await lost.page.waitForFunction(() => window.__dualResolved != null)
await lost.page.waitForFunction(() => document.querySelector('[role="status"][aria-live="polite"]')?.textContent?.includes('已提交裁决'))
const toast = await toastOf(lost.page)
check('……提交后的提示不替节点下结论', toast === '已提交裁决', toast)
})
await section('审批卡:认不出任何指标时留一个出口', async () => {
const { page, sent, errors } = await preview('dual-check-approval-empty', {
artifact: async () => ({ json: { id: CARD_ID, content: { metrics: [], model: null, same_model: false } } }),
})
await page.waitForSelector('[data-dual-check][data-state="ready"]')
check('写明没有可以裁决的指标', (await text(page.locator('[data-dual-empty]'))).includes('没有可以裁决的指标'))
check('……没有提交裁决的按钮', await page.locator('[data-dual-submit]').count() === 0)
const reject = page.locator('[data-dual-reject]')
check('……给一个「驳回并终止」,说清后果', (await text(reject)) === '驳回并终止' && (await text(page.locator('[data-dual-check]'))).includes('运行终止'))
await page.locator('[data-dual-note]').fill('审批内容缺失,重新运行')
await reject.click()
await page.waitForFunction(() => window.__dualResolved != null)
const body = sent.find((s) => s.path === `/api/approvals/${AP.id}/decide`)?.body
check('……驳回:approved 为假、带上备注,不带 value', body?.approved === false && body.note === '审批内容缺失,重新运行' && !('value' in body), JSON.stringify(body))
check('……提示已驳回、运行终止', (await text(page.locator('body'))).includes('已驳回,本次运行终止'))
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({ verify = () => fx.verify, ...opts } = {}) {
const out = await preview('dual-check-inspector', {
...opts,
extra: (req, url) => {
if (req.method() === 'GET' && url.pathname === '/api/settings') return { json: { run: {}, verify: 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))
// 字段名就是服务端校验报错里写的叫法(上面「叫法」一节和 schema.py 比过)
const { DUAL_CHECK_TEXT: D } = await import(await load('/src/lib/terms.ts'))
const shown = {}
for (const k of ['card', 'metrics', 'definitions', 'provider', 'model', 'on_reject', 'enabled']) {
shown[k] = (await page.locator(`[data-field="${k}"] label`).first().innerText()).trim()
}
const misnamed = Object.entries(shown).filter(([k, v]) => v !== D.field[k])
check('检查器上的字段名用 DUAL_CHECK_TEXT.field 的叫法', !misnamed.length, misnamed.map(([k, v]) => `${k}:${v} ≠ ${D.field[k]}`).join(';'))
// 口径卡:下拉选画布上的口径卡,上游的一组、其余的一组;留空取上游的那张
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')
// 盲算模型不在高级选项里:时间线「模型相同」那一行的「打开设置」要能直接定位到它(高级选项收着时定位不到)
check('盲算模型的接入和模型不用展开高级选项就能看到', await page.locator('[data-field="provider"] select').isVisible()
&& await page.locator('[data-field="model"] input').isVisible() && await page.locator('[data-field="tolerance"]').count() === 0)
// 高级:相对误差
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('检查器:上游有几张口径卡时必须选;「跟随设置」的当前值每次打开都重取', async () => {
let verifyNow = fx.verify
const { page, errors } = await inspector({ verify: () => verifyNow })
// 月报口径卡也接到双盲复算上游:服务端校验要求选一张(不选会挡住发起运行),不是「建议」
await page.evaluate(() => window.__studio.setState((s) => ({
edges: [...s.edges, { ...s.edges.find((e) => e.id === 'e4'), id: 'e-card2', source: 'card2' }],
})))
const card = page.locator('[data-field="card"] select')
await page.waitForFunction(() => document.querySelector('[data-field="card"] select option')?.textContent?.includes('2 张'))
check('上游两张:留空那一项写明要选,不说「建议」', (await card.locator('option').first().innerText()) === '请选择要复算的口径卡(上游有 2 张)'
&& !(await text(page.locator('[data-field="card"]'))).includes('建议'), await card.locator('option').first().innerText())
check('……下面用提醒色写明必须选一张', (await text(page.locator('[data-dual-card-warn]'))) === '上游有 2 张口径卡,请选择要复算的那一张')
const many = await summaryBecomes(page, '复算上游的口径卡 · 上游有 2 张,请指定')
check('……节点卡片的摘要也这么说', many.ok, many.seen)
await card.selectOption('card')
check('……选了一张:提醒收起', await page.locator('[data-dual-card-warn]').count() === 0 && (await config(page)).card === 'card')
// 去设置里关掉双盲复算、改了相对误差,再回到画布打开这个节点:「跟随设置」写的是新的值
const enabled = page.locator('[data-field="enabled"] [role="radio"][data-value="follow"]')
check('打开时:跟随设置(当前开启)', (await text(enabled)) === '跟随设置(当前开启)', await text(enabled))
verifyNow = { ...fx.verify, dual_check: { ...fx.verify.dual_check, enabled: false, tolerance: 0.01 } }
await page.evaluate(() => window.__studio.setState({ selectedId: null }))
await page.waitForSelector('[data-field="enabled"]', { state: 'detached' })
await page.evaluate(() => window.__studio.setState({ selectedId: 'dc' }))
await page.waitForFunction(() => document.querySelector('[data-field="enabled"] [data-value="follow"]')?.textContent === '跟随设置(当前关闭)', null, { timeout: 3000 })
.catch(() => {})
check('再打开:重取设置,写当前关闭', (await text(enabled)) === '跟随设置(当前关闭)', await text(enabled))
await page.locator('button', { hasText: '高级选项' }).click()
check('……相对误差的占位也是新值', await page.locator('[data-field="tolerance"] input').getAttribute('placeholder') === '跟随设置(0.01)',
await page.locator('[data-field="tolerance"] input').getAttribute('placeholder'))
check('页面没有报错', errors.length === 0, errors.join(';'))
})
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(';'))
}
})
// ---------------------------------------------------------------- 设置页「核查」一节
/**
* 设置页(真页面):设置的读写、今日核查花费、模型接入按夹具答,PUT 记下请求体、合进存着的那份再回给页面;
* put 给了就由它答(模拟 422)。别的 GET 放行到服务端,别的写请求一律拦掉
*/
async function settingsPage({ width = 1280, height = 900, theme = 'dark', verify = fx.verify, put = null } = {}) {
const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, timezoneId: 'Asia/Shanghai' })
opened.add(ctx)
ctx.setDefaultTimeout(6000)
await ctx.addInitScript((t) => {
try { localStorage.setItem('agentlab.theme', t); localStorage.setItem('agentlab_actor', '张工') } catch { /* 隐私窗口 */ }
}, theme)
const page = await ctx.newPage()
const errors = []
const sent = []
let spendGets = 0
let stored = {
ui: { theme }, run: { default_memory_scope: 'default', default_collection: 'default', confirm_dangerous_tools: true,
tool_gate_provider: null, tool_gate_model: null, agent_max_steps: 100, agent_budget_tokens: 2000000, agent_budget_usd: null },
limits: { max_graph_steps: 200, max_agent_steps: 100, max_run_seconds: 1800 },
judge: { provider: 'judge-provider-x', model: null, report_max_claims: 40, report_max_cost_usd: 0.05, report_timeout_s: 30,
click_max_cost_usd: 0.01, daily_max_usd: 2 },
...(verify ? { verify } : {}),
}
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 (url.pathname === '/api/settings' && req.method() === 'GET') return route.fulfill({ json: stored })
if (url.pathname === '/api/settings' && req.method() === 'PUT') {
const body = req.postDataJSON()
sent.push({ path: url.pathname, body })
if (put) {
const out = await put(body)
if (out) return route.fulfill({ status: out.status ?? 200, json: out.json })
}
stored = { ...stored, ...body.values }
return route.fulfill({ json: stored })
}
if (url.pathname === '/api/settings/verify/spend') {
spendGets += 1
return route.fulfill({ json: { date: '2026-10-05', usd: 0.0456, calls: 12, unpriced_calls: 1, daily_max_usd: stored.verify?.daily_max_usd ?? null } })
}
if (url.pathname === '/api/settings/judge/spend') return route.fulfill({ json: { date: '2026-10-05', usd: 0, calls: 0, unpriced_calls: 0 } })
if (url.pathname === '/api/providers') return route.fulfill({ json: fx.providers })
if (req.method() !== 'GET') {
sent.push({ path: url.pathname, blocked: true, method: req.method() })
return route.abort()
}
return route.continue()
})
await page.goto(`${WEB}/settings/prefs`, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('[data-prefs-bar], [data-judge-settings]', { timeout: 15000 })
return { ctx, page, errors, sent, spends: () => spendGets }
}
await section('设置页「核查」一节:读回各项、叫法和服务端一致', async () => {
const { page, errors } = await settingsPage()
await page.waitForSelector('[data-verify-settings]')
const box = page.locator('[data-verify-settings]')
const { VERIFY_SETTING_TEXT: T } = await import(await load('/src/lib/terms.ts'))
check('偏好设置里有「核查」一节,说明三项共用核查模型和每日金额上限', (await text(box.locator('h2'))) === '核查'
&& (await text(box)).includes('共用核查模型和每日金额上限'))
const label = async (id) => text(page.locator(`label[for="${id}"]`))
check('核查模型:接入、模型两项,叫法和服务端一致', await label('pref-verify-provider') === T.provider && await label('pref-verify-model') === T.model)
check('……接入留空时跟随证据裁判模型,并醒目提示未单独配置', (await page.locator('#pref-verify-provider option').first().innerText()) === '跟随证据裁判模型'
&& (await text(page.locator('[data-verify-not-set]'))).includes('未单独配置,将使用证据裁判的模型'))
const parts = await page.locator('[data-verify-part]').evaluateAll((els) => els.map((e) => e.dataset.verifyPart))
check('三项各一组:双盲复算、反方审稿、审批质询', parts.join(',') === 'dual_check,challenge,inquiry', parts.join(','))
const v = (id) => page.locator(`#${id}`).inputValue()
check('双盲复算:启用、允许的相对误差、每项最多查询次数、时长上限', await page.locator('[data-verify-toggle="dual_check.enabled"]').isChecked()
&& await v('pref-verify-dual_check-tolerance') === '0.000001' && await v('pref-verify-dual_check-max_queries') === '6'
&& await v('pref-verify-dual_check-timeout_s') === '120')
check('反方审稿:启用、每份报告最多审几句、每句最多查询次数、时长上限', await page.locator('[data-verify-toggle="challenge.enabled"]').isChecked()
&& await v('pref-verify-challenge-max_claims') === '5' && await v('pref-verify-challenge-max_queries') === '4'
&& await v('pref-verify-challenge-timeout_s') === '90')
check('审批质询:启用、每次最多查询次数、时长上限', await page.locator('[data-verify-toggle="inquiry.enabled"]').isChecked()
&& await v('pref-verify-inquiry-max_queries') === '6' && await v('pref-verify-inquiry-timeout_s') === '120')
check('每日金额上限', await v('pref-verify-daily_max_usd') === '2')
const ids = ['dual_check-tolerance', 'dual_check-max_queries', 'dual_check-timeout_s', 'challenge-max_claims', 'challenge-max_queries',
'challenge-timeout_s', 'inquiry-max_queries', 'inquiry-timeout_s', 'daily_max_usd']
const wrong = []
for (const id of ids) if (await label(`pref-verify-${id}`) !== T[id.replace('-', '.')]) wrong.push(id)
for (const k of ['dual_check.enabled', 'challenge.enabled', 'inquiry.enabled']) {
if ((await text(page.locator(`[data-verify-toggle="${k}"]`).locator('xpath=..'))) !== T[k]) wrong.push(k)
}
check('……每一项的字段名就是服务端报错时写的叫法', !wrong.length, wrong.join('、'))
const spend = await text(page.locator('[data-verify-spend]'))
check('每日上限旁边写今日核查花费', spend.includes('今日已花费 $0.0456(12 次调用)'), spend)
check('……估不出金额的调用另说', (await text(page.locator('[data-verify-unpriced]'))).includes('另有 1 次调用无法估算金额'))
check('没有运行时报错', errors.length === 0, errors.join(';'))
await shot(page, 'settings-dark')
})
await section('设置页「核查」一节:改了之后整组写 verify,不限存 null,错了不让存', async () => {
const { page, sent, errors, spends } = await settingsPage()
await page.waitForSelector('[data-verify-settings]')
const save = page.getByRole('button', { name: /保存设置/ })
await page.locator('[data-verify-toggle="challenge.enabled"]').uncheck()
await page.locator('#pref-verify-dual_check-tolerance').fill('0.001')
// 键盘勾「不限」:Tab 到复选框上按空格
await page.locator('[data-verify-limit="inquiry.timeout_s"] [data-verify-unlimited-toggle]').focus()
await page.keyboard.press('Space')
check('勾了「不限」:框变灰,写明还受什么约束', await page.locator('#pref-verify-inquiry-timeout_s').isDisabled()
&& (await text(page.locator('[data-verify-limit="inquiry.timeout_s"] [data-verify-unlimited]'))) === '不设上限,时长仅受模型接口自身的超时约束')
await page.locator('#pref-verify-challenge-max_claims').fill('0')
check('句数填 0:标红、保存不可用', await save.isDisabled()
&& (await text(page.locator('[data-verify-limit="challenge.max_claims"]'))).includes('请填写正整数,或勾选「不限」'))
await page.locator('#pref-verify-challenge-max_claims').fill('8')
await page.locator('#pref-verify-dual_check-tolerance').fill('-1')
check('相对误差填负数:标红、保存不可用', await save.isDisabled()
&& (await text(page.locator('[data-verify-part="dual_check"]'))).includes('请填写不小于 0 的数'))
await page.locator('#pref-verify-dual_check-tolerance').fill('0.001')
await page.locator('#pref-verify-provider').selectOption('云端接入')
await page.locator('#pref-verify-model').fill('model-b')
check('填了核查模型:「未单独配置」的提示收起', await page.locator('[data-verify-not-set]').count() === 0)
const bar = (await text(page.locator('[data-prefs-bar]')))
check('保存条写明改了哪几项(用的是同一套叫法)', bar.includes('有 6 项未保存') && bar.includes('反方审稿 · 启用') && bar.includes('双盲复算 · 允许的相对误差')
&& bar.includes('审批质询 · 每次时长上限(秒)') && bar.includes('核查模型 · 接入'), bar)
const before = spends()
await save.click()
await page.waitForFunction(() => document.querySelector('[data-prefs-bar]')?.textContent?.includes('已保存'))
const put = sent.filter((s) => s.path === '/api/settings' && !s.blocked).at(-1)?.body?.values ?? {}
check('保存:只写 verify 一组(运行默认值、裁判没改就不碰)', Object.keys(put).join(',') === 'verify', Object.keys(put).join(','))
const want = {
provider: '云端接入', model: 'model-b',
dual_check: { enabled: true, tolerance: 0.001, max_queries: 6, timeout_s: 120 },
challenge: { enabled: false, max_claims: 8, max_queries: 4, timeout_s: 90 },
inquiry: { enabled: true, max_queries: 6, timeout_s: null },
daily_max_usd: 2,
}
check('……整组写(没改的项照原值),不限存 null,数存成数', JSON.stringify(put.verify) === JSON.stringify(want), JSON.stringify(put.verify))
check('……存完再取一次今日花费', spends() > before, `${before} → ${spends()}`)
check('……存完读回来的就是刚存的', await page.locator('#pref-verify-model').inputValue() === 'model-b'
&& !(await page.locator('[data-verify-toggle="challenge.enabled"]').isChecked()))
await page.locator('[data-verify-limit="daily_max_usd"] [data-verify-unlimited-toggle]').check()
const daily = await text(page.locator('[data-verify-limit="daily_max_usd"] [data-verify-unlimited]'))
check('每日上限设成不限:写明每日费用还受什么约束', daily.startsWith('不设上限,每日核查费用仅受'), daily)
await page.locator('[data-verify-limit="daily_max_usd"] [data-verify-unlimited-toggle]').uncheck()
check('勾掉「不限」:填回原来的数', await page.locator('#pref-verify-daily_max_usd').inputValue() === '2')
check('没有别的写请求', !sent.some((s) => s.blocked), JSON.stringify(sent.filter((s) => s.blocked)))
check('没有运行时报错', errors.length === 0, errors.join(';'))
})
await section('设置页「核查」一节:服务端拒收时照实说(报错里的叫法对得上字段名)', async () => {
const detail = '「反方审稿 · 每份报告最多审几句」需要填写正整数(句数),留空表示不限,当前为「2.5」'
const { page } = await settingsPage({ put: async () => ({ status: 422, json: { detail } }) })
await page.waitForSelector('[data-verify-settings]')
await page.locator('[data-verify-toggle="inquiry.enabled"]').uncheck()
await page.getByRole('button', { name: /保存设置/ }).click()
await page.waitForSelector('[data-prefs-bar] [role="alert"]')
const bar = await text(page.locator('[data-prefs-bar]'))
check('保存失败:保存条照实说,改动还在', bar.includes('保存失败') && bar.includes('有 1 项未保存')
&& !(await page.locator('[data-verify-toggle="inquiry.enabled"]').isChecked()), bar)
check('……报错里写的叫法就是页面上的字段名', (await text(page.locator('label[for="pref-verify-challenge-max_claims"]'))) === '反方审稿 · 每份报告最多审几句')
})
await section('设置页「核查」一节:老服务端没有这一组时不显示;390px 不横向溢出、深浅色可读', async () => {
const old = await settingsPage({ verify: null })
await old.page.waitForSelector('[data-judge-settings]')
check('老服务端没有 verify 一组:这一节不出现', await old.page.locator('[data-verify-settings]').count() === 0)
for (const theme of ['dark', 'light']) {
const { page, errors } = await settingsPage({ width: 390, height: 844, theme })
await page.waitForSelector('[data-verify-settings]')
await page.locator('[data-verify-limit="challenge.max_queries"] [data-verify-unlimited-toggle]').check()
await page.locator('#pref-verify-model').fill('model-zzz')
await page.locator('[data-verify-settings]').scrollIntoViewIfNeeded()
// 设置页的外壳在 390px 下本来就有一处隐形提示条伸出视口(没有这一节时也一样),这里只看这一节自己:
// 自己不出横向滚动条,里面的每个元素都不越过视口右边
const w = await page.evaluate(() => {
const box = document.querySelector('[data-verify-settings]')
const vw = document.documentElement.clientWidth
const right = Math.max(...[box, ...box.querySelectorAll('*')].map((e) => e.getBoundingClientRect().right))
return { box: box.scrollWidth - box.clientWidth, beyond: Math.round(right - vw) }
})
check(`390px(${theme}):「核查」一节不横向溢出`, w.box <= 0 && w.beyond <= 0, JSON.stringify(w))
const c = await page.evaluate(() => {
const base = getComputedStyle(document.body).backgroundColor
const pick = (sel) => { const el = document.querySelector(sel); return el && getComputedStyle(el).color }
return { base, unlimited: pick('[data-verify-unlimited]'), hint: pick('[data-verify-by-model]'), spend: pick('[data-verify-spend]') }
})
for (const key of ['unlimited', 'hint', 'spend']) {
const ratio = contrast(c[key], c.base, c.base)
check(`${theme}:${({ unlimited: '不限的说明', hint: '只填了模型的提醒', spend: '今日花费' })[key]}对比度 ≥ 4.5`, ratio >= 4.5,
`${ratio.toFixed(2)}(${c[key]} on ${c.base})`)
}
await shot(page, `settings-narrow-${theme}`)
check(`390px(${theme}):没有运行时报错`, errors.length === 0, errors.join(';'))
}
})
await browser.close()
console.log(failed ? `\n✗ 双盲复算与反方审稿:${failed} 项未通过` : '\n✓ 双盲复算与反方审稿:全部通过')
process.exit(failed ? 1 : 0)