mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:51:59 +00:00
证据面板加数字推演:指标步骤上「推演」,改输入、勾掉查询结果里的行,看这个数和报告怎么变
- 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:
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user