合入期 4 证据面板核查状态与打磨:证据接口带上双盲复算、反方审稿的状态,指标步骤里遮罩列的输入只写「已遮罩」;可验证报告里输入取自遮罩列的口径卡只给 id;推演与口径卡共用取整;运行对比离开后晚到的答复不再跳走

This commit is contained in:
ThearchyHelios
2026-10-06 11:31:02 +08:00
22 changed files with 2673 additions and 96 deletions
+42 -11
View File
@@ -13,10 +13,11 @@
// 总览显示不通过;另做一次「哈希全部重算」的伪造:改报告里一个数字的引用值,文档、事件、清单、页面顶部的封存哈希
// 都重算一遍,①–④ 都对得上,只有 ⑤ 拿口径卡、快照对出来不通过,报告标题旁的徽标跟着不通过;
// - 深浅色、窄屏:390 宽不横向溢出,点开的数字面板在窄屏上从底部抽出,深色下底是深的、字是浅的;
// - 样本页:tests/bundle_samples.py 用虚构数据真实生成的几份页面(遮罩快照、两份报告、早期运行、失败结束、两条出具
// 判定、服务端缺文件、裸 NaN、别的运行的文档),逐份断言各项结果和原因文案;在上面篡改 ④ 引用链(换文档、拿掉报告、
// 多一份没人引用的工件)、⑥ 运行信息(运行结果、类别、出具档位)、拿掉被引用的口径卡、把口径卡标成遮罩、只改页面顶部
// 的封存哈希;宽屏点开表格里右对齐的数字不被面板盖住;不执行脚本时封存哈希、运行 ID 照样看得到,状态写「尚未核对」;
// - 样本页:tests/bundle_samples.py 用虚构数据真实生成的几份页面(遮罩快照、两份报告、早期运行、失败结束、先失败后
// 经继续运行跑到成功的早期运行、两条出具
// 判定、服务端缺文件、裸 NaN、别的运行的文档、口径卡有输入取自遮罩列),逐份断言各项结果和原因文案;在上面篡改 ④
// 引用链(换文档、拿掉报告、多一份没人引用的工件)、⑥ 运行信息(运行结果、类别、出具档位)、拿掉被引用的口径卡、把
// 报告文档标成遮罩、只改页面顶部的封存哈希;宽屏点开表格里右对齐的数字不被面板盖住;不执行脚本时封存哈希、运行 ID 照样看得到,状态写「尚未核对」;
// - 记录页的导出按钮(预览用例 ?syn=bundle-provenance-*,page.route 假装导出接口):已封存时可点、下载的文件名同服务端;
// 未封存时不可点并说明原因(左边写着「已封存」却不能点时原因写在屏幕上);服务端回 409 时提示原因;失败结束的运行
// 提示之后继续运行会重新封存。
@@ -486,7 +487,7 @@ await section('样本页:各种形状', async () => {
check('生成样本页(tests/bundle_samples.py)', false, String(e?.message ?? e))
return
}
check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 9, Object.keys(samples).join('、'))
check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 11, Object.keys(samples).join('、'))
await sample('base', async (s, page) => {
check('base:总览通过,有无法核对的项时写「可核对的项全部通过」', s.overall === 'pass' && s.text === '可核对的项全部通过',
@@ -571,6 +572,12 @@ await section('样本页:各种形状', async () => {
await sample('base', async (_, page) => {
check('成功结束的运行不提重新封存', await page.locator('#seal-failed').isHidden())
})
// 早期运行先失败、经继续运行跑到成功:范围里那条运行失败之后运行又继续了,它不是运行的结束;成功结束的那条在范围外
await sample('continued', async (s) => {
check('continued:总览通过,运行结果无法核对(成功结束的那条不在封存范围内),不拿中途那条失败去对',
s.overall === 'pass' && s.meta.status?.result === 'na' && hasItem(s, 'meta', 'na', '早期运行在结束事件落库之前封存'),
JSON.stringify(s.meta.status))
})
await sample('degraded', async (s) => {
check('degraded:出具档位认最后一条出具判定(降档出具)', s.meta.tier?.result === 'pass' && s.meta.tier.text.includes('降档出具'),
JSON.stringify(s.meta.tier))
@@ -594,6 +601,19 @@ await section('样本页:各种形状', async () => {
check('missing-card:引用口径卡的数字无法核对,写明服务端已没有文件', hasItem(s, 'numbers', 'na', '导出时服务端已没有这份工件的文件'))
check('missing-card:服务端缺文件是正常的无法核对,总览通过', s.overall === 'pass', `${s.overall}:${s.text}`)
})
// 口径卡上有输入取自遮罩列(会员号码):卡上记着号码原值和代入式,整张卡只给 id。这是正常的无法核对,不是被改过
await sample('masked-card', async (s, page) => {
const card = s.items.find((i) => i.check === 'artifacts' && i.text.includes('口径卡'))
check('masked-card:口径卡在 ③ 无法核对,写明「含遮罩列,未随文件附带,只能核对到哈希」,不判不通过',
card?.result === 'na' && card.text.includes('含遮罩列,未随文件附带,只能核对到哈希'), card?.text)
const metricNumber = s.items.find((i) => i.check === 'numbers' && i.text.includes('销售额'))
check('masked-card:引用这张卡的数字无法核对,原因同上', metricNumber?.result === 'na'
&& metricNumber.text.includes('含遮罩列'), metricNumber?.text)
check('masked-card:总览通过(可核对的项全部通过),没有一项不通过', s.overall === 'pass' && s.text === '可核对的项全部通过'
&& !s.items.some((i) => i.result === 'fail'), `${s.overall}:${s.text}`)
const html = await page.content()
check('masked-card:页面里找不到卡上那个遮罩列的号码', !html.includes('000-0001') && !html.includes('000-0002'))
})
await sample('nan', async (s) => {
check('nan:内容里有裸 NaN 的事件和工件照常通过', s.overall === 'pass' && s.checks.events === 'pass'
&& !s.items.some((i) => i.result === 'fail'), describe(s))
@@ -648,12 +668,13 @@ await section('样本页:篡改', async () => {
data.artifacts = data.artifacts.filter((a) => a.kind !== 'metric_set')
return '删掉口径卡条目'
}, (s) => [...turnsRed('numbers')(s), ['原因写明', hasItem(s, 'numbers', 'fail', '引用的工件不在文件的工件清单里')]])
await tamper(base, '口径卡标成遮罩', (data) => {
const card = byKind(data, 'metric_set')
delete card.b64
card.masked = true
return '口径卡去掉内容、标遮罩'
}, (s) => [...turnsRed('artifacts')(s), ['原因写明', hasItem(s, 'artifacts', 'fail', '只有查询快照会因遮罩不随文件附带')]])
// 口径卡有输入取自遮罩列时服务端照样只给 id(见样本页 masked-card),报告文档从来不会:把它标成遮罩只能是改过
await tamper(base, '报告文档标成遮罩', (data) => {
const doc = byKind(data, 'report_doc')
delete doc.b64
doc.masked = true
return '报告文档去掉内容、标遮罩'
}, (s) => [...turnsRed('artifacts')(s), ['原因写明', hasItem(s, 'artifacts', 'fail', '只有查询快照和口径卡会因遮罩不随文件附带')]])
await tamper(base, '报告文档的一个字节', (data) => flipByte(byKind(data, 'report_doc')), (s) => [
...turnsRed('artifacts')(s),
['数字不予核对', s.checks.numbers === 'na' && hasItem(s, 'numbers', 'na', '所在的报告文档没有通过核对')],
@@ -666,6 +687,16 @@ await section('样本页:篡改', async () => {
}, (s) => [...turnsRed('meta', ['events', 'manifest'])(s),
['页面显示事件里的「已完成」并标明文件里写的不同', s.meta.status?.result === 'fail' && s.meta.status.text.includes('已完成')
&& s.meta.status.text.includes('已失败'), JSON.stringify(s.meta.status)]])
// 先失败、经继续运行跑到成功的早期运行,运行结果改成「已失败」:范围里那条运行失败之后运行又继续了,它不是运行的
// 结束,不能拿它把「已失败」标成已核对
for (const [status, label] of [['failed', '已失败'], ['cancelled', '已取消']]) {
await tamper(samples.continued, `继续运行过的早期运行的运行结果改成${label}`, (data) => {
data.meta.status = status
return `succeeded → ${status}`
}, (s) => [...turnsRed('meta', ['events', 'manifest'])(s),
['运行结果不再标已核对,写明那条失败之后运行又继续了', s.meta.status?.result === 'fail'
&& hasItem(s, 'meta', 'fail', '之后运行又继续执行'), JSON.stringify(s.meta.status)]])
}
await tamper(base, '运行类别', (data) => {
data.meta.run_class = 'exploratory'
return 'formal → exploratory'
+106 -17
View File
@@ -7,10 +7,12 @@
// 404(找不到基准)只弹一句通用报错;窄屏(390px)被表格撑出横向滚动;浅色下分类标签、差异高亮看不清。
// 还有几处照服务端(engine/compare.py、api/compare.py)的实际输出才看得出来的:无法比较的指标原因只在 reasons 里;
// 同一节点两条查询的 (node_id, ordinal) 相同;一边的 SQL 是 null;基准运行读不到;本次的记录读不到;不属于已保存
// 工作流的运行;查找基准期间离开运行详情。下一步的提示要和页面上真能做的事对得上。
// 工作流的运行;查找基准期间离开运行详情(新页面画好之后、或者地址刚换新页面还没画出来时答复才回来,走过的地址里
// 都不能出现对比页)。下一步的提示要和页面上真能做的事对得上:下拉里没有能换的运行就不叫人去上方换,本次的记录
// 不存在时不给注定失败的「重试」。
//
// 全部用 page.route 假接口:虚构的「区域销售周报」三次已封存的正式运行和一次没封存的,运行详情、运行列表、
// compare/base、compare 都在浏览器层答掉,别的 GET 放行(外壳要的工作流、待审批),非 GET 一律拦掉。
// compare/base、compare、定时任务列表都在浏览器层答掉,别的 GET 放行(外壳要的工作流、待审批),非 GET 一律拦掉。
// 运行对比的两个接口只读,这里的假答复严格照接口约定的字段名和形状。
//
// node scripts/check-compare.mjs
@@ -181,6 +183,8 @@ const LABEL = { unchanged: '未变', data_only: '只数据变', caliber_and_data
const ORDER = ['data_only', 'caliber_and_data', 'sql_changed', 'params_only', 'added', 'removed', 'incomparable', 'unchanged']
const REASON = '默认基准:同一定时任务的上一次运行'
const FINDING = '正在查找上一次运行'
/** 409、422、缺基准的 404 时下拉里没有能换的运行:不叫人去上方换,照实说 */
const NO_OTHER = '同一工作流没有其他可换作基准的已封存运行'
// ---------------------------------------------------------------- 假接口
@@ -243,6 +247,8 @@ function defaults(opts) {
if (m[2] === '/artifacts') return { json: [] }
}
if (p === '/api/approvals' && url.searchParams.get('run_id')) return { json: [] }
// 查找基准期间离开运行详情去的那一页(定时任务列表):给空的列表,页面落定不靠服务端里有什么
if (p === '/api/schedules') return { json: { items: [] } }
return null
}
}
@@ -356,37 +362,85 @@ await section('从运行详情进入', async () => {
check('页面没有报错', errors.length === 0, errors.join(';'))
})
await section('查找基准期间:看得见在做事,离开之后不再跳走', async () => {
/** 查找基准期间离开去的那一页:定时任务列表。进去之后不再自己换地址(/data 会再换成 /data/databases),晚到的跳转盖不住 */
const AWAY = '/schedules'
/** 那一页落定:列表读回来了(假接口给的空列表) */
const awaySettled = (page) => page.locator('[data-schedules-page="list"] [data-schedules-empty]').waitFor({ timeout: 8000 })
const NO_BASE = '同一工作流没有其他已封存的正式运行'
/**
* 在运行详情里点「与上一次对比」,compare/base 的答复(answer,默认找到 B)先压住不放。交回页面、放行的函数、这一请求
* 了结的 Promise(答完或者被取消,交回那个请求)、点了之后有没有挂出「正在查找」的提示,和点了之后走过的路径
* (framenavigated 连 pushState、replaceState 都算):晚到的答复哪怕只让页面在对比页一闪而过、又被别的跳转盖掉,
* 走过的路径里也留着
*/
async function findingBase(answer = { json: { base_run_id: B, reason: 'same_schedule', base: refOf(B) } }) {
let release
const gate = new Promise((r) => { release = r })
const { page, log, errors } = await open(`/runs/${T}`,
{ base: () => ({ json: { base_run_id: B, reason: 'same_schedule', base: refOf(B) }, wait: gate }) })
// compare/base 这一请求了结(答完,或者被取消)
const opened_ = await open(`/runs/${T}`, { base: () => ({ ...answer, wait: gate }) })
const { page, log } = opened_
const ended = new Promise((resolve) => {
const done = (req) => { if (new URL(req.url()).pathname.endsWith('/compare/base')) resolve() }
const done = (req) => { if (new URL(req.url()).pathname.endsWith('/compare/base')) resolve(req) }
page.on('requestfinished', done)
page.on('requestfailed', done)
})
await page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 })
const visited = []
page.on('framenavigated', (f) => { if (f === page.mainFrame()) visited.push(new URL(f.url()).pathname) })
await page.getByRole('button', { name: '更多操作' }).click()
await page.locator('[data-menu=compare]').click()
// 菜单点完就收起了:查找期间要有一条看得见的提示,接口慢的时候不像点了没反应
check('点了之后有「正在查找上一次运行」的提示', await until(async () =>
await page.locator('[data-toast]', { hasText: FINDING }).count() === 1, 3000))
const noted = await until(async () => await page.locator('[data-toast]', { hasText: FINDING }).count() === 1, 3000)
await until(async () => log.includes(`GET /api/runs/${T}/compare/base`), 3000)
// 改主意去了别的页面:答复晚到也不能把人拽回对比页
await page.evaluate(() => { history.pushState({}, '', '/data'); dispatchEvent(new PopStateEvent('popstate')) })
await page.waitForURL((u) => u.pathname.startsWith('/data'), { timeout: 5000 })
return { ...opened_, release, ended, noted, visited }
}
await section('查找基准期间:看得见在做事,离开之后不再跳走', async () => {
const { page, release, ended, noted, visited, errors } = await findingBase()
check('点了之后有「正在查找上一次运行」的提示', noted)
// 改主意去了别的页面。等那一页画好、运行详情卸下之后再放行:答复晚到也不能把人拽回对比页
await page.evaluate((to) => { history.pushState({}, '', to); dispatchEvent(new PopStateEvent('popstate')) }, AWAY)
await awaySettled(page)
const unmounted = await until(async () => await page.locator('[data-run-detail]').count() === 0, 3000)
release()
// 等请求了结,再给跳转留足时间:第一次进 /data 要现编那一页,主线程忙的时候晚到的跳转会落在固定的等待之后
// 等请求了结,再给跳转留足时间:主线程忙的时候晚到的跳转会落在固定的等待之后
await Promise.race([ended, new Promise((r) => setTimeout(r, 4000))])
const jumped = await until(async () => new URL(page.url()).pathname === '/runs/compare', 2500)
const jumped = await until(async () => visited.includes('/runs/compare'), 2500)
const at = new URL(page.url())
check('离开运行详情之后答复才回来:留在原地,不跳到对比页', !jumped && at.pathname.startsWith('/data'), at.pathname + at.search)
check('新页面画好之后答复才回来:一直留在新页面,走过的地址里从没有对比页', unmounted && !jumped && at.pathname === AWAY,
`${visited.join(' → ')};现在 ${at.pathname}${at.search}`)
check('离开之后「正在查找」的提示也收起', await page.locator('[data-toast]', { hasText: FINDING }).count() === 0)
check('页面没有报错', errors.length === 0, errors.join(';'))
})
await section('查找基准期间离开、立刻放行:地址已换,新页面还没画出来', async () => {
// React Router 按过渡(startTransition)渲染换页:地址先换,新页面画好、运行详情卸下(这才取消请求)在后。这里只换
// 地址、先不通知路由,把这段空当撑住,答复就在这时放行——检查不靠时机碰运气。找到了基准的不跳,找不到的不弹提示
for (const [what, answer] of [['找到了基准', undefined], ['找不到基准', { status: 404, json: { detail: NO_BASE } }]]) {
const { page, release, ended, noted, visited, errors } = await findingBase(answer)
await page.evaluate((to) => { history.pushState({}, '', to) }, AWAY)
release()
const req = await Promise.race([ended, new Promise((r) => setTimeout(() => r(null), 4000))])
// 答复处理完的标志:菜单收尾时收起「正在查找」的提示
const handled = noted && await until(async () => await page.locator('[data-toast]', { hasText: FINDING }).count() === 0, 3000)
const held = !!req && !req.failure() && handled && await page.locator(`[data-run-detail="${T}"]`).count() === 1
const jumped = await until(async () => visited.includes('/runs/compare'), 1000)
const told = await page.locator('[data-toast]', { hasText: NO_BASE }).count()
// 空当结束:通知路由,新页面画出来
await page.evaluate(() => { dispatchEvent(new PopStateEvent('popstate')) })
await awaySettled(page).catch(() => {})
const at = new URL(page.url())
check(`${what}:答复回来时运行详情还在、请求没被取消(撑住了空当)`, held,
`请求${req ? (req.failure() ? '被取消' : '已答完') : '没有了结'},提示${handled ? '已收起' : '没收起'}`)
check(`${what}:不跳到对比页,走过的地址里从没有对比页,留在新页面`, !jumped && at.pathname === AWAY,
`${visited.join(' → ')};现在 ${at.pathname}${at.search}`)
check(`${what}:不弹找不到基准的提示`, told === 0)
check(`${what}:页面没有报错`, errors.length === 0, errors.join(';'))
for (const c of opened) await c.close().catch(() => {})
opened.clear()
}
})
await section('不属于已保存工作流的运行:入口不可点并说明原因', async () => {
const saved = RUNS[T]
// 两种都没有 workflow_id:画布上没保存就运行的,和工作流删掉之后被置空的(名字还是原来的)
@@ -765,6 +819,8 @@ await section('出错与找不到基准', async () => {
const sealed = await open(comparePath(T, B), { compare: () => ({ status: 409, json: { detail: broken, code: 'compare_seal_broken' } }) })
await settled(sealed.page, 'error')
const e409 = sealed.page.locator('[data-compare-error="409"]')
// 叫人去上方换基准的那句下一步,要等下拉读到同一工作流的运行、知道有没有能换的才写
await until(() => has(sealed.page, '[data-compare-error="409"] [data-compare-error-hint]'), 3000)
const t409 = (await e409.innerText().catch(() => '')).replace(/\s+/g, ' ')
check('409 封存核对不一致:标题照实写,带上服务端的原因', (await e409.locator('[data-compare-error-title]').innerText()) === '无法对比:封存核对不一致'
&& t409.includes(broken), t409)
@@ -790,13 +846,27 @@ await section('出错与找不到基准', async () => {
RUNS[T] = savedT
}
const other = await open(comparePath(T, B), { compare: () => ({ status: 422,
json: { detail: '两次运行不属于同一个工作流(「区域销售周报」与「门店日报」),不能对比', code: 'compare_mismatch' } }) })
const mismatch = '两次运行不属于同一个工作流(「区域销售周报」与「门店日报」),不能对比'
const other = await open(comparePath(T, B), { compare: () => ({ status: 422, json: { detail: mismatch, code: 'compare_mismatch' } }) })
await settled(other.page, 'error')
const e422 = other.page.locator('[data-compare-error="422"]')
await until(() => has(other.page, '[data-compare-error="422"] [data-compare-error-hint]'), 3000)
check('422:写明无法对比,带上原因和下一步', (await e422.innerText()).includes('两次运行不属于同一个工作流')
&& (await e422.innerText()).includes('同一工作流的另一次运行'), (await e422.innerText().catch(() => '')).replace(/\s+/g, ' '))
// 下拉里除了本次和当前的基准,没有别的已封存的运行:换基准这条路走不通,409、422 都不再叫人去上方换,照实说没有。
// 409 的基准和本次同属一个工作流(下拉里有它,可它正是出问题的那一次),另一次没封存;422 的基准属于别的工作流
for (const [code, answer, list] of [
['409', { status: 409, json: { detail: broken, code: 'compare_seal_broken' } }, [RUNS[T], RUNS[B], RUNS[U]]],
['422', { status: 422, json: { detail: mismatch, code: 'compare_mismatch' } }, [RUNS[T], RUNS[U]]],
]) {
const only = await open(comparePath(T, B), { compare: () => answer, list: () => ({ json: list }) })
await settled(only.page, 'error')
const said = await until(async () => (await maybeText(only.page.locator(`[data-compare-error="${code}"] [data-compare-error-hint]`))).trim(), 3000)
const box = (await only.page.locator(`[data-compare-error="${code}"]`).innerText()).replace(/\s+/g, ' ')
check(`${code}、下拉里没有能换的:不叫人去上方换基准,写明没有其他可换作基准的已封存运行`, said === NO_OTHER && !box.includes('上方'), box)
}
const gone = await open(comparePath(T, B), { compare: () => RUN_GONE })
await settled(gone.page, 'error')
check('404:写明运行记录不存在', (await gone.page.locator('[data-compare-error="404"]').innerText()).includes('运行记录不存在'))
@@ -836,6 +906,17 @@ await section('出错与找不到基准', async () => {
&& (await missing.page.locator('[data-compare-error-title]').innerText()) === '运行记录不存在'
&& (await gone404.innerText()).includes('链接可能已失效') && await gone404.locator('a[href="/runs"]').count() === 1,
(await gone404.innerText().catch(() => '')).replace(/\s+/g, ' '))
// 本次的记录不存在:不知道是哪个工作流、列不出候选,重试几次都一样。换基准的位置照实说,不给「重试」;地址里带了
// 基准的(对比回 404)也一样,整页都没有注定失败的「重试」
const goneWithBase = await open(comparePath(T, B), { compare: () => RUN_GONE, run: (id) => (id === T ? RECORD_GONE : null) })
await settled(goneWithBase.page, 'error')
for (const [what, p] of [['只带本次', missing.page], ['带着基准', goneWithBase.page]]) {
const area = p.locator('[data-compare-run="base"]')
const noted = await until(() => has(p, '[data-compare-run="base"] [data-compare-pick-note="target-gone"]'), 3000)
check(`本次不存在(${what}):换基准的位置写明列不出同一工作流的运行,整页不给「重试」`,
noted && (await area.innerText()).includes('本次运行的记录不存在') && await p.getByRole('button', { name: '重试' }).count() === 0,
(await area.innerText()).replace(/\s+/g, ' '))
}
// 只带本次、找得到:自己补上基准,写明怎么选的
const auto = await open(comparePath(T))
@@ -877,6 +958,14 @@ await section('基准运行读不到(删了、链接过期)', async () => {
await settled(page)
check('另选一次之后照常对比', new URL(page.url()).searchParams.get('base') === B && compareHits(log, T, B) === 1)
check('页面没有报错', errors.length === 0, errors.join(';'))
// 同一工作流只剩本次和一次没封存的:下拉里没有能换的,缺基准时同样不叫人去上方另选
const bare = await open(comparePath(T, GONE), { compare: () => ({ status: 404,
json: { detail: '作为对比基准的运行记录不存在', code: 'run_not_found' } }), list: () => ({ json: [RUNS[T], RUNS[U]] }) })
await settled(bare.page, 'error')
const said = await until(async () => (await maybeText(bare.page.locator('[data-compare-error="404"] [data-compare-error-hint]'))).trim(), 3000)
const bareBox = (await bare.page.locator('[data-compare-error="404"]').innerText()).replace(/\s+/g, ' ')
check('缺的是基准、下拉里没有能换的:不叫人去上方另选,写明没有其他可换作基准的已封存运行', said === NO_OTHER && !bareBox.includes('上方'), bareBox)
})
await section('本次的运行记录读不到:换基准不一直停在「正在读取」', async () => {
+292
View File
@@ -3289,6 +3289,298 @@ await section('copy-r2','按后端原文匹配的地方:后端现在的原文
got.suspect.join(' | '))
})
// ---------------------------------------------------------------- 期 4:核查状态
/**
* 双盲复算的几种状态,按片段挂到一期夹具的指标步骤上。形状照服务端(api/evidence.py 的 _dual_check_of):一致、不一致
* (审核人采用原流程 / 采用盲算 / 没选)、未能完成(带原因)、已跳过(带原因代码)。DUAL_MORE 是第二轮:跳过的另几种原因
* (关闭了、没有来自查询的输入、认不出的代码)和「两边都不对」
*/
const DUAL = {
s6: { status: 'agreed', node_id: 'dual', blind_rendered: '45,678.5元' },
s8: { status: 'disagreed', node_id: 'dual', choice: 'a', blind_rendered: '9.1%' },
s10: { status: 'disagreed', node_id: 'dual', choice: 'b', blind_rendered: '1,301单' },
s45: { status: 'disagreed', node_id: 'dual', blind_rendered: '45,900.0元' },
s12: { status: 'unchecked', node_id: 'dual', reason: '盲算没有交出可用的数:查询全部失败' },
s23: { status: 'skipped', node_id: 'dual', reason: 'not_targeted' },
}
const DUAL_MORE = {
s23: { status: 'skipped', node_id: 'dual', reason: 'disabled' },
s49: { status: 'skipped', node_id: 'dual', reason: 'no_inputs' },
s45: { status: 'skipped', node_id: 'dual', reason: 'no_such_reason' },
s10: { status: 'disagreed', node_id: 'dual', choice: 'neither', blind_rendered: '1,301单' },
}
/** 双盲复算那一行标签上的说明(DUAL_CHECK_TEXT.description):悬停、读屏都说得出是什么 */
const DUAL_ABOUT = '另起一路独立计算,与原结果逐项比对'
/** 虚构的反方审稿记录(challenge_artifact):整句面板里点开那一句,按它取理由 */
const CH_ART = 'c4'.repeat(32)
const CH_RATIONALE = '扣除退款后新客首单只占增长的一小部分,老客复购反而下降。'
/** 整份报告的审稿计数(服务端从节点产出里取):审了 6 句,经受住 3、被削弱 1、被推翻 2 */
const CH_COUNTS = { stands: 3, weakened: 1, refuted: 2, inconclusive: 0 }
/** 出具预告的几行(审批卡上说「出具会降档」那一套):证据面板看的是已经结束的运行,一行都不该有 */
const ISSUANCE_LINES = '[data-challenge-degrade], [data-challenge-degrade-note], [data-challenge-degrade-off], '
+ '[data-challenge-flag], [data-challenge-runclass]'
/** WCAG 对比度:rgb()/rgba()/color(srgb …) 都认 */
function contrastOf(fg, bg) {
const parse = (c) => {
const m = c.match(/color\(srgb ([\d.]+) ([\d.]+) ([\d.]+)/)
if (m) return [m[1], m[2], m[3]].map((x) => Number(x) * 255)
return (c.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number)
}
const lum = (c) => {
const [r, g, b] = parse(c).map((v) => {
const x = v / 255
return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4
})
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
const [a, b] = [lum(fg), lum(bg)].sort((x, y) => y - x)
return (a + 0.05) / (b + 0.05)
}
await section('verify', '期 4:指标步骤的双盲复算、取自遮罩列的输入、反方审稿的判断', async () => {
// ---- 指标步骤多一行双盲复算的结果;没复核过的不画
const r = await probe((id, body) => {
const metric = body.chain?.find((st) => st.step === 'metric')
if (metric && DUAL[id]) metric.dual_check = DUAL[id]
if (id === 's12' && metric) {
// 服务端遮罩之后的样子:订单数取自遮罩列,标签和代入式里都写「已遮罩」
metric.substituted = metric.substituted.replace('1234', '已遮罩')
for (const st of body.chain) {
if (st.step === 'input' && st.path === 'vars.kpi.order_cnt') Object.assign(st, { value: '已遮罩', masked: ['order_cnt'] })
}
body.redacted = { columns: ['order_cnt'], note: '这些列在数据源中设置了遮罩' }
}
if (id === 's23' && metric) {
// 遮没遮核对不了(快照读不出来):同样不显示值,原因是服务端写好的一句,不进遮罩列
for (const st of body.chain) {
if (st.step === 'input' && st.path === 'vars.kpi.refund_cnt') {
Object.assign(st, { value: '已遮罩', mask_unverified: '查询快照无法读取,核对不了这一格是否取自设置了遮罩的列' })
}
}
}
return body
})
const pg = r.page
const p = panel(pg)
const line = p.locator('[data-ev-dual-check]')
const lineText = async () => (await line.innerText().catch(() => '')).replace(/\s+/g, ' ')
for (const [id, status, words, token, none = []] of [
['s6', 'agreed', ['双盲复算', '与盲算结果一致', '盲算 45,678.5元'], '--st-done', ['缺口']],
['s8', 'disagreed', ['与盲算结果不一致', '盲算 9.1%', '审核人选择「采用原流程的结果」'], '--st-waiting', ['缺口']],
['s10', 'disagreed', ['与盲算结果不一致', '盲算 1,301单', '审核人选择「采用盲算的结果」',
'报告中的数仍取自原流程,这个指标已记为缺口'], '--st-failed', ['审核人采用盲算结果', '出具缺口', '降档']],
['s45', 'disagreed', ['与盲算结果不一致', '盲算 45,900.0元', '审核人没有作出选择', '报告中的数仍取自原流程'], '--st-failed'],
['s12', 'unchecked', ['未能完成', '查询全部失败', '这个指标已记为缺口'], '--st-waiting', ['无法复算']],
['s23', 'skipped', ['已跳过', '这个指标不在复算范围内'], '--text-dim', ['缺口']],
]) {
await openSeg(pg, id, '[data-ev-dual-check]')
const text = await lineText()
const got = await line.getAttribute('data-ev-dual-check').catch(() => null)
const color = await line.locator('span.font-medium').evaluate((el) => getComputedStyle(el).color).catch(() => '')
check(`双盲复算 ${status}(${words.slice(1).join('、') || words[0]}):指标步骤写成一行,颜色按要不要多看一眼`,
got === status && words.every((w) => text.includes(w)) && !none.some((w) => text.includes(w))
&& color === await tokenColor(pg, 'color', `var(${token})`), `${got}:${text}(${color})`)
await pg.keyboard.press('Escape')
await pg.waitForTimeout(80)
}
// 「双盲复算」和同一节里按代入式重算的「复算」是两件事:标签悬停、读屏都说明是什么,状态词也不同
await openSeg(pg, 's12', '[data-ev-dual-check]')
const label = p.locator('[data-ev-dual-check-label]')
const recompute = await p.locator('[data-ev-recompute]').innerText().catch(() => '')
check('双盲复算的标签悬停写明是什么(另起一路独立计算),读屏同样读得到', await label.getAttribute('title') === DUAL_ABOUT
&& (await label.locator('.sr-only').innerText().catch(() => '')).includes(DUAL_ABOUT))
check('双盲复算的状态词和「复算」那一行分得开:未能完成 ≠ 无法复算', (await lineText()).includes('未能完成')
&& !(await lineText()).includes('无法复算') && !!recompute, `${await lineText()} | ${recompute}`)
await pg.keyboard.press('Escape')
for (const theme of ['light', 'dark']) {
await pg.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
let worst = Infinity
for (const id of ['s6', 's10', 's8', 's23']) {
await openSeg(pg, id, '[data-ev-dual-check]')
const pair = await line.evaluate((el) => {
const back = (node) => {
for (let n = node; n; n = n.parentElement) {
const c = getComputedStyle(n).backgroundColor
if (c && !/rgba\(0, 0, 0, 0\)|transparent/.test(c)) return c
}
return getComputedStyle(document.body).backgroundColor
}
return [...el.querySelectorAll('span')].filter((sp) => !sp.classList.contains('sr-only'))
.map((sp) => [getComputedStyle(sp).color, back(sp)])
}).catch(() => [])
for (const [fg, bg] of pair) worst = Math.min(worst, contrastOf(fg, bg))
await pg.keyboard.press('Escape')
await pg.waitForTimeout(80)
}
check(`${theme}:双盲复算那一行的字对比度都 ≥ 4.5`, worst >= 4.5, worst.toFixed(2))
}
await pg.evaluate(() => document.documentElement.removeAttribute('data-theme'))
await openSeg(pg, 's49', '[data-ev-substituted]')
check('没有双盲复算复核过的指标:不画这一行', await line.count() === 0)
await pg.keyboard.press('Escape')
// ---- 取自遮罩列的输入:标签写「已遮罩」(遮罩格的样子),代入式里也是,底下写明遮罩不是安全边界
await openSeg(pg, 's12', '[data-ev-substituted]')
const masked = p.locator('[data-ev-input="vars.kpi.order_cnt"]')
const chip = (await masked.innerText().catch(() => '')).replace(/\s+/g, ' ')
const italic = await masked.locator('.italic').evaluate((el) => getComputedStyle(el).fontStyle).catch(() => '')
check('遮罩列的输入:标签写「已遮罩 ← 路径」,按遮罩格的样子画,不露出原值',
await masked.getAttribute('data-ev-input-masked') === '' && chip.includes('已遮罩 ← vars.kpi.order_cnt')
&& !chip.includes('1,234') && italic === 'italic', `${chip}(${italic})`)
const sub = await p.locator('[data-ev-substituted]').innerText().catch(() => '')
check('遮罩列的输入:代入式里那一处写「已遮罩」', sub === 'round(45678.5 / 已遮罩, 1)', sub)
check('没用到遮罩列的输入照常写值', (await p.locator('[data-ev-input="vars.kpi.gmv"]').innerText()).includes('45,678.5 ← vars.kpi.gmv')
&& await p.locator('[data-ev-input="vars.kpi.gmv"]').getAttribute('data-ev-input-masked') === null)
check('面板底下写明遮罩的列和遮罩不是安全边界', (await p.locator('[data-ev-mask-note]').innerText().catch(() => '')).includes('order_cnt'))
await pg.keyboard.press('Escape')
// 遮没遮核对不了:同样写「已遮罩」,悬停说明原因(快照读不出来),不算进遮罩列
await openSeg(pg, 's23', '[data-ev-substituted]')
const unsure = p.locator('[data-ev-input="vars.kpi.refund_cnt"]')
const unsureTitle = await unsure.getAttribute('title').catch(() => '')
check('遮没遮核对不了的输入:标签写「已遮罩」,悬停写明快照读不出来、不显示值',
await unsure.getAttribute('data-ev-input-masked') === '' && (await unsure.innerText()).includes('已遮罩')
&& (unsureTitle ?? '').includes('查询快照无法读取,核对不了这一格是否取自设置了遮罩的列,不显示值'), unsureTitle ?? '')
check('……面板底下的遮罩说明不把它算进遮罩列', await p.locator('[data-ev-mask-note]').count() === 0)
await pg.keyboard.press('Escape')
check('核查状态那几页没有运行时报错', r.errors.length === 0, r.errors.join(' | '))
await r.ctx.close()
// ---- 第二轮:跳过的另几种原因、「两边都不对」。原因代码认不出来的不写进句子;关闭了的不说两遍「双盲复算」
const r2 = await probe((id, body) => {
const metric = body.chain?.find((st) => st.step === 'metric')
if (metric && DUAL_MORE[id]) metric.dual_check = DUAL_MORE[id]
return body
})
const line2 = panel(r2.page).locator('[data-ev-dual-check]')
for (const [id, words, none] of [
['s23', ['已跳过', '节点或设置中已关闭'], ['双盲复算已关闭']],
['s49', ['已跳过', '这个指标没有来自查询的输入'], ['口径卡上没有']],
['s45', ['已跳过'], ['no_such_reason', ',']],
['s10', ['审核人选择「两边都不对」', '报告中的数仍取自原流程,这个指标已记为缺口'], ['审核人判断']],
]) {
await openSeg(r2.page, id, '[data-ev-dual-check]')
const text = (await line2.innerText().catch(() => '')).replace(/\s+/g, ' ').replace(`(${DUAL_ABOUT})`, '')
check(`双盲复算 ${id}:${words.join('、')}${none.length ? `,不写「${none.join('」「')}」` : ''}`,
words.every((w) => text.includes(w)) && !none.some((w) => text.includes(w)), text)
await r2.page.keyboard.press('Escape')
await r2.page.waitForTimeout(80)
}
check('第二轮没有运行时报错', r2.errors.length === 0, r2.errors.join(' | '))
await r2.ctx.close()
// ---- 输入来源:遮罩列的 Agent 字段核对不一致时只说不一致,不写两边的值
const q = await probeQ((id, body) => {
if (body.segment?.text !== '37.0元') return body
for (const st of body.chain) {
if (st.step === 'input' && st.path === 'vars.kpi.order_cnt') {
delete st.model_value
Object.assign(st, { value: '已遮罩', masked: ['order_cnt'] })
}
}
return body
})
await openQ(q.page, '37.0元', { wait: '[data-ev-sources]' })
const src = await panel(q.page).locator('[data-ev-source="vars.kpi.order_cnt"]').innerText().catch(() => '')
check('输入来源:遮罩列的字段核对不一致,只说不一致、不写值', src.includes('与快照不一致;取自设置了遮罩的列,不显示值')
&& !src.includes('1,240') && !src.includes('1,234'), src.replace(/\s+/g, ' '))
const verified = await panel(q.page).locator('[data-ev-source="vars.kpi.gmv"]').innerText().catch(() => '')
check('输入来源:没遮罩的字段照旧', verified.includes('与快照一致') && !verified.includes('遮罩'), verified.replace(/\s+/g, ' '))
await q.ctx.close()
// ---- 反方审稿:整句面板、片段面板都看得到这一句的判断。复用审批卡的展示,概况是整份报告的真实计数,不预告出具
const u = fxj.formal.units
const challenge = (verdict) => ({ verdict, node_id: 'chal', artifact: CH_ART, on_refuted: 'degrade', report_label: '写周报',
counts: CH_COUNTS })
const segPatch = (jset, body) => {
if (jset === fxj.formal && body.unit?.id === u.bad) body.unit.challenge = challenge('refuted')
if (jset === fxj.formal && body.unit?.id === u.ok) {
body.unit.challenge = challenge('weakened')
// 正式运行里的一个指标:双盲复算不一致、审核人采用盲算。记的是出具缺口
if (body.segment?.id === 's6') {
body.chain = [{ step: 'metric', metric: 'gmv', name: '销售额', value: 45678.5, rendered: '45,678.5', unit: '元',
expression: 'vars.kpi.gmv', substituted: '45678.5', recompute_ok: true,
dual_check: { status: 'disagreed', node_id: 'dual', choice: 'b', blind_rendered: '45,900.0元' } }]
}
}
// 计数缺了(格式不对):不画,免得审批卡的展示按这一句拼一个「被推翻 1 句、其余 0 句」
if (jset === fxj.formal && body.unit?.id === u.limit) body.unit.challenge = { verdict: 'refuted', node_id: 'chal' }
// 没开结论句裁判的报告:没有句末徽标,点句中的数字也要看得到
if (jset === fxj.plain && body.unit?.id === 'u3') body.unit.challenge = challenge('refuted')
return body
}
for (const [w, h] of [[1280, 1000], [390, 844]]) {
const c = await open('/ui-harness.html?evidence=1', { segPatch, w, h })
const artifactHits = []
await c.page.route((url) => new URL(url).pathname === `/api/artifacts/${CH_ART}`, (route) => {
artifactHits.push(route.request().url())
return route.fulfill({ json: { id: CH_ART, content: {
report: 'write', doc_artifact: fxj.formal.doc_artifact, model: 'challenger-x',
claims: [{ unit: u.bad, text: '增长主要来自新客首单,老客复购持平。', verdict: 'refuted', rationale: CH_RATIONALE,
rationale_doc: null, queries: [] }],
} } })
})
const cp = panel(c.page)
const block = cp.locator('[data-ev-challenge]')
const blockText = async () => (await block.innerText().catch(() => '')).replace(/\s+/g, ' ')
await badge(c.page, J, u.bad).click()
await c.page.waitForSelector('[data-evidence-panel] [data-ev-challenge] [data-challenge-item]', { timeout: 4000 }).catch(() => {})
const text = await blockText()
if (w === 1280) {
check('整句面板:反方审稿的判断(被推翻)挂在这一句下,写明是哪份报告的审稿', await block.getAttribute('data-ev-challenge', { timeout: 3000 }).catch(() => null) === 'refuted'
&& text.includes('报告「写周报」的反方审稿') && text.includes('被推翻') && text.includes('新客首单'), text)
check('……概况是整份报告的真实计数(服务端给的),不凭这一句拼', text.includes('经受住 3 句') && text.includes('被削弱 1 句')
&& text.includes('被推翻 2 句') && text.includes('无法判断 0 句') && await block.locator('[data-challenge-more="5"]').count() === 1, text)
check('……运行已经结束:不预告「出具会降档」,降没降档看报告的出具横幅', await block.locator(ISSUANCE_LINES).count() === 0
&& !text.includes('降档'), text)
check('……判断由反方模型给出,并非系统核对', text.includes('并非系统核对'), text)
await block.locator('[data-challenge-toggle]').click()
await c.page.waitForSelector('[data-evidence-panel] [data-challenge-detail-state="ok"]', { timeout: 4000 }).catch(() => {})
const why = await block.locator('[data-challenge-rationale]').innerText().catch(() => '')
check('……点开看得到反方的理由(按审稿记录取)', why.includes(CH_RATIONALE) && artifactHits.length === 1, why)
// 被削弱的那一句:概况照样是整份报告的数,不出现凭这一句拼的「被推翻 0 句」
await badge(c.page, J, u.ok).click()
await c.page.waitForSelector('[data-evidence-panel] [data-ev-challenge="weakened"]', { timeout: 4000 }).catch(() => {})
const weak = await blockText()
check('被削弱的那一句:概况写整份报告的计数(被推翻 2 句),不写凭这一句拼的「被推翻 0 句」', weak.includes('被推翻 2 句')
&& !weak.includes('被推翻 0 句') && !weak.includes('被削弱 1 句 · 被推翻 0 句'), weak)
// 片段面板:点这一句里的数字,同样看得到判断;正式运行里双盲复算记的是出具缺口
await c.page.locator(`${J} [data-seg="s6"]`).click()
await c.page.waitForSelector('[data-evidence-panel] [data-ev-challenge="weakened"]', { timeout: 4000 }).catch(() => {})
check('片段面板:点句中的数字,看得到这一句的反方审稿判断', await block.getAttribute('data-ev-challenge', { timeout: 3000 }).catch(() => null) === 'weakened'
&& (await blockText()).includes('被削弱'), await blockText())
const dual = (await cp.locator('[data-ev-dual-check]').innerText().catch(() => '')).replace(/\s+/g, ' ')
check('……正式运行:审核人采用盲算的,写明报告中的数仍取自原流程、已记为出具缺口',
dual.includes('审核人选择「采用盲算的结果」') && dual.includes('报告中的数仍取自原流程,这个指标已记为出具缺口'), dual)
// 计数格式不对的:不画
await badge(c.page, J, u.limit).click()
await c.page.waitForSelector('[data-evidence-panel] [data-ev-judge]', { timeout: 4000 }).catch(() => {})
await c.page.waitForTimeout(200)
check('服务端没给计数的判断:不画(不拼一个只数这一句的概况)', await block.count() === 0)
await badge(c.page, J, u.partial).click()
await c.page.waitForSelector('[data-evidence-panel] [data-ev-judge="partial"]', { timeout: 4000 }).catch(() => {})
await c.page.waitForTimeout(200)
check('没审到的句子:不画反方审稿', await block.count() === 0)
// 没开结论句裁判的报告:没有句末徽标,点句中的数字照样看得到判断
await c.page.keyboard.press('Escape')
await c.page.locator(`${JP} [data-seg="s14"]`).click()
await c.page.waitForSelector('[data-evidence-panel] [data-ev-challenge]', { timeout: 4000 }).catch(() => {})
check('没开结论句裁判的报告:点句中的数字看得到反方审稿判断', await block.getAttribute('data-ev-challenge', { timeout: 3000 }).catch(() => null) === 'refuted'
&& (await blockText()).includes('退款 29 单'), await blockText())
} else {
const wide = await c.page.evaluate(() => {
const el = document.querySelector('[data-evidence-panel]')
return { page: document.documentElement.scrollWidth - document.documentElement.clientWidth,
panel: el ? el.scrollWidth - el.clientWidth : 99 }
})
check('390px:整句面板带着反方审稿不横向溢出', !!text && wide.page <= 0 && wide.panel <= 1, JSON.stringify(wide))
}
check(`${w}px:没有运行时报错`, c.errors.length === 0, c.errors.join(' | '))
await c.ctx.close()
}
})
check('没有运行时报错(组件预览)', harness.errors.length === 0, harness.errors.join(' | '))
await harness.ctx.close()
await browser.close()