运行对比页对齐服务端的实际输出:变化百分比按百分数写,归因、遮罩、差异说明照服务端给的显示

对照运行对比后端的实现核过一遍字段的写法:
- delta_pct 服务端已经乘过 100(12.5 是 +12.5%),占比 share 仍是小数(0.4 是 40%),两处分开格式化;原先把
  delta_pct 也当小数,会写成「+1250.0%」
- 维度归因服务端只给前 10 项、另有几项写在 note 里,照写;归不了因(查询结果截断、度量列遮罩、快照读不出来)时
  给空的 rows 和一句说明,只写说明、不画空表;万一多给了仍只列 10 项并写明另有几项
- 约定之外服务端顺带给的几项按可选字段用上:输入的 masked(两边写「已遮罩」,不标变了)、查询的 differences
  (结构变了差在哪,列在并排的 SQL 上面)和 problem(快照读不出来、与哈希不一致)、模板配置项的 labels(按节点
  类型翻好的叫法,键名放进悬停提示;没给或对不上时照写键名)
- 找默认基准时的 404 分两种:本次这条运行不存在(机读码 run_not_found)写「运行记录不存在」,其余写「未找到
  可作为基准的运行」
- check-compare 的夹具照服务端的写法改,补上述几种情形;菜单入口另查「问到基准之后才跳」(先跳到只带本次的地址、
  再让对比页去找,原先查不出来);check-all 登记实测耗时
This commit is contained in:
ThearchyHelios
2026-10-05 08:58:34 +08:00
parent e6c77ea4ee
commit 72da2e178a
8 changed files with 148 additions and 54 deletions
+2
View File
@@ -2841,6 +2841,8 @@ export const RUN_COMPARE_TEXT = {
reasons: '变化说明',
inputs: '输入对比',
inputsNone: '口径卡没有记录这个指标的输入',
/** 取自设置了遮罩的列:两边的值都不显示 */
masked: '已遮罩',
colInput: '输入',
attribution: '维度归因',
attrBy: (measure: string) => `按「${measure}」的变化,从大到小`,
+18 -12
View File
@@ -5,7 +5,7 @@ import { NONE } from '../../../lib/format'
import { RUN_COMPARE_TEXT as T } from '../../../lib/terms'
import type { RunCompareAttribution, RunCompareDecomposition, RunCompareMetric } from '../../../types'
import { Block, ClassChip } from './parts'
import { metricKey, percent, sameValue, signedNumber, valueText } from './model'
import { changeText, metricKey, sameValue, shareText, signedNumber, valueText } from './model'
/** 维度归因列前几项:后端按变化从大到小排好,多的只写还有几项 */
const ATTR_TOP = 10
@@ -103,7 +103,7 @@ export function Metrics({ metrics, filter, onClearFilter }: {
function MetricRows({ m, k, detailId, open, onToggle }: {
m: RunCompareMetric; k: string; detailId: string; open: boolean; onToggle: () => void
}) {
const pct = m.delta_pct != null && m.delta_pct !== 0 ? percent(m.delta_pct, true) : null
const pct = m.delta_pct != null && m.delta_pct !== 0 ? changeText(m.delta_pct) : null
return (
<Fragment>
<tr className={clsx('border-b align-top last:border-0', open && 'bg-hover')} data-compare-metric={k} data-class={m.classification}>
@@ -235,14 +235,17 @@ function Inputs({ m }: { m: RunCompareMetric }) {
</thead>
<tbody>
{m.inputs.map((inp) => {
const changed = !sameValue(inp.base, inp.target)
// 遮罩列:两边的值服务端都不给,写「已遮罩」,也不说变没变
const changed = !inp.masked && !sameValue(inp.base, inp.target)
const shown = (v: unknown) => (inp.masked ? T.masked : valueText(v))
return (
<tr key={inp.path} className="border-b last:border-0" data-compare-input={inp.path} data-changed={changed ? '' : undefined}>
<tr key={inp.path} className="border-b last:border-0" data-compare-input={inp.path} data-changed={changed ? '' : undefined}
data-masked={inp.masked ? '' : undefined}>
<th scope="row" className="mono py-1 pr-2 text-left font-normal text-dim [overflow-wrap:anywhere]">{inp.path}</th>
<td className="tnum px-2 py-1 text-right text-dim [overflow-wrap:anywhere]">{valueText(inp.base)}</td>
<td className="tnum px-2 py-1 text-right text-dim [overflow-wrap:anywhere]">{shown(inp.base)}</td>
<td className={clsx('tnum py-1 pl-2 text-right [overflow-wrap:anywhere]', changed ? 'font-medium text-fg' : 'text-dim')}>
{changed && <span className="mr-1 inline-block h-1.5 w-1.5 rounded-full align-middle" style={{ background: DATA_COLOR }} aria-hidden />}
{valueText(inp.target)}
{shown(inp.target)}
</td>
</tr>
)
@@ -257,7 +260,8 @@ function Inputs({ m }: { m: RunCompareMetric }) {
/**
* 维度归因:按键对齐两边查询结果的行,列前 10 项的键、两边的值、变化和占比。占比只在度量列之和等于指标的输入时才有,
* 没有时那一栏写「—」,下面照服务端的说明写原因(没给说明时写一句通用的)
* 没有时那一栏写「—」,下面照服务端的说明写原因(没给说明时写一句通用的)。归不了因的(查询结果截断、度量列遮罩、
* 快照读不出来)服务端给空的 rows 和一句说明:不画空表,只写这句话
*/
function Attribution({ a }: { a: RunCompareAttribution | null }) {
if (!a) {
@@ -270,14 +274,14 @@ function Attribution({ a }: { a: RunCompareAttribution | null }) {
}
const rows = a.rows.slice(0, ATTR_TOP)
const more = a.rows.length - rows.length
const note = a.note || (a.rows.some((r) => r.share == null) ? T.attrNoShare : null)
const note = a.note || (!rows.length ? T.attrNone : a.rows.some((r) => r.share == null) ? T.attrNoShare : null)
return (
<div data-compare-attr="">
<h3 className="mb-1 flex flex-wrap items-baseline gap-x-2 text-2xs">
<span className="font-medium text-faint">{T.attribution}</span>
<span className="text-faint [overflow-wrap:anywhere]">{T.attrBy(a.measure)}{more > 0 ? ` · ${T.attrTop(ATTR_TOP)}` : ''}</span>
</h3>
<div className="overflow-x-auto">
{!!rows.length && <div className="overflow-x-auto">
<table className="w-full border-collapse text-2xs">
<thead>
<tr className="border-b text-left text-faint">
@@ -298,16 +302,18 @@ function Attribution({ a }: { a: RunCompareAttribution | null }) {
<td className="tnum px-2 py-1 text-right text-dim">{valueText(r.target)}</td>
<td className="tnum px-2 py-1 text-right text-fg">{signedNumber(r.delta)}</td>
<td className="tnum py-1 pl-2 text-right text-dim" data-compare-share="" title={r.share == null ? note ?? undefined : undefined}>
{r.share == null ? NONE : percent(r.share)}
{r.share == null ? NONE : shareText(r.share)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>}
<div className="mt-1 space-y-0.5 text-2xs text-faint">
{more > 0 && <p data-compare-attr-more="">{T.attrMore(more)}</p>}
{a.total_delta != null && <p className="tnum" data-compare-attr-total="">{T.attrTotal(signedNumber(a.total_delta))}</p>}
{!!rows.length && a.total_delta != null && (
<p className="tnum" data-compare-attr-total="">{T.attrTotal(signedNumber(a.total_delta))}</p>
)}
{note && <p className="leading-relaxed text-dim [overflow-wrap:anywhere]" data-compare-attr-note="">{note}</p>}
</div>
</div>
+13 -2
View File
@@ -20,8 +20,9 @@ const MARK = {
} as const
/**
* 查询区:每条查询的结构状态和行数。只参数变的列出变化的字面量;结构变的并排两条 SQL(窄屏上下排),逐词标出各自独有的
* 部分;新增、移除的只有一边。相同、只参数变的 SQL 默认收着,点开再看
* 查询区:每条查询的结构状态和行数。只参数变的列出变化的字面量;结构变的先写服务端说的差在哪(有的话),再并排两条
* SQL(窄屏上下排),逐词标出各自独有的部分;新增、移除的只有一边。相同、只参数变的 SQL 默认收着,点开再看。
* 快照读不出来、与哈希不一致的,照服务端的说明写在这一条上
*/
export function Queries({ queries }: { queries: RunCompareQuery[] }) {
const id = useId()
@@ -64,6 +65,16 @@ function QueryItem({ q, k, nth }: { q: RunCompareQuery; k: string; nth: number |
</button>
)}
</div>
{q.problem && (
<p className="mt-1 text-2xs leading-relaxed [overflow-wrap:anywhere]" style={{ color: 'var(--warn)' }} data-compare-query-problem="">
{q.problem}
</p>
)}
{q.structure === 'changed' && !!q.differences?.length && (
<ul className="mt-1.5 list-disc space-y-0.5 pl-4 text-2xs leading-relaxed text-dim [overflow-wrap:anywhere]" data-compare-differences="">
{q.differences.map((d, i) => <li key={i}>{d}</li>)}
</ul>
)}
{q.structure === 'params_only' && !!q.literals.length && (
<div className="mt-1.5">
<div className="mb-0.5 text-2xs text-faint">{T.literals}</div>
+11 -5
View File
@@ -6,8 +6,9 @@ import { Block } from './parts'
const CHANGE_COLOR: Record<string, string> = { added: 'var(--accent)', removed: 'var(--text-dim)', config: 'var(--warn)' }
/**
* 模板区:两次运行各用的版本,执行图变了的话列出变化的节点(新增、移除、配置变更)和改了的配置项。配置项写的是节点
* 配置里的键:同一个键在不同类型的节点里叫法不同,接口又不给节点类型,照原样写比猜一个中文名可靠
* 模板区:两次运行各用的版本,执行图变了的话列出变化的节点(新增、移除、配置变更)和改了的配置项。配置项的叫法用
* 服务端按节点类型翻好的 labels(同一个键在不同类型的节点里叫法不同,界面没有节点类型猜不准);没给、或者和键对不上
* 时照写键名,宁可露键名也不编一个界面上没有的名字。键名放在悬停提示里
*/
export function TemplateSection({ template }: { template: RunCompareTemplate }) {
const id = useId()
@@ -35,9 +36,14 @@ export function TemplateSection({ template }: { template: RunCompareTemplate })
{!!n.keys.length && (
<span className="flex min-w-0 flex-wrap items-center gap-1 text-2xs text-faint">
<span>{T.keys}</span>
{n.keys.map((k) => (
<code key={k} className="mono rounded bg-bg px-1 text-dim [overflow-wrap:anywhere]" data-compare-key="">{k}</code>
))}
{n.keys.map((k, i) => {
const label = n.labels?.length === n.keys.length ? n.labels[i] : undefined
return label && label !== k ? (
<span key={k} className="rounded bg-bg px-1 text-dim [overflow-wrap:anywhere]" title={k} data-compare-key={k}>{label}</span>
) : (
<code key={k} className="mono rounded bg-bg px-1 text-dim [overflow-wrap:anywhere]" data-compare-key={k}>{k}</code>
)
})}
</span>
)}
</li>
+20 -10
View File
@@ -65,16 +65,21 @@ export function signedNumber(v: number | null | undefined): string {
return `${v > 0 ? '+' : '-'}${plainNumber(Math.abs(v))}`
}
/**
* 小数写成百分比(一位小数)。signed 时带正负号:变化比例 0.125 →「+12.5%」;占比 0.4 →「40.0%」。
* 不是零、却小到一位小数写不出来的写「<0.1%」,不写成看着像没变的「0.0%」
*/
export function percent(v: number | null | undefined, signed = false): string {
/** 百分数(已经乘过 100)写成一位小数;不是零、却小到一位小数写不出来的写「<0.1%」,不写成看着像没变的「0.0%」 */
function pct(abs: number, sign: string): string {
return abs !== 0 && abs < 0.05 ? `${sign}<0.1%` : `${sign}${abs.toFixed(1)}%`
}
/** 变化百分比:服务端给的 delta_pct 已经乘过 100(12.5 是涨了 12.5%),带正负号:「+12.5%」「-3.0%」 */
export function changeText(v: number | null | undefined): string {
if (!isNum(v)) return NONE
const pct = Math.abs(v) * 100
const sign = signed && v !== 0 ? (v > 0 ? '+' : '-') : v < 0 ? '-' : ''
if (v !== 0 && pct < 0.05) return `${sign}<0.1%`
return `${sign}${pct.toFixed(1)}%`
return pct(Math.abs(v), v > 0 ? '+' : v < 0 ? '-' : '')
}
/** 归因的占比:服务端给的是小数(0.4 是 40%)。和总变化反向的一项占比是负的:「-5.0%」 */
export function shareText(v: number | null | undefined): string {
if (!isNum(v)) return NONE
return pct(Math.abs(v) * 100, v < 0 ? '-' : '')
}
/** 两个值算不算一样(输入对比里标「变了」用):数按值比,别的按 JSON 比 */
@@ -108,9 +113,14 @@ export function queryNumbers(queries: RunCompareQuery[]): Map<string, number | n
*/
export type CompareErrorKind = '409' | '422' | '404' | 'no-base' | 'other'
/** 服务端「运行记录不存在」的机读码(api/evidence.py 的 RUN_NOT_FOUND,运行对比的接口沿用) */
const RUN_NOT_FOUND = 'run_not_found'
export function compareErrorKind(e: unknown, finding: boolean): CompareErrorKind {
if (!(e instanceof ApiError) || e.kind !== 'http') return 'other'
if (finding) return e.status === 404 ? 'no-base' : 'other'
// 找基准时的 404 有两种:本次这条运行就不存在(机读码 run_not_found),和找不到可比的上一次
if (finding && e.status === 404) return e.code === RUN_NOT_FOUND ? '404' : 'no-base'
if (finding) return 'other'
if (e.status === 409) return '409'
if (e.status === 422) return '422'
if (e.status === 404) return '404'
+9 -1
View File
@@ -3211,6 +3211,8 @@ export interface RunCompareTemplateNode {
change: 'added' | 'removed' | 'config' | (string & {})
/** 改了的配置项(节点配置里的键) */
keys: string[]
/** 约定之外、服务端顺带给的:各配置项在界面上的叫法,和 keys 一一对应。没给(或对不上)时照写键名 */
labels?: string[]
}
export interface RunCompareTemplate {
@@ -3237,6 +3239,8 @@ export interface RunCompareInput {
path: string
base: unknown
target: unknown
/** 约定之外、服务端顺带给的:取自设置了遮罩的列,两边的值都不给 */
masked?: boolean
}
export interface RunCompareAttributionRow {
@@ -3272,7 +3276,7 @@ export interface RunCompareMetric {
delta: number | null
/** 带正负号;比率、百分数写「个百分点」。没变化、不是数时可能为 null */
delta_rendered: string | null
/** 相对基准的变化比例(小数,0.125 是 +12.5%);基准为 0、缺值时为 null */
/** 相对基准变化了百分之几(已乘 100,保留两位:12.5 是 +12.5%,和占比的小数写法不同);基准为 0、缺值时为 null */
delta_pct: number | null
classification: RunCompareClass | (string & {})
reasons: string[]
@@ -3300,6 +3304,10 @@ export interface RunCompareQuery {
target_rows: number | null
base_truncated: boolean | null
target_truncated: boolean | null
/** 约定之外、服务端顺带给的:结构变了时差在哪,一处一句中文(按对结果的影响从大到小) */
differences?: string[]
/** 约定之外、服务端顺带给的:快照读不出来、与哈希不一致时的一句说明 */
problem?: string
}
/** GET /runs/{id}/compare?base=。任一未封存或封存核对不一致 409;不是同一工作流、是同一次运行 422 */
+1 -1
View File
@@ -96,7 +96,7 @@ const ESTIMATE = {
stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87,
evidence: 91, versions: 60, catalog: 45, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
// 占位,换成真检查时按实测改
schedules: 1, compare: 1, verify: 1, inquiry: 1, bundle: 16, whatif: 33,
schedules: 1, compare: 30, verify: 1, inquiry: 1, bundle: 16, whatif: 33,
}
// 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段
// 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。
+74 -23
View File
@@ -70,12 +70,14 @@ const refOf = (id) => {
}
const REGIONS = ['青禾区', '白石区', '云杉区', '赤松区', '蓝湾区', '金穗区', '银杏区', '翠竹区', '月湖区', '星港区', '石桥区', '柳岸区']
// 各区的变化之和正好是总变化 2,531.5;占比按服务端的约定给小数(0.4 是 40%)
// 各区的变化之和正好是总变化 2,531.5;占比照服务端给小数(0.4 是 40%)。服务端按变化的绝对值只给前 10 项,
// 另有几项写在 note 里(ALL_ROWS 给「服务端多给了」那一节用)
const DELTAS = [1012.6, 506.3, 379.7, 253.2, 126.6, 101.3, 63.3, 38.0, 25.3, 12.6, 7.6, 5.0]
const ATTR_ROWS = REGIONS.map((name, i) => ({
const ALL_ROWS = REGIONS.map((name, i) => ({
key: [name], base: 3000 + i * 100, target: 3000 + i * 100 + DELTAS[i], delta: DELTAS[i],
share: Math.round(DELTAS[i] / 2531.5 * 10000) / 10000,
}))
const MORE_KEYS = '共 12 个维度值,列出变化最大的 10 个'
const metric = (over) => ({
card_id: 'caliber', card_title: '周报口径', unit: '', decimals: 1, reasons: [], decomposition: null,
@@ -83,34 +85,39 @@ const metric = (over) => ({
})
const METRICS = [
metric({ metric_id: 'gmv', name: '销售额', unit: '元', base: 45678.5, target: 48210, base_rendered: '45,678.5元',
target_rendered: '48,210.0元', delta: 2531.5, delta_rendered: '+2,531.5元', delta_pct: 0.0554, classification: 'data_only',
reasons: ['口径与 SQL 均未改动,变化来自数据'],
target_rendered: '48,210.0元', delta: 2531.5, delta_rendered: '+2,531.5元', delta_pct: 5.54, classification: 'data_only',
reasons: ['取数的 SQL 没有变化,查询结果有变化'],
inputs: [{ path: 'vars.kpi.gmv', base: 45678.5, target: 48210 }],
attribution: { columns: ['区域'], measure: 'amount', rows: ATTR_ROWS, total_delta: 2531.5, note: null } }),
attribution: { columns: ['区域'], measure: 'amount', rows: ALL_ROWS.slice(0, 10), total_delta: 2531.5, note: MORE_KEYS } }),
metric({ metric_id: 'orders', name: '订单数', unit: '单', decimals: 0, base: 1234, target: 1301, base_rendered: '1,234单',
target_rendered: '1,301单', delta: 67, delta_rendered: '+67单', delta_pct: 0.0543, classification: 'data_only' }),
target_rendered: '1,301单', delta: 67, delta_rendered: '+67单', delta_pct: 5.43, classification: 'data_only' }),
metric({ metric_id: 'aov', name: '客单价', unit: '元', base: 37.0, target: 41.5, base_rendered: '37.0元', target_rendered: '41.5元',
delta: 4.5, delta_rendered: '+4.5元', delta_pct: 0.1216, classification: 'caliber_and_data',
reasons: ['口径卡「周报口径」中客单价的计算式有改动'],
delta: 4.5, delta_rendered: '+4.5元', delta_pct: 12.16, classification: 'caliber_and_data',
reasons: ['表达式由「vars.kpi.gmv / vars.kpi.order_cnt」改为「vars.kpi.gmv / vars.kpi.paid_cnt」'],
decomposition: { caliber_effect: 5.8, data_effect: -1.3, caliber_rendered: '+5.8元', data_rendered: '-1.3元' },
inputs: [{ path: 'vars.kpi.gmv', base: 45678.5, target: 48210 }, { path: 'vars.kpi.order_cnt', base: 1234, target: 1234 }],
// 遮罩列的输入:服务端两边都不给值,另带 masked
inputs: [{ path: 'vars.kpi.gmv', base: 45678.5, target: 48210 }, { path: 'vars.kpi.order_cnt', base: 1234, target: 1234 },
{ path: 'vars.kpi.cost', base: null, target: null, masked: true }],
attribution: { columns: ['区域', '渠道'], measure: 'order_cnt', total_delta: 67,
note: '各区域订单数之和与指标的输入对不上,不给占比',
rows: [{ key: ['青禾区', '门店'], base: 400, target: 440, delta: 40, share: null },
{ key: ['白石区', '线上'], base: 300, target: 327, delta: 27, share: null }] } }),
metric({ metric_id: 'refund_rate', name: '退货率', unit: '%', decimals: 2, base: 0.0235, target: 0.0198, base_rendered: '2.35%',
target_rendered: '1.98%', delta: -0.0037, delta_rendered: '-0.37 个百分点', delta_pct: -0.1574, classification: 'sql_changed',
target_rendered: '1.98%', delta: -0.0037, delta_rendered: '-0.37 个百分点', delta_pct: -15.74, classification: 'sql_changed',
reasons: ['所依据的查询「退货明细」的筛选条件有改动'],
decomposition_note: '所依据的查询「退货明细」SQL 结构有改动,口径不同,不做拆分' }),
metric({ metric_id: 'visitors', name: '日均访客', unit: '人', decimals: 0, base: 5200, target: 5480, base_rendered: '5,200人',
target_rendered: '5,480人', delta: 280, delta_rendered: '+280人', delta_pct: 0.0538, classification: 'params_only',
reasons: ['「拉取销量」只有日期参数不同'] }),
target_rendered: '5,480人', delta: 280, delta_rendered: '+280人', delta_pct: 5.38, classification: 'params_only',
reasons: ['「拉取销量」只有日期参数不同'],
// 归不了因:服务端给空的 rows 和一句说明
attribution: { columns: [], measure: 'visits', rows: [], total_delta: null,
note: '查询结果被截断(基准运行只取回了前 500 行),不做维度归因' } }),
metric({ metric_id: 'new_users', name: '新客数', unit: '人', decimals: 0, base: null, target: 320, base_rendered: '—',
target_rendered: '320人', delta: null, delta_rendered: null, classification: 'added', reasons: ['本次新增的指标'] }),
metric({ metric_id: 'members', name: '会员数', unit: '人', decimals: 0, base: 8800, target: null, base_rendered: '8,800人',
target_rendered: '—', delta: null, delta_rendered: null, classification: 'removed', reasons: ['本次没有这个指标'] }),
metric({ metric_id: 'margin', name: '毛利率', unit: '%', decimals: 1, base: 0.31, target: 0.33, base_rendered: '31.0%',
target_rendered: '33.0%', delta: 0.02, delta_rendered: '+2.0 个百分点', delta_pct: 0.0645, classification: 'incomparable',
target_rendered: '33.0%', delta: 0.02, delta_rendered: '+2.0 个百分点', delta_pct: 6.45, classification: 'incomparable',
reasons: ['基准运行的口径卡没有记录输入'], decomposition_note: '基准运行的口径卡没有记录输入,无法拆分' }),
...[['stores', '门店数', '家', 56], ['complaints', '客诉数', '件', 12], ['sku', 'SKU 数', '个', 1500]].map(([id, name, unit, v]) =>
metric({ metric_id: id, name, unit, decimals: 0, base: v, target: v, base_rendered: `${v.toLocaleString('en-US')}${unit}`,
@@ -126,7 +133,8 @@ const QUERIES = [
{ node_id: 'refund', node_title: '退货明细', ordinal: 0, structure: 'changed',
base_sql: "SELECT COUNT(*) AS n\nFROM refunds r\nWHERE r.created_at >= '2026-09-21'",
target_sql: "SELECT COUNT(*) AS n\nFROM refunds r\nWHERE r.paid_at >= '2026-09-28' AND r.status = 'done'",
literals: [], base_rows: 500, target_rows: 488, base_truncated: true, target_truncated: false },
literals: [], base_rows: 500, target_rows: 488, base_truncated: true, target_truncated: false,
differences: ['时间条件的列不同:基准按 refunds.created_at,这次按 refunds.paid_at', "这次多了筛选条件 refunds.status = 'done'"] },
{ node_id: 'stores', node_title: '门店清单', ordinal: 0, structure: 'same', base_sql: 'SELECT COUNT(*) AS n FROM stores',
target_sql: 'SELECT COUNT(*) AS n FROM stores', literals: [], base_rows: 1, target_rows: 1, base_truncated: false, target_truncated: false },
{ node_id: 'pull', node_title: '拉取销量', ordinal: 1, structure: 'same', base_sql: 'SELECT COUNT(*) AS n FROM visits',
@@ -135,11 +143,12 @@ const QUERIES = [
target_sql: "SELECT COUNT(*) AS n FROM users WHERE first_order_on >= '2026-09-28'", literals: [], base_rows: null, target_rows: 1,
base_truncated: null, target_truncated: false },
{ node_id: 'member', node_title: '会员查询', ordinal: 0, structure: 'removed', base_sql: 'SELECT COUNT(*) AS n FROM members',
target_sql: null, literals: [], base_rows: 1, target_rows: null, base_truncated: false, target_truncated: null },
target_sql: null, literals: [], base_rows: 1, target_rows: null, base_truncated: false, target_truncated: null,
problem: '基准运行的查询快照无法读取' },
]
const TEMPLATE = { base_version: 3, target_version: 4, same_graph: false, nodes: [
{ node_id: 'caliber', title: '周报口径', change: 'config', keys: ['metrics'] },
{ node_id: 'refund', title: '退货明细', change: 'config', keys: ['sql', 'timeout'] },
{ node_id: 'refund', title: '退货明细', change: 'config', keys: ['sql', 'timeout'], labels: ['SQL', '超时(秒)'] },
{ node_id: 'newbie', title: '新客查询', change: 'added', keys: [] },
{ node_id: 'member', title: '会员查询', change: 'removed', keys: [] },
] }
@@ -285,6 +294,9 @@ const shot = async (page, name) => {
await section('从运行详情进入', async () => {
const { page, log, blocked, errors } = await open(`/runs/${T}`)
await page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 })
// 走过的地址(含 pushState / replaceState):菜单要先问到基准再跳,不能先跳到只带本次的地址、再让对比页去找
const visited = []
page.on('framenavigated', (f) => { if (f === page.mainFrame()) visited.push(f.url()) })
await page.getByRole('button', { name: '更多操作' }).click()
const item = page.locator('[data-menu=compare]')
check('「更多」菜单里有「与上一次对比」,可点', await item.count() === 1 && (await item.innerText()).includes('与上一次对比')
@@ -298,6 +310,9 @@ await section('从运行详情进入', async () => {
const iCmp = log.indexOf(`GET /api/runs/${T}/compare?base=${B}`)
check('先请求 compare/base,再请求 compare', iBase >= 0 && iCmp > iBase, `${iBase} → ${iCmp}`)
check('compare/base 只请求一次(页面不再自己找一遍)', log.filter((k) => k.startsWith(`GET /api/runs/${T}/compare/base`)).length === 1)
const compareUrls = visited.map((u) => new URL(u)).filter((u) => u.pathname === '/runs/compare')
check('菜单问到基准之后才跳,一步到带基准的地址', compareUrls.length >= 1 && compareUrls.every((u) => u.searchParams.get('base') === B),
compareUrls.map((u) => u.search).join(' → '))
check('写明基准是怎么选的', (await text(page, '[data-compare-reason]')).trim() === REASON, await text(page, '[data-compare-reason]').catch(() => ''))
check('没有发出写请求', blocked.length === 0, blocked.join(';'))
check('页面没有报错', errors.length === 0, errors.join(';'))
@@ -352,7 +367,7 @@ await section('指标表', async () => {
check('基准 → 本次、变化量用服务端渲染的写法', (await gmv.locator('[data-compare-base]').innerText()) === '45,678.5元'
&& (await gmv.locator('[data-compare-target]').innerText()) === '48,210.0元'
&& (await gmv.locator('[data-compare-delta]').innerText()) === '+2,531.5元')
check('变化百分比带正负号(0.0554 → +5.5%)', (await gmv.locator('[data-compare-pct]').innerText()) === '+5.5%',
check('变化百分比照服务端的百分数写、带正负号(5.54 → +5.5%)', (await gmv.locator('[data-compare-pct]').innerText()) === '+5.5%',
await gmv.locator('[data-compare-pct]').innerText().catch(() => ''))
const refund = page.locator('tr[data-compare-metric="caliber:refund_rate"]')
check('下降的百分比写负号', (await refund.locator('[data-compare-pct]').innerText()) === '-15.7%')
@@ -382,18 +397,20 @@ await section('展开:输入对比与维度归因', async () => {
await detail.waitFor()
check('展开后标为展开、指向明细', (await gmvBtn.getAttribute('aria-expanded')) === 'true'
&& (await gmvBtn.getAttribute('aria-controls')) === await detail.getAttribute('id'))
check('列出变化说明', (await detail.locator('[data-compare-reasons]').innerText()).includes('变化来自数据'))
check('列出变化说明', (await detail.locator('[data-compare-reasons]').innerText()).includes('查询结果有变化'))
const input = detail.locator('[data-compare-inputs] tr[data-compare-input="vars.kpi.gmv"]')
check('输入对比:路径、两边的值,变了的标出来', (await input.innerText()).includes('45,678.5') && (await input.innerText()).includes('48,210')
&& (await input.getAttribute('data-changed')) !== null, (await input.innerText().catch(() => '')).replace(/\s+/g, ' '))
const attrRows = detail.locator('[data-compare-attr] tr[data-compare-attr-row]')
check('维度归因只列前 10 项', await attrRows.count() === 10, String(await attrRows.count()))
check('写明另有几项没列', (await detail.locator('[data-compare-attr-more]').innerText()).includes('2'))
check('服务端说另有几项没列:照写', (await detail.locator('[data-compare-attr-note]').innerText()) === MORE_KEYS
&& await detail.locator('[data-compare-attr-more]').count() === 0)
const first = attrRows.first()
check('归因一行:键、两边的值、变化、占比', (await first.innerText()).includes('青禾区') && (await first.innerText()).includes('+1,012.6')
&& (await first.locator('[data-compare-share]').innerText()) === '40.0%', (await first.innerText()).replace(/\s+/g, ' '))
check('写明按哪一列归因', (await detail.locator('[data-compare-attr]').innerText()).includes('amount'))
check('有占比时不写缺占比的原因', await detail.locator('[data-compare-attr-note]').count() === 0)
check('有占比时不写缺占比的原因', !(await detail.locator('[data-compare-attr-note]').innerText()).includes('占比'))
check('写出合计变化', (await detail.locator('[data-compare-attr-total]').innerText()).includes('+2,531.5'))
// 键盘:Tab 到客单价的展开按钮,回车展开
const aovBtn = page.locator('tr[data-compare-metric="caliber:aov"] button[data-compare-expand]')
@@ -406,6 +423,9 @@ await section('展开:输入对比与维度归因', async () => {
(await aov.locator('[data-compare-decomp-sum]').innerText()).trim() === '口径效应 +5.8元,数据效应 -1.3元,两者之和等于总变化 +4.5元',
await aov.locator('[data-compare-decomp-sum]').innerText().catch(() => ''))
check('没有变的输入不标', (await aov.locator('tr[data-compare-input="vars.kpi.order_cnt"]').getAttribute('data-changed')) === null)
const masked = aov.locator('tr[data-compare-input="vars.kpi.cost"]')
check('遮罩列的输入:两边都写「已遮罩」,不标变了', (await masked.locator('td').allInnerTexts()).every((t) => t.trim() === '已遮罩')
&& (await masked.getAttribute('data-changed')) === null, (await masked.innerText().catch(() => '')).replace(/\s+/g, ' '))
const shares = await aov.locator('[data-compare-attr] [data-compare-share]').allInnerTexts()
check('没有占比:占比一栏写「—」', shares.length === 2 && shares.every((s) => s.trim() === '—'), shares.join(','))
check('没有占比:写出原因', (await aov.locator('[data-compare-attr-note]').innerText()).includes('各区域订单数之和与指标的输入对不上'))
@@ -420,9 +440,27 @@ await section('展开:输入对比与维度归因', async () => {
const orders = page.locator('tr[data-compare-detail="caliber:orders"]')
check('没有维度归因、没有输入时照实说', (await orders.locator('[data-compare-attr-none]').count()) === 1
&& (await orders.locator('[data-compare-inputs-none]').count()) === 1)
// 归不了因(查询结果截断):服务端给空的 rows 和一句说明,只写说明,不画空表
await page.locator('tr[data-compare-metric="caliber:visitors"] button[data-compare-expand]').click()
const visitors = page.locator('tr[data-compare-detail="caliber:visitors"]')
check('归不了因:写服务端的说明,不画空表', (await visitors.locator('[data-compare-attr-note]').innerText()).includes('被截断')
&& await visitors.locator('[data-compare-attr] table').count() === 0 && await visitors.locator('[data-compare-attr-total]').count() === 0)
check('页面没有报错', errors.length === 0, errors.join(';'))
})
await section('归因超过 10 项时只列前 10 项', async () => {
const { page } = await open(comparePath(T, B), { compare: (t, b) => {
const data = comparison(t, b)
return { json: { ...data, metrics: data.metrics.map((m) => (m.metric_id === 'gmv'
? { ...m, attribution: { ...m.attribution, rows: ALL_ROWS, note: null } } : m)) } }
} })
await settled(page)
await page.locator('tr[data-compare-metric="caliber:gmv"] button[data-compare-expand]').click()
const detail = page.locator('tr[data-compare-detail="caliber:gmv"]')
check('只列 10 项', await detail.locator('[data-compare-attr] tr[data-compare-attr-row]').count() === 10)
check('写明另有几项没列', (await detail.locator('[data-compare-attr-more]').innerText()).includes('另有 2 项'))
})
await section('摘要筛选', async () => {
const { page } = await open(comparePath(T, B))
await settled(page)
@@ -460,6 +498,10 @@ await section('查询', async () => {
check('高亮本次独有的部分', add.includes('paid_at') && add.includes("r.status = 'done'") && !add.includes('FROM'), add)
check('相同的部分不高亮', !(await baseSql.locator('mark').allInnerTexts()).join(' ').includes('refunds'))
check('截断照实标', (await refund.locator('[data-compare-rows]').innerText()).includes('已截断'))
check('结构变:列出服务端说的差在哪', JSON.stringify(await refund.locator('[data-compare-differences] li').allInnerTexts())
=== JSON.stringify(QUERIES[1].differences))
check('快照读不出来:照服务端的说明写在那一条上', (await page.locator('[data-compare-query="member:0"] [data-compare-query-problem]').innerText())
=== '基准运行的查询快照无法读取')
const pull = page.locator('[data-compare-query="pull:0"]')
const lits = await pull.locator('[data-compare-literals] li').allInnerTexts()
check('只参数变:列出变化的参数', lits.length === 2 && lits[0].includes("'2026-09-21'") && lits[0].includes("'2026-09-28'")
@@ -486,9 +528,13 @@ await section('模板与说明', async () => {
await settled(page)
check('模板:版本 v3 → v4', (await text(page, '[data-compare-versions]')).includes('v3') && (await text(page, '[data-compare-versions]')).includes('v4'))
const node = page.locator('[data-compare-node="refund"]')
check('变化的节点:名字、变化类型、配置项', (await node.getAttribute('data-change')) === 'config' && (await node.innerText()).includes('退货明细')
&& (await node.innerText()).includes('配置变更')
&& JSON.stringify(await node.locator('[data-compare-key]').allInnerTexts()) === JSON.stringify(['sql', 'timeout']))
check('变化的节点:名字、变化类型、配置项(用服务端给的叫法,键名在悬停提示里)', (await node.getAttribute('data-change')) === 'config'
&& (await node.innerText()).includes('退货明细') && (await node.innerText()).includes('配置变更')
&& JSON.stringify(await node.locator('[data-compare-key]').allInnerTexts()) === JSON.stringify(['SQL', '超时(秒)'])
&& JSON.stringify(await node.locator('[data-compare-key]').evaluateAll((els) => els.map((e) => e.getAttribute('title'))))
=== JSON.stringify(['sql', 'timeout']))
check('没给叫法的配置项照写键名', JSON.stringify(await page.locator('[data-compare-node="caliber"] [data-compare-key]').allInnerTexts())
=== JSON.stringify(['metrics']))
check('新增、移除的节点写明', (await text(page, '[data-compare-node="newbie"]')).includes('新增节点')
&& (await text(page, '[data-compare-node="member"]')).includes('移除节点'))
check('说明照实列出', JSON.stringify(await texts(page, '[data-compare-notes] li')) === JSON.stringify([NOTE]))
@@ -584,6 +630,11 @@ await section('出错与找不到基准', async () => {
await settled(lone.page)
check('手动选了之后照常对比', new URL(lone.page.url()).searchParams.get('base') === B2 && compareHits(lone.log, T, B2) === 1)
const missing = await open(comparePath(T), { base: () => ({ status: 404, json: { detail: '运行记录不存在', code: 'run_not_found' } }) })
await settled(missing.page, 'error')
check('找基准时本次就不存在:写运行记录不存在', await missing.page.locator('[data-compare-error="404"]').count() === 1
&& (await missing.page.locator('[data-compare-error-title]').innerText()) === '运行记录不存在')
// 只带本次、找得到:自己补上基准,写明怎么选的
const auto = await open(comparePath(T))
await settled(auto.page)