From c7a91164245f36e40973314ddced140104e8f425 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 10:16:15 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=90=E8=A1=8C=E5=AF=B9=E6=AF=94=E9=A1=B5?= =?UTF-8?q?=E5=A4=8D=E6=A0=B8=E4=BF=AE=E6=AD=A3=EF=BC=88=E7=AC=AC=201=20?= =?UTF-8?q?=E8=BD=AE=EF=BC=89=EF=BC=9A=E7=85=A7=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E7=9A=84=E5=AE=9E=E9=99=85=E8=BE=93=E5=87=BA=E6=94=B9=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E7=BC=96=E5=8F=B7=E3=80=81=E6=97=A0=E6=B3=95=E6=AF=94?= =?UTF-8?q?=E8=BE=83=E7=9A=84=E5=8E=9F=E5=9B=A0=E3=80=81=E8=AF=BB=E4=B8=8D?= =?UTF-8?q?=E5=88=B0=E7=9A=84=E5=9F=BA=E5=87=86=E5=92=8C=E6=9C=AA=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E5=B7=A5=E4=BD=9C=E6=B5=81=E7=9A=84=E8=AF=B4=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 查询区不再拿服务端的 (node_id, ordinal) 当键和编号:配上的一对取本次的序号、落单的基准查询取基准的序号, Agent 换了查询先后时两者会撞上,React 报键冲突,「第 N 条」重复或缺号。改按同一节点在列表里出现的先后编号, 键是「节点:第几」。 - 「拆分或原因」一栏:无法比较、新增、移除的指标,服务端只把原因放在 reasons 里,这一栏原来只剩「—」,现在写 reasons 的第一句。不能拆的「为什么没拆」也写进展开的明细,窄屏上那一格横着滚也看不全。服务端有意不做维度归因 的几类,写明为什么不做。检查夹具改成服务端的实际形状(decomposition_note 为 null):原来那一项是靠编出来的字段 通过的。 - 换基准下拉:当前的基准读不到时单列一项,写短 id 和「记录不存在」或「无法读取」,不再显示成另一次运行被选中、 选它又没反应。本次的记录读不到时写明原因并给重试,不再一直停在「正在读取」。运行卡读取失败也给重试,记录不存在 的照实写。 - 不属于已保存工作流的运行(没保存过,或工作流已删除):「与上一次对比」不可点并写明原因,对比页也不再叫人去上方 选基准。服务端对这两种运行都不比。 - 错误说明:409 按机读码分「尚未结束并封存」和「封存核对不一致」,去掉界面上没有的「封存凭证条」。基准不存在时 提示在上方另选。找不到默认基准时,下拉里有能选的运行才说「可在上方选择」。 - 菜单查找基准超过 300 毫秒就挂一条提示;离开运行详情时取消请求,晚到的答复不再把人拽到对比页。 - 一边的 SQL 拿不到时不做逐词比较,另一边不再整条标成「独有」。SQL 注释整段算一个词,注释里的撇号不再吞掉后面的 语句。 - 分类、结构的悬停说明照服务端的分类口径改写;查询按「条」计数,和「第 N 条」一致。 - check-compare 按服务端的实际输出补上这些场景,并加段过滤 COMPARE_ONLY;check-all 里 compare 的耗时估计按实测 改为 45 秒。 --- frontend/src/lib/terms.ts | 49 ++- frontend/src/pages/runs/ComparePage.tsx | 20 +- frontend/src/pages/runs/compare/Metrics.tsx | 35 +- frontend/src/pages/runs/compare/Queries.tsx | 55 ++-- frontend/src/pages/runs/compare/RunPair.tsx | 64 +++- frontend/src/pages/runs/compare/entry.tsx | 47 ++- frontend/src/pages/runs/compare/model.ts | 55 +++- frontend/src/pages/runs/compare/parts.tsx | 72 ++++- frontend/src/pages/runs/compare/sqlDiff.ts | 8 +- scripts/check-all.mjs | 2 +- scripts/check-compare.mjs | 336 +++++++++++++++++--- 11 files changed, 610 insertions(+), 133 deletions(-) diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 1ce0fe37..ad6de84c 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -2751,8 +2751,11 @@ export const RUN_COMPARE_TEXT = { // 入口:运行详情的「更多」菜单 menu: '与上一次对比', menuSealedOnly: '运行结束并封存后才能对比', + /** 没有 workflow_id 的运行:画布、问数据里没保存就运行的,或者工作流删掉之后被置空的。服务端只比同一个已保存的工作流 */ + menuNoWorkflow: '工作流未保存或已删除,无法对比', menuFinding: '正在查找上一次运行…', pickManually: '手动选择基准', + retry: '重试', // 顶部的两次运行 base: '基准', target: '本次', @@ -2763,6 +2766,7 @@ export const RUN_COMPARE_TEXT = { unsealed: '未封存', unsealedHint: '这次运行没有封存清单,不能参与对比', runFailed: '无法读取这次运行', + runMissing: '运行记录不存在', noBase: '未选择', reason: { same_schedule: '默认基准:同一定时任务的上一次运行', @@ -2773,25 +2777,37 @@ export const RUN_COMPARE_TEXT = { pickLoading: '正在读取同一工作流的运行…', pickFailed: '无法读取同一工作流的运行', pickNone: '同一工作流没有其他运行', - pickUnsaved: '临时构建的工作流没有其他运行可选', + pickUnsaved: '工作流未保存或已删除,没有可对比的运行', pickPlaceholder: '选择一次运行作为基准', /** 下拉里的一次运行:时间 · 类别和版本 · 短 id,没封存的另标 */ pickOption: (time: string, cls: string, id: string, sealed: boolean) => `${time} · ${cls} · ${id}${sealed ? '' : '(未封存)'}`, + /** 当前的基准读不到时下拉里的那一项(短 id):如实显示选的是哪一次 */ + pickGone: (id: string, missing: boolean) => `${id}(${missing ? '记录不存在' : '无法读取'})`, // 页面状态 noTarget: '未指定要对比的运行', noTargetHint: '在运行详情的「更多」菜单中选择「与上一次对比」,即可对照本次与上一次运行', toRuns: '前往记录', finding: '正在查找基准运行…', loading: '正在对比两次运行…', - errSealed: '无法对比:有运行未封存或封存核对未通过', - errSealedHint: '两次运行都需已结束并封存,且封存核对一致。可在运行详情的封存凭证条上核对', + // 对比失败:标题说是哪一种,服务端的原因原样跟在后面,最后一句下一步只写界面上真有的入口,服务端已经写了的不再重复 + errSealed: '无法对比:有运行未封存或封存核对不一致', + errUnsealed: '无法对比:有运行尚未结束并封存', + errSealBroken: '无法对比:封存核对不一致', + /** 409 时不知道(或者知道不是)本次的问题:服务端的原因里写着是哪一次 */ + errSealedHint: '若是基准运行的问题,可在上方更换基准', errMismatch: '无法对比这两次运行', - errMismatchHint: '请选择同一工作流的另一次运行作为基准', + errMismatchHint: '可在上方选择同一工作流的另一次运行作为基准', + /** 本次没有 workflow_id:哪一次都比不了,不给下一步 */ + errUnsaved: '无法对比:本次运行的工作流未保存或已删除', errMissing: '运行记录不存在', errMissingHint: '链接可能已失效,请从记录页重新打开', + /** 本次的记录读得到、对比却回 404:缺的是基准 */ + errBaseMissingHint: '可在上方选择另一次运行作为基准', errNoBase: '未找到可作为基准的运行', errNoBaseHint: '可在上方选择同一工作流的其他运行作为基准', + /** 下拉里一次能选的都没有(只有本次、或者都没封存) */ + errNoBaseNone: '同一工作流还没有其他已封存的运行', viewBase: '查看基准运行', viewTarget: '查看本次运行', // 摘要 @@ -2807,11 +2823,12 @@ export const RUN_COMPARE_TEXT = { incomparable: '无法比较', unchanged: '未变', } as Record, + // 照服务端(engine/compare.py)的分类口径写:口径变了含小数位、单位、显示格式,SQL 结构变含上游节点改配置 classHint: { data_only: '口径与 SQL 都没有改动,变化来自数据', - caliber_and_data: '口径卡的计算式有改动,数据也变了;能严格拆分的分别给出口径效应和数据效应', - sql_changed: '所依据的查询 SQL 结构有改动,口径不同,不做拆分', - params_only: '所依据的查询 SQL 结构相同,只有参数(如日期)不同', + caliber_and_data: '口径(计算式、口径版本、小数位、单位或显示格式)有改动,数据可能也有变化;能严格拆分的分别给出口径效应和数据效应', + sql_changed: '取数的 SQL 或上游节点的配置有改动,无法在口径与数据之间拆分', + params_only: '所依据的查询 SQL 结构相同,只有参数(如日期)或运行输入不同', added: '本次有、基准没有的指标', removed: '基准有、本次没有的指标', incomparable: '缺少比较所需的信息,无法判断变化来自哪里', @@ -2824,6 +2841,8 @@ export const RUN_COMPARE_TEXT = { metrics: '指标', metricsEmpty: '两次运行的口径卡上都没有指标', count: (n: number) => `${formatNumber(n)} 个`, + /** 查询按「条」数,和「第 N 条」一致 */ + queryCount: (n: number) => `${formatNumber(n)} 条`, colMetric: '指标', colBase: '基准', colTarget: '本次', @@ -2850,6 +2869,17 @@ export const RUN_COMPARE_TEXT = { attrMore: (n: number) => `另有 ${formatNumber(n)} 项未列出`, attrTotal: (value: string) => `合计变化 ${value}`, attrNone: '没有可按维度归因的明细', + /** + * 服务端只对只数据变、只参数变、口径与数据都变的指标做维度归因(两边的行说的是同一回事)。别的类不是「没有明细」, + * 是有意不做:写明为什么 + */ + attrSkipped: { + sql_changed: '取数的 SQL 或上游配置有改动,两边的行不能直接对照,不做维度归因', + incomparable: '无法判断变化来源,不做维度归因', + added: '基准没有这个指标,不做维度归因', + removed: '本次没有这个指标,不做维度归因', + unchanged: '数值没有变化,不做维度归因', + } as Record, /** 服务端没给占比、也没说为什么时的说法 */ attrNoShare: '无法计算各项占比', colKey: '维度', @@ -2867,7 +2897,8 @@ export const RUN_COMPARE_TEXT = { structureHint: { same: 'SQL 完全相同', params_only: 'SQL 结构相同,只有字面量不同', - changed: 'SQL 结构不同:表、连接、筛选条件、分组或聚合有改动', + // 服务端在只换了数据源、有一边的 SQL 读不出来时也标「结构变」 + changed: 'SQL 结构或数据源不同,或有一边的 SQL 无法读取', added: '本次新增的查询,基准中没有', removed: '基准中有、本次没有的查询', } as Record, @@ -2883,6 +2914,8 @@ export const RUN_COMPARE_TEXT = { baseSql: '基准的 SQL', targetSql: '本次的 SQL', bothSql: '两次相同的 SQL', + /** 快照读不出来、又拿不到执行时的参数:这一边没有 SQL,不能拿空串去和另一边比 */ + sqlUnreadable: '这一边的 SQL 无法读取', onlyBase: '基准独有', onlyTarget: '本次独有', showSql: '查看 SQL', diff --git a/frontend/src/pages/runs/ComparePage.tsx b/frontend/src/pages/runs/ComparePage.tsx index 30b9e8cf..2e28c161 100644 --- a/frontend/src/pages/runs/ComparePage.tsx +++ b/frontend/src/pages/runs/ComparePage.tsx @@ -7,8 +7,8 @@ import type { RunComparison } from '../../types' import { useCandidates, useComparison, useDefaultBase, useRunRecord } from './compare/data' import { comparePath, type CompareVia } from './compare/entry' import { Metrics } from './compare/Metrics' -import { compareErrorKind } from './compare/model' -import { CompareError } from './compare/parts' +import { compareErrorKind, isGone } from './compare/model' +import { CompareError, type CompareErrorContext } from './compare/parts' import { Queries } from './compare/Queries' import { RunPair } from './compare/RunPair' import { Summary } from './compare/Summary' @@ -51,6 +51,8 @@ export function ComparePage() { const workflowId = targetRun.state === 'ok' ? targetRun.data?.workflow_id : lastWorkflow.current const candidates = useCandidates(workflowId) const cmp = useComparison(target, base) + // 不属于已保存的工作流:没保存过,或者工作流删掉之后 workflow_id 被置空(名字还是原来的)。服务端两种都不比 + const unsaved = targetRun.state === 'ok' && !workflowId const go = (t: string, b: string) => navigate(comparePath(t, b), { replace: true }) @@ -59,6 +61,16 @@ export function ComparePage() { else if (!base) state = finder.state === 'error' ? 'error' : 'finding' else state = cmp.state === 'ok' ? 'ok' : cmp.state === 'error' ? 'error' : 'loading' + // 错误说明的下一步按页面已经知道的挑(parts.tsx 的 errorCopy):下拉里没有能选的就不叫人去上方选 + const errorContext: CompareErrorContext = { + targetKnown: targetRun.state === 'ok', + targetUnsaved: unsaved, + targetUnsealed: targetRun.state === 'ok' && !targetRun.data?.manifest_hash, + targetGone: targetRun.state === 'error' && isGone(targetRun.error), + baseGone: baseRun.state === 'error' && isGone(baseRun.error), + pickable: candidates.state === 'ok' ? (candidates.data ?? []).some((r) => r.id !== target && !!r.manifest_hash) : null, + } + return (
} title={RUN_COMPARE_TEXT.title} subtitle={RUN_COMPARE_TEXT.hint} /> @@ -70,7 +82,7 @@ export function ComparePage() {
go(target, b)} onSwap={() => { if (base) go(base, target) }} /> {state === 'finding' && } @@ -80,7 +92,7 @@ export function ComparePage() { kind={compareErrorKind(base ? cmp.error : finder.error, !base)} error={base ? cmp.error : finder.error} onRetry={base ? cmp.reload : finder.reload} - target={target} base={base} + target={target} base={base} context={errorContext} /> )} {state === 'ok' && cmp.data && } diff --git a/frontend/src/pages/runs/compare/Metrics.tsx b/frontend/src/pages/runs/compare/Metrics.tsx index 319c6bee..67486653 100644 --- a/frontend/src/pages/runs/compare/Metrics.tsx +++ b/frontend/src/pages/runs/compare/Metrics.tsx @@ -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 { changeText, metricKey, sameValue, shareText, signedNumber, valueText } from './model' +import { changeText, metricKey, sameValue, shareText, signedNumber, splitReason, valueText } from './model' /** 维度归因列前几项:后端按变化从大到小排好,多的只写还有几项 */ const ATTR_TOP = 10 @@ -20,7 +20,7 @@ const STICKY = 'sticky left-0 z-[1] min-w-[9em] shadow-[inset_-1px_0_0_var(--bor /** * 指标表:基准 → 本次、变化量和百分比(渲染值照服务端给的写)、分类、能严格拆分的口径效应和数据效应(并列的数加一段 - * 两截的条形),不能拆的写服务端给的原因。每行能展开:变化说明、输入对比、维度归因前 10 项。 + * 两截的条形),不能拆、比不了的写原因(splitReason)。每行能展开:变化说明、输入对比、维度归因前 10 项。 * * 表格比窄屏宽,在自己的框里横向滚动(框能用键盘聚焦),页面本身不横向溢出。展开的明细贴着框的左边、宽度等于框的 * 可见宽度:窄屏上不用横着滚也读得全 @@ -104,6 +104,7 @@ 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 ? changeText(m.delta_pct) : null + const why = m.decomposition ? null : splitReason(m) return ( @@ -129,9 +130,10 @@ function MetricRows({ m, k, detailId, open, onToggle }: { {m.decomposition ? ( - ) : m.decomposition_note ? ( -

- {m.decomposition_note} + ) : why ? ( +

+ {why}

) : ( {NONE} @@ -199,7 +201,11 @@ function Split({ d, total }: { d: RunCompareDecomposition; total: string }) { ) } -/** 展开的明细:变化说明、拆分写成一句话、输入对比、维度归因。贴着滚动框的左边,宽度等于框的可见宽度 */ +/** + * 展开的明细:变化说明、拆分写成一句话(不能拆的写服务端说的为什么)、输入对比、维度归因。贴着滚动框的左边,宽度 + * 等于框的可见宽度:窄屏上表格里「拆分或原因」那一格比钉在左边的指标名右侧剩下的地方宽,横着滚到哪儿都看不全, + * 这里读得全 + */ function MetricDetail({ m }: { m: RunCompareMetric }) { return (
@@ -212,15 +218,17 @@ function MetricDetail({ m }: { m: RunCompareMetric }) {
)} - {m.decomposition && ( + {m.decomposition ? (

{T.splitSum(m.decomposition.caliber_rendered, m.decomposition.data_rendered, totalText(m))}

- )} + ) : m.decomposition_note ? ( +

{m.decomposition_note}

+ ) : null}
- +
) @@ -270,14 +278,17 @@ function Inputs({ m }: { m: RunCompareMetric }) { /** * 维度归因:按键对齐两边查询结果的行,列前 10 项的键、两边的值、变化和占比。占比只在度量列之和等于指标的输入时才有, * 没有时那一栏写「—」,下面照服务端的说明写原因(没给说明时写一句通用的)。归不了因的(查询结果截断、度量列遮罩、 - * 快照读不出来)服务端给空的 rows 和一句说明:不画空表,只写这句话 + * 快照读不出来)服务端给空的 rows 和一句说明:不画空表,只写这句话。服务端有意不做归因的几类(SQL 结构变、无法 + * 比较、新增、移除、未变)写为什么不做,不说成「没有明细」 */ -function Attribution({ a }: { a: RunCompareAttribution | null }) { +function Attribution({ a, classification }: { a: RunCompareAttribution | null; classification: string }) { if (!a) { return (

{T.attribution}

-

{T.attrNone}

+

+ {T.attrSkipped[classification] ?? T.attrNone} +

) } diff --git a/frontend/src/pages/runs/compare/Queries.tsx b/frontend/src/pages/runs/compare/Queries.tsx index 1b982898..a12ec827 100644 --- a/frontend/src/pages/runs/compare/Queries.tsx +++ b/frontend/src/pages/runs/compare/Queries.tsx @@ -4,7 +4,7 @@ import clsx from 'clsx' import { RUN_COMPARE_TEXT as T } from '../../../lib/terms' import type { RunCompareQuery } from '../../../types' import { Block, StructureChip } from './parts' -import { queryKey, queryNumbers, valueText } from './model' +import { queryItems, valueText } from './model' import { diffSql, type DiffPart } from './sqlDiff' /** 基准独有、本次独有的高亮:颜色之外,删去的划线、新加的下划线,色弱、灰度打印也分得出 */ @@ -26,17 +26,14 @@ const MARK = { */ export function Queries({ queries }: { queries: RunCompareQuery[] }) { const id = useId() - const numbers = useMemo(() => queryNumbers(queries), [queries]) + const items = useMemo(() => queryItems(queries), [queries]) return ( - + {!queries.length ? (

{T.queriesEmpty}

) : (
    - {queries.map((q) => { - const k = queryKey(q) - return - })} + {items.map((it) => )}
)}
@@ -91,22 +88,37 @@ function QueryItem({ q, k, nth }: { q: RunCompareQuery; k: string; nth: number | )} - {q.structure === 'changed' && } - {q.structure === 'added' && } - {q.structure === 'removed' && } + {q.structure === 'changed' && } + {q.structure === 'added' && } + {q.structure === 'removed' && } {foldable && showSql && (
{q.structure === 'same' - ? - : } + ? + : }
)} ) } -/** 两条 SQL 并排,逐词标出各自独有的部分 */ -function SideBySide({ base, target }: { base: string; target: string }) { +/** + * 两条 SQL 并排,逐词标出各自独有的部分。有一边拿不到 SQL(快照读不出来、又没有执行时的参数)时不比:拿空串去比, + * 另一边会整条被标成「独有」,像是整条新写的,其实只是不知道。这时两边原样摆着,拿不到的那一边写明无法读取 + */ +function SideBySide({ base, target }: { base: string | null; target: string | null }) { + if (base == null || target == null) { + return ( +
+ + +
+ ) + } + return +} + +function Diffed({ base, target }: { base: string; target: string }) { const diff = useMemo(() => diffSql(base, target), [base, target]) return (
@@ -123,21 +135,26 @@ function SideBySide({ base, target }: { base: string; target: string }) { ) } -/** 一条 SQL。折行显示,不横向撑开;太长时在框里纵向滚动,框能用键盘聚焦 */ +/** + * 一条 SQL。折行显示,不横向撑开;太长时在框里纵向滚动,框能用键盘聚焦。sql 为 null(这一边拿不到 SQL)时 + * 框里写明无法读取,不摆一个空框 + */ function SqlPane({ which, label, sql, parts }: { - which: 'base' | 'target' | 'both'; label: string; sql?: string; parts?: DiffPart[] + which: 'base' | 'target' | 'both'; label: string; sql?: string | null; parts?: DiffPart[] }) { const kind = which === 'base' ? 'del' : 'add' + const missing = !parts && sql == null return (
{label}
-
+      
         {parts
           ? parts.map((p, i) => (p.changed
             ? {p.text}
             : {p.text}))
-          : sql}
+          : missing ? T.sqlUnreadable : sql}
       
) diff --git a/frontend/src/pages/runs/compare/RunPair.tsx b/frontend/src/pages/runs/compare/RunPair.tsx index 0cc011ee..b32719d8 100644 --- a/frontend/src/pages/runs/compare/RunPair.tsx +++ b/frontend/src/pages/runs/compare/RunPair.tsx @@ -1,12 +1,13 @@ import { useId, type ReactNode } from 'react' import { Link } from 'react-router-dom' -import { ArrowLeftRight, CalendarClock } from 'lucide-react' +import { ArrowLeftRight, CalendarClock, RotateCw } from 'lucide-react' import { Skeleton, useTicker } from '../../../components/ui' import { formatDateTime, formatTime, shortId } from '../../../lib/format' import { RUN_COMPARE_TEXT as T, runClassLabel, runName } from '../../../lib/terms' import type { RunWithSchedule } from '../../../types' import { ClassChip as RunClassChip } from '../parts' import type { Loaded } from './data' +import { isGone } from './model' /** * 顶部的两次运行:左边基准、右边本次(窄屏上下排),中间「互换」。每张卡写名字、时间、类别和版本,点名字进各自的 @@ -18,7 +19,7 @@ export function RunPair({ target, base, targetRun, baseRun, candidates, unsaved, targetRun: Loaded baseRun: Loaded candidates: Loaded - /** 本次的工作流没保存:没有同一工作流的其他运行可选 */ + /** 本次不属于已保存的工作流(没保存过、或者工作流已删除):没有同一工作流的其他运行可选 */ unsaved: boolean /** 默认基准是怎么选的(只在从「与上一次对比」、或只带本次打开时有) */ reason: string | null @@ -29,8 +30,8 @@ export function RunPair({ target, base, targetRun, baseRun, candidates, unsaved,
- +
+ ) +} + /** * 换基准。列的是运行列表接口按工作流取回的最近几次(新的在前),去掉本次;没封存的列出来但不能选,写明原因。 - * 当前的基准不在这几次里(更早、或者属于别的工作流)时也放进去,下拉不至于显示成别的运行 + * + * 当前的基准不在这几次里(更早、属于别的工作流、读不到)时也单列一项:受控的下拉值对不上任何选项时浏览器会选中 + * 第一项,看着像另一次运行被选中,而原生下拉再选当前显示的那一项不触发 change,最自然的补救点了没反应。读不到的 + * 写短 id 加原因,下拉如实反映当前选的是哪一次,其余每一项都能触发 change。 + * + * 列不出候选时说清楚、给重试,不停在「正在读取」:同一工作流的运行没取到;或者本次的记录没读到,不知道是哪个工作流 + * (候选的键是空的,不会自己去取) */ -function BasePicker({ target, base, baseRun, candidates, unsaved, onPick }: { - target: string; base: string | null; baseRun?: RunWithSchedule; candidates: Loaded; unsaved: boolean - onPick: (id: string) => void +function BasePicker({ target, base, baseRun, targetRun, candidates, unsaved, onPick }: { + target: string; base: string | null; baseRun: Loaded; targetRun: Loaded + candidates: Loaded; unsaved: boolean; onPick: (id: string) => void }) { const id = useId() const now = useTicker(60_000) - if (unsaved) return

{T.pickUnsaved}

- if (candidates.state === 'error') return

{T.pickFailed}

+ if (unsaved) return

{T.pickUnsaved}

+ const failed = candidates.state === 'error' ? candidates.reload + : candidates.state === 'idle' && targetRun.state === 'error' ? targetRun.reload : null + if (failed) { + return ( +
+ {T.pickFailed} + +
+ ) + } const loading = candidates.state !== 'ok' const list = (candidates.data ?? []).filter((r) => r.id !== target) - if (base && baseRun && !list.some((r) => r.id === base)) list.unshift(baseRun) - if (!loading && !list.length) return

{T.pickNone}

+ if (!loading && !list.length) return

{T.pickNone}

const label = (r: RunWithSchedule) => T.pickOption(formatTime(r.started_at ?? r.created_at ?? null, new Date(now)), runClassLabel(r.run_class, r.version), shortId(r.id), !!r.manifest_hash) + const current = base && !list.some((r) => r.id === base) ? base : null + const currentLabel = (b: string) => (baseRun.data ? label(baseRun.data) + : baseRun.state === 'error' ? T.pickGone(shortId(b), isGone(baseRun.error)) : shortId(b)) return (
@@ -123,6 +154,7 @@ function BasePicker({ target, base, baseRun, candidates, unsaved, onPick }: { aria-describedby={`${id}-hint`} onChange={(e) => { if (e.target.value) onPick(e.target.value) }}> {loading && } {!loading && !base && } + {!loading && current && } {!loading && list.map((r) => ( // 当前的基准即使没封存也要能显示出来(选中项);别的没封存的一律不能选 diff --git a/frontend/src/pages/runs/compare/entry.tsx b/frontend/src/pages/runs/compare/entry.tsx index bbe611f2..22b64843 100644 --- a/frontend/src/pages/runs/compare/entry.tsx +++ b/frontend/src/pages/runs/compare/entry.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { GitCompareArrows } from 'lucide-react' import { ApiError, api } from '../../../api/client' @@ -13,36 +13,69 @@ export interface CompareVia { base: string; reason: string } export const comparePath = (target: string, base?: string | null) => `/runs/compare?${new URLSearchParams(base ? { target, base } : { target })}` +/** 查找超过这么久才挂「正在查找」的提示:答复快的不闪一下 */ +const NOTE_DELAY_MS = 300 + /** * 运行详情「更多」菜单里的「与上一次对比」。先问服务端默认的基准(同一定时任务的上一次,没有就同一工作流、同一类别的 * 上一次),找到了再跳到对比页;找不到时留在原页,提示服务端给的原因,并给「手动选择基准」去对比页自己挑。 - * 没封存、还在运行的不给点:对比只认已封存的运行,点了也是 409。 + * 这几种不给点,点了也只会被拒:没封存、还在运行的(对比只认已封存的运行,409);不属于已保存工作流的——画布、问数据 + * 里没保存就运行的,和工作流删掉之后 workflow_id 被置空的(服务端找不到同一工作流的运行,手动选也是 422)。 + * + * 菜单点完就收起:查找慢于一眨眼时挂一条提示,接口慢的时候不至于像没反应。离开运行详情(组件卸载)时取消请求、 + * 收起提示,晚到的答复不再把人带去对比页。 * * 写成 hook(返回造菜单项的函数):运行详情里有提前返回,菜单项本身只能在拿到运行之后造,查找中的状态得在那之前就挂上 */ export function useCompareEntry(): (run: Run, live: boolean) => MenuItem { const navigate = useNavigate() const [busy, setBusy] = useState(false) + // 在路上的那一次:stop 收起(或者不再挂出)「正在查找」的提示 + const pending = useRef<{ ctrl: AbortController; stop: () => void } | null>(null) + useEffect(() => () => { + const p = pending.current + pending.current = null + p?.ctrl.abort() + p?.stop() + }, []) return useCallback((run: Run, live: boolean): MenuItem => { - const blocked = live || !run.manifest_hash + const orphan = !run.workflow_id + const unsealed = live || !run.manifest_hash return { key: 'compare', label: T.menu, icon: , - disabled: blocked || busy, - hint: blocked ? T.menuSealedOnly : busy ? T.menuFinding : undefined, + disabled: orphan || unsealed || busy, + hint: orphan ? T.menuNoWorkflow : unsealed ? T.menuSealedOnly : busy ? T.menuFinding : undefined, onSelect: () => { + const ctrl = new AbortController() + let note: number | null = null + const timer = setTimeout(() => { + note = toast.info(T.menuFinding, { sticky: true, key: `compare-finding:${run.id}` }) + }, NOTE_DELAY_MS) + const stop = () => { + clearTimeout(timer) + if (note != null) toast.dismiss(note) + } + pending.current = { ctrl, stop } setBusy(true) - api.compare.base(run.id).then((found) => { + api.compare.base(run.id, { signal: ctrl.signal }).then((found) => { + if (ctrl.signal.aborted) return const via: CompareVia = { base: found.base_run_id, reason: found.reason } navigate(comparePath(run.id, found.base_run_id), { state: via }) }, (e: unknown) => { + if (ctrl.signal.aborted) return if (e instanceof ApiError && e.kind === 'http' && e.status === 404) { toast.info(e.message, { action: { label: T.pickManually, onClick: () => navigate(comparePath(run.id)) } }) } else { toast.error(e) } - }).finally(() => setBusy(false)) + }).finally(() => { + stop() + if (pending.current?.ctrl !== ctrl) return + pending.current = null + setBusy(false) + }) }, } }, [navigate, busy]) diff --git a/frontend/src/pages/runs/compare/model.ts b/frontend/src/pages/runs/compare/model.ts index 58e14092..dee06273 100644 --- a/frontend/src/pages/runs/compare/model.ts +++ b/frontend/src/pages/runs/compare/model.ts @@ -34,8 +34,19 @@ export function classTone(k: string): { color: string; soft: boolean } { /** 一个指标在表里的键:同一张卡里指标 id 唯一,几张卡之间可能重名 */ export const metricKey = (m: Pick) => `${m.card_id}:${m.metric_id}` -/** 一条查询的键:同一节点里按服务端的序号区分 */ -export const queryKey = (q: Pick) => `${q.node_id}:${q.ordinal}` +/** + * 「拆分或原因」一栏没有拆分时写的那句话:服务端写了「为什么没拆」就用它。无法比较、新增、移除的服务端不写这一项, + * 原因只在 reasons 里——退回写 reasons 的第一句(全部在展开的变化说明里),不让这一栏只剩一个「—」、要展开才知道 + * 为什么。SQL 结构变的服务端一向写着,万一没写,reasons 说的就是哪条查询变了,也是不拆的原因。只数据变、只参数变、 + * 未变的本来就用不着拆;口径与数据都变的服务端总会给拆分或原因,它的 reasons 说的是口径改了什么、不是为什么不拆, + * 都不退回 + */ +const REASON_IN_CELL = new Set(['incomparable', 'added', 'removed', 'sql_changed']) + +export function splitReason(m: Pick): string | null { + if (m.decomposition_note) return m.decomposition_note + return REASON_IN_CELL.has(m.classification) && m.reasons?.length ? m.reasons[0] : null +} const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) @@ -92,19 +103,29 @@ export function sameValue(a: unknown, b: unknown): boolean { } } +/** 查询区的一项:位置键(同一对比里唯一)和同一节点里的「第 N 条」(节点只有一条时为 null) */ +export interface QueryItem { + q: RunCompareQuery + key: string + nth: number | null +} + /** - * 同一节点有几条查询时,界面按 1 起数写「第 N 条」。服务端的 ordinal 只用来排先后:它从 0 还是从 1 数、中间有没有 - * 空号(只有一边有的那条),都不该漏到界面上 + * 查询区按服务端给的先后列:服务端已经按本次的先后排好,基准多出来的接在后面。同一节点有几条时,按在列表里出现的 + * 先后从 1 数「第 N 条」,键是「节点:第几」。 + * + * 不拿服务端的 ordinal 当键、也不按它编号:配上的一对取本次的序号,落单的基准查询取基准的序号,Agent 换了查询的先后 + * 时两者会撞上(同一节点两条都是 1),按它当键 React 会报键冲突、编号会重复或缺号 */ -export function queryNumbers(queries: RunCompareQuery[]): Map { - const byNode = new Map() - for (const q of queries) byNode.set(q.node_id, [...(byNode.get(q.node_id) ?? []), q]) - const out = new Map() - for (const list of byNode.values()) { - const sorted = [...list].sort((a, b) => a.ordinal - b.ordinal) - sorted.forEach((q, i) => out.set(queryKey(q), list.length > 1 ? i + 1 : null)) - } - return out +export function queryItems(queries: RunCompareQuery[]): QueryItem[] { + const total = new Map() + for (const q of queries) total.set(q.node_id, (total.get(q.node_id) ?? 0) + 1) + const seen = new Map() + return queries.map((q) => { + const n = (seen.get(q.node_id) ?? 0) + 1 + seen.set(q.node_id, n) + return { q, key: `${q.node_id}:${n}`, nth: (total.get(q.node_id) ?? 0) > 1 ? n : null } + }) } /** @@ -115,6 +136,14 @@ export type CompareErrorKind = '409' | '422' | '404' | 'no-base' | 'other' /** 服务端「运行记录不存在」的机读码(api/evidence.py 的 RUN_NOT_FOUND,运行对比的接口沿用) */ const RUN_NOT_FOUND = 'run_not_found' +/** 409 的两种机读码(api/compare.py):没结束封存、封存核对不一致。标题按它分开写 */ +export const UNSEALED = 'compare_unsealed' +export const SEAL_BROKEN = 'compare_seal_broken' + +/** 请求回的是 404(记录不存在):和连不上、服务端出错分开说,前者重试也没用 */ +export function isGone(e: unknown): boolean { + return e instanceof ApiError && e.kind === 'http' && e.status === 404 +} export function compareErrorKind(e: unknown, finding: boolean): CompareErrorKind { if (!(e instanceof ApiError) || e.kind !== 'http') return 'other' diff --git a/frontend/src/pages/runs/compare/parts.tsx b/frontend/src/pages/runs/compare/parts.tsx index fb493b15..944ef95a 100644 --- a/frontend/src/pages/runs/compare/parts.tsx +++ b/frontend/src/pages/runs/compare/parts.tsx @@ -1,9 +1,10 @@ import type { ReactNode } from 'react' import { Link } from 'react-router-dom' import { AlertTriangle } from 'lucide-react' +import { ApiError } from '../../../api/client' import { ErrorState } from '../../../components/ui' import { RUN_COMPARE_TEXT as T } from '../../../lib/terms' -import { classTone, type CompareErrorKind } from './model' +import { SEAL_BROKEN, UNSEALED, classTone, type CompareErrorKind } from './model' /** 对比页的一节:带标题的卡片。count 写在标题旁(「11 个」),hint 是一句说明 */ export function Block({ id, title, count, hint, children, attr }: { @@ -44,7 +45,7 @@ export function ClassChip({ value }: { value: string }) { ) } -/** 查询的结构状态。结构变了用警示色并铺底(口径不同),新增用强调色,其余中性色 */ +/** 查询的结构状态。结构变了用警示色并铺底(数不能直接比),新增用强调色,其余中性色 */ export function StructureChip({ value }: { value: string }) { const warn = value === 'changed' const color = warn ? 'var(--warn)' : value === 'added' ? 'var(--accent)' : 'var(--text-dim)' @@ -55,19 +56,61 @@ export function StructureChip({ value }: { value: string }) { ) } -const ERROR_TEXT: Record, { title: string; hint: string }> = { - '409': { title: T.errSealed, hint: T.errSealedHint }, - '422': { title: T.errMismatch, hint: T.errMismatchHint }, - '404': { title: T.errMissing, hint: T.errMissingHint }, - 'no-base': { title: T.errNoBase, hint: T.errNoBaseHint }, +/** 页面上已经知道的几件事:错误说明的下一步按它挑,只指向这一刻真走得通的路 */ +export interface CompareErrorContext { + /** 本次的运行记录读到了 */ + targetKnown: boolean + /** 本次不属于已保存的工作流(没保存过、或者工作流已删除):和哪一次都比不了 */ + targetUnsaved: boolean + /** 本次的运行记录上没有封存清单 */ + targetUnsealed: boolean + /** 本次、基准的运行记录是不是 404 */ + targetGone: boolean + baseGone: boolean + /** 换基准的下拉里有没有能选的运行(已封存、不是本次);还不知道(在读、读失败)时为 null */ + pickable: boolean | null } /** - * 对比失败时的说明:先说是哪一种(未封存、不是同一工作流、运行不存在、找不到基准),再原样带上服务端的原因, - * 最后一句下一步。未封存、封存核对不一致的,给两次运行详情的入口:封存凭证条上能核对。别的错误按通用的出错态画 + * 标题和下一步。标题说是哪一种;服务端的原因原样跟在标题后面(CompareError 里),下一步只写界面上真有的入口、 + * 服务端已经写了的不再重复: + * - 409:按机读码分没结束封存、封存核对不一致。本次自己没封存时换基准也没用,不给下一步;否则说出在基准上时可以 + * 在上方换(服务端的原因里写着是哪一次)。去记录页核对的入口是下面两个「查看」按钮,服务端的原因里也写了「核对」; + * - 422:本次不属于已保存的工作流时哪一次都比不了,标题直说、不给下一步;否则在上方另选同一工作流的一次; + * - 404:缺的是本次(targetGone)才是链接失效;缺的是基准(基准的记录 404,或者本次的记录读得到)就在上方另选; + * 还不知道是哪一边时先不说; + * - 找不到默认基准:本次不属于已保存的工作流同 422;下拉里有能选的才说可以在上方选,一次都没有就照实说 */ -export function CompareError({ kind, error, onRetry, target, base }: { +function errorCopy(kind: Exclude, error: unknown, targetGone: boolean, ctx: CompareErrorContext): { + title: string; hint: string | null +} { + const code = error instanceof ApiError ? error.code : undefined + switch (kind) { + case '409': { + const title = code === SEAL_BROKEN ? T.errSealBroken : code === UNSEALED ? T.errUnsealed : T.errSealed + return { title, hint: code === UNSEALED && ctx.targetUnsealed ? null : T.errSealedHint } + } + case '422': + return ctx.targetUnsaved ? { title: T.errUnsaved, hint: null } : { title: T.errMismatch, hint: T.errMismatchHint } + case '404': + return { + title: T.errMissing, + hint: targetGone ? T.errMissingHint : ctx.baseGone || ctx.targetKnown ? T.errBaseMissingHint : null, + } + case 'no-base': + if (ctx.targetUnsaved) return { title: T.errUnsaved, hint: null } + return { title: T.errNoBase, hint: ctx.pickable == null ? null : ctx.pickable ? T.errNoBaseHint : T.errNoBaseNone } + } +} + +/** + * 对比失败时的说明:先说是哪一种(未封存、封存核对不一致、不是同一工作流、运行不存在、找不到基准),再原样带上 + * 服务端的原因,最后一句下一步(errorCopy)。未封存、封存核对不一致的,给两次运行详情的入口(运行详情上有「核对」); + * 本次的记录不存在时给去记录页的入口。别的错误按通用的出错态画,带重试 + */ +export function CompareError({ kind, error, onRetry, target, base, context }: { kind: CompareErrorKind; error: unknown; onRetry: () => void; target: string | null; base: string | null + context: CompareErrorContext }) { if (kind === 'other') { return ( @@ -76,7 +119,9 @@ export function CompareError({ kind, error, onRetry, target, base }: {
) } - const { title, hint } = ERROR_TEXT[kind] + // 缺的是不是本次:只带本次的地址(还没有基准)出的错来自找默认基准,那里的 404 只会是本次 + const targetGone = !base || context.targetGone + const { title, hint } = errorCopy(kind, error, targetGone, context) const reason = error instanceof Error ? error.message : '' return (
{title}
{reason && reason !== title &&
{reason}
} -
{hint}
+ {hint &&
{hint}
} {kind === '409' && (target || base) && (
{base && {T.viewBase}} {target && {T.viewTarget}}
)} + {kind === '404' && targetGone && ( +
{T.toRuns}
+ )}
) diff --git a/frontend/src/pages/runs/compare/sqlDiff.ts b/frontend/src/pages/runs/compare/sqlDiff.ts index d7540910..fa8ab74e 100644 --- a/frontend/src/pages/runs/compare/sqlDiff.ts +++ b/frontend/src/pages/runs/compare/sqlDiff.ts @@ -1,7 +1,7 @@ /** * 两条 SQL 逐词对比:并排显示时标出各自独有的部分(结构变了的那条查询,差在哪一眼能看到)。 * - * 按词(名字、关键字、数、字符串、运算符)而不是按字符比:按字符比会把 created_at 和 paid_at 里碰巧相同的「_at」 + * 按词(名字、关键字、数、字符串、运算符、注释)而不是按字符比:按字符比会把 created_at 和 paid_at 里碰巧相同的「_at」 * 当成没变,读起来支离破碎;按行比又太粗,模型写的 SQL 常常一整条写在一行里。关键字和不加引号的名字不分大小写 * (服务端的规范化也不算它们不同),字符串、加引号的名字照原样比。空白不参与比较,只是两个变了的词之间的空白也 * 跟着标,免得一段新加的条件被切成好几截。 @@ -26,8 +26,10 @@ export interface SqlDiff { /** 逐词比较时动态规划表的格数上限(Uint32Array,4 MB) */ const CELL_LIMIT = 1_000_000 -// 空白、字符串('' 转义)、加引号的名字("…"、`…`、[…])、名字和关键字、数、两个字符的运算符、其余单个字符 -const TOKEN = /\s+|'(?:[^']|'')*'?|"(?:[^"]|"")*"?|`[^`]*`?|\[[^\]\s]*\]?|[\p{L}_][\p{L}\p{N}_$]*|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|<=|>=|<>|!=|\|\||::|[\s\S]/gu +// 空白、注释(-- 到行尾、/* … */,没收尾的到末尾)、字符串('' 转义)、加引号的名字("…"、`…`、[…])、名字和关键字、 +// 数、两个字符的运算符、其余单个字符。注释整段算一个词,而且排在字符串前面:模型写的英文注释里常有 it's、user's, +// 把里面的撇号当成字符串的开头,会一直吞到下一个引号,后面没改的语句全被标成不同 +const TOKEN = /\s+|--[^\n]*|\/\*[\s\S]*?(?:\*\/|$)|'(?:[^']|'')*'?|"(?:[^"]|"")*"?|`[^`]*`?|\[[^\]\s]*\]?|[\p{L}_][\p{L}\p{N}_$]*|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|<=|>=|<>|!=|\|\||::|[\s\S]/gu interface Token { text: string diff --git a/scripts/check-all.mjs b/scripts/check-all.mjs index 33c35682..30f1973f 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: 30, verify: 1, inquiry: 1, bundle: 16, whatif: 33, + schedules: 1, compare: 45, verify: 1, inquiry: 1, bundle: 16, whatif: 33, } // 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段 // 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。 diff --git a/scripts/check-compare.mjs b/scripts/check-compare.mjs index a010b0b3..3abf62d8 100644 --- a/scripts/check-compare.mjs +++ b/scripts/check-compare.mjs @@ -5,6 +5,9 @@ // 渲染的写法对不上);不能拆的一声不吭;展开后归因只给数没有占比时不说为什么;SQL 结构变了只给一条、差异不标; // 换了基准、互换了却不重新请求,或者慢的旧答复盖掉新的;409(未封存、封存核对不一致)、422(不是同一工作流)、 // 404(找不到基准)只弹一句通用报错;窄屏(390px)被表格撑出横向滚动;浅色下分类标签、差异高亮看不清。 +// 还有几处照服务端(engine/compare.py、api/compare.py)的实际输出才看得出来的:无法比较的指标原因只在 reasons 里; +// 同一节点两条查询的 (node_id, ordinal) 相同;一边的 SQL 是 null;基准运行读不到;本次的记录读不到;不属于已保存 +// 工作流的运行;查找基准期间离开运行详情。下一步的提示要和页面上真能做的事对得上。 // // 全部用 page.route 假接口:虚构的「区域销售周报」三次已封存的正式运行和一次没封存的,运行详情、运行列表、 // compare/base、compare 都在浏览器层答掉,别的 GET 放行(外壳要的工作流、待审批),非 GET 一律拦掉。 @@ -12,7 +15,7 @@ // // node scripts/check-compare.mjs // 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-compare.mjs -// COMPARE_SHOTS=<目录> 时亮暗、宽窄各截几张,供人眼复核。 +// COMPARE_SHOTS=<目录> 时亮暗、宽窄各截几张,供人眼复核。COMPARE_ONLY=查询,窄屏 只跑标题里带这几截的段。 import { mkdirSync } from 'node:fs' import { chromium } from '../frontend/node_modules/playwright-core/index.mjs' @@ -21,12 +24,17 @@ const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Conte const SHOTS = process.env.COMPARE_SHOTS ?? '' let failed = 0 +let ran = 0 const check = (name, cond, detail = '') => { console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`) + ran++ if (!cond) failed++ } +// 只跑其中几段:COMPARE_ONLY=<段标题里的一截>,<…>,改样式、补用例时省时间(check-all 不往下传) +const ONLY = (process.env.COMPARE_ONLY ?? '').split(',').filter(Boolean) const opened = new Set() async function section(name, fn) { + if (ONLY.length && !ONLY.some((part) => name.includes(part))) return console.log(`\n=== ${name} ===`) try { await fn() @@ -79,6 +87,13 @@ const ALL_ROWS = REGIONS.map((name, i) => ({ })) const MORE_KEYS = '共 12 个维度值,列出变化最大的 10 个' +// 服务端(engine/compare.py)的原话:SQL 结构变的一律带这句「为什么没拆」;无法比较的只写 reasons, +// decomposition_note 为 null(新增、移除也一样)——夹具照这个形状给,检查才走得到真实的路径 +const SQL_NOT_SPLIT = '取数的 SQL 或上游配置有变化,变化无法在口径与数据之间拆分' +const NO_LINEAGE = '口径卡没有记录输入(生成于系统升级前),无法判断变化来源' +const ADDED_WHY = '基准运行的口径卡「周报口径」里没有这个指标' +const REMOVED_WHY = '这次运行的口径卡「周报口径」里没有这个指标' + const metric = (over) => ({ card_id: 'caliber', card_title: '周报口径', unit: '', decimals: 1, reasons: [], decomposition: null, decomposition_note: null, inputs: [], attribution: null, delta_pct: null, ...over, @@ -104,8 +119,7 @@ const METRICS = [ { 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: -15.74, classification: 'sql_changed', - reasons: ['所依据的查询「退货明细」的筛选条件有改动'], - decomposition_note: '所依据的查询「退货明细」SQL 结构有改动,口径不同,不做拆分' }), + reasons: ['查询「退货明细」的 SQL 结构有变化:时间条件的列不同'], decomposition_note: SQL_NOT_SPLIT }), 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: 5.38, classification: 'params_only', reasons: ['「拉取销量」只有日期参数不同'], @@ -113,12 +127,12 @@ const METRICS = [ 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: ['本次新增的指标'] }), + target_rendered: '320人', delta: null, delta_rendered: null, classification: 'added', reasons: [ADDED_WHY] }), 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: ['本次没有这个指标'] }), + target_rendered: '—', delta: null, delta_rendered: null, classification: 'removed', reasons: [REMOVED_WHY] }), 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: 6.45, classification: 'incomparable', - reasons: ['基准运行的口径卡没有记录输入'], decomposition_note: '基准运行的口径卡没有记录输入,无法拆分' }), + reasons: [NO_LINEAGE] }), ...[['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}`, target_rendered: `${v.toLocaleString('en-US')}${unit}`, delta: 0, delta_rendered: null, delta_pct: 0, classification: 'unchanged' })), @@ -126,23 +140,24 @@ const METRICS = [ const SUMMARY = { unchanged: 3, data_only: 2, caliber_and_data: 1, sql_changed: 1, params_only: 1, added: 1, removed: 1, incomparable: 1 } const PULL_BASE = "SELECT region, SUM(amount) AS gmv FROM sales WHERE sold_on >= '2026-09-21' AND sold_on < '2026-09-28' GROUP BY region" const PULL_TARGET = "SELECT region, SUM(amount) AS gmv FROM sales WHERE sold_on >= '2026-09-28' AND sold_on < '2026-10-05' GROUP BY region" +// ordinal 照服务端从 1 数(同一节点里第几次查询);查询项的位置键(data-compare-query)是「节点:同一节点里排第几」 const QUERIES = [ - { node_id: 'pull', node_title: '拉取销量', ordinal: 0, structure: 'params_only', base_sql: PULL_BASE, target_sql: PULL_TARGET, + { node_id: 'pull', node_title: '拉取销量', ordinal: 1, structure: 'params_only', base_sql: PULL_BASE, target_sql: PULL_TARGET, literals: [{ base: "'2026-09-21'", target: "'2026-09-28'" }, { base: "'2026-09-28'", target: "'2026-10-05'" }], base_rows: 12, target_rows: 12, base_truncated: false, target_truncated: false }, - { node_id: 'refund', node_title: '退货明细', ordinal: 0, structure: 'changed', + { node_id: 'refund', node_title: '退货明细', ordinal: 1, 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, 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', + { node_id: 'stores', node_title: '门店清单', ordinal: 1, 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', + { node_id: 'pull', node_title: '拉取销量', ordinal: 2, structure: 'same', base_sql: 'SELECT COUNT(*) AS n FROM visits', target_sql: 'SELECT COUNT(*) AS n FROM visits', literals: [], base_rows: 1, target_rows: 1, base_truncated: false, target_truncated: false }, - { node_id: 'newbie', node_title: '新客查询', ordinal: 0, structure: 'added', base_sql: null, + { node_id: 'newbie', node_title: '新客查询', ordinal: 1, structure: 'added', base_sql: null, 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', + { node_id: 'member', node_title: '会员查询', ordinal: 1, 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, problem: '基准运行的查询快照无法读取' }, ] @@ -165,6 +180,7 @@ const LABEL = { unchanged: '未变', data_only: '只数据变', caliber_and_data params_only: '只参数变', added: '新增', removed: '移除', incomparable: '无法比较' } const ORDER = ['data_only', 'caliber_and_data', 'sql_changed', 'params_only', 'added', 'removed', 'incomparable', 'unchanged'] const REASON = '默认基准:同一定时任务的上一次运行' +const FINDING = '正在查找上一次运行' // ---------------------------------------------------------------- 假接口 @@ -180,9 +196,15 @@ const GRAPH = { nodes: [ { id: 'caliber', type: 'metrics', position: { x: 200, y: 0 }, data: { label: '周报口径', config: {} } }, ], edges: [{ source: 'pull', target: 'caliber' }] } +/** 运行对比的接口说「运行记录不存在」:带机读码(api/evidence.py 的 RUN_NOT_FOUND) */ +const RUN_GONE = { status: 404, json: { detail: '运行记录不存在', code: 'run_not_found' } } +/** 读一次运行(GET /api/runs/{id})时记录不存在:api/runs.py 只给 detail,不带机读码 */ +const RECORD_GONE = { status: 404, json: { detail: '运行记录不存在' } } + /** - * 默认的假接口。opts.base(runId) / opts.compare(target, base, n) 可以换掉对应接口的答复,返回 {status, json, wait} - * (wait 是一个 Promise:等它落定再答,用来压住某一次请求) + * 默认的假接口。opts.base(runId) / opts.compare(target, base, n) / opts.run(runId, n) / opts.list(workflowId) 可以换掉 + * 对应接口的答复(返回 null 的照默认答),返回 {status, json, wait}(wait 是一个 Promise:等它落定再答,用来压住 + * 某一次请求)。不在夹具里的运行一律答 404,不放行到服务端 */ function defaults(opts) { return async (method, url, n) => { @@ -190,6 +212,8 @@ function defaults(opts) { if (method !== 'GET') return null if (p === '/api/runs') { const wf = url.searchParams.get('workflow_id') + const own = opts.list?.(wf) + if (own) return own const list = Object.values(RUNS).filter((r) => !wf || r.workflow_id === wf) .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)) return { json: list } @@ -207,6 +231,11 @@ function defaults(opts) { return { json: comparison(m[1], base) } } m = p.match(/^\/api\/runs\/([^/]+)(\/[a-z]+)?$/) + if (m && !m[2]) { + const own = opts.run?.(m[1], n) + if (own) return own + if (!RUNS[m[1]]) return RECORD_GONE + } if (m && RUNS[m[1]]) { if (!m[2]) return { json: RUNS[m[1]] } if (m[2] === '/events') return { json: events() } @@ -222,7 +251,8 @@ const browser = await chromium.launch({ executablePath: CHROME }) /** * 开一页:/api 的请求先问 defaults(再由 opts 换掉其中几个),答不了的 GET 放行到服务端,非 GET 一律拦掉。 - * 记下每次请求(「GET /api/runs/…?base=…」)和页面报错;原生对话框都算失败 + * 记下每次请求(「GET /api/runs/…?base=…」)和页面报错;原生对话框都算失败。console 里的报错另记一份 + * (React 的列表键冲突只打在 console 上,不抛错) */ async function open(path, { width = 1280, height = 900, theme = 'dark', waitUntil = 'networkidle', ...opts } = {}) { const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, timezoneId: 'Asia/Shanghai' }) @@ -239,8 +269,10 @@ async function open(path, { width = 1280, height = 900, theme = 'dark', waitUnti const log = [] const blocked = [] const errors = [] + const consoleErrors = [] page.on('pageerror', (e) => errors.push(e.message)) page.on('dialog', (d) => { errors.push(`原生对话框:${d.message()}`); void d.dismiss() }) + page.on('console', (msg) => { if (msg.type() === 'error') consoleErrors.push(msg.text().slice(0, 200)) }) const answer = defaults(opts) let n = 0 await page.route((u) => new URL(u).pathname.startsWith('/api/'), async (route) => { @@ -267,14 +299,19 @@ async function open(path, { width = 1280, height = 900, theme = 'dark', waitUnti void getComputedStyle(root).backgroundColor requestAnimationFrame(() => root.classList.remove('theme-switching')) }, theme) - return { ctx, page, log, blocked, errors } + return { ctx, page, log, blocked, errors, consoleErrors } } +/** React 的「两个子元素用了同一个键」告警 */ +const keyClashes = (consoleErrors) => consoleErrors.filter((t) => t.includes('same key')) + const comparePath = (target, base) => `/runs/compare?target=${target}${base ? `&base=${base}` : ''}` /** 等对比页落定:state 是 ok / error / no-target / finding… */ const settled = (page, state = 'ok') => page.locator(`[data-compare-page][data-compare-state="${state}"]`).waitFor({ timeout: 8000 }) const text = (page, sel) => page.locator(sel).first().innerText() const texts = (page, sel) => page.locator(sel).allInnerTexts() +/** 有就取第一个的文字,没有给空串:不在这里干等超时,没找到的照常记成一项不通过 */ +const maybeText = (loc) => loc.count().then((c) => (c ? loc.first().innerText() : '')) const has = (page, sel) => page.locator(sel).count().then((c) => c > 0) const compareHits = (log, target, base) => log.filter((k) => k === `GET /api/runs/${target}/compare?base=${base}`).length const until = async (fn, ms = 6000, step = 100) => { @@ -314,10 +351,62 @@ await section('从运行详情进入', async () => { 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('跳过去之后「正在查找」的提示收起', await until(async () => await page.locator('[data-toast]', { hasText: FINDING }).count() === 0)) check('没有发出写请求', blocked.length === 0, blocked.join(';')) check('页面没有报错', errors.length === 0, errors.join(';')) }) +await section('查找基准期间:看得见在做事,离开之后不再跳走', async () => { + let release + const gate = new Promise((r) => { release = r }) + const { page, log, errors } = await open(`/runs/${T}`, + { base: () => ({ json: { base_run_id: B, reason: 'same_schedule', base: refOf(B) }, wait: gate }) }) + // compare/base 这一请求了结(答完,或者被取消) + const ended = new Promise((resolve) => { + const done = (req) => { if (new URL(req.url()).pathname.endsWith('/compare/base')) resolve() } + page.on('requestfinished', done) + page.on('requestfailed', done) + }) + await page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 }) + await page.getByRole('button', { name: '更多操作' }).click() + await page.locator('[data-menu=compare]').click() + // 菜单点完就收起了:查找期间要有一条看得见的提示,接口慢的时候不像点了没反应 + check('点了之后有「正在查找上一次运行」的提示', await until(async () => + await page.locator('[data-toast]', { hasText: FINDING }).count() === 1, 3000)) + await until(async () => log.includes(`GET /api/runs/${T}/compare/base`), 3000) + // 改主意去了别的页面:答复晚到也不能把人拽回对比页 + await page.evaluate(() => { history.pushState({}, '', '/data'); dispatchEvent(new PopStateEvent('popstate')) }) + await page.waitForURL((u) => u.pathname.startsWith('/data'), { timeout: 5000 }) + release() + // 等请求了结,再给跳转留足时间:第一次进 /data 要现编那一页,主线程忙的时候晚到的跳转会落在固定的等待之后 + await Promise.race([ended, new Promise((r) => setTimeout(r, 4000))]) + const jumped = await until(async () => new URL(page.url()).pathname === '/runs/compare', 2500) + const at = new URL(page.url()) + check('离开运行详情之后答复才回来:留在原地,不跳到对比页', !jumped && at.pathname.startsWith('/data'), at.pathname + at.search) + check('离开之后「正在查找」的提示也收起', await page.locator('[data-toast]', { hasText: FINDING }).count() === 0) + check('页面没有报错', errors.length === 0, errors.join(';')) +}) + +await section('不属于已保存工作流的运行:入口不可点并说明原因', async () => { + const saved = RUNS[T] + // 两种都没有 workflow_id:画布上没保存就运行的,和工作流删掉之后被置空的(名字还是原来的) + for (const [what, over] of [['未保存的工作流', { workflow_name: '未保存的工作流' }], ['工作流已删除', {}]]) { + RUNS[T] = { ...saved, workflow_id: null, ...over } + try { + const { page, log } = await open(`/runs/${T}`) + await page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 }) + await page.getByRole('button', { name: '更多操作' }).click() + const item = page.locator('[data-menu=compare]') + const label = (await item.innerText()).replace(/\s+/g, ' ') + check(`${what}:「与上一次对比」不可点,写明工作流未保存或已删除`, await item.isDisabled() && label.includes('未保存或已删除'), label) + check(`${what}:不给「手动选择基准」,也不请求 compare/base`, !log.some((k) => k.includes('/compare')) + && await page.getByRole('button', { name: '手动选择基准' }).count() === 0) + } finally { + RUNS[T] = saved + } + } +}) + await section('未封存的运行:入口不可点并说明原因', async () => { const saved = RUNS[T] RUNS[T] = { ...saved, manifest_hash: null, manifest_seq: null } @@ -377,10 +466,16 @@ await section('指标表', async () => { (await aov.locator('[data-compare-decomp]').innerText().catch(() => '')).replace(/\s+/g, ' ')) check('能拆分的:画了两段条形', await aov.locator('[data-compare-decomp] [data-compare-bar] > i').count() === 2) check('表头说明两者之和等于总变化', (await text(page, '[data-compare-legend]')).includes('两者之和等于总变化')) - check('不能拆的:写出服务端给的原因', (await refund.locator('[data-compare-decomp-note]').innerText()) - === '所依据的查询「退货明细」SQL 结构有改动,口径不同,不做拆分') - check('无法比较的也写原因', (await page.locator('tr[data-compare-metric="caliber:margin"] [data-compare-decomp-note]').innerText()) - .includes('没有记录输入')) + check('不能拆的:写出服务端给的原因', (await refund.locator('[data-compare-decomp-note]').innerText()) === SQL_NOT_SPLIT) + // 「拆分或原因」一栏(第 5 个 td):无法比较、新增、移除的服务端只给 reasons、不给 decomposition_note, + // 这一栏不能只剩一个「—」,要展开才知道为什么 + const splitCell = (id) => page.locator(`tr[data-compare-metric="caliber:${id}"] td`).nth(4).innerText().then((s) => s.trim()) + check('无法比较的:「拆分或原因」写出原因(服务端只放在 reasons 里)', await splitCell('margin') === NO_LINEAGE, await splitCell('margin')) + check('新增、移除的:「拆分或原因」写出原因', await splitCell('new_users') === ADDED_WHY && await splitCell('members') === REMOVED_WHY, + `${await splitCell('new_users')} | ${await splitCell('members')}`) + check('分类的悬停说明和服务端的分类口径一致', (await aov.locator('[data-compare-chip]').getAttribute('title')).includes('小数位') + && (await refund.locator('[data-compare-chip]').getAttribute('title')).includes('上游'), + `${await aov.locator('[data-compare-chip]').getAttribute('title')} | ${await refund.locator('[data-compare-chip]').getAttribute('title')}`) check('新增的:基准写「—」,本次照写', (await page.locator('tr[data-compare-metric="caliber:new_users"] [data-compare-base]').innerText()) === '—' && (await page.locator('tr[data-compare-metric="caliber:new_users"] [data-compare-target]').innerText()) === '320人') check('未变的:变化写「无变化」', (await page.locator('tr[data-compare-metric="caliber:stores"] [data-compare-delta]').innerText()) === '无变化') @@ -445,6 +540,19 @@ await section('展开:输入对比与维度归因', async () => { 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) + // 不能拆的:明细里也写「为什么没拆」——窄屏上表格那一格横着滚也看不全,明细按框的可见宽度排 + await page.locator('tr[data-compare-metric="caliber:refund_rate"] button[data-compare-expand]').click() + const refundDetail = page.locator('tr[data-compare-detail="caliber:refund_rate"]') + await refundDetail.waitFor() + check('不能拆的:明细里也写出原因', (await maybeText(refundDetail.locator('[data-compare-detail-note]'))).trim() === SQL_NOT_SPLIT, + await maybeText(refundDetail.locator('[data-compare-detail-note]'))) + // 服务端只对只数据变、只参数变、口径与数据都变的做维度归因:别的类不是「没有明细」,是有意不做,写明为什么 + check('SQL 结构变的:写明为什么不做维度归因', (await maybeText(refundDetail.locator('[data-compare-attr-none]'))).includes('不做维度归因'), + await maybeText(refundDetail.locator('[data-compare-attr-none]'))) + await page.locator('tr[data-compare-metric="caliber:margin"] button[data-compare-expand]').click() + const marginNone = page.locator('tr[data-compare-detail="caliber:margin"] [data-compare-attr-none]') + await marginNone.waitFor() + check('无法比较的:写明为什么不做维度归因', (await maybeText(marginNone)).includes('无法判断变化来源'), await maybeText(marginNone)) check('页面没有报错', errors.length === 0, errors.join(';')) }) @@ -483,7 +591,7 @@ await section('查询', async () => { await settled(page) const qs = page.locator('[data-compare-query]') check('每条查询一项', await qs.count() === QUERIES.length, String(await qs.count())) - const refund = page.locator('[data-compare-query="refund:0"]') + const refund = page.locator('[data-compare-query="refund:1"]') check('结构变:标「结构变」', (await refund.getAttribute('data-structure')) === 'changed' && (await refund.locator('[data-compare-structure]').innerText()).trim() === '结构变') const baseSql = refund.locator('[data-compare-sql="base"]') @@ -500,29 +608,85 @@ await section('查询', async () => { 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()) + check('快照读不出来:照服务端的说明写在那一条上', (await page.locator('[data-compare-query="member:1"] [data-compare-query-problem]').innerText()) === '基准运行的查询快照无法读取') - const pull = page.locator('[data-compare-query="pull:0"]') + const pull = page.locator('[data-compare-query="pull:1"]') 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'") && lits[1].includes("'2026-10-05'"), lits.join(' | ')) check('只参数变:默认不并排摆两条 SQL', await pull.locator('[data-compare-sql]').count() === 0) - const same = page.locator('[data-compare-query="stores:0"]') + const same = page.locator('[data-compare-query="stores:1"]') check('相同的:默认不展开 SQL', (await same.locator('[data-compare-structure]').innerText()).trim() === '相同' && await same.locator('[data-compare-sql]').count() === 0) await same.locator('button[data-compare-sql-toggle]').click() check('相同的:点开能看 SQL', (await same.locator('[data-compare-sql]').first().innerText()).includes('FROM stores')) - check('同一节点的第二条查询写「第 2 条」', (await page.locator('[data-compare-query="pull:1"] [data-compare-nth]').innerText()).includes('第 2 条') + check('同一节点的第二条查询写「第 2 条」', (await page.locator('[data-compare-query="pull:2"] [data-compare-nth]').innerText()).includes('第 2 条') && (await pull.locator('[data-compare-nth]').innerText()).includes('第 1 条')) check('只有一条查询的节点不写第几条', await refund.locator('[data-compare-nth]').count() === 0) - const added = page.locator('[data-compare-query="newbie:0"]') + const added = page.locator('[data-compare-query="newbie:1"]') check('新增、移除的查询各只有一边的 SQL', await added.locator('[data-compare-sql="target"]').count() === 1 && await added.locator('[data-compare-sql="base"]').count() === 0 - && await page.locator('[data-compare-query="member:0"] [data-compare-sql="base"]').count() === 1) + && await page.locator('[data-compare-query="member:1"] [data-compare-sql="base"]').count() === 1) + const head = (await page.locator('[data-compare-queries] header').innerText()).replace(/\s+/g, ' ') + check('查询的个数按「条」写(和「第 N 条」一致)', head.includes(`${QUERIES.length} 条`), head) + check('「结构变」的悬停说明也算上数据源不同、SQL 读不出来', (await refund.locator('[data-compare-structure]').getAttribute('title')).includes('数据源'), + await refund.locator('[data-compare-structure]').getAttribute('title')) await refund.scrollIntoViewIfNeeded() await shot(page, 'queries-dark') }) +await section('查询的边界:照服务端配对、读快照的实际输出', async () => { + // 服务端配对(engine/compare.py 的 _pair):配上的一对用本次的 node_id / ordinal,落单的基准查询用基准的。Agent 在基准里 + // 查了两次、这次只查一次且和基准的第 2 次同构时,配上的一对是 agent·1,落单的基准第 1 次也是 agent·1——(node_id, ordinal) + // 不唯一。一边的 SQL 拿不到时服务端标「结构变」,那一边的 SQL 是 null + const AGENT = [ + { node_id: 'agent', node_title: '分析助手', ordinal: 1, structure: 'same', base_sql: 'SELECT channel, SUM(amount) AS gmv FROM sales GROUP BY channel', + target_sql: 'SELECT channel, SUM(amount) AS gmv FROM sales GROUP BY channel', literals: [], base_rows: 3, target_rows: 3, + base_truncated: false, target_truncated: false, differences: [] }, + { node_id: 'agent', node_title: '分析助手', ordinal: 1, structure: 'removed', base_sql: 'SELECT region, SUM(amount) AS gmv FROM sales GROUP BY region', + target_sql: null, literals: [], base_rows: 12, target_rows: null, base_truncated: false, target_truncated: null, differences: [] }, + { node_id: 'pull', node_title: '拉取销量', ordinal: 1, structure: 'changed', base_sql: null, target_sql: 'SELECT region, amount FROM sales WHERE sold_on >= 20260928', + literals: [], base_rows: null, target_rows: 12, base_truncated: null, target_truncated: false, + differences: ['基准运行的查询快照无法读取,无法比较 SQL'], problem: '基准运行的查询快照无法读取' }, + ] + const { page, errors, consoleErrors } = await open(comparePath(T, B), { compare: (t, b) => ({ json: { ...comparison(t, b), queries: AGENT } }) }) + await settled(page) + const items = await page.locator('[data-compare-query]').evaluateAll((els) => els.map((e) => ({ + key: e.getAttribute('data-compare-query'), structure: e.getAttribute('data-structure'), nth: e.querySelector('[data-compare-nth]')?.textContent?.trim() ?? null, + }))) + const agent = items.filter((i) => i.key?.startsWith('agent')) + check('同一节点的两条查询 (node_id, ordinal) 相同:各写「第 1 条」「第 2 条」,按列表的先后数', + JSON.stringify(agent.map((i) => i.nth)) === JSON.stringify(['第 1 条', '第 2 条']), JSON.stringify(items)) + check('每一项的位置键都不重复', new Set(items.map((i) => i.key)).size === items.length, items.map((i) => i.key).join(',')) + check('没有 React 列表键冲突的告警', keyClashes(consoleErrors).length === 0, keyClashes(consoleErrors)[0] ?? '') + const pull = page.locator('[data-compare-query="pull:1"]') + const baseText = (await maybeText(pull.locator('[data-compare-sql="base"]'))).trim() + const marks = await pull.locator('mark[data-diff]').count() + check('一边的 SQL 读不出来:不做逐词比较,另一边不标「独有」', marks === 0, `${marks} 处标记`) + check('一边的 SQL 读不出来:这一边写明无法读取', baseText.includes('无法读取'), baseText) + check('一边的 SQL 读不出来:另一边照原文显示', (await maybeText(pull.locator('[data-compare-sql="target"]'))).includes('sold_on >= 20260928')) + check('页面没有报错', errors.length === 0, errors.join(';')) +}) + +await section('SQL 逐词比较:注释里的撇号不吞掉后面的语句', async () => { + const { page } = await open(comparePath(T, B)) + await settled(page) + // 纯函数:直接 import Vite 现编的 sqlDiff.ts。模型写的英文注释里常有 it's、user's + const out = await page.evaluate(async () => { + const { diffSql } = await import('/src/pages/runs/compare/sqlDiff.ts') + const changed = (parts) => parts.filter((p) => p.changed).map((p) => p.text.trim()) + const line = diffSql("SELECT a -- it's a note\nFROM t WHERE x = 1", "SELECT a -- it's a note\nFROM t WHERE x = 2") + const block = diffSql("SELECT a /* user's pick */ FROM t WHERE x = 1", "SELECT a /* user's pick */ FROM t WHERE x = 2") + const edited = diffSql('SELECT a -- old note\nFROM t', 'SELECT a -- new note\nFROM t') + return { line: [changed(line.base), changed(line.target)], block: [changed(block.base), changed(block.target)], + edited: [changed(edited.base), changed(edited.target)] } + }) + check('行注释里有撇号:只标出真正不同的 1 和 2', JSON.stringify(out.line) === JSON.stringify([['1'], ['2']]), JSON.stringify(out.line)) + check('块注释里有撇号:只标出真正不同的 1 和 2', JSON.stringify(out.block) === JSON.stringify([['1'], ['2']]), JSON.stringify(out.block)) + check('注释改了:整段注释算一处不同,后面没改的语句不标', JSON.stringify(out.edited) === JSON.stringify([['-- old note'], ['-- new note']]), + JSON.stringify(out.edited)) +}) + await section('模板与说明', async () => { const { page } = await open(comparePath(T, B)) await settled(page) @@ -596,24 +760,44 @@ await section('过时的答复不盖掉新的', async () => { /** 「其他错误」那一节:第一次对比回 500,之后照常 */ let flakyCalls = 0 await section('出错与找不到基准', async () => { - const sealed = await open(comparePath(T, B), { compare: () => ({ status: 409, json: { detail: '基准运行的封存核对不一致,无法对比' } }) }) + // 409 照服务端(api/compare.py)的原话和机读码:封存核对不一致的原因里已经写了去记录页点「核对」 + const broken = '作为基准的运行的封存核对不一致:封存后有事件被修改、删除或插入,不能用于对比。可在记录页点「核对」查看核对结果' + const sealed = await open(comparePath(T, B), { compare: () => ({ status: 409, json: { detail: broken, code: 'compare_seal_broken' } }) }) await settled(sealed.page, 'error') const e409 = sealed.page.locator('[data-compare-error="409"]') - check('409:写明未封存或封存核对不一致,带上服务端的原因', (await e409.locator('[data-compare-error-title]').innerText()).includes('封存') - && (await e409.innerText()).includes('基准运行的封存核对不一致,无法对比'), (await e409.innerText().catch(() => '')).replace(/\s+/g, ' ')) + const t409 = (await e409.innerText().catch(() => '')).replace(/\s+/g, ' ') + check('409 封存核对不一致:标题照实写,带上服务端的原因', (await e409.locator('[data-compare-error-title]').innerText()) === '无法对比:封存核对不一致' + && t409.includes(broken), t409) + check('409:下一步只指向界面上有的入口(不提「封存凭证条」,可在上方更换基准)', !t409.includes('封存凭证条') && t409.includes('上方更换基准'), t409) check('409:给出去两次运行详情核对的入口', await e409.locator(`a[href="/runs/${B}"]`).count() === 1 && await e409.locator(`a[href="/runs/${T}"]`).count() === 1) check('409:两张运行卡仍在,下拉仍能换基准', await sealed.page.locator('[data-compare-run="base"]').count() === 1 && await sealed.page.locator('select[data-compare-base-picker]').isEnabled()) check('409:不显示摘要、指标', await sealed.page.locator('[data-compare-summary], tr[data-compare-metric]').count() === 0) - const other = await open(comparePath(T, B), { compare: () => ({ status: 422, json: { detail: '两次运行不属于同一个工作流' } }) }) + // 本次自己没封存:换基准也没用,服务端的原因里已经写了要等运行结束,不再叫人去换基准 + const savedT = RUNS[T] + RUNS[T] = { ...savedT, manifest_hash: null, manifest_seq: null } + try { + const open409 = await open(comparePath(T, B), { compare: () => ({ status: 409, + json: { detail: '这次运行尚未结束并封存:对比只用封存的运行记录,运行结束后才能对比', code: 'compare_unsealed' } }) }) + await settled(open409.page, 'error') + const u409 = open409.page.locator('[data-compare-error="409"]') + const tu = (await u409.innerText().catch(() => '')).replace(/\s+/g, ' ') + check('409 本次未封存:标题写「尚未结束并封存」,不叫人去换基准', (await u409.locator('[data-compare-error-title]').innerText()).includes('尚未结束并封存') + && !tu.includes('更换基准'), tu) + } finally { + RUNS[T] = savedT + } + + const other = await open(comparePath(T, B), { compare: () => ({ status: 422, + json: { detail: '两次运行不属于同一个工作流(「区域销售周报」与「门店日报」),不能对比', code: 'compare_mismatch' } }) }) await settled(other.page, 'error') const e422 = other.page.locator('[data-compare-error="422"]') check('422:写明无法对比,带上原因和下一步', (await e422.innerText()).includes('两次运行不属于同一个工作流') - && (await e422.innerText()).includes('同一工作流'), (await e422.innerText().catch(() => '')).replace(/\s+/g, ' ')) + && (await e422.innerText()).includes('同一工作流的另一次运行'), (await e422.innerText().catch(() => '')).replace(/\s+/g, ' ')) - const gone = await open(comparePath(T, B), { compare: () => ({ status: 404, json: { detail: '运行记录不存在' } }) }) + const gone = await open(comparePath(T, B), { compare: () => RUN_GONE }) await settled(gone.page, 'error') check('404:写明运行记录不存在', (await gone.page.locator('[data-compare-error="404"]').innerText()).includes('运行记录不存在')) @@ -631,14 +815,27 @@ await section('出错与找不到基准', async () => { check('找不到基准:写明原因', (await noBase.locator('[data-compare-error-title]').innerText()).includes('未找到可作为基准的运行') && (await noBase.innerText()).includes('同一工作流没有其他已封存的正式运行')) check('找不到基准:仍能手动选', await lone.page.locator('select[data-compare-base-picker]').isEnabled()) + check('找不到基准、下拉里有能选的:说可在上方选', await until(async () => (await noBase.innerText()).includes('可在上方选择')), + (await noBase.innerText()).replace(/\s+/g, ' ')) + // 同一工作流只有一次没封存的:下拉里没有能选的,不能再叫人去上方选 + const bare = await open(comparePath(T), { base: () => ({ status: 404, json: { detail: '同一工作流没有其他已封存的正式运行', code: 'compare_no_base' } }), + list: () => ({ json: [RUNS[T], RUNS[U]] }) }) + await settled(bare.page, 'error') + const bareBox = bare.page.locator('[data-compare-error="no-base"]') + await until(async () => (await bare.page.locator('select[data-compare-base-picker] option').count()) > 1) + const tb = (await bareBox.innerText()).replace(/\s+/g, ' ') + check('找不到基准、下拉里没有能选的:不说「可在上方选择」,写明同一工作流没有已封存的运行', !tb.includes('可在上方') && tb.includes('已封存'), tb) await lone.page.locator('select[data-compare-base-picker]').selectOption(B2) 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' } }) }) + const missing = await open(comparePath(T), { base: () => RUN_GONE, run: (id) => (id === T ? RECORD_GONE : null) }) 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 gone404 = missing.page.locator('[data-compare-error="404"]') + check('找基准时本次就不存在:写运行记录不存在、链接可能已失效,给去记录页的入口', await gone404.count() === 1 + && (await missing.page.locator('[data-compare-error-title]').innerText()) === '运行记录不存在' + && (await gone404.innerText()).includes('链接可能已失效') && await gone404.locator('a[href="/runs"]').count() === 1, + (await gone404.innerText().catch(() => '')).replace(/\s+/g, ' ')) // 只带本次、找得到:自己补上基准,写明怎么选的 const auto = await open(comparePath(T)) @@ -664,6 +861,67 @@ await section('出错与找不到基准', async () => { check('什么都没带:说明从哪里进来', (await text(none.page, '[data-compare-page]')).includes('与上一次对比')) }) +await section('基准运行读不到(删了、链接过期)', async () => { + const GONE = 'c0ffee0gone0000000000000000000009' + const { page, log, errors } = await open(comparePath(T, GONE), { compare: (t, b) => (b === GONE + ? { status: 404, json: { detail: '作为对比基准的运行记录不存在', code: 'run_not_found' } } : { json: comparison(t, b) }) }) + await settled(page, 'error') + const picker = page.locator('select[data-compare-base-picker]') + await until(async () => (await picker.locator('option').count()) > 1) + const current = await picker.evaluate((s) => ({ value: s.value, text: s.selectedOptions[0]?.textContent ?? '' })) + // 受控下拉的值对不上任何选项时浏览器会选中第一项:显示成另一次运行被选中,而选它又不触发 change + check('下拉如实显示当前的基准(短 id,写明读不到),不显示成别的运行', current.value === GONE && current.text.includes('不存在'), JSON.stringify(current)) + const box = (await page.locator('[data-compare-error="404"]').innerText()).replace(/\s+/g, ' ') + check('缺的是基准:提示在上方另选基准,不叫人回记录页', box.includes('在上方选择另一次运行作为基准') && !box.includes('链接可能已失效'), box) + await picker.selectOption(B) + await settled(page) + check('另选一次之后照常对比', new URL(page.url()).searchParams.get('base') === B && compareHits(log, T, B) === 1) + check('页面没有报错', errors.length === 0, errors.join(';')) +}) + +await section('本次的运行记录读不到:换基准不一直停在「正在读取」', async () => { + let calls = 0 + const { page, errors } = await open(comparePath(T, B), { run: (id) => (id === T && calls++ === 0 + ? { status: 500, json: { detail: '服务暂时不可用' } } : null) }) + await settled(page) + const failed = page.locator('[data-compare-run="base"] [data-compare-pick-failed]') + check('读不到本次的记录、不知道是哪个工作流:写明读不到同一工作流的运行,不显示加载中', + await until(() => failed.count().then((c) => c === 1), 3000) + && await page.locator('select[data-compare-base-picker]').count() === 0, + (await page.locator('[data-compare-run="base"]').innerText()).replace(/\s+/g, ' ')) + await failed.getByRole('button', { name: '重试' }).click() + const picker = page.locator('select[data-compare-base-picker]') + check('点「重试」重新读本次的记录,之后下拉照常可用', await until(async () => (await picker.count()) === 1 && await picker.isEnabled(), 4000) + && await picker.inputValue() === B) + check('页面没有报错', errors.length === 0, errors.join(';')) +}) + +await section('不属于已保存工作流的运行:对比页的说明前后一致', async () => { + const saved = RUNS[T] + RUNS[T] = { ...saved, workflow_id: null, workflow_name: '未保存的工作流' } + try { + // 只带本次:服务端找不到基准(这种运行没有可比的) + const lone = await open(comparePath(T), { base: () => ({ status: 404, json: { + detail: '没有找到可以对比的上一次运行:需要同一工作流、同一运行类别(定时任务发起的优先找同一定时任务)、更早结束并已封存的运行', + code: 'compare_no_base' } }) }) + await settled(lone.page, 'error') + await until(async () => (await lone.page.locator('[data-compare-run="base"]').innerText()).includes('未保存或已删除'), 3000) + const card = (await lone.page.locator('[data-compare-run="base"]').innerText()).replace(/\s+/g, ' ') + const box = (await lone.page.locator('[data-compare-error]').innerText()).replace(/\s+/g, ' ') + check('基准卡:写明工作流未保存或已删除(不说「临时构建」)', card.includes('未保存或已删除') && !card.includes('临时构建'), card) + check('说明框:写明原因,不叫人去上方选', box.includes('未保存或已删除') && !box.includes('上方'), box) + // 地址里带了基准:服务端回 422 + const other = await open(comparePath(T, B), { compare: () => ({ status: 422, + json: { detail: '这次运行不属于任何已保存的工作流,无法确认两次运行是同一个工作流,不能对比', code: 'compare_mismatch' } }) }) + await settled(other.page, 'error') + await until(async () => (await other.page.locator('[data-compare-error]').innerText()).includes('未保存或已删除'), 3000) + const b422 = (await other.page.locator('[data-compare-error]').innerText()).replace(/\s+/g, ' ') + check('422:不再叫人选另一次运行作为基准', b422.includes('未保存或已删除') && !b422.includes('作为基准') && !b422.includes('上方'), b422) + } finally { + RUNS[T] = saved + } +}) + await section('空的对比', async () => { const { page } = await open(comparePath(T, B), { compare: (t, b) => ({ json: { ...comparison(t, b), metrics: [], queries: [], notes: [], summary: Object.fromEntries(ORDER.map((k) => [k, 0])), template: { base_version: 4, target_version: 4, same_graph: true, nodes: [] } } }) }) @@ -703,7 +961,7 @@ await section('窄屏', async () => { }) check(`390px(${theme}):指标表在自己的框里横向滚动,键盘可聚焦`, !!scroller && scroller.sw > scroller.cw && scroller.focusable, JSON.stringify(scroller)) - const refund = page.locator('[data-compare-query="refund:0"]') + const refund = page.locator('[data-compare-query="refund:1"]') const [bb, tb] = [await refund.locator('[data-compare-sql="base"]').boundingBox(), await refund.locator('[data-compare-sql="target"]').boundingBox()] check(`390px(${theme}):两条 SQL 上下排、都在屏幕里`, !!bb && !!tb && tb.y >= bb.y + bb.height - 1 && bb.x >= 0 && bb.x + bb.width <= 390, JSON.stringify({ bb, tb })) @@ -778,5 +1036,7 @@ await section('深浅色可读', async () => { }) await browser.close() -console.log(failed ? `\n✗ 运行对比:${failed} 项未通过` : '\n✓ 运行对比:全部通过') +// 段过滤一段都没对上:报成没通过,不让一个写错的过滤词顶着「全部通过」 +if (!ran) failed++ +console.log(failed ? `\n✗ 运行对比:${ran ? `${failed} 项未通过` : '一项都没有检查'}` : '\n✓ 运行对比:全部通过') process.exit(failed ? 1 : 0)