mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:31:58 +00:00
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 里已经不成立的「下面两种还是占位」注释
529 lines
38 KiB
JavaScript
529 lines
38 KiB
JavaScript
// 双盲复算与反方审稿的回归检查(期 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)
|