运行对比页加「分析卡」一区(服务端 compare 的 analyses):两次运行的分析卡按节点和分析项对齐,一项一行——分析卡的名字和项的标识、算子的叫法(契约)、两次的判定语照原样(没有的一边写「—」,算不了的照判定语写原因)、变了没有(判定一致 / 判定变了 / 判定一致而判定语里的数变了 / 这次新增 / 这次没有,按服务端比出来的代码挑说法和颜色);只列判定,不拆变化来源;两次都没有分析卡(老服务端没有这一项)时不画。check-compare 加一节:先后照服务端、几种变化、新增与没有、老服务端不画、窄屏不溢出;夹具的形状照服务端真算的(compare.compare 跑虚构周报加分析卡)

This commit is contained in:
ThearchyHelios
2026-10-21 18:38:48 +08:00
parent ee923ef6f8
commit 468f670f89
3 changed files with 114 additions and 0 deletions
+2
View File
@@ -6,6 +6,7 @@ import { RUN_COMPARE_TEXT } from '../../lib/terms'
import type { RunComparison } from '../../types' import type { RunComparison } from '../../types'
import { useCandidates, useComparison, useDefaultBase, useRunRecord } from './compare/data' import { useCandidates, useComparison, useDefaultBase, useRunRecord } from './compare/data'
import { comparePath, type CompareVia } from './compare/entry' import { comparePath, type CompareVia } from './compare/entry'
import { Analyses } from './compare/Analyses'
import { Metrics } from './compare/Metrics' import { Metrics } from './compare/Metrics'
import { compareErrorKind, isGone } from './compare/model' import { compareErrorKind, isGone } from './compare/model'
import { CompareError, type CompareErrorContext } from './compare/parts' import { CompareError, type CompareErrorContext } from './compare/parts'
@@ -124,6 +125,7 @@ function Comparison({ data }: { data: RunComparison }) {
<Summary summary={data.summary} filter={filter} onFilter={setFilter} /> <Summary summary={data.summary} filter={filter} onFilter={setFilter} />
<NotesSection notes={data.notes ?? []} /> <NotesSection notes={data.notes ?? []} />
<Metrics metrics={data.metrics ?? []} filter={filter} onClearFilter={() => setFilter(null)} /> <Metrics metrics={data.metrics ?? []} filter={filter} onClearFilter={() => setFilter(null)} />
<Analyses analyses={data.analyses} />
<Queries queries={data.queries ?? []} /> <Queries queries={data.queries ?? []} />
<TemplateSection template={data.template} /> <TemplateSection template={data.template} />
</> </>
@@ -0,0 +1,57 @@
import { useId } from 'react'
import { ANALYSIS_TEXT, analysisMethodLabel } from '../../../lib/termsAnalysis'
import type { RunCompareAnalysis, RunCompareAnalysisSide } from '../../../types'
import { Block } from './parts'
const C = ANALYSIS_TEXT.compare
/**
* 分析卡区(engine/compare._analyses):两次运行的分析卡按节点和分析项对齐,一项一行——算子的叫法、两次的判定语、变了没有。
* 只列出两次的判定,不拆变化来源(方案 W1-c 的最小可用)。判定语、判定都是服务端给的原样;变化(same / verdict / phrase /
* added / removed)是服务端比出来的,界面只按它挑颜色。两次运行都没有分析卡(老服务端没有这一项)时不画这一区
*/
export function Analyses({ analyses }: { analyses?: RunCompareAnalysis[] }) {
const id = useId()
if (!analyses?.length) return null
return (
<Block id={`${id}-analyses`} title={C.title} count={String(analyses.length)} hint={C.hint} attr={{ 'data-compare-analyses': '' }}>
<ul className="divide-y">
{analyses.map((a) => <AnalysisItem key={`${a.node_id}:${a.item}`} a={a} />)}
</ul>
</Block>
)
}
function Side({ label, side }: { label: string; side: RunCompareAnalysisSide | null }) {
return (
<div className="min-w-0" data-compare-analysis-side={label}>
<div className="text-2xs text-faint">{label}</div>
<div className="text-xs [overflow-wrap:anywhere]" style={side?.status === 'refused' ? { color: 'var(--st-waiting)' } : undefined}>
{side ? (side.phrase || C.none) : C.none}
</div>
</div>
)
}
function AnalysisItem({ a }: { a: RunCompareAnalysis }) {
const changed = a.change !== 'same'
const color = a.change === 'verdict' ? 'var(--warn)' : a.change === 'added' ? 'var(--accent)' : 'var(--text-dim)'
return (
<li className="py-2.5 first:pt-0 last:pb-0" data-compare-analysis={`${a.node_id}:${a.item}`} data-change={a.change}>
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span className="min-w-0 text-xs font-medium [overflow-wrap:anywhere]" title={a.node_id}>{C.item(a.title || a.node_id, a.item)}</span>
<span className="text-2xs text-faint">{analysisMethodLabel(a.op, a.mode)}</span>
<span className="chip shrink-0" data-compare-analysis-change="" style={{
color, borderColor: `color-mix(in srgb, ${color} 40%, transparent)`,
background: changed && a.change === 'verdict' ? `color-mix(in srgb, ${color} 12%, transparent)` : undefined,
}}>
{C.change[a.change] ?? a.change}
</span>
</div>
<div className="mt-1.5 grid grid-cols-2 gap-3 max-sm:grid-cols-1">
<Side label={C.base} side={a.base} />
<Side label={C.target} side={a.target} />
</div>
</li>
)
}
+55
View File
@@ -180,6 +180,28 @@ const NOTE = '基准运行中「退货明细」的查询结果已截断(只保
/** 以更早的一次(B2)为基准时多一条说明:换基准、过时答复的检查靠它认出页面上是哪一份 */ /** 以更早的一次(B2)为基准时多一条说明:换基准、过时答复的检查靠它认出页面上是哪一份 */
const NOTE_B2 = '以更早的一次运行为基准' const NOTE_B2 = '以更早的一次运行为基准'
/**
* 分析卡(分析能力一轮,engine/compare._analyses):两次运行的分析卡按节点和项的标识对齐。形状照服务端真算出来的(用
* tests/fixtures/compare_runs 的虚构周报加分析卡的 analysis.end 跑 compare.compare):判定变了两项、判定一样判定语的数变了
* 一项、这次新增(算不了)一项、这次没有一项
*/
const side = (verdict, direction, phrase, status = 'ok', item = '') => ({ status, verdict, direction, phrase, artifact: `art-${item}-${verdict}` })
const ANALYSES = [
{ node_id: 'insight', title: '异动分析', item: 'wow', op: 'baseline', mode: null, change: 'verdict',
base: side('within_band', 'down', '销售额下降 3.1%,在过去 8 周的常规波动范围内(-6.2% 至 5.8%)', 'ok', 'wow'),
target: side('outside_band', 'down', '销售额下降 9.8%,超出过去 8 周的常规波动范围(-6.2% 至 5.8%)', 'ok', 'wow') },
{ node_id: 'insight', title: '异动分析', item: 'where', op: 'decompose', mode: 'additive', change: 'verdict',
base: side('driver', 'down', '销售额下降 340(-12.6%),主要来自华东(-300,占 88.2%)', 'ok', 'where'),
target: side('dispersed', 'down', '销售额下降 120(-4.5%),分散在各地区,没有哪一段占到一半', 'ok', 'where') },
{ node_id: 'insight', title: '异动分析', item: 'trend', op: 'trend', mode: null, change: 'phrase',
base: side('rising', 'up', '过去 8 周线上销售额呈上升趋势(每周约 2.1%)', 'ok', 'trend'),
target: side('rising', 'up', '过去 8 周线上销售额呈上升趋势(每周约 2.4%)', 'ok', 'trend') },
{ node_id: 'insight', title: '异动分析', item: 'refund', op: 'differ', mode: 'rate', change: 'added', base: null,
target: side(null, null, '差异检验没有做成:两组各只有 1 个样本,拿不到 p 值', 'refused', 'refund') },
{ node_id: 'insight', title: '异动分析', item: 'gone', op: 'comparable', mode: null, change: 'removed',
base: side('comparable', null, '两期可比', 'ok', 'gone'), target: null },
]
function comparison(target, base) { function comparison(target, base) {
return { target: refOf(target), base: refOf(base), summary: SUMMARY, template: TEMPLATE, metrics: METRICS, queries: QUERIES, return { target: refOf(target), base: refOf(base), summary: SUMMARY, template: TEMPLATE, metrics: METRICS, queries: QUERIES,
notes: base === B2 ? [NOTE, NOTE_B2] : [NOTE] } notes: base === B2 ? [NOTE, NOTE_B2] : [NOTE] }
@@ -1418,6 +1440,39 @@ const overflow = (page) => page.evaluate(() => {
return { root: root.scrollWidth - root.clientWidth, right: Math.round(r.right - innerWidth), wide: wide.slice(0, 3) } return { root: root.scrollWidth - root.clientWidth, right: Math.round(r.right - innerWidth), wide: wide.slice(0, 3) }
}) })
await section('分析卡:两次的判定按节点和分析项对齐,变了没有照服务端', async () => {
const { page, errors } = await open(comparePath(T, B), { compare: (t, b) => ({ json: { ...comparison(t, b), analyses: ANALYSES } }) })
await settled(page)
const box = page.locator('[data-compare-analyses]')
check('分析卡一区:标题、项数、说明(只列判定,不拆变化来源)', (await box.innerText().catch(() => '')).includes('分析卡')
&& (await box.innerText()).includes('不拆变化来源'), (await box.innerText().catch(() => '')).slice(0, 80))
const rows = box.locator('[data-compare-analysis]')
check('一项一行,先后照服务端', JSON.stringify(await rows.evaluateAll((els) => els.map((e) => e.dataset.compareAnalysis)))
=== JSON.stringify(ANALYSES.map((a) => `${a.node_id}:${a.item}`)))
const row = (item) => box.locator(`[data-compare-analysis="insight:${item}"]`)
const wow = (await row('wow').innerText()).replace(/\s+/g, ' ')
check('判定变了:写「判定变了」,两边的判定语照原样', wow.includes('判定变了') && wow.includes('在过去 8 周的常规波动范围内')
&& wow.includes('超出过去 8 周的常规波动范围'), wow)
check('……标题写分析卡的名字和项的标识、算子的叫法(契约)', wow.includes('异动分析 · wow') && wow.includes('波动基线')
&& (await row('where').innerText()).includes('变化拆解(可加)'))
check('判定一样、判定语里的数变了', (await row('trend').innerText()).includes('判定一致,判定语里的数变了'))
const added = (await row('refund').innerText()).replace(/\s+/g, ' ')
check('这次新增的一项:基准一边写「—」,算不了的判定语照原样', added.includes('这次新增') && added.includes('—')
&& added.includes('差异检验没有做成'), added)
check('这次没有的一项', (await row('gone').innerText()).includes('这次没有'))
check('……变化的说法用服务端的代码挑(data-change)', JSON.stringify(await rows.evaluateAll((els) => els.map((e) => e.dataset.change)))
=== JSON.stringify(ANALYSES.map((a) => a.change)))
check('没有运行时报错', errors.length === 0, errors.join(' | '))
// 老服务端、两次都没有分析卡:不画这一区
const old = await open(comparePath(T, B))
await settled(old.page)
check('没有分析卡(老服务端没有这一项):不画这一区', await old.page.locator('[data-compare-analyses]').count() === 0)
const narrow = await open(comparePath(T, B), { width: 390, height: 900, compare: (t, b) => ({ json: { ...comparison(t, b), analyses: ANALYSES } }) })
await settled(narrow.page)
const o = await overflow(narrow.page)
check('窄屏:分析卡一区不撑出横向滚动', o.root <= 0 && o.right <= 0, JSON.stringify(o))
})
await section('窄屏', async () => { await section('窄屏', async () => {
for (const theme of ['dark', 'light']) { for (const theme of ['dark', 'light']) {
const { page, errors } = await open(comparePath(T, B), { width: 390, height: 844, theme }) const { page, errors } = await open(comparePath(T, B), { width: 390, height: 844, theme })