证据面板加数字推演:指标步骤上「推演」,改输入、勾掉查询结果里的行,看这个数和报告怎么变

- run/WhatIf.tsx:推演区。顶上醒目横幅「推演,非出具:不会写进运行记录,也不会改动报告」,运行未封存另行提示;
  旧值 → 新值和变化量(比率、百分数写「个百分点」)、推演代入式、输入(数值可改:停顿 450 毫秒或回车、失焦才提交,
  写的不是数提示并保留上次的结果,Esc 退回)、不支持推演的输入和原因、报告中会变的地方(可定位)、可能受影响的
  下游口径卡、恢复原值;加载、失败(可重试)、空态都有。新改动来了旧请求直接丢掉,答复按请求体缓存
- EvidencePanel.tsx:MetricPart 挂「推演」开关和推演区;QueryPart 只在推演时把结果窗口换成带勾选框的表(WhatIfRows),
  勾掉的行划掉、遮罩列照旧写「已遮罩」。草稿按(运行、报告、口径卡、指标)认,同一个指标的几处引用之间来回切不清空
- 接口 api.whatif.run、推演相关类型(types.ts 末尾独立一段)、WHATIF_TEXT 文案
- 预览用例 ?syn=whatif / whatif-rail 和 check-whatif.mjs(54 项,用 page.route 假接口):请求体、防抖、回车、排除行、
  恢复原值、收起、不支持的输入、遮罩列、加载 / 失败 / 未封存、390px 与 360px 右栏不横向溢出、深浅色横幅对比度
This commit is contained in:
ThearchyHelios
2026-10-05 04:04:30 +08:00
parent 9852e4d33a
commit cf038c95f5
8 changed files with 1133 additions and 20 deletions
+1 -1
View File
@@ -96,7 +96,7 @@ const ESTIMATE = {
stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87,
evidence: 91, versions: 60, catalog: 45, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
// 占位,换成真检查时按实测改
schedules: 1, compare: 1, verify: 1, inquiry: 1, bundle: 1, whatif: 1,
schedules: 1, compare: 1, verify: 1, inquiry: 1, bundle: 1, whatif: 25,
}
// 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段
// 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。
+421 -6
View File
@@ -1,8 +1,423 @@
// 数字推演的检查。现在只是占位:功能还在开发,这里只打一行通过,让 check-all 的清单先排上它。
// 数字推演的工作包把它换成真检查:沿用别的检查的输出格式——每项缩进两格、以 ✓ / ✗ 开头,最后顶格一行
// 总结,没过的退出码非 0(check-all 按缩进的 ✓ / ✗ 计数)。
// 数字推演(证据面板里口径卡上的数,run/WhatIf.tsx)的回归检查。
//
// 守的是这些「看着没坏、其实用不了」的地方:横幅没了或者不醒目(推演被当成出具)、边打字边发请求、请求体里的
// 输入路径和行号不对(行号对人从 1 数,对接口从 0 数)、勾选框在不推演时也冒出来、恢复原值之后还留着一行没勾回去、
// 不支持的输入一声不吭、窄屏(390px)被推演区撑出横向滚动、浅色主题下横幅看不清。
//
// 跑在预览页上:/preview.html?syn=whatif(宽屏右侧弹出、窄屏底部抽出)和 ?syn=whatif-rail(360px 画布右栏)。
// 文档是 frontend/src/run/__tests__/evidence-query.json 里后端 compose_doc 真跑出来的(只用通用名);片段接口
// 按它的 segments 作答,推演接口按后端 engine/recompute.py 的规则在这里算(只覆盖用到的三个指标),非 GET 一律拦掉。
//
// node scripts/check-whatif.mjs
console.log('=== 数字推演 ===')
console.log(' ✓ 占位:功能开发中')
console.log('\n✓ 数字推演:占位通过')
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-whatif.mjs
// WHATIF_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.WHATIF_SHOTS ?? ''
const root = new URL('..', import.meta.url).pathname
const fx = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-query.json`, 'utf8'))
const RUN = fx.run_id
const BANNER = '推演,非出具:不会写进运行记录,也不会改动报告'
let failed = 0
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
if (!cond) failed++
}
async function section(name, fn) {
console.log(`\n=== ${name} ===`)
try {
await fn()
} catch (e) {
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
}
}
// ---------------------------------------------------------------- 夹具
/** 片段接口的答复:夹具里的原样,s22 改成老运行的容器输入(推演不了的那种) */
function segmentReply(sid) {
const body = structuredClone(fx.segments[sid])
if (sid === 's22') {
body.chain[0] = { ...body.chain[0], expression: 'sum(vars.adjust.items)', substituted: 'sum(vars.adjust.items)' }
body.chain[1] = { step: 'input', path: 'vars.adjust.items', value: '[4 项]', node_id: 'adjust', via: 'code',
field: 'items', role: 'compute', status: 'ok' }
}
return body
}
const chainOf = (sid) => fx.segments[sid].chain
const inputsOf = (sid) => chainOf(sid).filter((s) => s.step === 'input')
const Q1 = chainOf('s12')[1].artifact
const Q4 = chainOf('s12')[2].artifact
const Q3 = inputsOf('s70')[0].artifact
const thousands1 = (v) => v.toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 })
/** 推演接口在这里按后端的规则算:给了新值的用新值;cell / agent 字段那一格所在的行被排除了,指标排除后无法计算 */
const METRICS = {
wow: {
sid: 's12', name: '环比增幅', unit: '%', calc: ([g, p]) => Math.round((g - p) / p * 1000) / 10,
render: (v) => `${v.toFixed(1)}%`, delta: (d) => `${d > 0 ? '+' : '-'}${Math.abs(d).toFixed(1)} 个百分点`,
},
spread: {
sid: 's70', name: '首末单差额', unit: '元', calc: ([t, l]) => Math.round((t - l) * 10) / 10,
render: (v) => `${thousands1(v)}元`, delta: (d) => `${d > 0 ? '+' : '-'}${thousands1(Math.abs(d))}元`,
},
}
function whatifReply(body) {
const overrides = body?.overrides ?? {}
const inputs = overrides.inputs ?? {}
const exclude = overrides.exclude_rows ?? {}
const base = { run_id: RUN, card: { node_id: 'caliber', artifact: chainOf('s12')[0].artifact, caliber: '周报口径', version: 'v3' },
report: body?.report ?? null, sealed: true, seal: { sealed: true, ok: true }, affected_cards: [] }
if (body?.metric === 'adj') {
return { ...base, metrics: [{ id: 'adj', name: '调整后销售额', unit: '元', old: 44000, new: 44000, delta: 0,
rendered_old: '44,000.0元', rendered_new: '44,000.0元', rendered_delta: null, changed: false, status: 'unchanged',
reason: null, substituted: 'sum(vars.adjust.items)',
inputs: [{ path: 'vars.adjust.items', via: 'code', node_id: 'adjust', kind: 'container', editable: false,
old: '[4 项]', new: '[4 项]', changed: false }] }],
segments: [{ report: 'write', unit: 'u4', sid: 's22', metric: 'adj', rendered_old: '44,000.0元', rendered_new: '44,000.0元', changed: false }],
unsupported: [{ metric: 'adj', input: 'vars.adjust.items', reason: '无法推演:当时没有保存这个输入的完整值' }] }
}
const def = METRICS[body?.metric]
const step = chainOf(def.sid)[0]
const views = inputsOf(def.sid).map((inp) => {
const row = inp.locator?.row
const view = { path: inp.path, via: inp.via, node_id: inp.node_id, artifact: inp.artifact, locator: inp.locator,
kind: 'number', editable: true, old: inp.value }
if (inp.path in inputs) return { ...view, new: inputs[inp.path], changed: inputs[inp.path] !== inp.value, touched: true }
if ((exclude[inp.artifact] ?? []).includes(row)) {
return { ...view, new: null, changed: true, excluded_rows: [row], touched: true, gone: true }
}
return { ...view, new: inp.value, changed: false }
})
const touched = views.some((v) => v.touched)
const gone = views.find((v) => v.gone)
let status = 'unchanged'; let value = step.value; let reason = null
if (touched && gone) {
status = 'excluded'; value = null; reason = `排除后无法计算:${gone.path} 所在的第 ${gone.locator.row + 1} 行已排除`
} else if (touched) {
status = 'ok'; value = def.calc(views.map((v) => v.new))
}
const delta = value == null ? null : Math.round((value - step.value) * 10) / 10
const rendered = value == null ? '—' : def.render(value)
const metric = { id: step.metric, name: def.name, unit: def.unit, old: step.value, new: value, delta,
rendered_old: step.rendered, rendered_new: rendered, rendered_delta: delta ? def.delta(delta) : null,
changed: value !== step.value, status, reason, substituted: status === 'ok' ? `(推演代入式 ${views.map((v) => v.new).join('、')})` : step.substituted,
inputs: views.map(({ touched: _t, gone: _g, ...v }) => v) }
return { ...base, metrics: [metric], unsupported: [],
segments: [{ report: 'write', unit: fx.segments[def.sid].unit.id, sid: def.sid, metric: step.metric,
rendered_old: step.rendered, rendered_new: rendered, changed: rendered !== step.rendered }] }
}
// ---------------------------------------------------------------- 页面
const browser = await chromium.launch({ executablePath: CHROME })
/**
* 开一页:片段接口按夹具答,推演接口按 whatifReply 答(reply 给了就由它改:延迟、出错、未封存),记下每次推演的
* 请求体和时刻。别的非 GET 一律拦掉,别的 GET 放行
*/
async function open(syn, { width = 1280, height = 900, theme = 'dark', reply = null } = {}) {
const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, timezoneId: 'Asia/Shanghai' })
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 (req.method() === 'POST' && url.pathname === `/api/runs/${RUN}/evidence/whatif`) {
const body = req.postDataJSON()
sent.push({ body, at: Date.now() })
const out = reply ? await reply(body, sent.length) : { json: whatifReply(body) }
return route.fulfill({ status: out.status ?? 200, json: out.json })
}
if (req.method() !== 'GET') return route.abort()
const seg = url.pathname.match(new RegExp(`/api/runs/${RUN}/evidence/segments/([^/]+)$`))
if (seg) return route.fulfill({ json: segmentReply(decodeURIComponent(seg[1])) })
return route.continue()
})
await page.goto(`${WEB}/preview.html?syn=${syn}&theme=${theme}`, { waitUntil: 'networkidle' })
await page.waitForSelector('[data-evidence-doc]')
return { ctx, page, sent, errors }
}
/** 点开一个片段,等指标步骤和「推演」开关出来 */
async function openMetric(page, sid) {
await page.click(`[data-seg="${sid}"]`)
await page.waitForSelector('[data-ev-metric] [data-whatif-toggle]')
}
/** 展开推演,等第一份答复画出来 */
async function openWhatIf(page) {
await page.click('[data-whatif-toggle]')
await page.waitForSelector('[data-whatif-result="unchanged"], [data-whatif-result="ok"]')
}
const settle = (page, ms = 700) => page.waitForTimeout(ms)
const text = (page, sel) => page.locator(sel).first().innerText()
const shot = async (page, name) => {
if (!SHOTS) return
mkdirSync(SHOTS, { recursive: true })
await page.screenshot({ path: `${SHOTS}/${name}.png`, fullPage: false })
}
await section('打开推演', async () => {
const { ctx, page, sent, errors } = await open('whatif')
await openMetric(page, 's12')
const toggle = page.locator('[data-whatif-toggle]')
check('指标步骤上有「推演」,默认收起', (await toggle.getAttribute('aria-expanded')) === 'false')
check('不推演时查询步骤里没有勾选框', await page.locator('[data-whatif-rows], [data-whatif-row] input').count() === 0)
await openWhatIf(page)
check('展开后开关标为展开、指向推演区', (await toggle.getAttribute('aria-expanded')) === 'true'
&& (await page.locator(`#${await toggle.getAttribute('aria-controls')}[data-whatif]`).count()) === 1)
check('顶上醒目写着「推演,非出具」', (await text(page, '[data-whatif-banner]')).trim() === BANNER)
check('第一次请求不带改动', JSON.stringify(sent[0]?.body) === JSON.stringify(
{ report: 'write', card: 'caliber', metric: 'wow', overrides: { inputs: {}, exclude_rows: {} } }), JSON.stringify(sent[0]?.body))
check('原值 → 原值,标「无变化」', (await text(page, '[data-whatif-new]')) === '8.7%'
&& (await text(page, '[data-whatif-delta]')) === '无变化')
const fields = await page.locator('[data-whatif-field]').count()
check('两个数值输入都能改,框里是原值', fields === 2
&& await page.locator('[data-whatif-input="vars.kpi.gmv"] [data-whatif-field]').inputValue() === '45678.5')
check('取自查询的输入给「去勾选要排除的行」', await page.locator('[data-whatif-pick]').count() === 2)
check('输入落在的两个查询步骤里出现勾选框', await page.locator('[data-whatif-rows]').count() === 2
&& await page.locator('[data-whatif-row] input[type="checkbox"]').count() === 2)
check('报告里引用它的一处不受影响', (await text(page, '[data-whatif-segments-none]')).includes('1 处不受影响'))
check('「恢复原值」在没有改动时不可点', await page.locator('[data-whatif-reset]').isDisabled())
await shot(page, 'open-dark')
check('页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
})
await section('改一个输入', async () => {
const { ctx, page, sent } = await open('whatif')
await openMetric(page, 's12')
await openWhatIf(page)
const field = page.locator('[data-whatif-input="vars.kpi.gmv"] [data-whatif-field]')
await field.fill('')
const before = sent.length
await field.pressSequentially('50000', { delay: 40 })
await page.waitForTimeout(150)
check('边打字不发请求', sent.length === before)
await page.waitForSelector('[data-whatif-result="ok"]')
check('停顿之后只发一次', sent.length === before + 1, `${sent.length - before} 次`)
check('请求体带着改动的输入', JSON.stringify(sent.at(-1).body.overrides) === JSON.stringify({ inputs: { 'vars.kpi.gmv': 50000 }, exclude_rows: {} }),
JSON.stringify(sent.at(-1).body.overrides))
check('显示新值和变化量', (await text(page, '[data-whatif-new]')) === '19.0%'
&& (await text(page, '[data-whatif-delta]')) === '变化 +10.3 个百分点', await text(page, '[data-whatif-result]'))
check('报告里会变的那一处:8.7% → 19.0%,可定位', (await text(page, '[data-whatif-segment="s12"]')).includes('19.0%')
&& await page.locator('[data-whatif-locate="s12"]').count() === 1)
check('横幅一直在', (await text(page, '[data-whatif-banner]')).trim() === BANNER)
// 回车立刻提交,不等停顿
await field.fill('60000')
const t0 = Date.now()
await field.press('Enter')
await page.waitForFunction(() => document.querySelector('[data-whatif-new]')?.textContent === '42.8%')
check('回车立刻提交', sent.at(-1).body.overrides.inputs['vars.kpi.gmv'] === 60000 && sent.at(-1).at - t0 < 300,
`${sent.at(-1).at - t0}ms`)
// 写的不是数:提示,保留上一次的结果
await field.fill('六万')
await settle(page)
check('不是数:提示「请输入数值」、不发请求', (await page.locator('[data-whatif-input="vars.kpi.gmv"]').innerText()).includes('请输入数值')
&& sent.at(-1).body.overrides.inputs['vars.kpi.gmv'] === 60000)
// Esc 退回已提交的值,面板不关
await field.press('Escape')
check('Esc 退回已提交的值,面板还开着', await field.inputValue() === '60000' && await page.locator('[data-evidence-panel]').count() === 1)
// 定位:焦点回到正文里那一处,推演不收起
await page.click('[data-whatif-locate="s12"]')
await page.waitForTimeout(200)
check('定位到正文里那一处,推演照旧展开', await page.evaluate(() => document.activeElement?.getAttribute('data-seg')) === 's12'
&& await page.locator('[data-whatif]').count() === 1 && (await text(page, '[data-whatif-new]')) === '42.8%')
await ctx.close()
})
await section('勾掉一行', async () => {
const { ctx, page, sent } = await open('whatif')
await openMetric(page, 's12')
await openWhatIf(page)
const box = page.locator(`[data-ev-query="${Q4}"] [data-whatif-row="0"] input[type="checkbox"]`)
check('勾选框的读屏名从 1 数', (await box.getAttribute('aria-label')) === '排除第 1 行')
await box.check()
await page.waitForSelector('[data-whatif-result="excluded"]')
check('请求体带 exclude_rows(快照工件 id → 从 0 数的行号)',
JSON.stringify(sent.at(-1).body.overrides) === JSON.stringify({ inputs: {}, exclude_rows: { [Q4]: [0] } }), JSON.stringify(sent.at(-1).body.overrides))
check('这一行划掉', await page.locator(`[data-ev-query="${Q4}"] [data-whatif-row="0"][data-excluded]`).count() === 1)
check('排除后无法计算:写明原因、新值是「—」', (await text(page, '[data-whatif-reason]')).startsWith('排除后无法计算')
&& (await text(page, '[data-whatif-new]')) === '—')
check('输入下写明排除了哪一行', (await text(page, `[data-whatif-input="cell(nodes.pull, 0, 'gmv')"] [data-whatif-excluded]`)) === '已排除第 1 行')
check('结果窗口下写明已排除几行', (await text(page, `[data-ev-query="${Q4}"] [data-whatif-excluded-count]`)) === '已排除 1 行')
await shot(page, 'excluded-dark')
// 恢复原值:输入框、勾选框、结果都回去
await page.locator('[data-whatif-input="vars.kpi.gmv"] [data-whatif-field]').fill('50000')
await page.locator('[data-whatif-input="vars.kpi.gmv"] [data-whatif-field]').press('Enter')
await settle(page, 300)
await page.click('[data-whatif-reset]')
await page.waitForSelector('[data-whatif-result="unchanged"]')
check('恢复原值:结果回到原值', (await text(page, '[data-whatif-new]')) === '8.7%')
check('恢复原值:输入框回到原值、勾选框都放开',
await page.locator('[data-whatif-input="vars.kpi.gmv"] [data-whatif-field]').inputValue() === '45678.5'
&& !(await box.isChecked()) && await page.locator('[data-whatif-row][data-excluded]').count() === 0)
check('恢复原值后按钮不可点', await page.locator('[data-whatif-reset]').isDisabled())
// 收起:勾选框跟着收起,结果窗口换回原来的表格
await page.click('[data-whatif-toggle]')
check('收起后推演区和勾选框都没了,查询结果照常显示', await page.locator('[data-whatif], [data-whatif-rows]').count() === 0
&& await page.locator(`[data-ev-query="${Q4}"] table`).count() === 1)
await ctx.close()
})
await section('多行快照与遮罩', async () => {
const { ctx, page, sent } = await open('whatif')
await openMetric(page, 's70')
await openWhatIf(page)
const rows = page.locator(`[data-ev-query="${Q3}"] [data-whatif-row]`)
check('窗口里每行一个勾选框', await rows.count() === chainOf('s70').find((s) => s.step === 'query').rows.length)
check('遮罩列照旧写「已遮罩」', await page.locator(`[data-ev-query="${Q3}"] [data-whatif-rows] td[data-masked]`).count() > 0
&& (await page.locator(`[data-ev-query="${Q3}"] [data-whatif-rows] td[data-masked]`).first().innerText()).trim() === '已遮罩')
await page.locator(`[data-ev-query="${Q3}"] [data-whatif-row="5"] input`).check()
await page.waitForSelector('[data-whatif-result="excluded"]')
check('勾掉第 6 行:请求里是行号 5', JSON.stringify(sent.at(-1).body.overrides.exclude_rows) === JSON.stringify({ [Q3]: [5] }))
await ctx.close()
})
await section('不支持的输入', async () => {
const { ctx, page } = await open('whatif')
await openMetric(page, 's22')
await openWhatIf(page)
check('不支持推演的输入列出原因', (await text(page, '[data-whatif-unsupported-input="vars.adjust.items"]'))
.includes('无法推演:当时没有保存这个输入的完整值'))
check('容器输入不给输入框', await page.locator('[data-whatif-field]').count() === 0
&& (await text(page, '[data-whatif-input="vars.adjust.items"] [data-whatif-value]')).includes('[4 项]'))
await ctx.close()
})
await section('加载、失败、未封存', async () => {
let release
const gate = new Promise((r) => { release = r })
const slow = await open('whatif', { reply: async (body, n) => {
if (n === 1) await gate
return { json: whatifReply(body) }
} })
await openMetric(slow.page, 's12')
await slow.page.click('[data-whatif-toggle]')
await slow.page.waitForSelector('[data-whatif-loading]')
check('答复回来之前显示「推演中」,横幅已经在', (await text(slow.page, '[data-whatif-loading]')).includes('推演中')
&& (await text(slow.page, '[data-whatif-banner]')).trim() === BANNER)
release()
await slow.page.waitForSelector('[data-whatif-result="unchanged"]')
check('答复回来后加载提示消失', await slow.page.locator('[data-whatif-loading]').count() === 0)
await slow.ctx.close()
const flaky = await open('whatif', { reply: async (body, n) => (n === 1
? { status: 500, json: { detail: '推演服务暂时不可用' } } : { json: whatifReply(body) }) })
await openMetric(flaky.page, 's12')
await flaky.page.click('[data-whatif-toggle]')
await flaky.page.waitForSelector('[data-whatif] [role="alert"]')
check('失败时给出原因和「重试」', (await text(flaky.page, '[data-whatif] [role="alert"]')).includes('重试'))
await flaky.page.locator('[data-whatif] [role="alert"]').getByRole('button', { name: '重试' }).click()
await flaky.page.waitForSelector('[data-whatif-result="unchanged"]')
check('重试之后正常显示', await flaky.page.locator('[data-whatif] [role="alert"]').count() === 0)
await flaky.ctx.close()
const open_ = await open('whatif', { reply: async (body) => ({ json: { ...whatifReply(body), sealed: false, seal: { sealed: false, ok: null } } }) })
await openMetric(open_.page, 's12')
await openWhatIf(open_.page)
check('运行未封存时提示数据可能还会变', (await text(open_.page, '[data-whatif-unsealed]')) === '运行未封存,数据可能还会变')
await open_.ctx.close()
})
/** 页面和面板正文都没有横向滚动(表格自己的横向滚动条不算:那是表格容器里的) */
async function overflow(page) {
return page.evaluate(() => {
const doc = document.documentElement
const body = document.querySelector('[data-ev-body]')
const box = document.querySelector('[data-whatif]')
return { page: doc.scrollWidth - doc.clientWidth, body: body ? body.scrollWidth - body.clientWidth : 0,
whatif: box ? box.scrollWidth - box.clientWidth : 0 }
})
}
await section('窄屏', async () => {
for (const theme of ['dark', 'light']) {
const { ctx, page, errors } = await open('whatif', { width: 390, height: 844, theme })
await openMetric(page, 's12')
check(`390px(${theme}):面板从底部抽出`, await page.locator('[data-evidence-panel="drawer"]').count() === 1)
await openWhatIf(page)
await page.locator('[data-whatif-input="vars.kpi.gmv"] [data-whatif-field]').fill('50000')
await page.locator('[data-whatif-input="vars.kpi.gmv"] [data-whatif-field]').press('Enter')
await page.waitForSelector('[data-whatif-result="ok"]')
await page.locator(`[data-ev-query="${Q4}"] [data-whatif-row="0"] input`).check()
await page.waitForSelector('[data-whatif-result="excluded"]')
const o = await overflow(page)
check(`390px(${theme}):推演展开、勾了行也没有横向溢出`, o.page <= 0 && o.body <= 1 && o.whatif <= 1, JSON.stringify(o))
await shot(page, `narrow-${theme}`)
check(`390px(${theme}):页面没有报错`, errors.length === 0, errors.join(';'))
await ctx.close()
}
const rail = await open('whatif-rail', { width: 1100, height: 900 })
await openMetric(rail.page, 's70')
check('360px 画布右栏:面板栏内展开', await rail.page.locator('[data-evidence-panel="inline"]').count() === 1)
await openWhatIf(rail.page)
const o = await rail.page.evaluate(() => {
const box = document.querySelector('[data-fixture="whatif-rail"]')
const panel = document.querySelector('[data-whatif]')
return { rail: box.scrollWidth - box.clientWidth, whatif: panel.scrollWidth - panel.clientWidth }
})
check('360px 画布右栏:推演区不撑宽右栏', o.rail <= 0 && o.whatif <= 1, JSON.stringify(o))
await shot(rail.page, 'rail-dark')
await rail.ctx.close()
})
/** 两个颜色的对比度(WCAG):颜色是 getComputedStyle 给的 rgb / rgba / color(srgb …),半透明的叠在底色上 */
function contrast(fg, bg, base) {
const parse = (c) => {
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 () => {
for (const theme of ['dark', 'light']) {
const { ctx, page } = await open('whatif', { theme })
await openMetric(page, 's12')
await openWhatIf(page)
const c = await page.evaluate(() => {
const banner = document.querySelector('[data-whatif-banner]')
const panel = document.querySelector('[data-evidence-panel]')
const s = getComputedStyle(banner)
return { fg: s.color, bg: s.backgroundColor, base: getComputedStyle(panel).backgroundColor,
weight: Number(s.fontWeight) }
})
const ratio = contrast(c.fg, c.bg, c.base)
check(`${theme}:横幅文字对比度 ≥ 4.5`, ratio >= 4.5, `${ratio.toFixed(2)}(${c.fg} on ${c.bg})`)
check(`${theme}:横幅加粗`, c.weight >= 500, String(c.weight))
await shot(page, `open-${theme}`)
await ctx.close()
}
})
await browser.close()
console.log(failed ? `\n✗ 数字推演:${failed} 项未通过` : '\n✓ 数字推演:全部通过')
process.exit(failed ? 1 : 0)