mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:51:57 +00:00
双盲复算前端(二):两路对不上时的审批卡,三选一裁决
- run/DualCheckApproval.tsx:期 0 的空插槽换成真组件。先按 payload.summary 画两个值、相差比例、摘要里的差异,
完整对照(card_artifact)到了再补盲算模型、全部具体差异和「对照两条 SQL」;只有兜底说明时写明未能归纳成具体差异
并默认展开对照;对照区先列每个输入两边各取到多少,只对照不同的输入,两条 SQL 按卡片宽度左右并排或上下排,
text_diff 里有的行在原文中高亮,规范化后对不上原文的不乱标、另列规范化后不同的行;只差一个方面时说明选择会记入
数据目录;每个指标三选一(原生单选,键盘可操作),缺一个都不能提交,回复走审批接口,value 为 {choices, note};
后果按运行类别说,写明「采用盲算」不替换报告里的数,探索运行不提出具;完整对照取不到时按摘要照样能裁决
- RunPanel:payload.kind 为 dual_check 时不画通用的输入框、备注和「提交 / 驳回并终止」,署名那行保留,
onResolved 交给 DualCheckApproval;其他审批卡不变
- 预览用例 dev/fixtures/dual_check.tsx(夹具 dual_check.json 与检查脚本共用,数据虚构)
- check-verify:期 0 的占位换成真检查——核查设置的叫法和服务端逐项比对,审批卡的摘要与补全、具体差异、只有兜底、
并排与高亮、不选全不能提交、请求体、取不到完整对照、探索运行、390px 与右栏不溢出、深浅色对比度
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"_note": "双盲复算的预览用例和 scripts/check-verify.mjs 共用的夹具。表名、数字都是虚构的通用示例,不来自任何真实数据。",
|
||||
"approval": {
|
||||
"id": "ap-dc-0001",
|
||||
"run_id": "run-dc-0001",
|
||||
"node_id": "dc",
|
||||
"mode": "input",
|
||||
"title": "双盲复算:2 个指标不一致",
|
||||
"status": "pending",
|
||||
"response": {},
|
||||
"workflow_name": "门店周报",
|
||||
"node_label": "复核周报口径",
|
||||
"run_class": "formal",
|
||||
"payload": {
|
||||
"kind": "dual_check",
|
||||
"node_id": "dc",
|
||||
"title": "双盲复算:2 个指标不一致",
|
||||
"card_artifact": "d0ab1e5c0ffee000d0ab1e5c0ffee000d0ab1e5c0ffee000d0ab1e5c0ffee000",
|
||||
"summary": [
|
||||
{
|
||||
"metric_id": "gmv",
|
||||
"name": "销售额",
|
||||
"unit": "元",
|
||||
"original_rendered": "45,678.5元",
|
||||
"blind_rendered": "51,388.3元",
|
||||
"delta_pct": 12.5,
|
||||
"diff": ["原流程按支付时间 paid_at 筛选时间范围,盲算按下单时间 created_at 筛选"],
|
||||
"fallback": false
|
||||
},
|
||||
{
|
||||
"metric_id": "refund_rate",
|
||||
"name": "退款率",
|
||||
"unit": "",
|
||||
"original_rendered": "2.35%",
|
||||
"blind_rendered": "3.10%",
|
||||
"delta_pct": 31.91,
|
||||
"diff": [],
|
||||
"fallback": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
"model": "blind-model-x",
|
||||
"same_model": false,
|
||||
"metrics": [
|
||||
{
|
||||
"metric_id": "gmv",
|
||||
"name": "销售额",
|
||||
"unit": "元",
|
||||
"decimals": 1,
|
||||
"original": {
|
||||
"value": 45678.5,
|
||||
"rendered": "45,678.5元",
|
||||
"inputs": [
|
||||
{
|
||||
"path": "vars.kpi.gmv",
|
||||
"value": 45678.5,
|
||||
"sql": "SELECT SUM(amount) AS gmv\nFROM orders\nWHERE paid_at >= '2026-09-01'\n AND paid_at < '2026-09-08'",
|
||||
"source": "shop",
|
||||
"alias": "Q1"
|
||||
}
|
||||
]
|
||||
},
|
||||
"blind": {
|
||||
"value": 51388.3,
|
||||
"rendered": "51,388.3元",
|
||||
"inputs": [
|
||||
{
|
||||
"path": "vars.kpi.gmv",
|
||||
"value": 51388.3,
|
||||
"sql": "SELECT SUM(amount) AS gmv\nFROM orders\nWHERE created_at >= '2026-09-01'\n AND created_at < '2026-09-08'",
|
||||
"source": "shop",
|
||||
"alias": "B1",
|
||||
"rationale": "销售额按下单时间统计本周全部订单金额之和"
|
||||
}
|
||||
]
|
||||
},
|
||||
"diff": {
|
||||
"equivalent": false,
|
||||
"items": [
|
||||
{ "aspect": "time", "text": "原流程按支付时间 paid_at 筛选时间范围,盲算按下单时间 created_at 筛选" }
|
||||
],
|
||||
"fallback": false,
|
||||
"text_diff": [
|
||||
"- WHERE paid_at >= '2026-09-01'",
|
||||
"- AND paid_at < '2026-09-08'",
|
||||
"+ WHERE created_at >= '2026-09-01'",
|
||||
"+ AND created_at < '2026-09-08'"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"metric_id": "refund_rate",
|
||||
"name": "退款率",
|
||||
"unit": "",
|
||||
"decimals": 4,
|
||||
"original": {
|
||||
"value": 0.0235,
|
||||
"rendered": "2.35%",
|
||||
"inputs": [
|
||||
{
|
||||
"path": "vars.kpi.refunds",
|
||||
"value": 47,
|
||||
"sql": "SELECT COUNT(*) AS refunds FROM refunds r JOIN orders o ON o.id = r.order_id WHERE o.status <> 'test'",
|
||||
"source": "shop",
|
||||
"alias": "Q2"
|
||||
},
|
||||
{
|
||||
"path": "vars.kpi.orders",
|
||||
"value": 2000,
|
||||
"sql": "SELECT COUNT(*) AS orders FROM orders WHERE status <> 'test'",
|
||||
"source": "shop",
|
||||
"alias": "Q3"
|
||||
}
|
||||
]
|
||||
},
|
||||
"blind": {
|
||||
"value": 0.031,
|
||||
"rendered": "3.10%",
|
||||
"inputs": [
|
||||
{
|
||||
"path": "vars.kpi.refunds",
|
||||
"value": 62,
|
||||
"sql": "SELECT COUNT(DISTINCT order_id) AS refunds FROM refunds WHERE order_id IN (SELECT id FROM orders)",
|
||||
"source": "shop",
|
||||
"alias": "B2",
|
||||
"rationale": "退款笔数按有退款记录的订单去重计数"
|
||||
},
|
||||
{
|
||||
"path": "vars.kpi.orders",
|
||||
"value": 2000,
|
||||
"sql": "SELECT COUNT(*) AS orders FROM orders WHERE status <> 'test'",
|
||||
"source": "shop",
|
||||
"alias": "B3",
|
||||
"rationale": "订单总数,不含测试订单"
|
||||
}
|
||||
]
|
||||
},
|
||||
"diff": {
|
||||
"equivalent": false,
|
||||
"items": [
|
||||
{ "aspect": "other", "text": "两条 SQL 的写法不同,未能归纳成具体差异,请对照原文" }
|
||||
],
|
||||
"fallback": true,
|
||||
"text_diff": [
|
||||
"- SELECT COUNT(*) AS refunds FROM refunds AS r JOIN orders AS o ON o.id = r.order_id WHERE o.status <> 'test'",
|
||||
"+ SELECT COUNT(DISTINCT order_id) AS refunds FROM refunds WHERE order_id IN (SELECT id FROM orders)"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { JSX } from 'react'
|
||||
import { ApprovalCard } from '../../run/RunPanel'
|
||||
import type { Approval } from '../../types'
|
||||
import data from './dual_check.json'
|
||||
|
||||
/**
|
||||
* 双盲复算的预览用例,scripts/check-verify.mjs 按它们检查(夹具在同目录的 dual_check.json,检查脚本读的是同一份):
|
||||
* - ?syn=dual-check-approval:正式运行里两路对不上的审批卡,宽栏(两条 SQL 左右并排)
|
||||
* - ?syn=dual-check-approval-rail:同一张卡摆在 360px 的画布右栏里(两条 SQL 上下排)
|
||||
* - ?syn=dual-check-approval-exploratory:探索运行,后果里不提出具
|
||||
*
|
||||
* 完整对照的工件(GET /api/artifacts/{id})和审批回复接口由检查脚本用 page.route 伪造,直接打开时卡上照实说
|
||||
* 无法加载完整对照。提交后 onResolved 只把运行 id 记到 window.__dualResolved,不去接事件流
|
||||
*/
|
||||
const approval = (patch: Partial<Approval> = {}): Approval => ({
|
||||
...(data.approval as unknown as Approval),
|
||||
created_at: new Date(Date.now() - 180_000).toISOString().replace('Z', ''),
|
||||
...patch,
|
||||
})
|
||||
|
||||
function Card({ value }: { value: Approval }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border" style={{ borderColor: 'var(--warn)' }}>
|
||||
<ApprovalCard approval={value} onResolved={(id) => { (window as any).__dualResolved = id }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const cases: Record<string, () => JSX.Element> = {
|
||||
'dual-check-approval': () => (
|
||||
<div className="mx-auto max-w-3xl p-4 text-sm" data-fixture="dual-check-approval"><Card value={approval()} /></div>
|
||||
),
|
||||
'dual-check-approval-rail': () => (
|
||||
<div className="h-full w-[360px] max-w-full overflow-y-auto border-r bg-panel" data-fixture="dual-check-approval-rail">
|
||||
<Card value={approval()} />
|
||||
</div>
|
||||
),
|
||||
'dual-check-approval-exploratory': () => (
|
||||
<div className="mx-auto max-w-3xl p-4 text-sm" data-fixture="dual-check-approval-exploratory">
|
||||
<Card value={approval({ run_class: 'exploratory' })} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -1,15 +1,459 @@
|
||||
import type { Approval } from '../types'
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { AlertTriangle, Check, ChevronRight } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { api } from '../api/client'
|
||||
import { useCatalog } from '../store/catalog'
|
||||
import { useStudio } from '../store/studio'
|
||||
import { Spinner, useToast } from '../components/ui'
|
||||
import { NONE } from '../lib/format'
|
||||
import { DUAL_CHECK_TEXT } from '../lib/terms'
|
||||
import type {
|
||||
Approval, DualCheckBlindInput, DualCheckCard, DualCheckChoice, DualCheckInput, DualCheckMetric, DualCheckReply,
|
||||
DualCheckSummaryItem,
|
||||
} from '../types'
|
||||
|
||||
const T = DUAL_CHECK_TEXT.approval
|
||||
const CHOICES: DualCheckChoice[] = ['a', 'b', 'neither']
|
||||
|
||||
export interface DualCheckApprovalProps {
|
||||
/** payload.kind 为 'dual_check' 的审批:双盲复算两路对不上、交给人裁决 */
|
||||
approval: Approval
|
||||
/**
|
||||
* 提交之后谁来重新接上运行的事件流,同 ApprovalCard 的 onResolved:对话页、运行页各自接,没给就走画布的 attachRun。
|
||||
* 两处都接会开两条订阅,各收一份事件
|
||||
*/
|
||||
onResolved?: (runId: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
/** 一个不一致的指标在卡上要画的东西:先由 payload.summary 填,完整对照(card_artifact)到了再补全 */
|
||||
interface MetricView {
|
||||
id: string
|
||||
name: string
|
||||
original: string | null
|
||||
blind: string | null
|
||||
/** 已乘过 100;null 是原流程值为 0(约定),undefined 是没给 */
|
||||
deltaPct: number | null | undefined
|
||||
/** 具体差异,一条一句(兜底说明不算) */
|
||||
items: string[]
|
||||
/** 只有兜底说明:两条 SQL 的写法不同,未能归纳成具体差异 */
|
||||
fallbackOnly: boolean
|
||||
/** 具体差异之外还有未能归纳的部分(服务端那句「另有未能归纳的差异」) */
|
||||
rest: string | null
|
||||
/** 两条 SQL 等价,差在取用的结果上 */
|
||||
equivalent: boolean
|
||||
/** 只差一个方面:审核人的选择会记入数据目录 */
|
||||
single: boolean
|
||||
full: DualCheckMetric | null
|
||||
}
|
||||
|
||||
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined)
|
||||
const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
|
||||
|
||||
/** payload.summary 里认得出的项(老服务端、写坏了的项跳过,不让整张卡崩掉) */
|
||||
function summaryOf(raw: unknown): DualCheckSummaryItem[] {
|
||||
return Array.isArray(raw)
|
||||
? raw.filter((s): s is DualCheckSummaryItem => !!s && typeof s === 'object' && typeof s.metric_id === 'string' && !!s.metric_id)
|
||||
: []
|
||||
}
|
||||
|
||||
/** 工件内容像不像一份对照:至少得有 metrics 数组,里面的项带 metric_id */
|
||||
function cardOf(content: unknown): DualCheckCard | null {
|
||||
if (!content || typeof content !== 'object' || !Array.isArray((content as DualCheckCard).metrics)) return null
|
||||
const card = content as DualCheckCard
|
||||
return { ...card, metrics: card.metrics.filter((m) => !!m && typeof m === 'object' && typeof m.metric_id === 'string') }
|
||||
}
|
||||
|
||||
function viewsOf(summary: DualCheckSummaryItem[], card: DualCheckCard | null): MetricView[] {
|
||||
const full = new Map((card?.metrics ?? []).map((m) => [m.metric_id, m]))
|
||||
// 先按摘要的顺序,工件里另有的(约定里不该有,有就照实列出来)跟在后面
|
||||
const ids = [...new Set([...summary.map((s) => s.metric_id), ...full.keys()])]
|
||||
return ids.map((id) => {
|
||||
const s = summary.find((x) => x.metric_id === id)
|
||||
const f = full.get(id) ?? null
|
||||
const base = {
|
||||
id,
|
||||
name: str(s?.name) ?? str(f?.name) ?? id,
|
||||
original: str(s?.original_rendered) ?? str(f?.original?.rendered) ?? null,
|
||||
blind: str(s?.blind_rendered) ?? str(f?.blind?.rendered) ?? null,
|
||||
deltaPct: s && 'delta_pct' in s ? num(s.delta_pct) ?? null : f?.delta_pct === null ? null : num(f?.delta_pct),
|
||||
}
|
||||
if (f) {
|
||||
const items = (Array.isArray(f.diff?.items) ? f.diff.items : []).filter((i) => !!i && typeof i.text === 'string' && !!i.text)
|
||||
// aspect 为 other 的是兜底说明,不算具体差异;只有它的时候卡上写那句固定的说法、默认展开对照
|
||||
const concrete = items.filter((i) => i.aspect !== 'other')
|
||||
const other = items.filter((i) => i.aspect === 'other')
|
||||
const equivalent = f.diff?.equivalent === true
|
||||
const aspects = new Set(concrete.map((i) => i.aspect))
|
||||
return {
|
||||
...base,
|
||||
items: concrete.map((i) => i.text),
|
||||
fallbackOnly: !equivalent && !concrete.length,
|
||||
rest: concrete.length && other.length ? other.map((i) => i.text).join(';') : null,
|
||||
equivalent,
|
||||
// 「只差一个方面」以服务端为准(它决定写不写数据目录),这里只在没用兜底、具体差异都落在同一方面时提示
|
||||
single: !equivalent && f.diff?.fallback !== true && aspects.size === 1 && !aspects.has('parse'),
|
||||
full: f,
|
||||
}
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
items: Array.isArray(s?.diff) ? s.diff.filter((t): t is string => typeof t === 'string' && !!t) : [],
|
||||
fallbackOnly: s?.fallback === true,
|
||||
rest: null,
|
||||
equivalent: false,
|
||||
single: false,
|
||||
full: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 相差比例:服务端给的已乘过 100、保留两位(12.5 是相差 12.5%),带正负号;小到两位小数写不出来的写「<0.01%」 */
|
||||
function deltaText(pct: number | null | undefined): string | null {
|
||||
if (pct === undefined) return null
|
||||
if (pct === null) return T.deltaZero
|
||||
const abs = Math.abs(pct)
|
||||
const sign = pct > 0 ? '+' : pct < 0 ? '-' : ''
|
||||
return T.delta(abs < 0.01 ? `${sign}<0.01%` : `${sign}${abs.toLocaleString('en-US', { maximumFractionDigits: 2 })}%`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批卡里双盲复算那一块(RunPanel 的 ApprovalCard 在 payload.kind === 'dual_check' 时渲染它)。
|
||||
* 审批卡里双盲复算那一块(RunPanel 的 ApprovalCard 在 payload.kind === 'dual_check' 时渲染它,通用的输入框、备注和
|
||||
* 「提交 / 驳回并终止」不画——mode 是 input,通用那套会把一个字符串当 value 发出去)。
|
||||
*
|
||||
* 现在是占位,什么都不画;双盲复算的工作包在这里补上两路结果的对照,payload 的形状由那边定。
|
||||
* 先按 payload.summary 画:两个值、相差多少、摘要里的差异,选择也能先做;完整对照(card_artifact)到了再补上盲算模型、
|
||||
* 全部具体差异和「对照两条 SQL」。取不到完整对照时照实说,按摘要照样能裁决。每个指标三选一、缺一个都不能提交,
|
||||
* 回复走审批接口:value 为 {choices, note}
|
||||
*/
|
||||
export function DualCheckApproval(_props: DualCheckApprovalProps) {
|
||||
return null
|
||||
export function DualCheckApproval({ approval, onResolved }: DualCheckApprovalProps) {
|
||||
const toast = useToast()
|
||||
const refreshApprovals = useCatalog((s) => s.refreshApprovals)
|
||||
const attachRun = useStudio((s) => s.attachRun)
|
||||
const uid = useId()
|
||||
const payload = approval.payload ?? {}
|
||||
const summary = useMemo(() => summaryOf(payload.summary), [payload.summary])
|
||||
const cardId = str(payload.card_artifact) ?? ''
|
||||
|
||||
const [card, setCard] = useState<{ id: string; data?: DualCheckCard | null } | null>(null)
|
||||
useEffect(() => {
|
||||
if (!cardId) return
|
||||
let alive = true
|
||||
setCard({ id: cardId })
|
||||
api.artifact(cardId).then(
|
||||
(res) => { if (alive) setCard({ id: cardId, data: cardOf(res?.content) }) },
|
||||
// 取不到(工件被清理、网络断了)和取回来认不出一样处理:按摘要裁决
|
||||
() => { if (alive) setCard({ id: cardId, data: null }) },
|
||||
)
|
||||
return () => { alive = false }
|
||||
}, [cardId])
|
||||
const slot = card?.id === cardId ? card : null
|
||||
const data = slot?.data ?? null
|
||||
const loading = !!cardId && slot?.data === undefined
|
||||
const lost = !cardId || slot?.data === null
|
||||
|
||||
const views = useMemo(() => viewsOf(summary, data), [summary, data])
|
||||
const [choices, setChoices] = useState<Record<string, DualCheckChoice>>({})
|
||||
const [note, setNote] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const pending = views.filter((v) => !choices[v.id]).length
|
||||
|
||||
const submit = async () => {
|
||||
if (busy || pending || !views.length) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const value: DualCheckReply = {
|
||||
choices: Object.fromEntries(views.map((v) => [v.id, choices[v.id]])),
|
||||
note: note.trim(),
|
||||
}
|
||||
// 照 ApprovalCard 里 mode 为 input 时的写法:备注外层也放一份,节点拿到的是 {approved, note, value}
|
||||
await api.approvals.decide(approval.id, { approved: true, note: value.note, value })
|
||||
await refreshApprovals()
|
||||
if (onResolved) await onResolved(approval.run_id)
|
||||
else await attachRun(approval.run_id)
|
||||
toast(T.done, 'ok')
|
||||
} catch (e) {
|
||||
toast.error(e)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="@container mb-2 space-y-2" data-dual-check="" data-state={loading ? 'loading' : lost ? 'summary' : 'ready'}>
|
||||
{views.length > 0 && <p className="text-xs leading-relaxed" data-dual-intro="">{T.intro(views.length)}</p>}
|
||||
{loading && (
|
||||
<div className="flex items-center gap-1.5 text-2xs text-faint" role="status" data-dual-loading="">
|
||||
<Spinner size={10} /> {T.loading}
|
||||
</div>
|
||||
)}
|
||||
{lost && views.length > 0 && (
|
||||
<div className="text-2xs leading-relaxed" style={{ color: 'var(--st-waiting)' }} data-dual-load-failed="">{T.loadFailed}</div>
|
||||
)}
|
||||
{data && (str(data.model) || data.same_model) && (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-2xs">
|
||||
{str(data.model) && <span className="mono text-dim [overflow-wrap:anywhere]" data-dual-model="">{T.model(data.model!)}</span>}
|
||||
{data.same_model && (
|
||||
<span className="flex items-center gap-1" style={{ color: 'var(--st-waiting)' }} data-dual-same-model="">
|
||||
<AlertTriangle size={11} aria-hidden className="shrink-0" /> {T.sameModel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!views.length ? (
|
||||
<p className="text-2xs leading-relaxed text-dim" data-dual-empty="">{T.empty}</p>
|
||||
) : (
|
||||
<>
|
||||
{views.map((v) => (
|
||||
<MetricBlock key={v.id} view={v} name={`${uid}-${v.id}`} choice={choices[v.id]}
|
||||
onChoose={(c) => setChoices((cur) => ({ ...cur, [v.id]: c }))} />
|
||||
))}
|
||||
|
||||
{/* 选了会怎样,常显在提交按钮上面:审核人得在提交之前知道「采用盲算」并不替换报告里的数 */}
|
||||
<div className="text-2xs leading-relaxed text-dim" data-dual-consequence="">
|
||||
{T.consequence(approval.run_class)}
|
||||
<span className="mt-0.5 block">{T.consequenceFail}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor={`${uid}-note`}>{T.note}</label>
|
||||
<textarea id={`${uid}-note`} className="field" rows={2} value={note} placeholder={T.notePlaceholder}
|
||||
data-dual-note="" onChange={(e) => setNote(e.target.value)} />
|
||||
</div>
|
||||
{pending > 0 && (
|
||||
<div id={`${uid}-pending`} className="text-2xs" style={{ color: 'var(--st-waiting)' }} data-dual-pending="">
|
||||
{T.pending(pending)}
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="btn btn-primary w-full justify-center" disabled={busy || pending > 0}
|
||||
aria-describedby={pending > 0 ? `${uid}-pending` : undefined} data-dual-submit="" onClick={() => void submit()}>
|
||||
{busy ? <Spinner size={11} /> : <Check size={12} aria-hidden />} {T.submit}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 一个不一致的指标:名字、两个值、相差多少、差异说明、对照两条 SQL、三选一 */
|
||||
function MetricBlock({ view, name, choice, onChoose }: {
|
||||
view: MetricView; name: string; choice?: DualCheckChoice; onChoose: (c: DualCheckChoice) => void
|
||||
}) {
|
||||
const uid = useId()
|
||||
// 只有兜底说明时默认展开:一条具体说法都没有,审核人只能对照原文判断
|
||||
const [open, setOpen] = useState<boolean | null>(null)
|
||||
const expanded = !!view.full && (open ?? view.fallbackOnly)
|
||||
const delta = deltaText(view.deltaPct)
|
||||
return (
|
||||
<div className="rounded border bg-bg p-2" role="group" aria-labelledby={`${uid}-name`} data-dual-metric={view.id}>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span id={`${uid}-name`} className="min-w-0 text-xs font-medium [overflow-wrap:anywhere]" data-dual-name="">{view.name}</span>
|
||||
{delta && (
|
||||
<span className="tnum ml-auto shrink-0 text-2xs font-medium" style={{ color: 'var(--st-waiting)' }} title={T.deltaHint}
|
||||
data-dual-delta="">{delta}</span>
|
||||
)}
|
||||
</div>
|
||||
<dl className="mt-1 grid grid-cols-2 gap-2" data-dual-values="">
|
||||
<div className="min-w-0">
|
||||
<dt className="text-2xs text-faint">{DUAL_CHECK_TEXT.original}</dt>
|
||||
<dd className="tnum m-0 text-sm font-medium [overflow-wrap:anywhere]" data-dual-value="original">{view.original ?? NONE}</dd>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<dt className="text-2xs text-faint">{DUAL_CHECK_TEXT.blind}</dt>
|
||||
<dd className="tnum m-0 text-sm font-medium [overflow-wrap:anywhere]" data-dual-value="blind">{view.blind ?? NONE}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="mt-1.5" data-dual-diff="">
|
||||
<div className="text-2xs text-faint">{T.diffHead}</div>
|
||||
{view.equivalent ? (
|
||||
<p className="text-2xs leading-relaxed text-dim" data-dual-equivalent="">{T.equivalent}</p>
|
||||
) : view.fallbackOnly ? (
|
||||
<p className="text-2xs leading-relaxed" style={{ color: 'var(--st-waiting)' }} data-dual-fallback="">{T.fallbackOnly}</p>
|
||||
) : view.items.length > 0 && (
|
||||
<ul className="list-disc space-y-0.5 pl-4 text-2xs leading-relaxed [overflow-wrap:anywhere]">
|
||||
{view.items.map((t, i) => <li key={i} data-dual-diff-item="">{t}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{view.rest && <p className="mt-0.5 text-2xs leading-relaxed text-faint" data-dual-diff-rest="">{view.rest}</p>}
|
||||
</div>
|
||||
|
||||
{view.full && (
|
||||
<button type="button" className="btn btn-xs mt-1.5" aria-expanded={expanded} aria-controls={expanded ? `${uid}-cmp` : undefined}
|
||||
data-dual-compare-toggle="" onClick={() => setOpen(!expanded)}>
|
||||
<ChevronRight size={11} aria-hidden style={{ transform: expanded ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }} />
|
||||
{expanded ? T.compareClose : T.compare}
|
||||
</button>
|
||||
)}
|
||||
{expanded && view.full && <Compare id={`${uid}-cmp`} metric={view.full} />}
|
||||
{view.single && <p className="mt-1.5 text-2xs leading-relaxed text-dim" data-dual-catalog="">{T.catalog}</p>}
|
||||
|
||||
{/* 原生单选:Tab 进组、方向键换选项,读屏念得出是哪个指标的哪一项 */}
|
||||
<fieldset className="mt-2 min-w-0 border-0 p-0" data-dual-choice={view.id}>
|
||||
<legend className="sr-only">{T.choiceGroup(view.name)}</legend>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{CHOICES.map((c) => (
|
||||
<label key={c}
|
||||
className={clsx('flex cursor-pointer items-center gap-1.5 rounded border px-2 py-1 text-2xs transition-colors hover:bg-hover',
|
||||
choice === c ? 'font-medium' : 'text-dim')}
|
||||
style={choice === c ? { borderColor: 'var(--accent)', background: 'var(--accent-soft)', color: 'var(--text)' } : undefined}>
|
||||
<input type="radio" className="accent-[var(--accent)]" name={name} value={c} checked={choice === c}
|
||||
onChange={() => onChoose(c)} />
|
||||
{T.choices[c]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 一个输入在两边的取值和 SQL */
|
||||
interface Pair {
|
||||
path: string
|
||||
original?: DualCheckInput
|
||||
blind?: DualCheckBlindInput
|
||||
source: string | null
|
||||
/** SQL 或取到的值不同 */
|
||||
differs: boolean
|
||||
}
|
||||
|
||||
/** 比 SQL 时不看空白和大小写:原样写成一行和换了行的是同一条 */
|
||||
const normSql = (sql: unknown): string => (typeof sql === 'string' ? sql.replace(/\s+/g, ' ').trim().toLowerCase() : '')
|
||||
/** 比一行时再去掉行尾的逗号、分号:规范化的写法逗号在行尾,原文不一定 */
|
||||
const normLine = (line: string): string => line.replace(/\s+/g, ' ').trim().replace(/[,;]+$/, '').trim().toLowerCase()
|
||||
const sameValue = (a: unknown, b: unknown): boolean => {
|
||||
if (typeof a === 'number' && typeof b === 'number') return a === b
|
||||
try { return JSON.stringify(a) === JSON.stringify(b) } catch { return false }
|
||||
}
|
||||
|
||||
function pairsOf(metric: DualCheckMetric): Pair[] {
|
||||
const byPath = new Map<string, { original?: DualCheckInput; blind?: DualCheckBlindInput }>()
|
||||
const valid = (i: unknown): i is DualCheckInput => !!i && typeof i === 'object' && typeof (i as DualCheckInput).path === 'string'
|
||||
for (const i of (Array.isArray(metric.original?.inputs) ? metric.original.inputs : []).filter(valid)) {
|
||||
byPath.set(i.path, { ...byPath.get(i.path), original: i })
|
||||
}
|
||||
for (const i of (Array.isArray(metric.blind?.inputs) ? metric.blind.inputs : []).filter(valid)) {
|
||||
byPath.set(i.path, { ...byPath.get(i.path), blind: i })
|
||||
}
|
||||
return [...byPath].map(([path, p]) => ({
|
||||
path, ...p,
|
||||
source: str(p.original?.source) ?? str(p.blind?.source) ?? null,
|
||||
differs: normSql(p.original?.sql) !== normSql(p.blind?.sql) || !sameValue(p.original?.value, p.blind?.value),
|
||||
}))
|
||||
}
|
||||
|
||||
/** text_diff(「- 」原流程、「+ 」盲算)→ 两边各自要高亮的行(按 normLine 比) */
|
||||
function marksOf(textDiff: unknown): { original: Set<string>; blind: Set<string> } {
|
||||
const original = new Set<string>()
|
||||
const blind = new Set<string>()
|
||||
for (const line of Array.isArray(textDiff) ? textDiff : []) {
|
||||
if (typeof line !== 'string') continue
|
||||
if (line.startsWith('- ')) original.add(normLine(line.slice(2)))
|
||||
else if (line.startsWith('+ ')) blind.add(normLine(line.slice(2)))
|
||||
}
|
||||
original.delete('')
|
||||
blind.delete('')
|
||||
return { original, blind }
|
||||
}
|
||||
|
||||
function valueText(v: unknown): string {
|
||||
if (v == null) return NONE
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v.toLocaleString('en-US', { maximumFractionDigits: 6 }) : String(v)
|
||||
if (typeof v === 'string' || typeof v === 'boolean') return String(v)
|
||||
try {
|
||||
const s = JSON.stringify(v)
|
||||
return s.length > 80 ? `${s.slice(0, 79)}…` : s
|
||||
} catch {
|
||||
return String(v)
|
||||
}
|
||||
}
|
||||
|
||||
/** 高亮的行:两边都用同一种提醒底色——谁对谁错由审核人判断,不用红绿暗示哪边是「删掉的」 */
|
||||
const MARK: CSSProperties = {
|
||||
background: 'color-mix(in srgb, var(--warn) 16%, transparent)', color: 'var(--text)', boxShadow: 'inset 2px 0 0 var(--warn)',
|
||||
}
|
||||
|
||||
/**
|
||||
* 对照两条 SQL:先列每个输入两边各取到多少,再把不同的输入的两条 SQL 左右并排(卡片窄于 32rem 时上下排,按卡片
|
||||
* 自己的宽度算,画布右栏里也是上下排),规范化后不同的行在原文里高亮;规范化的写法和原文对不上(模型常把 SQL 写成
|
||||
* 一行)时原文不乱标,规范化后不同的行另列一份,每行标是哪一边
|
||||
*/
|
||||
function Compare({ id, metric }: { id: string; metric: DualCheckMetric }) {
|
||||
const pairs = useMemo(() => pairsOf(metric), [metric])
|
||||
const marks = useMemo(() => marksOf(metric.diff?.text_diff), [metric])
|
||||
const lines = useMemo(() => (Array.isArray(metric.diff?.text_diff) ? metric.diff.text_diff : [])
|
||||
.filter((l): l is string => typeof l === 'string' && (l.startsWith('- ') || l.startsWith('+ ')))
|
||||
.map((l) => ({ side: l.startsWith('- ') ? 'original' as const : 'blind' as const, text: l.slice(2) })), [metric])
|
||||
const differing = pairs.filter((p) => p.differs)
|
||||
return (
|
||||
<div id={id} className="mt-1.5 min-w-0 space-y-2" data-dual-compare="">
|
||||
{pairs.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-0.5 text-2xs text-faint">{T.inputs}</div>
|
||||
<ul className="space-y-0.5">
|
||||
{pairs.map((p) => (
|
||||
<li key={p.path} className="flex min-w-0 flex-wrap items-baseline gap-x-2 text-2xs" data-dual-input={p.path}
|
||||
data-differs={p.differs ? '' : undefined}>
|
||||
<code className="mono min-w-0 [overflow-wrap:anywhere]">{p.path}</code>
|
||||
<span className="tnum text-dim">{DUAL_CHECK_TEXT.original} {valueText(p.original?.value)}</span>
|
||||
<span className={clsx('tnum', !p.differs && 'text-dim')} style={p.differs ? { color: 'var(--st-waiting)' } : undefined}>
|
||||
{DUAL_CHECK_TEXT.blind} {valueText(p.blind?.value)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{(differing.length ? differing : pairs).map((p) => (
|
||||
<div key={p.path} className="min-w-0" data-dual-pair={p.path}>
|
||||
<div className="mb-0.5 text-2xs text-faint [overflow-wrap:anywhere]">
|
||||
<code className="mono">{p.path}</code>{p.source ? ` · ${T.source(p.source)}` : ''}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 @lg:grid-cols-2">
|
||||
<SqlPane side="original" label={T.sqlOriginal} sql={p.original?.sql} marks={marks.original} />
|
||||
<SqlPane side="blind" label={T.sqlBlind} sql={p.blind?.sql} marks={marks.blind} />
|
||||
</div>
|
||||
{str(p.blind?.rationale) && (
|
||||
<p className="mt-1 text-2xs leading-relaxed text-dim [overflow-wrap:anywhere]" data-dual-rationale="">
|
||||
<span className="text-faint">{T.rationale}:</span>{p.blind!.rationale}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{lines.length > 0 && (
|
||||
<div data-dual-text-diff="">
|
||||
<div className="mb-0.5 text-2xs text-faint">{T.textDiff}</div>
|
||||
<div className="mono max-h-48 overflow-y-auto rounded border bg-panel px-2 py-1.5 text-2xs leading-relaxed focus-visible:outline-2 focus-visible:outline-[color:var(--accent)]"
|
||||
tabIndex={0} role="group" aria-label={T.textDiff}>
|
||||
{lines.map((l, i) => (
|
||||
<div key={i} className="flex min-w-0 gap-2" data-diff-side={l.side}>
|
||||
{/* 标签列等宽:两边的行首对齐,一眼看出是同一处的两种写法 */}
|
||||
<span className="w-[3em] shrink-0 font-sans text-faint">{l.side === 'original' ? DUAL_CHECK_TEXT.original : DUAL_CHECK_TEXT.blind}</span>
|
||||
<span className="min-w-0 whitespace-pre-wrap text-dim [overflow-wrap:anywhere]">{l.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 一条 SQL:逐行画,text_diff 里有的行高亮。折行显示、不横向撑开,太长时框里纵向滚动,框能用键盘聚焦 */
|
||||
function SqlPane({ side, label, sql, marks }: { side: 'original' | 'blind'; label: string; sql?: string | null; marks: Set<string> }) {
|
||||
const lines = typeof sql === 'string' && sql.trim() ? sql.replace(/\s+$/, '').split('\n') : null
|
||||
return (
|
||||
<figure className="m-0 min-w-0">
|
||||
<figcaption className="mb-0.5 text-2xs text-faint">{label}</figcaption>
|
||||
<pre className={clsx('max-h-56 overflow-y-auto whitespace-pre-wrap rounded border bg-panel px-1 py-1.5 text-2xs leading-relaxed [overflow-wrap:anywhere] focus-visible:outline-2 focus-visible:outline-[color:var(--accent)]',
|
||||
lines ? 'mono text-dim' : 'font-sans text-faint')}
|
||||
tabIndex={0} aria-label={label} data-dual-sql={side}>
|
||||
{lines
|
||||
? lines.map((line, i) => (marks.has(normLine(line))
|
||||
? <mark key={i} className="block rounded-sm px-1" style={MARK} data-diff-line={side === 'original' ? 'del' : 'add'}>{line || ' '}</mark>
|
||||
: <span key={i} className="block px-1">{line || ' '}</span>))
|
||||
: <span className="px-1">{T.sqlMissing}</span>}
|
||||
</pre>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -104,6 +104,9 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: {
|
||||
const where = approval.node_label || approval.node_id
|
||||
// 人工审批节点挂起的审批(工具、代码的审批另有 kind):卡上标明运行类别、批了会怎样,审的是报告时附上核对概况
|
||||
const humanNode = payload.kind === 'human_node'
|
||||
// 双盲复算两路对不上的审批:mode 是 input,但三选一和提交由 DualCheckApproval 自己画——通用的输入框、备注和
|
||||
// 「提交 / 驳回并终止」会和它重复,通用的提交还会把一个字符串当 value 发出去
|
||||
const dualCheck = payload.kind === 'dual_check'
|
||||
const reports: ApprovalReport[] = Array.isArray(payload.reports)
|
||||
? payload.reports.filter((r: unknown): r is ApprovalReport =>
|
||||
!!r && typeof r === 'object' && typeof (r as ApprovalReport).node_id === 'string')
|
||||
@@ -147,7 +150,7 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: {
|
||||
)}
|
||||
|
||||
{/* 双盲复算对不上、交给人裁决的审批,卡上画两路结果的对照 */}
|
||||
{payload.kind === 'dual_check' && <DualCheckApproval approval={approval} />}
|
||||
{dualCheck && <DualCheckApproval approval={approval} onResolved={onResolved} />}
|
||||
|
||||
<ApprovalReports reports={reports} runId={approval.run_id} runClass={approval.run_class} />
|
||||
<ChallengeSummary reports={reports} runId={approval.run_id} />
|
||||
@@ -169,7 +172,7 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: {
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{!approveMode && (
|
||||
{!approveMode && !dualCheck && (
|
||||
<textarea
|
||||
className="field mb-2"
|
||||
rows={4}
|
||||
@@ -180,40 +183,42 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: {
|
||||
/>
|
||||
)}
|
||||
|
||||
<input
|
||||
className="field mb-2"
|
||||
aria-label="备注"
|
||||
placeholder="备注(将传递给后续节点)"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
{!dualCheck && (<>
|
||||
<input
|
||||
className="field mb-2"
|
||||
aria-label="备注"
|
||||
placeholder="备注(将传递给后续节点)"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button className="btn btn-primary flex-1 justify-center" disabled={busy} onClick={() => decide(true)}>
|
||||
<Check size={12} aria-hidden /> {approveMode ? '批准' : '提交'}
|
||||
</button>
|
||||
{trustKey && (
|
||||
<button className="btn flex-1 justify-center" disabled={busy} data-approval-always
|
||||
title={TOOL_TRUST_TEXT.alwaysHint(trustKey)} aria-describedby={`approval-always-${approval.id}`}
|
||||
onClick={() => decide(true, true)}>
|
||||
<CheckCheck size={12} aria-hidden /> {TOOL_TRUST_TEXT.alwaysButton}
|
||||
<div className="flex gap-2">
|
||||
<button className="btn btn-primary flex-1 justify-center" disabled={busy} onClick={() => decide(true)}>
|
||||
<Check size={12} aria-hidden /> {approveMode ? '批准' : '提交'}
|
||||
</button>
|
||||
{trustKey && (
|
||||
<button className="btn flex-1 justify-center" disabled={busy} data-approval-always
|
||||
title={TOOL_TRUST_TEXT.alwaysHint(trustKey)} aria-describedby={`approval-always-${approval.id}`}
|
||||
onClick={() => decide(true, true)}>
|
||||
<CheckCheck size={12} aria-hidden /> {TOOL_TRUST_TEXT.alwaysButton}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-danger flex-1 justify-center" disabled={busy} onClick={() => decide(false)}>
|
||||
<Ban size={12} aria-hidden /> {approveMode ? '驳回' : '驳回并终止'}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-danger flex-1 justify-center" disabled={busy} onClick={() => decide(false)}>
|
||||
<Ban size={12} aria-hidden /> {approveMode ? '驳回' : '驳回并终止'}
|
||||
</button>
|
||||
</div>
|
||||
{/* 批了会怎样,常显而不是藏在 hover 里:审批人得在按下去之前知道 */}
|
||||
<div className="mt-1.5 text-2xs leading-relaxed text-dim">
|
||||
{approveMode
|
||||
? '批准:继续运行;驳回:转入「驳回」分支'
|
||||
: '提交:以你填写的内容继续运行;驳回:终止本次运行'}
|
||||
</div>
|
||||
{trustKey && (
|
||||
<div id={`approval-always-${approval.id}`} className="mt-0.5 text-2xs leading-relaxed text-dim">
|
||||
{TOOL_TRUST_TEXT.alwaysButton}:{TOOL_TRUST_TEXT.alwaysHint(trustKey)}
|
||||
</div>
|
||||
)}
|
||||
{/* 批了会怎样,常显而不是藏在 hover 里:审批人得在按下去之前知道 */}
|
||||
<div className="mt-1.5 text-2xs leading-relaxed text-dim">
|
||||
{approveMode
|
||||
? '批准:继续运行;驳回:转入「驳回」分支'
|
||||
: '提交:以你填写的内容继续运行;驳回:终止本次运行'}
|
||||
</div>
|
||||
{trustKey && (
|
||||
<div id={`approval-always-${approval.id}`} className="mt-0.5 text-2xs leading-relaxed text-dim">
|
||||
{TOOL_TRUST_TEXT.alwaysButton}:{TOOL_TRUST_TEXT.alwaysHint(trustKey)}
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
<div className="mt-1 flex items-center gap-1 text-2xs leading-relaxed"
|
||||
style={actor ? { color: 'var(--text-dim)' } : { color: 'var(--st-waiting)' }}>
|
||||
<PenLine size={10} aria-hidden className="shrink-0" />
|
||||
|
||||
+363
-6
@@ -1,8 +1,365 @@
|
||||
// 双盲复算与反方审稿的检查。现在只是占位:功能还在开发,这里只打一行通过,让 check-all 的清单先排上它。
|
||||
// 双盲复算与反方审稿的工作包把它换成真检查:沿用别的检查的输出格式——每项缩进两格、以 ✓ / ✗ 开头,最后顶格一行
|
||||
// 总结,没过的退出码非 0(check-all 按缩进的 ✓ / ✗ 计数)。
|
||||
// 双盲复算与反方审稿的回归检查(期 3)。
|
||||
//
|
||||
// 双盲复算这一半守的是这些「看着没坏、其实用不了」的地方:
|
||||
// - 审批卡:两路对不上时,卡上要先按摘要画出来(完整对照还在路上时不能一片空白),再按工件补全;具体差异逐条列,
|
||||
// 只有兜底说明时写明「未能归纳成具体差异」并默认展开对照;对照两条 SQL 时左右并排(窄栏上下排),规范化后不同的
|
||||
// 行高亮;三个选择缺一个都不能提交,提交的回复是 {choices, note};卡上不能再冒出通用的输入框和「提交 / 驳回并终止」
|
||||
// (mode 是 input,通用那套会把一个字符串当 value 发出去);后果按运行类别说,探索运行不提出具;
|
||||
// - 检查器:口径卡、指标、口径说明、盲算模型、相对误差、不采用原流程时、启用,每一项都真的写进节点配置;
|
||||
// 节点卡片的摘要写出复算哪张口径卡、几个指标;
|
||||
// - 设置页「核查」一节:读回各项,改了之后整组写 verify(不限存 null),叫法和服务端 VERIFY_SETTING_LABEL 一字不差;
|
||||
// - 390px 窄屏不横向溢出,深浅色都读得清,键盘能操作。
|
||||
//
|
||||
// 页面都跑在预览页(/preview.html?syn=…,用例在 frontend/src/dev/fixtures/dual_check.tsx,夹具 dual_check.json 两边
|
||||
// 共用)和设置页上,接口一律 page.route 伪造:完整对照的工件、审批回复、设置的读写都在浏览器里答,别的非 GET 拦掉,
|
||||
// 不碰真实的写接口。文案对照那一节不开浏览器:读 backend/app/api/settings.py,terms.ts 借 vite 转译后直接 import。
|
||||
//
|
||||
// node scripts/check-verify.mjs
|
||||
console.log('=== 双盲复算与反方审稿 ===')
|
||||
console.log(' ✓ 占位:功能开发中')
|
||||
console.log('\n✓ 双盲复算与反方审稿:占位通过')
|
||||
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-verify.mjs
|
||||
// VERIFY_SHOTS=<目录> 时亮暗两套各截几张,供人眼复核。
|
||||
import { mkdirSync, readFileSync } from 'node:fs'
|
||||
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
|
||||
|
||||
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
|
||||
const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
||||
const SHOTS = process.env.VERIFY_SHOTS ?? ''
|
||||
const root = new URL('..', import.meta.url).pathname
|
||||
const fx = JSON.parse(readFileSync(`${root}frontend/src/dev/fixtures/dual_check.json`, 'utf8'))
|
||||
const AP = fx.approval
|
||||
const CARD_ID = AP.payload.card_artifact
|
||||
|
||||
let failed = 0
|
||||
const check = (name, cond, detail = '') => {
|
||||
const d = String(detail ?? '').replace(/\s+/g, ' ').slice(0, 160)
|
||||
console.log(` ${cond ? '✓' : '✗'} ${name}${d ? ` — ${d}` : ''}`)
|
||||
if (!cond) failed++
|
||||
}
|
||||
/** 一节一节地跑:某一节里等待超时、元素找不到,只记成这一节失败,收掉它开的页面,接着跑下一节 */
|
||||
const opened = new Set()
|
||||
async function section(name, fn) {
|
||||
console.log(`\n=== ${name} ===`)
|
||||
try {
|
||||
await fn()
|
||||
} catch (e) {
|
||||
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
|
||||
} finally {
|
||||
for (const c of opened) await c.close().catch(() => {})
|
||||
opened.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// terms.ts 借 vite 转译成 data: URL 再 import(同 check-decode):依赖也各自转好替换进去
|
||||
const loaded = new Map()
|
||||
async function load(path) {
|
||||
if (loaded.has(path)) return loaded.get(path)
|
||||
const res = await fetch(`${WEB}${path}?t=${Date.now()}`)
|
||||
if (!res.ok) throw new Error(`${path} → HTTP ${res.status}`)
|
||||
let code = await res.text()
|
||||
const deps = new Set([...code.matchAll(/from\s+["'](\/src\/[^"'?]+)(?:\?[^"']*)?["']/g)].map((m) => m[1]))
|
||||
for (const dep of deps) {
|
||||
const url = await load(dep)
|
||||
const pattern = new RegExp(`(["'])${dep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\?[^"']*)?\\1`, 'g')
|
||||
code = code.replace(pattern, JSON.stringify(url))
|
||||
}
|
||||
const url = 'data:text/javascript;base64,' + Buffer.from(code).toString('base64')
|
||||
loaded.set(path, url)
|
||||
return url
|
||||
}
|
||||
|
||||
const shot = async (page, name) => {
|
||||
if (!SHOTS) return
|
||||
mkdirSync(SHOTS, { recursive: true })
|
||||
await page.screenshot({ path: `${SHOTS}/${name}.png`, fullPage: true })
|
||||
}
|
||||
const text = async (loc) => (await loc.first().innerText().catch(() => '')).replace(/\s+/g, ' ').trim()
|
||||
|
||||
/** 两个颜色的对比度(WCAG):颜色是 getComputedStyle 给的 rgb / rgba / color(srgb …),半透明的叠在底色上 */
|
||||
function contrast(fg, bg, base) {
|
||||
const parse = (c) => {
|
||||
const m = c.match(/color\(srgb ([\d.]+) ([\d.]+) ([\d.]+)(?: \/ ([\d.]+))?\)/)
|
||||
if (m) return [m[1] * 255, m[2] * 255, m[3] * 255, m[4] == null ? 1 : Number(m[4])]
|
||||
const n = c.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/)
|
||||
return n ? [Number(n[1]), Number(n[2]), Number(n[3]), n[4] == null ? 1 : Number(n[4])] : null
|
||||
}
|
||||
const over = (top, under) => top.slice(0, 3).map((v, i) => v * top[3] + under[i] * (1 - top[3]))
|
||||
const lum = ([r, g, b]) => {
|
||||
const f = (v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 }
|
||||
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b)
|
||||
}
|
||||
const b0 = parse(base); const b1 = parse(bg); const f0 = parse(fg)
|
||||
if (!b0 || !b1 || !f0) return 0
|
||||
const under = over(b1, b0.slice(0, 3))
|
||||
const a = lum(over(f0, under)); const b = lum(under)
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 文案对照(不开浏览器)
|
||||
|
||||
await section('核查设置的叫法和服务端一字不差', async () => {
|
||||
// 设置页保存时 422 的报错写的是 VERIFY_SETTING_LABEL 里的叫法:界面上的字段名得是同一套,人才找得到是哪一项
|
||||
const py = readFileSync(`${root}backend/app/api/settings.py`, 'utf8')
|
||||
const block = py.match(/^VERIFY_SETTING_LABEL = \{([\s\S]*?)^\}/m)?.[1] ?? ''
|
||||
const labels = Object.fromEntries([...block.matchAll(/"([^"]+)":\s*"([^"]+)"/g)].map((m) => [m[1], m[2]]))
|
||||
check('从 settings.py 读出了 VERIFY_SETTING_LABEL', Object.keys(labels).length >= 10, `${Object.keys(labels).length} 项`)
|
||||
const { VERIFY_SETTING_TEXT: T } = await import(await load('/src/lib/terms.ts'))
|
||||
const keys = Object.keys(T).filter((k) => k !== 'section')
|
||||
const missing = Object.keys(labels).filter((k) => !(k in T))
|
||||
const extra = keys.filter((k) => !(k in labels))
|
||||
check('VERIFY_SETTING_TEXT 顶层的键和服务端完全相同(section 除外)', !missing.length && !extra.length,
|
||||
`缺 ${missing.join('、') || '无'};多 ${extra.join('、') || '无'}`)
|
||||
const wrong = Object.entries(labels).filter(([k, v]) => T[k] !== v)
|
||||
check('……每一项的叫法一字不差', !wrong.length, wrong.map(([k, v]) => `${k}:${T[k]} ≠ ${v}`).join(';'))
|
||||
check('……这一节其余的文案放在 section 里', !!T.section?.title && typeof T.section.spend === 'function')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 页面
|
||||
|
||||
const browser = await chromium.launch({ executablePath: CHROME })
|
||||
|
||||
/**
|
||||
* 开一个预览用例。完整对照的工件按 artifact 答(默认夹具里的那份;给函数可以延迟、出错),审批回复记下请求体、
|
||||
* 回一个伪造的运行;审批列表回空;别的非 GET 一律拦掉,别的 GET 放行
|
||||
*/
|
||||
async function preview(syn, { width = 1280, height = 900, theme = 'dark', artifact = null, extra = null } = {}) {
|
||||
const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, timezoneId: 'Asia/Shanghai' })
|
||||
opened.add(ctx)
|
||||
ctx.setDefaultTimeout(6000)
|
||||
const page = await ctx.newPage()
|
||||
const sent = []
|
||||
const errors = []
|
||||
page.on('pageerror', (e) => errors.push(e.message))
|
||||
page.on('dialog', (d) => { errors.push(`原生对话框:${d.message()}`); void d.dismiss() })
|
||||
await page.route((u) => new URL(u).pathname.startsWith('/api/'), async (route) => {
|
||||
const req = route.request()
|
||||
const url = new URL(req.url())
|
||||
if (extra) {
|
||||
const out = await extra(req, url)
|
||||
if (out) return route.fulfill({ status: out.status ?? 200, json: out.json })
|
||||
}
|
||||
if (req.method() === 'POST' && url.pathname === `/api/approvals/${AP.id}/decide`) {
|
||||
sent.push({ path: url.pathname, body: req.postDataJSON() })
|
||||
return route.fulfill({ json: { id: AP.run_id, status: 'running', run_class: AP.run_class, input: {}, output: {}, usage: {} } })
|
||||
}
|
||||
if (req.method() !== 'GET') {
|
||||
sent.push({ path: url.pathname, blocked: true, method: req.method() })
|
||||
return route.abort()
|
||||
}
|
||||
if (url.pathname === `/api/artifacts/${CARD_ID}`) {
|
||||
const out = artifact ? await artifact() : { json: { id: CARD_ID, content: fx.card } }
|
||||
return route.fulfill({ status: out.status ?? 200, json: out.json })
|
||||
}
|
||||
if (url.pathname === '/api/approvals') return route.fulfill({ json: [] })
|
||||
return route.continue()
|
||||
})
|
||||
await page.goto(`${WEB}/preview.html?syn=${syn}&theme=${theme}&actor=张工`, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForSelector(`[data-fixture="${syn}"]`)
|
||||
return { ctx, page, sent, errors }
|
||||
}
|
||||
|
||||
const metric = (page, id) => page.locator(`[data-dual-metric="${id}"]`)
|
||||
|
||||
await section('审批卡:先按摘要画,再取完整对照', async () => {
|
||||
// 完整对照晚一点到:这段时间里卡上照样有两个值、相差多少、摘要里的差异,选择也能先做
|
||||
let release
|
||||
const gate = new Promise((r) => { release = r })
|
||||
const { page, sent, errors } = await preview('dual-check-approval', {
|
||||
artifact: async () => { await gate; return { json: { id: CARD_ID, content: fx.card } } },
|
||||
})
|
||||
await page.waitForSelector('[data-dual-check]')
|
||||
const card = page.locator('[data-approval]')
|
||||
check('卡头是审批标题', (await text(card)).includes('双盲复算:2 个指标不一致'))
|
||||
check('开头一句说几个指标不一致、要逐个选', (await text(page.locator('[data-dual-intro]'))) === '盲算与原流程有 2 个指标不一致,请逐个选择采用哪一边的结果')
|
||||
check('每个不一致的指标一块,按摘要的顺序', (await page.locator('[data-dual-metric]').evaluateAll((els) => els.map((e) => e.dataset.dualMetric))).join(',')
|
||||
=== 'gmv,refund_rate')
|
||||
const gmv = metric(page, 'gmv')
|
||||
check('指标名、原流程值、盲算值', (await text(gmv.locator('[data-dual-name]'))) === '销售额'
|
||||
&& (await text(gmv.locator('[data-dual-value="original"]'))) === '45,678.5元' && (await text(gmv.locator('[data-dual-value="blind"]'))) === '51,388.3元')
|
||||
check('……两个值各标是哪一边', (await text(gmv.locator('[data-dual-values]'))).includes('原流程') && (await text(gmv.locator('[data-dual-values]'))).includes('盲算'))
|
||||
check('相差百分比带正负号', (await text(gmv.locator('[data-dual-delta]'))) === '相差 +12.5%' && (await text(metric(page, 'refund_rate').locator('[data-dual-delta]'))) === '相差 +31.91%',
|
||||
`${await text(gmv.locator('[data-dual-delta]'))} / ${await text(metric(page, 'refund_rate').locator('[data-dual-delta]'))}`)
|
||||
check('完整对照还没到:先列摘要里的具体差异', (await text(gmv.locator('[data-dual-diff-item]'))) === AP.payload.summary[0].diff[0])
|
||||
check('……只有兜底的那个指标写明未能归纳', (await text(metric(page, 'refund_rate').locator('[data-dual-fallback]'))) === '两条 SQL 的写法不同,未能归纳成具体差异')
|
||||
check('……说明正在加载完整对照', (await page.locator('[data-dual-loading]').count()) > 0)
|
||||
check('卡上没有通用的输入框和备注', await card.locator('textarea[aria-label="你的输入"], input[aria-label="备注"]').count() === 0)
|
||||
check('……也没有通用的「提交」「驳回并终止」', await card.getByRole('button', { name: '提交', exact: true }).count() === 0
|
||||
&& await card.getByRole('button', { name: /驳回/ }).count() === 0)
|
||||
check('……通用那句「提交:以你填写的内容继续运行」也不在', !(await text(card)).includes('以你填写的内容继续运行'))
|
||||
check('署名那一行还在', (await text(card)).includes('将以「张工」签批'))
|
||||
release()
|
||||
await page.waitForSelector('[data-dual-check][data-state="ready"]')
|
||||
check('完整对照到了:写出盲算模型', (await text(page.locator('[data-dual-model]'))) === '盲算模型:blind-model-x')
|
||||
check('……具体差异按工件逐条列', (await gmv.locator('[data-dual-diff-item]').count()) === 1
|
||||
&& (await text(gmv.locator('[data-dual-diff-item]'))) === fx.card.metrics[0].diff.items[0].text)
|
||||
check('……具体差异的那个指标默认收着对照,给「对照两条 SQL」', (await gmv.locator('[data-dual-compare-toggle]').getAttribute('aria-expanded')) === 'false'
|
||||
&& (await text(gmv.locator('[data-dual-compare-toggle]'))) === '对照两条 SQL' && await gmv.locator('[data-dual-compare]').count() === 0)
|
||||
const rr = metric(page, 'refund_rate')
|
||||
check('……只有兜底的那个指标默认展开对照', (await rr.locator('[data-dual-compare-toggle]').getAttribute('aria-expanded')) === 'true'
|
||||
&& await rr.locator('[data-dual-compare]').count() === 1)
|
||||
check('……兜底的原话不再列成一条「具体差异」', await rr.locator('[data-dual-diff-item]').count() === 0)
|
||||
check('只差一个方面的指标:说明选择会记入数据目录', (await text(gmv.locator('[data-dual-catalog]'))).includes('记入数据目录')
|
||||
&& await rr.locator('[data-dual-catalog]').count() === 0)
|
||||
const after = await text(page.locator('[data-dual-consequence]'))
|
||||
check('正式运行:后果写明报告里的数仍取自原流程、采用盲算或都不对会让出具降档', after.includes('报告中的数仍取自原流程') && after.includes('出具降档'), after)
|
||||
check('没有发出任何写请求', sent.length === 0, JSON.stringify(sent))
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await shot(page, 'approval-dark')
|
||||
})
|
||||
|
||||
await section('审批卡:并排对照与高亮', async () => {
|
||||
const { page, errors } = await preview('dual-check-approval')
|
||||
await page.waitForSelector('[data-dual-check][data-state="ready"]')
|
||||
const gmv = metric(page, 'gmv')
|
||||
const toggle = gmv.locator('[data-dual-compare-toggle]')
|
||||
// 键盘:Tab 到按钮上回车展开
|
||||
await toggle.focus()
|
||||
await page.keyboard.press('Enter')
|
||||
check('键盘回车展开对照,按钮标为展开、指向对照区', (await toggle.getAttribute('aria-expanded')) === 'true'
|
||||
&& await page.locator(`#${await toggle.getAttribute('aria-controls')}[data-dual-compare]`).count() === 1)
|
||||
check('……按钮改说「收起对照」', (await text(toggle)) === '收起对照')
|
||||
const orig = gmv.locator('[data-dual-sql="original"]')
|
||||
const blind = gmv.locator('[data-dual-sql="blind"]')
|
||||
check('两条 SQL 都在,各标是哪一边', (await text(orig)).includes('paid_at') && (await text(blind)).includes('created_at')
|
||||
&& (await text(gmv.locator('[data-dual-pair]'))).includes('原流程的 SQL') && (await text(gmv.locator('[data-dual-pair]'))).includes('盲算的 SQL'))
|
||||
const marks = await gmv.evaluate((el) => ({
|
||||
del: [...el.querySelectorAll('[data-dual-sql="original"] [data-diff-line="del"]')].map((m) => m.textContent.trim()),
|
||||
add: [...el.querySelectorAll('[data-dual-sql="blind"] [data-diff-line="add"]')].map((m) => m.textContent.trim()),
|
||||
}))
|
||||
check('text_diff 的行在原流程那边高亮', marks.del.join('|') === "WHERE paid_at >= '2026-09-01'|AND paid_at < '2026-09-08'", marks.del.join('|'))
|
||||
check('……在盲算那边也高亮', marks.add.join('|') === "WHERE created_at >= '2026-09-01'|AND created_at < '2026-09-08'", marks.add.join('|'))
|
||||
check('……相同的行不高亮', !(await orig.locator('[data-diff-line]').allInnerTexts()).some((t) => t.includes('FROM orders')))
|
||||
const o = await orig.boundingBox()
|
||||
const b = await blind.boundingBox()
|
||||
check('宽栏里左右并排:同一行、盲算在右边', !!o && !!b && Math.abs(o.y - b.y) < 2 && b.x >= o.x + o.width - 1, JSON.stringify({ o, b }))
|
||||
check('盲算的说明', (await text(gmv.locator('[data-dual-rationale]'))).includes('销售额按下单时间统计本周全部订单金额之和'))
|
||||
check('输入取值:路径、两边的值', (await text(gmv.locator('[data-dual-input="vars.kpi.gmv"]'))).includes('45,678.5')
|
||||
&& (await text(gmv.locator('[data-dual-input="vars.kpi.gmv"]'))).includes('51,388.3'))
|
||||
const lines = await gmv.locator('[data-dual-text-diff] [data-diff-side]').evaluateAll((els) => els.map((e) => e.dataset.diffSide))
|
||||
check('规范化后不同的行另列一份,每行标是哪一边', lines.join(',') === 'original,original,blind,blind', lines.join(','))
|
||||
const rr = metric(page, 'refund_rate')
|
||||
check('两个输入、只有一个不同:只对照不同的那一个', (await rr.locator('[data-dual-pair]').evaluateAll((els) => els.map((e) => e.dataset.dualPair))).join(',')
|
||||
=== 'vars.kpi.refunds')
|
||||
check('……输入取值两项都列,不同的那项标出来', await rr.locator('[data-dual-input]').count() === 2
|
||||
&& await rr.locator('[data-dual-input="vars.kpi.refunds"][data-differs]').count() === 1
|
||||
&& await rr.locator('[data-dual-input="vars.kpi.orders"][data-differs]').count() === 0)
|
||||
// 原流程那条规范化后多了 AS,和原文对不上:不乱标;盲算那条规范化后和原文一字不差:照样高亮
|
||||
check('……一行写完的 SQL:规范化后对不上原文的不乱标,对得上的照样高亮', await rr.locator('[data-dual-sql="original"] [data-diff-line]').count() === 0
|
||||
&& await rr.locator('[data-dual-sql="blind"] [data-diff-line="add"]').count() === 1)
|
||||
check('……规范化后不同的行照样另列', await rr.locator('[data-dual-text-diff] [data-diff-side]').count() === 2)
|
||||
await page.keyboard.press('Enter')
|
||||
check('再按一次回车收起', (await toggle.getAttribute('aria-expanded')) === 'false' && await gmv.locator('[data-dual-compare]').count() === 0)
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
})
|
||||
|
||||
await section('审批卡:不选全不能提交,提交的回复是 {choices, note}', async () => {
|
||||
const { page, sent, errors } = await preview('dual-check-approval')
|
||||
await page.waitForSelector('[data-dual-check][data-state="ready"]')
|
||||
const submit = page.locator('[data-dual-submit]')
|
||||
check('三个选项必选:一个都没选时不能提交', await submit.isDisabled() && (await text(page.locator('[data-dual-pending]'))) === '还有 2 个指标未选择')
|
||||
const gmv = metric(page, 'gmv')
|
||||
const opts = await gmv.locator('[data-dual-choice] label').allInnerTexts()
|
||||
check('三个选项的说法', opts.map((t) => t.trim()).join('|') === '采用原流程的结果|采用盲算的结果|两边都不对', opts.join('|'))
|
||||
check('……每组有读屏名:哪个指标采用哪一边', (await text(gmv.locator('[data-dual-choice] legend'))) === '「销售额」采用哪一边的结果')
|
||||
await gmv.locator('label', { hasText: '采用原流程的结果' }).click()
|
||||
check('选了一个:还差一个,仍不能提交', await submit.isDisabled() && (await text(page.locator('[data-dual-pending]'))) === '还有 1 个指标未选择')
|
||||
// 键盘:方向键在一组里换选项
|
||||
const rr = metric(page, 'refund_rate')
|
||||
await rr.locator('input[value="a"]').focus()
|
||||
await page.keyboard.press('ArrowRight')
|
||||
await page.keyboard.press('ArrowRight')
|
||||
check('键盘方向键在一组里换选项', await rr.locator('input[value="neither"]').isChecked() && !(await rr.locator('input[value="b"]').isChecked()))
|
||||
check('全选了:可以提交、不再提示未选', !(await submit.isDisabled()) && await page.locator('[data-dual-pending]').count() === 0)
|
||||
const note = '销售额以支付时间为准;退款率两边都没有剔除部分退款'
|
||||
await page.locator('[data-dual-note]').fill(note)
|
||||
await submit.click()
|
||||
await page.waitForFunction(() => window.__dualResolved != null)
|
||||
const posts = sent.filter((s) => s.path === `/api/approvals/${AP.id}/decide`)
|
||||
check('走审批回复接口,只发一次', posts.length === 1, String(posts.length))
|
||||
const body = posts[0]?.body ?? {}
|
||||
check('请求体:value 是 {choices, note}', JSON.stringify(body.value) === JSON.stringify({ choices: { gmv: 'a', refund_rate: 'neither' }, note }),
|
||||
JSON.stringify(body.value))
|
||||
check('……照 input 模式的写法:approved 为真、备注同时放在外层', body.approved === true && body.note === note, JSON.stringify(body))
|
||||
check('提交后交给调用方重新接上运行', await page.evaluate(() => window.__dualResolved) === AP.run_id)
|
||||
check('……提示已提交、运行继续', (await text(page.locator('body'))).includes('已提交裁决,运行继续'))
|
||||
check('没有别的写请求', !sent.some((s) => s.blocked), JSON.stringify(sent.filter((s) => s.blocked)))
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
})
|
||||
|
||||
await section('审批卡:完整对照取不到时,按摘要照样能裁决', async () => {
|
||||
const { page, sent } = await preview('dual-check-approval', {
|
||||
artifact: async () => ({ status: 404, json: { detail: '工件不存在' } }),
|
||||
})
|
||||
await page.waitForSelector('[data-dual-load-failed]')
|
||||
check('写明无法加载完整对照、以下仅为摘要', (await text(page.locator('[data-dual-load-failed]'))) === '无法加载完整对照,以下仅为摘要')
|
||||
check('……摘要照样在:值、相差、差异', (await text(metric(page, 'gmv').locator('[data-dual-value="blind"]'))) === '51,388.3元'
|
||||
&& await metric(page, 'gmv').locator('[data-dual-diff-item]').count() === 1)
|
||||
check('……没有可对照的 SQL,不给「对照两条 SQL」', await page.locator('[data-dual-compare-toggle]').count() === 0)
|
||||
for (const id of ['gmv', 'refund_rate']) await metric(page, id).locator('label', { hasText: '采用盲算的结果' }).click()
|
||||
await page.locator('[data-dual-submit]').click()
|
||||
await page.waitForFunction(() => window.__dualResolved != null)
|
||||
const body = sent.find((s) => s.path === `/api/approvals/${AP.id}/decide`)?.body
|
||||
check('……照样能提交', JSON.stringify(body?.value?.choices) === JSON.stringify({ gmv: 'b', refund_rate: 'b' }), JSON.stringify(body))
|
||||
})
|
||||
|
||||
await section('审批卡:探索运行不提出具', async () => {
|
||||
const { page } = await preview('dual-check-approval-exploratory')
|
||||
await page.waitForSelector('[data-dual-check][data-state="ready"]')
|
||||
const said = await text(page.locator('[data-dual-consequence]'))
|
||||
check('后果只说记为缺口,不写任何出具相关的话', said.includes('记为缺口') && !said.includes('出具'), said)
|
||||
})
|
||||
|
||||
await section('审批卡:390px 不横向溢出、窄栏里上下排、深浅色可读', async () => {
|
||||
for (const theme of ['dark', 'light']) {
|
||||
const { page, errors } = await preview('dual-check-approval', { width: 390, height: 844, theme })
|
||||
await page.waitForSelector('[data-dual-check][data-state="ready"]')
|
||||
await metric(page, 'gmv').locator('[data-dual-compare-toggle]').click()
|
||||
const w = await page.evaluate(() => ({
|
||||
page: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
card: (() => { const c = document.querySelector('[data-dual-check]'); return c.scrollWidth - c.clientWidth })(),
|
||||
}))
|
||||
check(`390px(${theme}):两边都展开后不横向溢出`, w.page <= 0 && w.card <= 1, JSON.stringify(w))
|
||||
const o = await metric(page, 'gmv').locator('[data-dual-sql="original"]').boundingBox()
|
||||
const b = await metric(page, 'gmv').locator('[data-dual-sql="blind"]').boundingBox()
|
||||
check(`390px(${theme}):两条 SQL 上下排`, !!o && !!b && b.y >= o.y + o.height - 1, JSON.stringify({ o, b }))
|
||||
const c = await page.evaluate(() => {
|
||||
const card = document.querySelector('[data-approval]')
|
||||
const base = getComputedStyle(document.body).backgroundColor
|
||||
const pick = (sel) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (!el) return null
|
||||
// 往上找第一层不透明(或半透明)的底色
|
||||
let bg = 'rgba(0, 0, 0, 0)'
|
||||
for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
|
||||
const v = getComputedStyle(n).backgroundColor
|
||||
if (v !== 'rgba(0, 0, 0, 0)' && v !== 'transparent') { bg = v; break }
|
||||
}
|
||||
return { fg: getComputedStyle(el).color, bg }
|
||||
}
|
||||
return { base, card: getComputedStyle(card).backgroundColor, delta: pick('[data-dual-delta]'),
|
||||
consequence: pick('[data-dual-consequence]'), mark: pick('[data-diff-line="del"]'), fallback: pick('[data-dual-fallback]') }
|
||||
})
|
||||
for (const key of ['delta', 'consequence', 'mark', 'fallback']) {
|
||||
const v = c[key]
|
||||
const ratio = v ? contrast(v.fg, v.bg, c.base) : 0
|
||||
check(`${theme}:${({ delta: '相差比例', consequence: '后果说明', mark: '高亮的行', fallback: '兜底说明' })[key]}对比度 ≥ 4.5`, ratio >= 4.5,
|
||||
`${ratio.toFixed(2)}(${v?.fg} on ${v?.bg})`)
|
||||
}
|
||||
await shot(page, `approval-narrow-${theme}`)
|
||||
check(`390px(${theme}):页面没有报错`, errors.length === 0, errors.join(';'))
|
||||
}
|
||||
const rail = await preview('dual-check-approval-rail', { width: 1280, height: 900 })
|
||||
await rail.page.waitForSelector('[data-dual-check][data-state="ready"]')
|
||||
await metric(rail.page, 'gmv').locator('[data-dual-compare-toggle]').click()
|
||||
const r = await rail.page.evaluate(() => {
|
||||
const box = document.querySelector('[data-fixture="dual-check-approval-rail"]')
|
||||
return box.scrollWidth - box.clientWidth
|
||||
})
|
||||
check('360px 画布右栏:展开对照也不撑宽右栏', r <= 0, `${r}px`)
|
||||
const o = await metric(rail.page, 'gmv').locator('[data-dual-sql="original"]').boundingBox()
|
||||
const b = await metric(rail.page, 'gmv').locator('[data-dual-sql="blind"]').boundingBox()
|
||||
check('360px 画布右栏:宽屏下也按卡片的宽度上下排', !!o && !!b && b.y >= o.y + o.height - 1, JSON.stringify({ o, b }))
|
||||
await shot(rail.page, 'approval-rail-dark')
|
||||
})
|
||||
|
||||
await browser.close()
|
||||
console.log(failed ? `\n✗ 双盲复算与反方审稿:${failed} 项未通过` : '\n✓ 双盲复算与反方审稿:全部通过')
|
||||
process.exit(failed ? 1 : 0)
|
||||
|
||||
Reference in New Issue
Block a user