diff --git a/frontend/src/api/whatif.ts b/frontend/src/api/whatif.ts index 3afce99f..f7ecb7a5 100644 --- a/frontend/src/api/whatif.ts +++ b/frontend/src/api/whatif.ts @@ -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(`/runs/${encodeURIComponent(runId)}/evidence/whatif`, { + method: 'POST', body: JSON.stringify(body), signal: opts?.signal, + }), +} diff --git a/frontend/src/dev/fixtures/whatif.tsx b/frontend/src/dev/fixtures/whatif.tsx new file mode 100644 index 00000000..ef36d400 --- /dev/null +++ b/frontend/src/dev/fixtures/whatif.tsx @@ -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 ( + + ) +} + +export const cases: Record JSX.Element> = { + whatif: () => ( +
+ ), + 'whatif-rail': () => ( +
+ +
+ ), +} diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 57e85bf3..68e05972 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -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: '这个指标的计算式没有引用任何输入,无可推演', } /** 双盲复算节点:节点库说明、检查器和运行结果 */ diff --git a/frontend/src/run/EvidencePanel.tsx b/frontend/src/run/EvidencePanel.tsx index b4b9dd14..0f98a344 100644 --- a/frontend/src/run/EvidencePanel.tsx +++ b/frontend/src/run/EvidencePanel.tsx @@ -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, ? : found ? + unit={found.unit} block={found.block} onViolations={onViolations} onLocate={onLocate} /> : claimAt ? @@ -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 && ( + + )} + )} {isMetric && inputs.some(notableInput) && ( @@ -396,7 +424,10 @@ function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block )} {queries.map((q, i) => ( - + whatif.toggleRow(q.artifact!, row) } + : undefined} /> ))} {provenanceOn && runId && } @@ -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 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} )} + {whatif && } {(caliber || version || artifact || from) && (
@@ -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)}

)} + {children} ) } @@ -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 && (
0 && 'mt-1.5')}> - + {pick && win.index + ?
)} {!win.rows.length && ( diff --git a/frontend/src/run/WhatIf.tsx b/frontend/src/run/WhatIf.tsx new file mode 100644 index 00000000..e17d0223 --- /dev/null +++ b/frontend/src/run/WhatIf.tsx @@ -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 + /** 已提交的新值:请求按它发 */ + inputs: Record + /** 勾掉的行:查询快照的工件 id → 快照里的行号(从 0 数) */ + exclude: Record +} + +const freshDraft = (scope: string, open = false): DraftState => ({ scope, open, text: {}, inputs: {}, exclude: {} }) + +export interface WhatIfDraft { + open: boolean + toggle: () => void + text: Record + /** 输入框改了:停顿一会儿才提交。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): Record { + const out: Record = {} + 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(() => freshDraft(scope)) + // 换了一个指标(另一张卡、另一个数,或者不是指标了):推演收起、改动清空 + const state = stored.scope === scope ? stored : freshDraft(scope) + if (stored.scope !== scope) setState(state) + const timer = useRef>(undefined) + const olds = useRef>({}) + 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 ( + + ) +} + +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({ key: '', status: 'loading' }) + const [nonce, setNonce] = useState(0) + const cache = useRef(new Map()) + 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 ( +
+ {/* 醒目靠底色、左边的色条和图标;字用正文色:提醒色的字压在同色的浅底上,浅色主题下对比度不到 4.5 */} +

+ + {WHATIF_TEXT.banner} +

+ {data && !data.sealed && ( +

+ {data.seal.sealed && data.seal.ok === false ? WHATIF_TEXT.sealBroken : WHATIF_TEXT.unsealed} +

+ )} + + {status === 'error' && setNonce((n) => n + 1)} />} + {!data && loading && ( +

{WHATIF_TEXT.loading}

+ )} + + {m && status !== 'error' && ( +
+
{WHATIF_TEXT.result}
+
+ {m.rendered_old} + → + {m.rendered_new ?? NONE} + {m.rendered_delta + ? {WHATIF_TEXT.delta(m.rendered_delta)} + : m.changed === false && {WHATIF_TEXT.unchanged}} + {loading && } +
+ {m.reason &&

{m.reason}

} + {m.status === 'ok' && m.changed && m.substituted && ( +
+
{WHATIF_TEXT.substituted}
+ + {m.substituted} + +
+ )} +
+ )} + + {m && ( +
+
{WHATIF_TEXT.inputs}
+ {m.inputs.length ? ( +
    + {m.inputs.map((inp, i) => ( + + ))} +
+ ) :

{WHATIF_TEXT.empty}

} +
+ )} + + {unsupported.length > 0 && ( +
+
{WHATIF_TEXT.unsupported}
+
    + {unsupported.map((u) => ( +
  • + {u.input ?? WHATIF_TEXT.wholeMetric} + :{u.reason} +
  • + ))} +
+
+ )} + + {m && !!data?.segments.length && ( +
+
{WHATIF_TEXT.segments}
+ {unknown ?

{WHATIF_TEXT.segmentsUnknown}

+ : changedSegs.length ? ( +
    + {changedSegs.map((s) => ( +
  • + {s.rendered_old} + → + {s.rendered_new} + {onLocate && (!report || s.report === report) + ? ( + + ) + : {WHATIF_TEXT.inReport(s.report)}} +
  • + ))} +
+ ) :

{WHATIF_TEXT.segmentsNone(data.segments.length)}

} +
+ )} + + {!!data?.affected_cards.length && ( +
+
{WHATIF_TEXT.affected}
+

{WHATIF_TEXT.affectedHint}

+
    + {data.affected_cards.map((c) => ( +
  • + {WHATIF_TEXT.affectedCard(c.caliber, c.version)} + {!c.direct && · {WHATIF_TEXT.indirect}} +
  • + ))} +
+
+ )} + +
+ + {loading && data && {WHATIF_TEXT.loading}} +
+
+ ) +} + +/** 一个输入:能改的给输入框(原值写在旁边),别的照实显示;排除了几行、去哪一步勾行,写在下面 */ +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) => { + 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 ( +
  • +
    {input.path}
    + {input.masked ? ( +

    {WHATIF_TEXT.masked}

    + ) : input.editable ? ( +
    + draft.type(input.path, e.target.value, input.old)} + onKeyDown={onKeyDown} onBlur={draft.commit} data-whatif-field="" /> + {WHATIF_TEXT.original(valueText(input.old))} + {invalid && {WHATIF_TEXT.notNumber}} +
    + ) : ( +

    + {valueText(input.old)} + {input.changed && <> → {valueText(input.new)}} +

    + )} + {!!input.excluded_rows?.length && ( +

    + {WHATIF_TEXT.excludedRows(input.excluded_rows)} +

    + )} + {fromQuery && ( + + )} +
  • + ) +} + +/** + * 推演时查询步骤里的结果窗口:每行前面一个勾选框,勾掉的行在推演里视为不存在(划掉、变淡)。只有知道每一行 + * 在快照里是第几行时才画(index);被遮罩的列照样写「已遮罩」,高亮的格和平时一样描边 + */ +export function WhatIfRows({ win, hidden, excluded, onToggle }: { + win: ReturnType; 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 ( +
    +
    + + + + {/* 勾选框和行号两列只占自己的宽度:表格撑满窄栏时多出来的宽度留给数据列 */} + + + {win.columns.map((c) => )} + + + + {win.rows.map((row, i) => { + const n = index[i] + const off = excluded.includes(n) + const hit = marked.has(i) + return ( + + + + {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 ( + + ) + })} + + ) + })} + +
    {WHATIF_TEXT.excludeHead}#{c}
    + onToggle(n)} + aria-label={WHATIF_TEXT.excludeRow(n)} /> + {n + 1} + {mask ? {WHATIF_TEXT.masked} + : cell == null ? null + : typeof cell === 'object' ? JSON.stringify(cell) : String(cell)} +
    +
    +
    + {WHATIF_TEXT.rowsHint} + {excluded.length > 0 && ( + {WHATIF_TEXT.excludedCount(excluded.length)} + )} +
    +
    + ) +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f37b6540..37d2353c 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 + exclude_rows: Record +} + +/** 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[] +} diff --git a/scripts/check-all.mjs b/scripts/check-all.mjs index 2002181e..2ea23576 100644 --- a/scripts/check-all.mjs +++ b/scripts/check-all.mjs @@ -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 兜住「其余全部」,以后新加的段 // 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。 diff --git a/scripts/check-whatif.mjs b/scripts/check-whatif.mjs index eb9cae7b..8f57c164 100644 --- a/scripts/check-whatif.mjs +++ b/scripts/check-whatif.mjs @@ -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)