mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:31:58 +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:
@@ -1,7 +1,19 @@
|
||||
/**
|
||||
* 数字推演的接口(/api/runs/{run_id}/…)。现在是空的,数字推演的工作包往里加,挂在 api.whatif 上。
|
||||
* 数字推演的接口(/api/runs/{run_id}/evidence/whatif),挂在 api.whatif 上。推演只读:不写运行记录、不改报告。
|
||||
*
|
||||
* 请求函数(get / post / del……)和 ApiError 从 ./http 导入,不要从 ./client 导入:client.ts 要导入
|
||||
* 这个文件把它挂到 api 上,反过来导入就成了循环(见 http.ts 开头的说明)。
|
||||
*/
|
||||
export const whatif = {}
|
||||
import type { WhatIfRequest, WhatIfResult } from '../types'
|
||||
import { request } from './http'
|
||||
|
||||
export const whatif = {
|
||||
/**
|
||||
* 在一次运行的口径卡上推演。signal 用来丢掉过时的请求:输入还在改、又发了新的一次时,上一次的答复不再要。
|
||||
* 取消照旧抛 AbortError(request 不把它当成连不上服务端)
|
||||
*/
|
||||
run: (runId: string, body: WhatIfRequest, opts?: { signal?: AbortSignal }) =>
|
||||
request<WhatIfResult>(`/runs/${encodeURIComponent(runId)}/evidence/whatif`, {
|
||||
method: 'POST', body: JSON.stringify(body), signal: opts?.signal,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { JSX } from 'react'
|
||||
import { EvidenceDoc } from '../../run/EvidenceDoc'
|
||||
import evidenceQuery from '../../run/__tests__/evidence-query.json'
|
||||
import type { EvidenceDocData } from '../../types'
|
||||
|
||||
/**
|
||||
* 数字推演的预览用例,scripts/check-whatif.mjs 按它们检查:
|
||||
* - ?syn=whatif:一份报告(evidence-query.json 里后端 compose_doc 真跑出来的文档,只用通用名),点数字打开证据面板,
|
||||
* 指标步骤上的「推演」展开推演区。宽屏从右侧弹出,窄屏(390px)从底部抽出
|
||||
* - ?syn=whatif-rail:同一份报告摆在 360px 的画布右栏里,面板栏内展开
|
||||
*
|
||||
* 片段接口、推演接口都由检查脚本用 page.route 伪造(按运行 id 认出这份夹具),直接打开时面板照实说取不到证据链。
|
||||
* 给了 runClass,文档不再按运行 id 去问运行类别。
|
||||
*/
|
||||
const DOC = evidenceQuery.doc as unknown as EvidenceDocData
|
||||
|
||||
function Report({ dense = false }: { dense?: boolean }) {
|
||||
return (
|
||||
<EvidenceDoc doc={DOC} artifact={evidenceQuery.doc_artifact} runId={evidenceQuery.run_id} label="周报" tally
|
||||
dense={dense} runClass="formal" />
|
||||
)
|
||||
}
|
||||
|
||||
export const cases: Record<string, () => JSX.Element> = {
|
||||
whatif: () => (
|
||||
<div className="mx-auto max-w-3xl p-4 text-sm" data-fixture="whatif"><Report /></div>
|
||||
),
|
||||
'whatif-rail': () => (
|
||||
<div className="h-full w-[360px] max-w-full overflow-y-auto border-r bg-panel p-2.5" data-fixture="whatif-rail">
|
||||
<Report dense />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -2758,9 +2758,47 @@ export const INQUIRY_TEXT = {
|
||||
title: '审批质询',
|
||||
}
|
||||
|
||||
/** 数字推演:在一次运行的基础上改动输入,推演指标的变化 */
|
||||
/**
|
||||
* 数字推演:在一次运行的基础上改动输入,推演指标的变化(证据面板里口径卡上的数,run/WhatIf.tsx)。
|
||||
* 推演永远不是出具:横幅要说清不会写进运行记录、不会改动报告。行号对人一律从 1 数(和查询结果表格的 # 列一致)
|
||||
*/
|
||||
export const WHATIF_TEXT = {
|
||||
title: '数字推演',
|
||||
open: '推演',
|
||||
openHint: '改动输入或排除查询结果中的行,查看这个数和报告会如何变化',
|
||||
close: '收起推演',
|
||||
banner: '推演,非出具:不会写进运行记录,也不会改动报告',
|
||||
unsealed: '运行未封存,数据可能还会变',
|
||||
sealBroken: '封存核对未通过,推演所依据的数据可能已被改动',
|
||||
loading: '推演中…',
|
||||
result: '推演结果',
|
||||
unchanged: '无变化',
|
||||
delta: (text: string) => `变化 ${text}`,
|
||||
substituted: '推演代入式',
|
||||
inputs: '输入',
|
||||
original: (value: string) => `原值 ${value}`,
|
||||
inputLabel: (path: string) => `${path} 的推演值`,
|
||||
notNumber: '请输入数值',
|
||||
masked: '已遮罩',
|
||||
excludedRows: (rows: number[]) => `已排除第 ${rows.map((r) => r + 1).join('、')} 行`,
|
||||
pickRows: (alias: string) => `在查询 ${alias} 中勾选要排除的行`,
|
||||
rowsHint: '勾选的行在推演中视为不存在',
|
||||
excludeHead: '排除',
|
||||
excludeRow: (row: number) => `排除第 ${row + 1} 行`,
|
||||
excludedCount: (n: number) => `已排除 ${n} 行`,
|
||||
segments: '报告中会变的地方',
|
||||
segmentsNone: (n: number) => `报告中引用这个指标的 ${n} 处不受影响`,
|
||||
segmentsUnknown: '这个指标无法推演,报告中引用它的地方会如何变化无法确定',
|
||||
locate: '定位',
|
||||
inReport: (node: string) => `另一份报告(${node})`,
|
||||
unsupported: '不支持推演的输入',
|
||||
wholeMetric: '整个指标',
|
||||
affected: '可能受影响的下游口径卡',
|
||||
affectedHint: '这些口径卡引用了本卡的指标,推演不会重算它们',
|
||||
affectedCard: (caliber: string, version: string) => `口径卡「${caliber || '未命名'}」${version ? ` ${version}` : ''}`,
|
||||
indirect: '间接引用',
|
||||
reset: '恢复原值',
|
||||
empty: '这个指标的计算式没有引用任何输入,无可推演',
|
||||
}
|
||||
|
||||
/** 双盲复算节点:节点库说明、检查器和运行结果 */
|
||||
|
||||
@@ -30,6 +30,7 @@ import { useEvidenceHost } from './evidenceHost'
|
||||
import { EvidenceProvenance } from './EvidenceProvenance'
|
||||
import { CopyChip } from './Markdown'
|
||||
import { SqlCheckList } from './SqlChecks'
|
||||
import { WhatIf, WhatIfRows, WhatIfToggle, useWhatIfDraft } from './WhatIf'
|
||||
|
||||
/**
|
||||
* 证据面板:点开报告里的一个片段,看它从哪来。
|
||||
@@ -63,6 +64,9 @@ import { SqlCheckList } from './SqlChecks'
|
||||
* Excel 导入期 4:上传表格的单元格多三节——数据版本、推断的来源、相关核对(EvidenceProvenance)。只在片段是单元格
|
||||
* 引用、且片段接口的查询步骤带 provenance: true 时请求推断来源接口;推断来源的缓存只在面板开着期间有效。
|
||||
*
|
||||
* 数字推演(WhatIf.tsx):指标步骤上的「推演」展开推演区,改输入的值、在查询步骤里勾掉几行,看这个数和报告会变成
|
||||
* 多少。推演不是出具,不写运行记录、不改报告。勾选框只在推演时出现在查询步骤的结果窗口里。
|
||||
*
|
||||
* 四种摆法:side 从右侧弹出(宽屏),drawer 从底部抽出(窄屏),inline 在 360px 的
|
||||
* 画布右栏里直接栏内展开,dock 放进页面给的一块常驻位置(记录页的「证据」页签)。
|
||||
* 都不是模态的:开着面板照样能在正文里走。
|
||||
@@ -161,7 +165,7 @@ export function EvidencePanel({ id, mode, doc, artifact, runId, runClass, view,
|
||||
? <ViolationList doc={doc} onLocate={onLocate} />
|
||||
: found
|
||||
? <SegmentBody panelId={id} doc={doc} artifact={artifact} runId={runId} runClass={runClass} seg={found.seg}
|
||||
unit={found.unit} block={found.block} onViolations={onViolations} />
|
||||
unit={found.unit} block={found.block} onViolations={onViolations} onLocate={onLocate} />
|
||||
: claimAt
|
||||
? <ClaimBody doc={doc} artifact={artifact} runId={runId} runClass={runClass} unit={claimAt.unit}
|
||||
block={claimAt.block} />
|
||||
@@ -205,11 +209,13 @@ function flashOnce(el: HTMLElement) {
|
||||
setTimeout(() => { if (el.dataset.flash === 'focus') delete el.dataset.flash }, 1600)
|
||||
}
|
||||
|
||||
function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block, onViolations }: {
|
||||
function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block, onViolations, onLocate }: {
|
||||
panelId: string
|
||||
doc: EvidenceDocData; artifact?: string; runId?: string; runClass?: string; seg: EvidenceSegment; unit: EvidenceUnit
|
||||
block: EvidenceBlock
|
||||
onViolations: () => void
|
||||
/** 跳到正文里的某个片段(推演区「报告中会变的地方」的定位) */
|
||||
onLocate: (segId: string) => void
|
||||
}) {
|
||||
const report = doc.node_id || undefined
|
||||
const key = runId ? segmentKey(runId, seg.id, report) : ''
|
||||
@@ -295,6 +301,22 @@ function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block
|
||||
return i >= 0 ? { index: i, alias: queries[i].alias ?? '', go: () => jumpTo(i) } : undefined
|
||||
}
|
||||
const violations = (doc.violations ?? []).filter((v) => v.segment === seg.id)
|
||||
// 数字推演:按(运行、报告、口径卡、指标)认一份草稿。同一个指标的几处引用之间来回切,推演不收起(定位到另一处
|
||||
// 引用也是这样);换了指标、换成别的片段才收起清空。卡和指标先按文档目录认:切片段时证据链还没回来,也认得出是同一个
|
||||
const whatifCard = entry?.kind === 'metric' ? entry.node_id : metricStep?.node_id
|
||||
const whatifMetric = entry?.kind === 'metric' ? entry.locator?.metric : metricStep?.metric
|
||||
const whatif = useWhatIfDraft(runId && isMetric && whatifCard && whatifMetric
|
||||
? [runId, report ?? '', whatifCard, whatifMetric].join('\u0000') : '')
|
||||
// 证据链回来了、正文就是封存的那一份才给推演:链是别的报告的,推演出来的数也对不上正文
|
||||
const whatifReady = !!runId && !docIssue && !!metricStep && !!whatifCard && !!whatifMetric
|
||||
const whatifOpen = whatif.open && whatifReady
|
||||
const whatifId = `${panelId}-whatif`
|
||||
// 能勾行的查询步骤:指标的输入(单元格取数、Agent 字段)落在它上面。合并查询的输入步骤不给——指标引用的是合并结果
|
||||
const pickable = new Set(inputs.map((inp) => inp.artifact).filter((a): a is string => !!a))
|
||||
const queryLink = (artifactOf?: string) => {
|
||||
const i = artifactOf ? queries.findIndex((q) => q.artifact === artifactOf && !q.merged_into) : -1
|
||||
return i >= 0 ? { alias: queries[i].alias ?? '', go: () => jumpTo(i) } : undefined
|
||||
}
|
||||
const seal: EvidenceSeal | undefined = docIssue ? undefined : detail?.seal ?? graphData?.seal
|
||||
const reason = reasonOf(seg) || detail?.note || ''
|
||||
const verdict = sealVerdict(seal, {
|
||||
@@ -369,7 +391,13 @@ function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block
|
||||
failed={chainFailed}
|
||||
resolved={state === 'deterministic'}
|
||||
linkOf={linkOf}
|
||||
workflowName={workflowName(pinnedWorkflow(metricStep))} />
|
||||
workflowName={workflowName(pinnedWorkflow(metricStep))}
|
||||
whatif={whatifReady ? { open: whatifOpen, onToggle: whatif.toggle, controls: whatifId } : undefined}>
|
||||
{whatifOpen && runId && whatifCard && whatifMetric && (
|
||||
<WhatIf id={whatifId} runId={runId} report={report} card={whatifCard} metric={whatifMetric} draft={whatif}
|
||||
queryOf={queryLink} onLocate={onLocate} />
|
||||
)}
|
||||
</MetricPart>
|
||||
)}
|
||||
|
||||
{isMetric && inputs.some(notableInput) && (
|
||||
@@ -396,7 +424,10 @@ function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block
|
||||
)}
|
||||
|
||||
{queries.map((q, i) => (
|
||||
<QueryPart key={`${q.artifact ?? ''}-${i}`} id={queryId(i)} step={q} seal={seal} masked={masked} />
|
||||
<QueryPart key={`${q.artifact ?? ''}-${i}`} id={queryId(i)} step={q} seal={seal} masked={masked}
|
||||
pick={whatifOpen && q.artifact && !q.merged_into && pickable.has(q.artifact)
|
||||
? { excluded: whatif.excluded(q.artifact), onToggle: (row) => whatif.toggleRow(q.artifact!, row) }
|
||||
: undefined} />
|
||||
))}
|
||||
|
||||
{provenanceOn && runId && <EvidenceProvenance runId={runId} segId={seg.id} report={report} />}
|
||||
@@ -1073,8 +1104,8 @@ function Sentence({ unit, active }: { unit: EvidenceUnit; active: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** 指标步骤:名称、值、口径与版本(来源、升版处置)、原式、代入式(输入标签)、复算 */
|
||||
function MetricPart({ step, entry, inputs, runId, seal, pending, failed, resolved, linkOf, workflowName }: {
|
||||
/** 指标步骤:名称、值、口径与版本(来源、升版处置)、原式、代入式(输入标签)、复算;可以展开数字推演 */
|
||||
function MetricPart({ step, entry, inputs, runId, seal, pending, failed, resolved, linkOf, workflowName, whatif, children }: {
|
||||
step?: EvidenceStep
|
||||
entry?: Record<string, any>
|
||||
inputs: EvidenceInput[]
|
||||
@@ -1090,6 +1121,10 @@ function MetricPart({ step, entry, inputs, runId, seal, pending, failed, resolve
|
||||
linkOf: (input: EvidenceInput) => InputLink
|
||||
/** 钉住的上游工作流在目录里的名字(接口没给名字时用) */
|
||||
workflowName?: string
|
||||
/** 「推演」开关:证据链回来了、正文是封存的那一份才有 */
|
||||
whatif?: { open: boolean; onToggle: () => void; controls: string }
|
||||
/** 展开的推演区 */
|
||||
children?: ReactNode
|
||||
}) {
|
||||
const host = useEvidenceHost()
|
||||
const name = step?.name ?? entry?.name ?? step?.metric ?? entry?.locator?.metric ?? NONE
|
||||
@@ -1131,6 +1166,7 @@ function MetricPart({ step, entry, inputs, runId, seal, pending, failed, resolve
|
||||
{EVIDENCE_TEXT.sqlCheckFailed}
|
||||
</span>
|
||||
)}
|
||||
{whatif && <WhatIfToggle open={whatif.open} onToggle={whatif.onToggle} controls={whatif.controls} />}
|
||||
</div>
|
||||
{(caliber || version || artifact || from) && (
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-2xs text-dim">
|
||||
@@ -1194,6 +1230,7 @@ function MetricPart({ step, entry, inputs, runId, seal, pending, failed, resolve
|
||||
{pending ? EVIDENCE_TEXT.chainPending : failed || (runId ? EVIDENCE_TEXT.chainMissing : EVIDENCE_TEXT.noRun)}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</Part>
|
||||
)
|
||||
}
|
||||
@@ -1322,7 +1359,11 @@ function SourcesPart({ inputs, linkOf }: { inputs: EvidenceInput[]; linkOf: (inp
|
||||
* 复验哈希),复用工件查看。被遮罩的列在表里写「已遮罩」。查询快照哈希对不上时用失败色写出来:
|
||||
* 这时候这些行不能当证据,比「有出处」重要得多
|
||||
*/
|
||||
function QueryPart({ id, step, seal, masked }: { id: string; step: EvidenceStep; seal?: EvidenceSeal; masked: string[] }) {
|
||||
function QueryPart({ id, step, seal, masked, pick }: {
|
||||
id: string; step: EvidenceStep; seal?: EvidenceSeal; masked: string[]
|
||||
/** 推演时:结果窗口每行前面加勾选框,勾掉的行在推演里视为不存在 */
|
||||
pick?: { excluded: number[]; onToggle: (row: number) => void }
|
||||
}) {
|
||||
const host = useEvidenceHost()
|
||||
const win = useMemo(() => queryWindow(step), [step])
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -1412,9 +1453,13 @@ function QueryPart({ id, step, seal, masked }: { id: string; step: EvidenceStep;
|
||||
)}
|
||||
{win.columns.length > 0 && win.rows.length > 0 && (
|
||||
<div ref={scroller} className={clsx(bad.length > 0 && 'mt-1.5')}>
|
||||
<ResultTable table={table} full title={EVIDENCE_TEXT.query(alias)}
|
||||
highlight={win.marks.length || win.cells?.length ? { rows: win.marks, cols: win.cols, cells: win.cells } : undefined}
|
||||
masked={hidden} />
|
||||
{pick && win.index
|
||||
? <WhatIfRows win={win} hidden={hidden} excluded={pick.excluded} onToggle={pick.onToggle} />
|
||||
: (
|
||||
<ResultTable table={table} full title={EVIDENCE_TEXT.query(alias)}
|
||||
highlight={win.marks.length || win.cells?.length ? { rows: win.marks, cols: win.cols, cells: win.cells } : undefined}
|
||||
masked={hidden} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!win.rows.length && (
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { CornerDownRight, FlaskConical, LocateFixed, RotateCcw } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
import { ErrorNotice, Spinner, isComposing } from '../components/ui'
|
||||
import { evidenceValue as valueText, type queryWindow } from '../lib/evidence'
|
||||
import { NONE } from '../lib/format'
|
||||
import { WHATIF_TEXT } from '../lib/terms'
|
||||
import type { WhatIfInput, WhatIfMetric, WhatIfOverrides, WhatIfRequest, WhatIfResult } from '../types'
|
||||
|
||||
/**
|
||||
* 数字推演:证据面板里点开口径卡上的一个数,改某个输入的值、或者在查询结果里勾掉几行,立即看到这个指标和报告里
|
||||
* 引用它的地方会变成多少(POST …/evidence/whatif,后端 engine/recompute.py 按口径卡同一套规则重算)。
|
||||
*
|
||||
* 推演永远不是出具:不写进运行记录、不改报告。顶上的横幅一直在,运行没封存时另说「数据可能还会变」。
|
||||
*
|
||||
* 三样东西分开放:
|
||||
* - useWhatIfDraft:改动的草稿(输入框里的原文、提交了的新值、勾掉的行)。放在证据面板的片段那一层(SegmentBody),
|
||||
* 推演区和查询步骤的勾选框共用一份;同一个指标的几处引用之间来回切不清空,换了指标才清空
|
||||
* - WhatIf:推演区。输入框停顿一会儿(或回车、失焦)才提交,勾行立刻提交;请求在路上时上一次的结果变淡留着,
|
||||
* 新的改动来了旧请求直接丢掉。答复按请求体缓存,「恢复原值」回到原来那份不再请求
|
||||
* - WhatIfRows:推演时查询步骤里的结果窗口换成它,每行前面多一个勾选框
|
||||
*
|
||||
* 不能改的输入(文本、列表、遮罩列、决定取哪一格的下标)照实显示,原因列在「不支持推演的输入」里。
|
||||
*/
|
||||
|
||||
/** 输入框停顿多久提交一次:边打字边请求,结果跟着抖 */
|
||||
const SETTLE_MS = 450
|
||||
|
||||
interface DraftState {
|
||||
scope: string
|
||||
open: boolean
|
||||
/** 输入框里的原文。没碰过的输入不在里面,框里显示原值 */
|
||||
text: Record<string, string>
|
||||
/** 已提交的新值:请求按它发 */
|
||||
inputs: Record<string, number>
|
||||
/** 勾掉的行:查询快照的工件 id → 快照里的行号(从 0 数) */
|
||||
exclude: Record<string, number[]>
|
||||
}
|
||||
|
||||
const freshDraft = (scope: string, open = false): DraftState => ({ scope, open, text: {}, inputs: {}, exclude: {} })
|
||||
|
||||
export interface WhatIfDraft {
|
||||
open: boolean
|
||||
toggle: () => void
|
||||
text: Record<string, string>
|
||||
/** 输入框改了:停顿一会儿才提交。old 是这个输入的原值,填回原值不算改动 */
|
||||
type: (path: string, value: string, old: unknown) => void
|
||||
/** 回车、失焦:立刻提交 */
|
||||
commit: () => void
|
||||
/** Esc:输入框退回已提交的值(没提交过就是原值) */
|
||||
revert: (path: string) => void
|
||||
toggleRow: (artifact: string, row: number) => void
|
||||
excluded: (artifact: string | undefined) => number[]
|
||||
overrides: WhatIfOverrides
|
||||
/** 有改动(含还没提交的输入框):「恢复原值」可点 */
|
||||
dirty: boolean
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
/** 输入框里的文本 → 数。空的是 undefined(回到原值),不是数的是 null。千分位逗号、空格不算错 */
|
||||
export function parseNumber(raw: string): number | null | undefined {
|
||||
const text = raw.replace(/[,,\s]/g, '')
|
||||
if (!text) return undefined
|
||||
if (!/^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/.test(text)) return null
|
||||
const n = Number(text)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
/** 原值写进输入框的样子:数照原样(不加千分位,改起来不用先删逗号),缺的是空 */
|
||||
const plain = (value: unknown): string => (typeof value === 'number' && Number.isFinite(value) ? String(value) : '')
|
||||
|
||||
/**
|
||||
* 提交:输入框里写的是数、而且和原值不同的才算改动。清空了回到原值;写的不是数的保留上一次提交的值——
|
||||
* 输错一个字,结果不该悄悄跳回原值
|
||||
*/
|
||||
function settled(state: DraftState, olds: Record<string, unknown>): Record<string, number> {
|
||||
const out: Record<string, number> = {}
|
||||
for (const [path, raw] of Object.entries(state.text)) {
|
||||
const n = parseNumber(raw)
|
||||
if (n === null) {
|
||||
if (path in state.inputs) out[path] = state.inputs[path]
|
||||
} else if (n !== undefined && olds[path] !== n) {
|
||||
out[path] = n
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function useWhatIfDraft(scope: string): WhatIfDraft {
|
||||
const [stored, setState] = useState<DraftState>(() => freshDraft(scope))
|
||||
// 换了一个指标(另一张卡、另一个数,或者不是指标了):推演收起、改动清空
|
||||
const state = stored.scope === scope ? stored : freshDraft(scope)
|
||||
if (stored.scope !== scope) setState(state)
|
||||
const timer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const olds = useRef<Record<string, unknown>>({})
|
||||
useEffect(() => () => clearTimeout(timer.current), [])
|
||||
|
||||
const commit = useCallback(() => {
|
||||
clearTimeout(timer.current)
|
||||
setState((s) => ({ ...s, inputs: settled(s, olds.current) }))
|
||||
}, [])
|
||||
const type = useCallback((path: string, value: string, old: unknown) => {
|
||||
olds.current[path] = old
|
||||
setState((s) => ({ ...s, text: { ...s.text, [path]: value } }))
|
||||
clearTimeout(timer.current)
|
||||
timer.current = setTimeout(commit, SETTLE_MS)
|
||||
}, [commit])
|
||||
const revert = useCallback((path: string) => {
|
||||
setState((s) => {
|
||||
const text = { ...s.text }
|
||||
if (path in s.inputs) text[path] = String(s.inputs[path])
|
||||
else delete text[path]
|
||||
return { ...s, text }
|
||||
})
|
||||
}, [])
|
||||
const toggleRow = useCallback((artifact: string, row: number) => {
|
||||
setState((s) => {
|
||||
const rows = s.exclude[artifact] ?? []
|
||||
const next = rows.includes(row) ? rows.filter((r) => r !== row) : [...rows, row].sort((a, b) => a - b)
|
||||
const exclude = { ...s.exclude }
|
||||
if (next.length) exclude[artifact] = next
|
||||
else delete exclude[artifact]
|
||||
return { ...s, exclude }
|
||||
})
|
||||
}, [])
|
||||
const reset = useCallback(() => {
|
||||
clearTimeout(timer.current)
|
||||
setState((s) => freshDraft(s.scope, s.open))
|
||||
}, [])
|
||||
// 收起就清空:再展开从原值开始,勾选框也跟着收起,不留一份看不见的改动
|
||||
const toggle = useCallback(() => {
|
||||
clearTimeout(timer.current)
|
||||
setState((s) => (s.open ? freshDraft(s.scope) : { ...s, open: true }))
|
||||
}, [])
|
||||
const overrides = useMemo(() => ({ inputs: state.inputs, exclude_rows: state.exclude }), [state.inputs, state.exclude])
|
||||
const excluded = useCallback((artifact: string | undefined) => (artifact ? state.exclude[artifact] ?? [] : []),
|
||||
[state.exclude])
|
||||
return {
|
||||
open: state.open, toggle, text: state.text, type, commit, revert, toggleRow, excluded, overrides, reset,
|
||||
dirty: Object.keys(state.text).length > 0 || Object.keys(state.inputs).length > 0 || Object.keys(state.exclude).length > 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** 指标步骤上的「推演」开关 */
|
||||
export function WhatIfToggle({ open, onToggle, controls }: { open: boolean; onToggle: () => void; controls: string }) {
|
||||
return (
|
||||
<button type="button" className={clsx('btn btn-xs ml-auto shrink-0', open && 'border-[var(--st-waiting)]')}
|
||||
aria-expanded={open} aria-controls={controls} title={open ? WHATIF_TEXT.close : WHATIF_TEXT.openHint}
|
||||
onClick={onToggle} data-whatif-toggle={open ? 'open' : 'closed'}>
|
||||
<FlaskConical size={11} aria-hidden /> {WHATIF_TEXT.open}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
type Slot = { key: string; status: 'loading' | 'ok' | 'error'; data?: WhatIfResult; error?: unknown; last?: WhatIfResult }
|
||||
|
||||
/** 推演区:横幅、结果、输入、不支持的输入、报告里会变的地方、下游口径卡、恢复原值 */
|
||||
export function WhatIf({ id, runId, report, card, metric, draft, queryOf, onLocate }: {
|
||||
id: string
|
||||
runId: string
|
||||
/** 正文这份报告的撰写节点 id:按它选口径卡算过的那一份,报告里会变的地方也只列它的(都能定位) */
|
||||
report?: string
|
||||
/** 口径卡节点 id */
|
||||
card: string
|
||||
metric: string
|
||||
draft: WhatIfDraft
|
||||
/** 输入落在哪个查询步骤上:有的话给「在查询 Qn 中勾选要排除的行」,点一下跳过去 */
|
||||
queryOf: (artifact?: string) => { alias: string; go: () => void } | undefined
|
||||
/** 定位到正文里的一个片段 */
|
||||
onLocate?: (segId: string) => void
|
||||
}) {
|
||||
const body: WhatIfRequest = useMemo(() => ({ ...(report ? { report } : {}), card, metric, overrides: draft.overrides }),
|
||||
[report, card, metric, draft.overrides])
|
||||
const key = JSON.stringify(body)
|
||||
const [slot, setSlot] = useState<Slot>({ key: '', status: 'loading' })
|
||||
const [nonce, setNonce] = useState(0)
|
||||
const cache = useRef(new Map<string, WhatIfResult>())
|
||||
useEffect(() => {
|
||||
const hit = cache.current.get(key)
|
||||
if (hit) {
|
||||
setSlot({ key, status: 'ok', data: hit, last: hit })
|
||||
return
|
||||
}
|
||||
const ctrl = new AbortController()
|
||||
setSlot((s) => ({ key, status: 'loading', last: s.last }))
|
||||
api.whatif.run(runId, JSON.parse(key) as WhatIfRequest, { signal: ctrl.signal }).then((data) => {
|
||||
cache.current.set(key, data)
|
||||
setSlot({ key, status: 'ok', data, last: data })
|
||||
}, (error: unknown) => {
|
||||
if ((error as { name?: string } | null)?.name === 'AbortError') return
|
||||
setSlot((s) => ({ key, status: 'error', error, last: s.last }))
|
||||
})
|
||||
return () => ctrl.abort()
|
||||
}, [runId, key, nonce])
|
||||
|
||||
// 改动之后、请求还没发出去的那一下也算在路上:别拿上一次的答复当这次的
|
||||
const status = slot.key === key ? slot.status : 'loading'
|
||||
const data = status === 'ok' ? slot.data : slot.last
|
||||
const loading = status === 'loading'
|
||||
const m: WhatIfMetric | undefined = data?.metrics.find((x) => x.id === metric) ?? data?.metrics[0]
|
||||
const unsupported = (data?.unsupported ?? []).filter((u) => u.metric === metric)
|
||||
const unknown = !!m && m.rendered_new == null
|
||||
const changedSegs = (data?.segments ?? []).filter((s) => s.changed)
|
||||
|
||||
return (
|
||||
<section id={id} className="mt-2 space-y-2.5 rounded border p-2" data-whatif="" aria-label={WHATIF_TEXT.title}
|
||||
style={{ borderColor: 'color-mix(in srgb, var(--st-waiting) 45%, var(--border))' }}>
|
||||
{/* 醒目靠底色、左边的色条和图标;字用正文色:提醒色的字压在同色的浅底上,浅色主题下对比度不到 4.5 */}
|
||||
<p role="note" className="flex items-start gap-1.5 rounded-r px-2 py-1.5 font-medium leading-snug" data-whatif-banner=""
|
||||
style={{ color: 'var(--text)', background: 'var(--st-waiting-soft)', borderLeft: '3px solid var(--st-waiting)' }}>
|
||||
<FlaskConical size={13} className="mt-px shrink-0" style={{ color: 'var(--st-waiting)' }} aria-hidden />
|
||||
<span>{WHATIF_TEXT.banner}</span>
|
||||
</p>
|
||||
{data && !data.sealed && (
|
||||
<p className="text-2xs" style={{ color: 'var(--st-waiting)' }} data-whatif-unsealed={data.seal.sealed ? 'broken' : 'open'}>
|
||||
{data.seal.sealed && data.seal.ok === false ? WHATIF_TEXT.sealBroken : WHATIF_TEXT.unsealed}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === 'error' && <ErrorNotice error={slot.error} onRetry={() => setNonce((n) => n + 1)} />}
|
||||
{!data && loading && (
|
||||
<p className="flex items-center gap-1.5 text-dim" data-whatif-loading=""><Spinner size={12} /> {WHATIF_TEXT.loading}</p>
|
||||
)}
|
||||
|
||||
{m && status !== 'error' && (
|
||||
<div aria-live="polite" aria-busy={loading || undefined} className={clsx('transition-opacity', loading && 'opacity-60')}
|
||||
data-whatif-result={loading ? 'loading' : m.status}>
|
||||
<div className="mb-0.5 text-2xs font-medium text-faint">{WHATIF_TEXT.result}</div>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span className="mono tnum text-dim" data-whatif-old="">{m.rendered_old}</span>
|
||||
<span className="text-faint" aria-hidden>→</span>
|
||||
<span className="mono tnum text-sm font-semibold" data-whatif-new=""
|
||||
style={m.changed ? { color: 'var(--accent)' } : undefined}>{m.rendered_new ?? NONE}</span>
|
||||
{m.rendered_delta
|
||||
? <span className="tnum text-2xs" data-whatif-delta="">{WHATIF_TEXT.delta(m.rendered_delta)}</span>
|
||||
: m.changed === false && <span className="text-2xs text-faint" data-whatif-delta="">{WHATIF_TEXT.unchanged}</span>}
|
||||
{loading && <span className="text-faint"><Spinner size={11} /></span>}
|
||||
</div>
|
||||
{m.reason && <p className="mt-0.5" style={{ color: 'var(--st-waiting)' }} data-whatif-reason="">{m.reason}</p>}
|
||||
{m.status === 'ok' && m.changed && m.substituted && (
|
||||
<div className="mt-1">
|
||||
<div className="text-2xs text-faint">{WHATIF_TEXT.substituted}</div>
|
||||
<code className="mono block whitespace-pre-wrap break-all rounded bg-bg px-1.5 py-1 text-2xs" data-whatif-substituted="">
|
||||
{m.substituted}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{m && (
|
||||
<div data-whatif-inputs="">
|
||||
<div className="mb-1 text-2xs font-medium text-faint">{WHATIF_TEXT.inputs}</div>
|
||||
{m.inputs.length ? (
|
||||
<ul className="space-y-1.5">
|
||||
{m.inputs.map((inp, i) => (
|
||||
<InputRow key={inp.path} id={`${id}-in${i}`} input={inp} draft={draft} link={queryOf(inp.artifact)} />
|
||||
))}
|
||||
</ul>
|
||||
) : <p className="text-dim" data-whatif-empty="">{WHATIF_TEXT.empty}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unsupported.length > 0 && (
|
||||
<div data-whatif-unsupported="">
|
||||
<div className="mb-0.5 text-2xs font-medium text-faint">{WHATIF_TEXT.unsupported}</div>
|
||||
<ul className="space-y-0.5">
|
||||
{unsupported.map((u) => (
|
||||
<li key={u.input ?? ''} className="[overflow-wrap:anywhere]" data-whatif-unsupported-input={u.input ?? ''}>
|
||||
<span className="mono">{u.input ?? WHATIF_TEXT.wholeMetric}</span>
|
||||
<span className="text-dim">:{u.reason}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{m && !!data?.segments.length && (
|
||||
<div data-whatif-segments="">
|
||||
<div className="mb-0.5 text-2xs font-medium text-faint">{WHATIF_TEXT.segments}</div>
|
||||
{unknown ? <p className="text-dim">{WHATIF_TEXT.segmentsUnknown}</p>
|
||||
: changedSegs.length ? (
|
||||
<ul className="space-y-0.5">
|
||||
{changedSegs.map((s) => (
|
||||
<li key={`${s.report}-${s.sid}`} className="flex flex-wrap items-center gap-x-1.5" data-whatif-segment={s.sid}>
|
||||
<span className="mono tnum text-dim">{s.rendered_old}</span>
|
||||
<span className="text-faint" aria-hidden>→</span>
|
||||
<span className="mono tnum font-semibold">{s.rendered_new}</span>
|
||||
{onLocate && (!report || s.report === report)
|
||||
? (
|
||||
<button type="button" className="btn btn-xs btn-ghost" onClick={() => onLocate(s.sid)}
|
||||
data-whatif-locate={s.sid}>
|
||||
<LocateFixed size={10} aria-hidden /> {WHATIF_TEXT.locate}
|
||||
</button>
|
||||
)
|
||||
: <span className="text-2xs text-faint">{WHATIF_TEXT.inReport(s.report)}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : <p className="text-dim" data-whatif-segments-none="">{WHATIF_TEXT.segmentsNone(data.segments.length)}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!data?.affected_cards.length && (
|
||||
<div data-whatif-affected="">
|
||||
<div className="text-2xs font-medium text-faint">{WHATIF_TEXT.affected}</div>
|
||||
<p className="mb-0.5 text-2xs text-faint">{WHATIF_TEXT.affectedHint}</p>
|
||||
<ul className="space-y-0.5">
|
||||
{data.affected_cards.map((c) => (
|
||||
<li key={c.node_id} data-whatif-affected-card={c.node_id}>
|
||||
{WHATIF_TEXT.affectedCard(c.caliber, c.version)}
|
||||
{!c.direct && <span className="text-faint"> · {WHATIF_TEXT.indirect}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 border-t pt-2">
|
||||
<button type="button" className="btn btn-xs" onClick={draft.reset} disabled={!draft.dirty} data-whatif-reset="">
|
||||
<RotateCcw size={11} aria-hidden /> {WHATIF_TEXT.reset}
|
||||
</button>
|
||||
{loading && data && <span className="flex items-center gap-1 text-2xs text-faint"><Spinner size={11} /> {WHATIF_TEXT.loading}</span>}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** 一个输入:能改的给输入框(原值写在旁边),别的照实显示;排除了几行、去哪一步勾行,写在下面 */
|
||||
function InputRow({ id, input, draft, link }: {
|
||||
id: string; input: WhatIfInput; draft: WhatIfDraft; link?: { alias: string; go: () => void }
|
||||
}) {
|
||||
const raw = draft.text[input.path]
|
||||
const committed = draft.overrides.inputs[input.path]
|
||||
const baseline = committed !== undefined ? String(committed) : plain(input.old)
|
||||
const invalid = raw !== undefined && parseNumber(raw) === null
|
||||
const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
if (isComposing(e)) return
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
draft.commit()
|
||||
} else if (e.key === 'Escape' && raw !== undefined && raw !== baseline) {
|
||||
// 改了一半的值先退回去:这一下 Esc 不关面板
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
draft.revert(input.path)
|
||||
}
|
||||
}
|
||||
const fromQuery = !!link && !input.masked && (input.kind === 'container' || !!input.locator)
|
||||
return (
|
||||
<li className="rounded border px-2 py-1.5" data-whatif-input={input.path} data-whatif-kind={input.kind}
|
||||
style={input.changed ? { borderColor: 'color-mix(in srgb, var(--accent) 55%, var(--border))' } : undefined}>
|
||||
<div className="mono truncate text-2xs text-dim" title={input.path}>{input.path}</div>
|
||||
{input.masked ? (
|
||||
<p className="mt-0.5 italic text-faint" data-whatif-masked="">{WHATIF_TEXT.masked}</p>
|
||||
) : input.editable ? (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<input id={id} type="text" inputMode="decimal" autoComplete="off" spellCheck={false}
|
||||
className={clsx('field mono tnum w-36 min-w-0 py-1', invalid && 'border-[var(--err)]')}
|
||||
value={raw ?? plain(input.old)} placeholder={input.old == null ? NONE : undefined}
|
||||
aria-label={WHATIF_TEXT.inputLabel(input.path)} aria-invalid={invalid || undefined}
|
||||
aria-describedby={invalid ? `${id}-err` : undefined}
|
||||
onChange={(e) => draft.type(input.path, e.target.value, input.old)}
|
||||
onKeyDown={onKeyDown} onBlur={draft.commit} data-whatif-field="" />
|
||||
<span className="tnum text-2xs text-faint">{WHATIF_TEXT.original(valueText(input.old))}</span>
|
||||
{invalid && <span id={`${id}-err`} className="text-2xs" style={{ color: 'var(--err)' }}>{WHATIF_TEXT.notNumber}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mono tnum mt-0.5 [overflow-wrap:anywhere]" data-whatif-value="">
|
||||
{valueText(input.old)}
|
||||
{input.changed && <> <span className="text-faint" aria-hidden>→</span> {valueText(input.new)}</>}
|
||||
</p>
|
||||
)}
|
||||
{!!input.excluded_rows?.length && (
|
||||
<p className="mt-0.5 text-2xs" style={{ color: 'var(--st-waiting)' }} data-whatif-excluded="">
|
||||
{WHATIF_TEXT.excludedRows(input.excluded_rows)}
|
||||
</p>
|
||||
)}
|
||||
{fromQuery && (
|
||||
<button type="button" className="btn btn-xs btn-ghost -ml-1 mt-0.5" onClick={link!.go} data-whatif-pick="">
|
||||
<CornerDownRight size={10} aria-hidden /> {WHATIF_TEXT.pickRows(link!.alias)}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 推演时查询步骤里的结果窗口:每行前面一个勾选框,勾掉的行在推演里视为不存在(划掉、变淡)。只有知道每一行
|
||||
* 在快照里是第几行时才画(index);被遮罩的列照样写「已遮罩」,高亮的格和平时一样描边
|
||||
*/
|
||||
export function WhatIfRows({ win, hidden, excluded, onToggle }: {
|
||||
win: ReturnType<typeof queryWindow>; hidden: string[]; excluded: number[]; onToggle: (row: number) => void
|
||||
}) {
|
||||
if (!win.index) return null
|
||||
const index = win.index
|
||||
const masked = new Set(hidden)
|
||||
const cells = win.cells ? new Set(win.cells.map(([r, c]) => `${r}\u0000${c}`)) : null
|
||||
const marked = new Set([...win.marks, ...(win.cells ?? []).map(([r]) => r)])
|
||||
const cols = new Set(win.cols)
|
||||
return (
|
||||
<div className="overflow-hidden rounded border" data-whatif-rows="">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-2xs">
|
||||
<thead>
|
||||
<tr className="border-b bg-elev">
|
||||
{/* 勾选框和行号两列只占自己的宽度:表格撑满窄栏时多出来的宽度留给数据列 */}
|
||||
<th className="w-px whitespace-nowrap px-1.5 py-1 text-left font-medium text-dim">{WHATIF_TEXT.excludeHead}</th>
|
||||
<th className="mono w-px px-1.5 py-1 text-right font-medium text-dim">#</th>
|
||||
{win.columns.map((c) => <th key={c} className="mono px-1.5 py-1 text-left font-medium text-dim">{c}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{win.rows.map((row, i) => {
|
||||
const n = index[i]
|
||||
const off = excluded.includes(n)
|
||||
const hit = marked.has(i)
|
||||
return (
|
||||
<tr key={n} className="border-b last:border-0" data-whatif-row={n} data-excluded={off ? '' : undefined}
|
||||
style={hit && !off ? { background: 'var(--st-done-soft)' } : undefined}>
|
||||
<td className="w-px px-1.5 py-1 text-center">
|
||||
<input type="checkbox" className="align-middle" checked={off} onChange={() => onToggle(n)}
|
||||
aria-label={WHATIF_TEXT.excludeRow(n)} />
|
||||
</td>
|
||||
<td className="mono w-px px-1.5 py-1 text-right text-faint">{n + 1}</td>
|
||||
{row.map((cell, j) => {
|
||||
const col = win.columns[j]
|
||||
const mark = hit && !off && (cells ? cells.has(`${i}\u0000${col}`) : cols.has(col))
|
||||
const mask = masked.has(col)
|
||||
return (
|
||||
<td key={j} className={clsx('mono max-w-[160px] truncate px-1.5 py-1',
|
||||
off ? 'text-faint line-through' : mark ? 'font-semibold' : 'text-dim')}
|
||||
data-highlight={mark ? 'cell' : undefined} data-masked={mask ? '' : undefined}
|
||||
style={mark ? { outline: '1px solid var(--st-done)', outlineOffset: -1, color: 'var(--text)' } : undefined}
|
||||
title={mask ? WHATIF_TEXT.masked : cell == null ? '' : String(cell)}>
|
||||
{mask ? <span className="font-sans italic text-faint">{WHATIF_TEXT.masked}</span>
|
||||
: cell == null ? <span className="opacity-40">null</span>
|
||||
: typeof cell === 'object' ? JSON.stringify(cell) : String(cell)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-2 border-t px-1.5 py-1 text-2xs text-faint">
|
||||
<span>{WHATIF_TEXT.rowsHint}</span>
|
||||
{excluded.length > 0 && (
|
||||
<span style={{ color: 'var(--st-waiting)' }} data-whatif-excluded-count="">{WHATIF_TEXT.excludedCount(excluded.length)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3020,3 +3020,116 @@ export interface SqlCheckItem {
|
||||
relation_id?: string
|
||||
sql_excerpt?: string
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 数字推演(后端 app/api/whatif.py、app/engine/recompute.py):在一次运行的口径卡上改输入、排除查询快照里的行,
|
||||
// 看指标和报告里引用它的地方会变成多少。推演不是出具:不写运行记录、不改报告
|
||||
// ===========================================================================
|
||||
|
||||
/** 推演的改动:输入路径 → 新值;查询快照的工件 id → 要排除的行号(快照里的行号,从 0 数) */
|
||||
export interface WhatIfOverrides {
|
||||
inputs: Record<string, number>
|
||||
exclude_rows: Record<string, number[]>
|
||||
}
|
||||
|
||||
/** POST /runs/{id}/evidence/whatif 的请求体 */
|
||||
export interface WhatIfRequest {
|
||||
/** 报告撰写节点 id:只列这份报告里引用的地方 */
|
||||
report?: string
|
||||
/** 口径卡节点 id */
|
||||
card: string
|
||||
/** 只推演这一个指标 */
|
||||
metric?: string
|
||||
overrides: WhatIfOverrides
|
||||
}
|
||||
|
||||
/** 一个输入在推演里的样子 */
|
||||
export interface WhatIfInput {
|
||||
path: string
|
||||
via?: string | null
|
||||
node_id?: string | null
|
||||
/** 落在哪份查询快照上(cell() 取的格、agent 字段、整份查询结果):排除这份快照里的行会影响它 */
|
||||
artifact?: string
|
||||
locator?: EvidenceLocator
|
||||
kind: 'number' | 'text' | 'bool' | 'missing' | 'container' | (string & {})
|
||||
/** 数值(或原本缺失)的标量、不是遮罩列、不决定取哪一格:可以改 */
|
||||
editable: boolean
|
||||
/** 原值、推演用的值。容器写大概(「[12 项]」);遮罩列的一律是 null */
|
||||
old?: unknown
|
||||
new?: unknown
|
||||
changed?: boolean
|
||||
/** 起了作用的被排除行(快照里的行号,从 0 数) */
|
||||
excluded_rows?: number[]
|
||||
masked?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* unchanged:没碰到改动,原值原样交回;ok:重算了;excluded:排除行后缺了输入,算不出来;error:新值让计算出错(除以零……);
|
||||
* unsupported:有输入的完整值找不回来,推演不了;masked:用到了遮罩列,不推演
|
||||
*/
|
||||
export type WhatIfStatus = 'unchanged' | 'ok' | 'excluded' | 'error' | 'unsupported' | 'masked'
|
||||
|
||||
export interface WhatIfMetric {
|
||||
id: string
|
||||
name: string
|
||||
unit: string
|
||||
decimals?: number | null
|
||||
format?: string
|
||||
old: unknown
|
||||
new: unknown
|
||||
delta: number | null
|
||||
/** 按口径卡的格式渲染(和报告里的字同一套规则) */
|
||||
rendered_old: string
|
||||
/** unsupported、masked 时为 null:不知道会变成什么 */
|
||||
rendered_new: string | null
|
||||
/** 带正负号;比率、百分数写「个百分点」。没变化、不是数时为 null */
|
||||
rendered_delta: string | null
|
||||
changed: boolean | null
|
||||
status: WhatIfStatus | (string & {})
|
||||
reason: string | null
|
||||
/** 推演用的代入式(重算了才有;没碰到改动的是卡上原来的代入式) */
|
||||
substituted: string | null
|
||||
inputs: WhatIfInput[]
|
||||
}
|
||||
|
||||
/** 报告里引用这个指标的一处,推演之后会写成什么 */
|
||||
export interface WhatIfSegment {
|
||||
report: string
|
||||
unit: string
|
||||
sid: string
|
||||
metric: string
|
||||
rendered_old: string
|
||||
rendered_new: string | null
|
||||
changed: boolean | null
|
||||
}
|
||||
|
||||
export interface WhatIfUnsupported {
|
||||
metric: string
|
||||
/** null:整个指标都推演不了(口径卡没记输入) */
|
||||
input: string | null
|
||||
reason: string
|
||||
}
|
||||
|
||||
/** 引用了这张口径卡的下游口径卡:只标可能受影响,不重算 */
|
||||
export interface WhatIfAffectedCard {
|
||||
node_id: string
|
||||
artifact: string
|
||||
caliber: string
|
||||
version: string
|
||||
metrics: string[]
|
||||
/** 直接引用(false 是经别的卡间接引用) */
|
||||
direct: boolean
|
||||
}
|
||||
|
||||
export interface WhatIfResult {
|
||||
run_id: string
|
||||
card: { node_id: string; artifact: string; caliber: string; version: string }
|
||||
report: string | null
|
||||
/** 运行已封存且封存核对一致:推演依据的数据不会再变 */
|
||||
sealed: boolean
|
||||
seal: { sealed: boolean; ok: boolean | null }
|
||||
metrics: WhatIfMetric[]
|
||||
segments: WhatIfSegment[]
|
||||
unsupported: WhatIfUnsupported[]
|
||||
affected_cards: WhatIfAffectedCard[]
|
||||
}
|
||||
|
||||
@@ -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