Files
AgentLab/scripts/check-evidence.mjs
T
ThearchyHelios 9ad6c035d0 口径账本的界面检查:check-ledger 换成真检查,check-evidence、check-chat 各加一段
都用 page.route 假接口加夹具(scripts/ledger-fixtures.mjs:GET /api/runs/{id}/ledger 的形状照服务端 run_view,挂在可点击
证据的夹具 evidence-doc.json 上,只用通用名),写请求在浏览器层拦下、核对请求体,认不出的写请求一律记账、结束时必须为空。

- check-ledger(骨架里的占位换掉):
  · 比对还没出结果:「口径」一步写「正在比对口径」、按退避再取(间隔越来越长),等满了照实说、给「再取一次」、不再取,
    点了重新取、比对过了就画出来,之后不再取(store/ledger 在开发构建里挂 window.__ledger,把等待起点挪到两分钟前);
  · 三种情况:来自口径(口径、级别、怎么认的、参数、事后补比的说明)、相关口径(差在哪 + 看差多少)、这次的推理;
  · 对账:参数要核对时表单直接打开、照入口字段画 → 发起带核对过的参数(空着的不送)→ 409 带回的选项画「选择要对照的
    指标」、一组没选不发起 → 选好再发起带上对照 → 结果卡片先写进行中、再取一次后画出按你的问法 / 按口径 / 差值 / 相对差,
    点差值打开出处链;
  · 可沉淀的问题:工作流列表弹窗里的抽屉,三类来源分开写、各写怎么认出来的;聚类只按那一轮固化;「不是口径」不写理由
    不能确认、写了才记下并拿掉这一项;固化走 from-run 的 freeze(带同组的别的运行和来源轮次)、打开画布里的草稿;
  · 使用者:不能出具的照服务端的原因写「请搭建者出具」、不摆按钮,对账照样能用,出具过的正式运行不给链接、写由搭建者查看;
  · 设置里口径账本一组:显示生效的值,不合规的值写范围、不让存,保存条写改了哪几项,整组写 ledger。
- check-evidence「口径账本」一节:来自口径的数字挂「受管 / 已发布」小标记(紧跟数字、悬停写口径和认法、读屏念在数字上),
  相关口径、这次的推理、没比对的不挂;面板「口径」一步在出处链最上面,四种标签各写各的,不是数的片段不画;同一次运行只取
  一次账本。别的节的夹具一律答不比对,不落到真服务端。
- check-chat「口径账本」一节:交付了的轮次底部先写「正在比对口径」,按退避再取到比对出结果后写摘要、比完不再取;按口径正式
  出具、对账按钮照服务端的动作摆,出具的请求只说按哪份口径,出具过的正式运行挂在轮次下面(档位、打开运行);对账请求带
  哪条查询、哪份口径、核对过的参数。
2026-10-16 20:34:21 +08:00

4215 lines
321 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 可点击证据(第一期:数字层)的回归检查:报告文档逐段渲染、证据面板、键盘、读屏、
// 减少动效、窄栏,以及旧契约「按位置标记」和没有 _evidence 的旧回答。
//
// 守的是这些「看着没坏、其实用不了」的地方:两种状态的下划线长得一样(去掉颜色就分不
// 出有没有出处)、整份报告每个数字各占一个 Tab 位、面板关了焦点丢到页首、减少动效时面板
// 照样滑进来、360px 的画布右栏被面板撑出横向滚动、日期里的 15 被当成回指不上的 15、
// 旧运行的答案被新组件改了样子。
//
// 跑在预览页上:/ui-harness.html?evidence=1(组件)和 /preview.html?syn=evidence(问数据页
// 的答案形态)。文档是后端 compose_doc 真跑出来的夹具(frontend/src/run/__tests__/
// evidence-doc.json,只用通用名);片段接口、证据图、报告工件都用 page.route 伪造,非 GET
// 一律拦掉,不写库。
//
// 跑之前前端得起着(./scripts/dev.sh),默认连 5273。对别的实例(比如一份沙箱拷贝)跑时
// 带上地址:
// AGENTLAB_WEB=http://localhost:<前端端口> AGENTLAB_API=http://localhost:<后端端口>/api node scripts/check-evidence.mjs
// EVIDENCE_SHOTS=<目录> 时亮暗两套各截几张,供人眼复核。
import { readFileSync } from 'node:fs'
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
import { ledgerOf } from './ledger-fixtures.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.EVIDENCE_SHOTS
const root = new URL('..', import.meta.url).pathname
const fx = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-doc.json`, 'utf8'))
// 第二期:报告直接引用查询单元格、整表,口径卡的输入核对到 agent 字段和查询快照。文档同样是
// compose_doc 真跑出来的(查询快照走 loader),片段接口的答复按方案第 5 节和 NOTES-A2 的形状拼
const fxq = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-query.json`, 'utf8'))
// 第三期:表名字段名(反引号里的、标记的、自动链接的)、可疑实体、核对不了的名字、逐字引文,
// 以及没有契约的旧运行按数值猜的候选。文档和猜测都是后端真跑出来的(compose_doc / guess_sources)
const fxe = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-entity.json`, 'utf8'))
// 第四期:结论句裁判。文档由 compose_doc 真跑,候选句由 judge.prepare 真跑,判定是剧本(模拟裁判模型),
// 经 judge 的 summarize / apply_judgement 写回:正式运行(节点里判好的)、探索运行(按需)、没开裁判的探索运行
const fxj = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-judge.json`, 'utf8'))
// 裁判拆档(JR):证据相矛盾、证据不足(写明缺什么)、讲方法的句子、裁判模型和写作模型相同、模型不在价格表里。
// 文档由 compose_doc 真跑、证据链由 api/evidence.py 的 _chain 真跑;判定和表名实体步骤的 fields 照前后端接口约定写
const fxv = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-verdict.json`, 'utf8'))
const JUDGE_SETS = [fxj.formal, fxj.explore, fxj.plain, fxv.formal, fxv.explore]
// Excel 导入期 4:推断的来源。文档由 compose_doc 真跑(带文档标记),片段答复由 _chain 真跑,推断来源的答复按契约
// (backend/app/data/provenance_types.py)拼;cases 写明每个场景是哪个片段、片段文字、覆盖 P4-SPEC 4.4 的哪几条
const fxp = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-provenance.json`, 'utf8'))
/** 推断来源夹具里各期导入清单的工件 id:夹具只收了两份清单,其余的按「工件不存在」答,不落到真服务端 */
const FXP_MANIFESTS = new Set(Object.values(fxp.provenance).flatMap((a) => a.version?.parts ?? []).map((p) => p.manifest))
/** 夹具里某段文字是哪个片段:检查按文字认片段,重新生成夹具时编号变了也不用改 */
const segOf = (doc, text) => doc.blocks.flatMap((b) => b.units.flatMap((u) => u.segments)).find((s) => s.text === text)?.id
let failed = 0
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
if (!cond) failed++
}
// EVIDENCE_ONLY=look,keys 只跑这几节(改坏验证时省时间);check-all 不会把它传下来
const ONLY = (process.env.EVIDENCE_ONLY ?? '').split(',').filter(Boolean)
async function section(key, name, fn) {
if (ONLY.length && !ONLY.includes(key)) return
console.log(`\n=== ${name} ===`)
try {
await fn()
} catch (e) {
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
}
}
const browser = await chromium.launch({ executablePath: CHROME })
/**
* 按需裁判的伪造答复:evidence.judged 的载荷形状(NOTES-A4 §8、E4-node 的 POST …/evidence/judge)。
* 夹具里按句子备好了答复的用夹具的;别的句子给一条「有依据」。正式运行回 409
*/
function judgeReply(set, body) {
if (set === fxj.formal || set === fxv.formal) return { status: 409, json: { detail: '正式运行的结论句已在报告撰写节点内完成裁判,结果随报告一起封存,不能再按需裁判', code: 'evidence_judge_formal' } }
const unit = body?.units?.[0]
const named = Object.entries(set.units).find(([, u]) => u === unit)?.[0]
if (named && set.judge?.[named]) return { json: set.judge[named] }
return { json: { report: { node_id: 'write', doc_artifact: set.doc_artifact }, verdicts: { [unit]: { status: 'supported',
rationale: '引用的证据支持这句话', judge: fxj.model, post_seal: true, used: [] } }, model: fxj.model, limits_hit: [], post_seal: true } }
}
/**
* 伪造证据相关的 GET;片段请求记账(缓存:同一个片段点两次只取一次)。四期:按需裁判的 POST 也在这里认
* (记进 hits 的 judge:<运行>:<句子>),别的非 GET 一律拦掉。judge 给了就用它答(改坏验证、触顶、出错);
* segPatch 给了就改片段接口的答复(比如 on_demand 说运行还没封存)。
*
* Excel 导入期 4:推断来源接口(…/segments/<id>/provenance)在片段正则之前单独认,不分运行、不分夹具,一律记成
* prov:<运行>:<片段>——片段正则以 $ 结尾认不出它,落到 r.continue() 会得到一个没有机读码的 404,界面按「老服务端」
* 三节都不画,只看 DOM 的断言不论前端发没发请求都会过。推断来源夹具的运行按 provenance[<片段>] 作答,其余一律回
* 可识别的哨兵(418,harness_unexpected_provenance),界面会画出「未能获取推断的来源」。prov 给了就由它改答复
* (prov(运行, 片段, 默认答复) → {status?, json} 或它的 Promise:延迟、出错、老服务端)
*
* 期 4:graph 给了就由它改四期夹具(结论句裁判那几份)的证据图答复(graph(夹具, 证据图拷贝) → 证据图),并把每次请求
* 记成 graph:<运行>(数得出同一次运行取了几回);不给时照夹具答、不记
*/
async function routed(page, { judge = null, segPatch = null, prov = null, graph = null } = {}) {
const hits = []
const judged = new Set()
await page.route((u) => new URL(u).pathname.startsWith('/api/'), async (r) => {
const req = r.request()
const url = new URL(req.url())
const jset = JUDGE_SETS.find((x) => url.pathname.includes(`/runs/${x.run_id}/`) || url.pathname.endsWith(`/runs/${x.run_id}`))
if (jset && req.method() === 'POST' && /\/evidence\/judge$/.test(url.pathname)) {
const body = req.postDataJSON()
hits.push(`judge:${jset.run_id}:${(body?.units ?? []).join(',')}:${body?.report ?? ''}`)
const reply = judge ? judge(jset, body) : judgeReply(jset, body)
if (reply.status == null || reply.status < 300) for (const u of body?.units ?? []) judged.add(`${jset.run_id}:${u}`)
return r.fulfill({ status: reply.status ?? 200, json: reply.json })
}
if (req.method() !== 'GET') return r.abort()
// 口径账本(next-round 02):证据正文、面板会按运行取一次。各节的夹具都不比对(只有「口径账本」那一节另给答复),
// 免得落到真服务端、按夹具的假运行答 404
const lm = url.pathname.match(/^\/api\/runs\/([^/]+)\/ledger$/)
if (lm) {
hits.push(`ledger:${decodeURIComponent(lm[1])}`)
return r.fulfill({ json: { run_id: decodeURIComponent(lm[1]), state: 'ineligible', reason: '检查脚本的夹具不比对口径' } })
}
const pm = url.pathname.match(/\/api\/runs\/([^/]+)\/evidence\/segments\/([^/]+)\/provenance$/)
if (pm) {
const [run, sid] = [decodeURIComponent(pm[1]), decodeURIComponent(pm[2])]
hits.push(`prov:${run}:${sid}`)
const own = run === fxp.run_id && fxp.provenance[sid]
let reply = own ? { json: structuredClone(own) } : fxp.errors.unexpected
if (prov) reply = await prov(run, sid, reply)
return r.fulfill({ status: reply.status ?? 200, json: reply.json })
}
if (url.pathname.includes(`/runs/${fxp.run_id}/`)) {
const seg = url.pathname.match(/\/evidence\/segments\/([^/]+)$/)
if (seg) {
const id = decodeURIComponent(seg[1])
hits.push(`p:${id}`)
const body = fxp.segments[id]
return body ? r.fulfill({ json: structuredClone(body) })
: r.fulfill({ status: 404, json: { detail: '报告中没有这个片段', code: 'evidence_segment_not_found' } })
}
if (/\/evidence$/.test(url.pathname)) return r.fulfill({ json: fxp.graph })
}
// 「查看导入清单」按清单的工件 id 取(内容寻址)
const art = url.pathname.match(/^\/api\/artifacts\/([^/]+)$/)?.[1]
if (art && fxp.artifacts[art]) {
hits.push(`artifact:P:${art}`)
return r.fulfill({ json: fxp.artifacts[art] })
}
if (art && FXP_MANIFESTS.has(art)) return r.fulfill({ status: 404, json: { detail: '工件不存在' } })
// 四期的报告工件:问数据页的答案(Output → EvidenceField)按工件 id 取文档
const jart = JUDGE_SETS.find((x) => url.pathname === `/api/artifacts/${x.doc_artifact}`)
if (jart) return r.fulfill({ json: { id: jart.doc_artifact, content: jart.doc } })
// 裁判拆档:讲方法的句子在「挂的依据」下列出 SQL,取的是查询快照工件
const vsnap = Object.entries(fxv.snapshots).find(([, x]) => url.pathname === `/api/artifacts/${x.artifact}`)
if (vsnap) {
hits.push(`artifact:V:${vsnap[0]}`)
return r.fulfill({ json: { id: vsnap[1].artifact, content: vsnap[1].content } })
}
if (jset) {
const seg = url.pathname.match(/\/evidence\/segments\/([^/]+)$/)
if (seg) {
const id = decodeURIComponent(seg[1])
hits.push(`j:${id}`)
// 判过之后片段接口按最新的 evidence.judged 叠上判定(封存后追加):夹具里备着判过那一句的答复
const trigger = jset.units.bad ?? jset.units.method
const after = jset.segments_after && judged.has(`${jset.run_id}:${trigger}`) ? jset.segments_after : jset.segments
let body = structuredClone(after[id])
if (body && segPatch) body = segPatch(jset, body)
return body ? r.fulfill({ json: body })
: r.fulfill({ status: 404, json: { detail: '报告中没有这个片段', code: 'evidence_segment_not_found' } })
}
if (/\/evidence$/.test(url.pathname)) {
if (!graph) return r.fulfill({ json: jset.graph })
hits.push(`graph:${jset.run_id}`)
return r.fulfill({ json: graph(jset, structuredClone(jset.graph)) })
}
if (url.pathname.endsWith(`/runs/${jset.run_id}`)) {
hits.push(`run:${jset.run_id}`)
return r.fulfill({ json: { id: jset.run_id, run_class: jset.run_class, status: 'succeeded' } })
}
}
const seg = url.pathname.match(/\/api\/runs\/([^/]+)\/evidence\/segments\/([^/]+)$/)
// 按运行认夹具:第一期、第二期(查询链)、第三期(实体、引文)各有自己的片段
const set = url.pathname.includes(`/runs/${fxq.run_id}/`) ? fxq : url.pathname.includes(`/runs/${fxe.run_id}/`) ? fxe : fx
if (seg) {
hits.push(`${set === fxq ? 'q:' : set === fxe ? 'e:' : ''}${decodeURIComponent(seg[2])}`)
const body = set.segments[decodeURIComponent(seg[2])]
return body ? r.fulfill({ json: body })
: r.fulfill({ status: 404, json: { detail: '报告中没有这个片段', code: 'evidence_segment_not_found' } })
}
if (/\/api\/runs\/[^/]+\/evidence$/.test(url.pathname)) return r.fulfill({ json: set.graph })
if (url.pathname === `/api/artifacts/${fx.doc_artifact}`) return r.fulfill({ json: { id: fx.doc_artifact, content: fx.doc } })
if (url.pathname === `/api/artifacts/${fxq.snapshots.Q3.artifact}`) {
hits.push('artifact:Q3')
// 工件接口给的是原值(遮罩只在证据接口里换):夹具里存的是换过的,这里换回「原值」
const content = structuredClone(fxq.snapshots.Q3.content)
const j = content.columns.indexOf('phone')
content.rows.forEach((row, i) => { row[j] = `RAW-PHONE-${i}` })
return r.fulfill({ json: { id: fxq.snapshots.Q3.artifact, content } })
}
return r.continue()
})
return hits
}
async function open(url, { w = 1280, h = 1000, reduced = false, patchQuery = null, judge = null, segPatch = null, prov = null,
graph = null, before = null } = {}) {
const ctx = await browser.newContext({ viewport: { width: w, height: h }, ...(reduced ? { reducedMotion: 'reduce' } : {}) })
const page = await ctx.newPage()
// 找不到元素时 8 秒就报,不等默认的 30 秒:改坏验证时一节里十几处都找不到
page.setDefaultTimeout(8000)
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
const hits = await routed(page, { judge, segPatch, prov, graph })
// 打开页面之前再挂的路由(排在 routed 前面,认不出的交回 routed):报告一挂上就取这次运行的证据图,换答复得赶在那之前
if (before) await before(page)
// 预览页直接 import 第二期夹具(vite 把 JSON 当模块给):要一份改过的文档时把那个模块换掉
if (patchQuery) {
await page.route((u) => new URL(u).pathname.endsWith('/run/__tests__/evidence-query.json'), (r) =>
r.fulfill({ contentType: 'application/javascript', body: `export default ${JSON.stringify(patchQuery(structuredClone(fxq)))}` }))
}
await page.goto(`${WEB}${url}`, { waitUntil: 'networkidle' })
await page.waitForSelector('[data-evidence-doc], [data-turn]', { timeout: 8000 })
return { ctx, page, errors, hits }
}
const active = (page) => page.evaluate(() => document.activeElement?.getAttribute('data-seg')
?? (document.activeElement?.closest('[data-evidence-panel]') ? 'panel' : document.activeElement?.tagName ?? null))
const panel = (page) => page.locator('[data-evidence-panel]')
let harness
try {
harness = await open('/ui-harness.html?evidence=1')
} catch (e) {
console.error(`✗ 打不开预览页(${WEB}/ui-harness.html?evidence=1)——前端没起?先跑 ./scripts/dev.sh\n ${e.message}`)
await browser.close()
process.exit(1)
}
const { page } = harness
const wide = '#evidence-wide'
await section('look', '两种状态四通道都分得开:线型、字形、颜色、文字', async () => {
const look = await page.evaluate((sel) => {
const pick = (state) => {
const el = document.querySelector(`${sel} [data-ev-state="${state}"]`)
if (!el) return null
const cs = getComputedStyle(el)
return { line: cs.textDecorationLine, style: cs.textDecorationStyle, color: cs.textDecorationColor,
tag: el.tagName, type: el.getAttribute('type'), font: cs.fontSize, fg: cs.color }
}
const body = getComputedStyle(document.querySelector(`${sel} [data-evidence-doc] p`)).color
return { det: pick('deterministic'), none: pick('none'), body }
}, wide)
check('确定性画实线下划线', look.det?.line.includes('underline') && look.det.style === 'solid', JSON.stringify(look.det))
check('无证据画点状下划线', look.none?.line.includes('underline') && look.none.style === 'dotted', JSON.stringify(look.none))
check('两种线型不同(读计算后的 text-decoration-style)', look.det?.style !== look.none?.style)
check('两种下划线颜色也不同', look.det?.color !== look.none?.color, `${look.det?.color} / ${look.none?.color}`)
check('片段是原生 button type=button', look.det?.tag === 'BUTTON' && look.det.type === 'button')
check('片段外观和正文一样:字色、字号继承正文', look.det?.fg === look.body, `${look.det?.fg} vs ${look.body}`)
const labels = await page.evaluate((sel) => Object.fromEntries(['s8', 's16', 's33', 's4'].map((id) =>
[id, document.querySelector(`${sel} [data-seg="${id}"]`)?.getAttribute('aria-label') ?? ''])), wide)
check('aria-label 写了状态和出处(有出处)', labels.s8 === '8.7%,有出处:口径卡指标 环比增幅', labels.s8)
check('aria-label 写了状态和原因(裸数字)', labels.s16.startsWith('12,无证据:'), labels.s16)
check('aria-label 写了解析不了的原因', labels.s33.includes('无证据') && labels.s33.includes('显示为 0'), labels.s33)
check('运行输入也写明出处', labels.s4 === '2026-W37,有出处:运行输入 week', labels.s4)
const tags = await page.locator(`${wide} .ev-tag`).allInnerTexts()
check('含无证据的句子末尾挂「?无证据」(字形 + 文字),表格格子里不挂', tags.length === 5 && tags.every((t) => t === '?无证据'),
tags.join('|'))
const summary = await page.locator(`${wide} [data-evidence-summary]`).innerText().catch(() => '')
check('顶部有一段读屏摘要,说清计数和按键', summary.includes('7/12 数字有出处 · 无证据 5 · 另有 1 处引用无法解析') && summary.includes('方向键'), summary)
check('读屏摘要说清画不了线的在哪:列表序号里的数字,不混说成「句末依据」', summary.includes('其中 1 个数字位于列表序号')
&& !summary.includes('句末依据'), summary)
const sr = await page.evaluate((sel) => {
const el = document.querySelector(`${sel} [data-evidence-summary]`)
const r = el?.getBoundingClientRect()
return !!r && r.width <= 1 && r.height <= 1
}, wide)
check('读屏摘要不占视觉位置(sr-only)', sr)
const tally = await page.locator(`${wide} [data-evidence-tally]`).innerText().catch(() => '')
check('没有出具横幅时文档自己给出计数条', tally.includes('7/12 数字有出处 · 无证据 5 · 另有 1 处引用无法解析') && tally.includes('定位下一处'), tally)
const [cited, total, gaps] = (tally.match(/(\d+)\/(\d+) 数字有出处 · 无证据 (\d+)/) ?? []).slice(1).map(Number)
check('计数算得平:无证据 = 总数 − 有出处', total - cited === gaps, tally)
})
await section('keys', '键盘:整份报告一个 Tab 位,←/→、↑/↓、n/N', async () => {
const stops = await page.locator(`${wide} [data-seg][tabindex="0"]`).count()
const segs = await page.locator(`${wide} [data-seg]`).count()
check('只有一个片段在 Tab 顺序里(roving tabindex)', stops === 1 && segs > 5, `${stops}/${segs}`)
await page.locator(`${wide} [data-evidence-list]`).focus()
await page.keyboard.press('Tab')
check('Tab 进报告落在第一个片段', await active(page) === 's4', await active(page))
await page.keyboard.press('Tab')
// 下一个 Tab 位是表格的「复制为 CSV」之类的控件,不能是另一个片段
const next = await active(page)
check('再按一次 Tab 不会落到另一个片段上', !/^s\d+$/.test(String(next)), String(next))
await page.locator(`${wide} [data-seg="s4"]`).focus()
const walk = []
for (const key of ['ArrowRight', 'ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp']) {
await page.keyboard.press(key)
walk.push(await active(page))
}
check('→ → ← 在片段间走', walk.slice(0, 3).join(',') === 's6,s8,s6', walk.join(','))
check('↓ 到下一句的第一个片段,↑ 回来', walk[3] === 's16' && walk[4] === 's4', walk.join(','))
const stop0 = await page.locator(`${wide} [data-seg][tabindex="0"]`).getAttribute('data-seg')
check('Tab 位跟着焦点走', stop0 === 's4', stop0)
const jumps = []
for (const key of ['n', 'n', 'Shift+N', 'N']) {
await page.keyboard.press(key)
jumps.push(await active(page))
}
check('n 跳到下一处无证据、N / Shift+N 回上一处', jumps.join(',') === 's16,s26,s16,s36', jumps.join(','))
await page.keyboard.press('End')
check('End 到最后一个片段', await active(page) === 's49', await active(page))
await page.keyboard.press('ArrowRight')
check('→ 首尾相接', await active(page) === 's4', await active(page))
check('只是走动不打开面板', await panel(page).count() === 0)
})
await section('panel', '面板:回车打开、能看到代入式、Esc 关掉焦点回原片段', async () => {
await page.locator(`${wide} [data-seg="s8"]`).focus()
await page.keyboard.press('Enter')
await page.waitForSelector('[data-evidence-panel] [data-ev-substituted]', { timeout: 4000 })
const p = panel(page)
check('宽屏从侧边弹出', await p.getAttribute('data-evidence-panel') === 'side')
const sub = await p.locator('[data-ev-substituted]').innerText()
check('代入式', sub === 'round((45678.5 - 42010.0) / 42010.0 * 100, 1)', sub)
check('原式', (await p.locator('[data-ev-expression]').innerText()).includes('vars.kpi.gmv_prev'))
const chips = await p.locator('[data-ev-input]').allInnerTexts()
check('每个输入是「值 ← 路径」的小标签', chips.join('|') === '45,678.5 ← vars.kpi.gmv|42,010 ← vars.kpi.gmv_prev', chips.join('|'))
const text = await p.innerText()
check('指标名称、值、口径与版本', text.includes('环比增幅') && text.includes('8.7%') && text.includes('口径卡「周报口径」') && text.includes('v2'))
check('复算结果', await p.locator('[data-ev-recompute]').getAttribute('data-ev-recompute') === 'ok' && text.includes('复算一致'))
check('封存状态用状态徽标', await p.locator('[data-ev-seal] svg[data-status="done"]').count() === 1 && text.includes('已封存 · 核对一致'))
check('所在的句子里点开的那段描底', (await p.locator('[data-ev-sentence] [data-ev-here]').innerText()) === '8.7%')
check('片段标着展开了哪个面板', await page.locator(`${wide} [data-seg="s8"]`).getAttribute('aria-expanded') === 'true'
&& !!(await page.locator(`${wide} [data-seg="s8"]`).getAttribute('aria-controls')))
check('焦点留在片段上,读屏从 live region 听到标题', await active(page) === 's8'
&& (await page.locator(`${wide} [aria-live="polite"]`).innerText()).includes('8.7%,有出处'))
await page.keyboard.press('ArrowRight')
await page.waitForTimeout(150)
check('面板开着时方向键走到哪,面板跟到哪', (await p.locator('h3').innerText()) === '1,234单', await p.locator('h3').innerText())
await page.keyboard.press('Escape')
await page.waitForTimeout(150)
check('Esc 关面板,焦点仍在原片段', await panel(page).count() === 0 && await active(page) === 's10', await active(page))
await page.keyboard.press('Enter')
await page.waitForSelector('[data-evidence-panel]')
await page.keyboard.press('Tab')
check('Tab 从片段直接进面板', await active(page) === 'panel', await active(page))
await page.keyboard.press('Escape')
await page.waitForTimeout(200)
check('在面板里按 Esc:关掉并把焦点还回原片段', await panel(page).count() === 0 && await active(page) === 's10', await active(page))
await page.locator(`${wide} [data-seg="s8"]`).click()
await page.waitForSelector('[data-evidence-panel] [data-ev-substituted]')
const again = harness.hits.filter((h) => h === 's8').length
check('同一个片段再点开不重复请求(按 runId:segId 缓存)', again === 1, `请求了 ${again} 次`)
await page.locator('[data-evidence-panel] button[aria-label="关闭证据"]').click()
check('关闭按钮', await panel(page).count() === 0)
})
await section('none', '没有证据的地方:原因、缺输入 / 显示不出来、违规清单', async () => {
await page.locator(`${wide} [data-seg="s26"]`).click()
await page.waitForSelector('[data-evidence-panel] [data-ev-seal-state="done"]')
let text = await panel(page).innerText()
check('无法解析的引用说出原因', text.includes('缺少输入'), text.slice(0, 80))
check('value 为 null:写「缺少输入」', await panel(page).locator('[data-ev-missing]').count() === 1
&& await panel(page).locator('[data-ev-unshowable]').count() === 0)
check('没有证据的片段不说「这个数已封存」', text.includes('报告文档已封存'))
await page.locator(`${wide} [data-seg="s33"]`).click()
await page.waitForTimeout(200)
text = await panel(page).innerText()
check('value 不为 null、rendered 为「—」:写「有值但显示不出来」,和缺输入分开',
(await panel(page).locator('[data-ev-unshowable]').innerText().catch(() => '')).includes('有值(29)')
&& await panel(page).locator('[data-ev-missing]').count() === 0, text.slice(0, 120))
await page.locator(`${wide} [data-seg="s16"]`).click()
await page.waitForTimeout(200)
text = await panel(page).innerText()
check('裸数字说清没写引用标记', text.includes('未使用引用标记') && text.includes('报告中直接写出了数字'), text.slice(0, 120))
await page.locator('[data-evidence-panel] [data-ev-open-violations]').click()
await page.waitForSelector('[data-ev-violations]')
const items = page.locator('[data-ev-violation]')
check('违规清单列全 6 条', await items.count() === 6, String(await items.count()))
const hundred = items.filter({ hasText: '100' })
check('列表序号里的 100 在清单里找得到,并说清为什么正文里没画线',
await hundred.getAttribute('data-ev-locatable') === 'no' && (await hundred.innerText()).includes('列表序号'),
await hundred.innerText().catch(() => ''))
await items.filter({ hasText: '数字「3」' }).getByRole('button', { name: '定位' }).click()
await page.waitForTimeout(250)
check('清单里「定位」跳到正文那一处并打开它的证据', await active(page) === 's36'
&& (await panel(page).locator('h3').innerText()) === '3', await active(page))
await page.keyboard.press('Escape')
await page.locator(`${wide} [data-evidence-next]`).click()
await page.waitForTimeout(250)
const first = await active(page)
const flash = await page.locator(`${wide} [data-seg="s16"]`).getAttribute('data-flash')
await page.locator(`${wide} [data-evidence-next]`).click()
await page.waitForTimeout(250)
check('「定位下一处」依次跳到无证据的片段、描一下边、打开面板', first === 's16' && flash === 'focus'
&& await active(page) === 's26' && await panel(page).count() === 1, `${first} ${flash} ${await active(page)}`)
await page.keyboard.press('Escape')
})
await section('narrow', '360px:画布右栏里栏内展开,不出横向滚动', async () => {
const narrow = '#evidence-narrow'
const overflow = () => page.evaluate((sel) => {
const el = document.querySelector(sel)
return { sw: el.scrollWidth, cw: el.clientWidth, w: el.getBoundingClientRect().width }
}, narrow)
const before = await overflow()
await page.locator(`${narrow} [data-seg="s8"]`).click()
await page.waitForSelector(`${narrow} [data-evidence-panel="inline"] [data-ev-substituted]`)
const after = await overflow()
check('窄栏宽 360px', Math.round(before.w) === 360, String(before.w))
check('窄栏里没有横向滚动(面板打开前后)', before.sw <= before.cw && after.sw <= after.cw, JSON.stringify({ before, after }))
check('窄栏里的面板在栏内、紧跟着片段所在的块', await page.evaluate((sel) => {
const p = document.querySelector(`${sel} [data-evidence-panel]`)
return p?.previousElementSibling?.getAttribute('data-block') === 'b1'
}, narrow))
check('栏内面板带「回到正文」', await page.locator(`${narrow} [data-evidence-panel] button`, { hasText: '回到正文' }).count() === 1)
await page.locator(`${narrow} [data-evidence-panel] button`, { hasText: '回到正文' }).click()
await page.waitForTimeout(150)
check('回到正文:面板收起,焦点回片段', await page.locator(`${narrow} [data-evidence-panel]`).count() === 0 && await active(page) === 's8')
const small = await open('/ui-harness.html?evidence=1', { w: 360, h: 780 })
const scroll = () => small.page.evaluate(() => ({ sw: document.documentElement.scrollWidth, vw: innerWidth }))
const a = await scroll()
await small.page.locator('#evidence-wide [data-seg="s8"]').click()
await small.page.waitForSelector('[data-evidence-panel] [data-ev-substituted]')
const b = await scroll()
check('360px 宽的屏幕:面板从底部抽出', await small.page.locator('[data-evidence-panel]').getAttribute('data-evidence-panel') === 'drawer')
check('360px 宽的屏幕:整页没有横向滚动(面板打开前后)', a.sw <= a.vw && b.sw <= b.vw, JSON.stringify({ a, b }))
const box = await small.page.locator('[data-evidence-panel]').boundingBox()
check('抽屉不超出屏幕', !!box && box.x >= 0 && box.x + box.width <= 361, JSON.stringify(box))
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await small.page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await small.page.waitForTimeout(250)
await small.page.screenshot({ path: `${SHOTS}/evidence-360-drawer-${theme}.png` })
}
}
check('没有运行时报错(360px)', small.errors.length === 0, small.errors.join(' | '))
await small.ctx.close()
})
await section('motion', '减少动效:面板直接出现,没有动画', async () => {
// 先证明这条检查抓得住:正常情况下面板是滑进来的
// 全局的减少动效规则把过渡压到 0.01ms(主题色切换那种),那不算动画;动画和更长的过渡才算
const running = () => {
const p = document.querySelector('[data-evidence-panel]')
if (!p) return -1
return p.getAnimations({ subtree: true })
.filter((a) => !(a instanceof CSSTransition && Number(a.effect?.getTiming().duration) <= 0.01)).length
}
await page.locator(`${wide} [data-seg="s8"]`).click()
const moving = await page.evaluate(running)
check('正常情况下面板有入场动画(对照)', moving > 0, String(moving))
await page.keyboard.press('Escape')
const r = await open('/ui-harness.html?evidence=1', { reduced: true })
await r.page.locator('#evidence-wide [data-seg="s8"]').click()
await r.page.waitForSelector('[data-evidence-panel] [data-ev-substituted]').catch(() => {})
const still = await r.page.evaluate((fn) => ({
n: new Function(`return (${fn})()`)(),
name: getComputedStyle(document.querySelector('[data-evidence-panel]')).animationName,
}), running.toString())
check('减少动效时面板没有任何动画', still.n === 0 && still.name === 'none', JSON.stringify(still))
await r.page.locator('#evidence-narrow [data-seg="s8"]').click()
const inline = await r.page.evaluate(() => document.querySelector('#evidence-narrow [data-evidence-panel]')
?.getAnimations({ subtree: true })
.filter((a) => !(a instanceof CSSTransition && Number(a.effect?.getTiming().duration) <= 0.01)).length ?? -1)
check('减少动效时栏内面板也不动', inline === 0, String(inline))
await r.ctx.close()
})
await section('legacy', '旧契约按位置标记(REQ-A-2)', async () => {
const pos = await page.evaluate(() => {
const box = document.querySelector('#legacy-marks')
const marks = [...box.querySelectorAll('[data-mark]')]
return marks.map((m) => ({ tone: m.dataset.mark, text: m.textContent, title: m.title,
before: (m.previousSibling?.textContent ?? '').slice(-3) }))
})
const warn = pos.filter((m) => m.tone === 'warn')
check('按位置:只标正文里回指不上的那个 15,日期 2026-09-15 里的不标', warn.length === 1 && warn[0].text === '15'
&& !warn[0].before.includes('-'), JSON.stringify(warn))
const amb = pos.filter((m) => m.tone === 'ambiguous')
check('出处不唯一写「出处不唯一:候选 a、b」', amb.length === 2 && amb.every((m) => m.title === '出处不唯一:候选 refund_cnt、reship_cnt'),
JSON.stringify(amb))
check('不再出现「来自口径卡指标「」」', !pos.some((m) => m.title.includes('指标「」')))
check('回指上的照常标出来源', pos.some((m) => m.tone === 'ok' && m.text === '1,234' && m.title.includes('orders')))
const loose = await page.locator('#legacy-loose [data-mark="warn"]').allInnerTexts()
check('老运行没有位置:退回按字符串标(日期里的 15 也会被标,和以前一样)', loose.length === 2 && loose.every((t) => t === '15'),
loose.join('|'))
})
await section('chat', '问数据页的答案:带 _evidence 的换成证据文档,没有的和以前一样', async () => {
const chat = await open('/preview.html?syn=evidence')
const p = chat.page
await p.waitForSelector('[data-turn="evidence"] [data-evidence-doc]', { timeout: 6000 })
const line = await p.locator('[data-turn="evidence"] [data-evidence-line]').innerText().catch(() => '')
check('出具横幅多一行「N/N 数字有出处 · 无证据 M」和「定位下一处」', line.includes('7/12 数字有出处 · 无证据 5 · 另有 1 处引用无法解析') && line.includes('定位下一处'), line)
const note = await p.locator('[data-turn="evidence"] [data-unmatched-note]').innerText().catch(() => '')
check('横幅不再说「正文里已用虚线标出」(列表序号里的 100 画不了线),改指违规清单', !note.includes('虚线')
&& note.includes('违规清单'), note)
await p.locator('[data-turn="evidence"] [data-issuance-banner] [data-evidence-list]').click()
await p.waitForSelector('[data-evidence-panel] [data-ev-violations]', { timeout: 3000 }).catch(() => {})
check('横幅上的「违规清单」打开报告的违规清单,100 在里面', (await p.locator('[data-evidence-panel] [data-ev-violation]')
.filter({ hasText: '100' }).count()) === 1)
await p.keyboard.press('Escape')
await p.locator('[data-evidence-panel] button[aria-label="关闭证据"]').click().catch(() => {})
check('有横幅时文档不再重复计数条', await p.locator('[data-turn="evidence"] [data-evidence-tally]').count() === 0)
await p.locator('[data-turn="evidence"] [data-evidence-next]').click()
await p.waitForSelector('[data-evidence-panel]')
check('横幅的「定位下一处」跳到第一处无证据并打开面板', await active(p) === 's16')
await p.keyboard.press('Escape')
const legacy = await p.evaluate(() => {
const turn = document.querySelector('[data-turn="evidence-legacy"]')
const md = turn?.querySelector('div.space-y-2.leading-relaxed')
const base = document.querySelector('#md-baseline > div')
return { segs: turn?.querySelectorAll('[data-seg], [data-evidence-doc]').length ?? -1,
same: !!md && !!base && md.outerHTML === base.outerHTML, md: !!md, base: !!base }
})
check('没有 _evidence 的回答不出现证据片段', legacy.segs === 0, String(legacy.segs))
check('没有 _evidence 的回答渲染和普通 Markdown 一字不差', legacy.same, JSON.stringify(legacy))
const row = await p.locator('[data-turn="evidence"]').innerText()
check('执行过程里有「核对报告」那一行', row.includes('核对报告:7/12 数字有出处 · 无证据 5 · 另有 1 处引用无法解析'))
const legacyNote = await p.locator('[data-turn="evidence-legacy"] [data-unmatched-note]').count()
check('没有 _evidence 的旧回答不挂横幅也不挂证据说法', legacyNote === 0)
check('没有运行时报错(问数据页)', chat.errors.length === 0, chat.errors.join(' | '))
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await p.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await p.locator('[data-turn="evidence"] [data-seg="s8"]').click()
await p.waitForSelector('[data-evidence-panel] [data-ev-substituted]')
await p.waitForTimeout(350)
await p.screenshot({ path: `${SHOTS}/evidence-chat-${theme}.png` })
await p.keyboard.press('Escape')
}
}
await chat.ctx.close()
})
await section('artifacts', '记录页的工件:口径卡指标集、报告文档有名字(REQ-A-1)', async () => {
// 按页面自己加载的那一份取(热更新过的模块带 ?t=,另 import 一份也是同一套代码,这里只读纯函数)
const got = await page.evaluate(async () => {
const m = await import('/src/pages/runs/model.ts')
return {
metric: m.artifactKindLabel('metric_set'), report: m.artifactKindLabel('report_doc'),
card: m.artifactDescription('metric_set', { caliber: '周报口径', version: 'v2' }),
bare: m.artifactDescription('metric_set', null), doc: m.artifactDescription('report_doc', {}),
evidence: m.isEvidence('metric_set') && m.isEvidence('report_doc'),
}
})
check('工件种类:口径卡指标集、报告文档', got.metric === '口径卡指标集' && got.report === '报告文档', JSON.stringify(got))
check('口径卡那件写成「口径卡「x」v2」', got.card === '口径卡「周报口径」v2', got.card)
check('没有 meta 的老数据也有一句说明', got.bare === '口径卡的指标、算式和输入' && got.doc.includes('报告'))
check('两种都算证据(列表里醒目)', got.evidence)
})
await section('long', '长报告:块级 content-visibility、折叠、跳进折叠区自动展开', async () => {
const box = '#evidence-long'
const info = await page.evaluate((sel) => {
const blocks = [...document.querySelectorAll(`${sel} [data-block]`)]
return { n: blocks.length, cv: blocks.map((b) => getComputedStyle(b).contentVisibility),
fold: document.querySelector(sel).innerText.includes('以下内容已折叠') }
}, box)
check('超过 40 块的报告:每块 content-visibility: auto', info.cv.length > 0 && info.cv.every((v) => v === 'auto'), info.cv.slice(0, 3).join(','))
check('超过 3000 字先折叠,说清「后面还有」', info.fold && info.n < 128, `${info.n} 块`)
const tally = await page.locator(`${box} [data-evidence-tally]`).innerText()
check('没有 stats、没有违规清单时从片段自己数(16 份:数字 4 处 + 非数字引用 1 处)',
tally.includes('112/176 数字有出处 · 无证据 64 · 另有 16 处引用无法解析'), tally)
await page.locator(`${box} [data-seg="s4-0"]`).focus()
await page.keyboard.press('End')
await page.waitForTimeout(250)
const after = await page.evaluate((sel) => ({ n: document.querySelectorAll(`${sel} [data-block]`).length,
active: document.activeElement?.getAttribute('data-seg') }), box)
check('跳到折叠区里的片段:先展开再聚焦', after.n === 128 && after.active === 's49-15', JSON.stringify(after))
const stops = await page.locator(`${box} [data-seg][tabindex="0"]`).count()
check('长报告也只占一个 Tab 位', stops === 1, String(stops))
})
await section('integrity', '证据接口复核出问题:醒目写出来,不说「有出处」就完事', async () => {
const bad = await open('/ui-harness.html?evidence=1')
await bad.page.unroute((u) => new URL(u).pathname.startsWith('/api/'))
await bad.page.route((u) => new URL(u).pathname.startsWith('/api/'), (r) => {
const m = new URL(r.request().url()).pathname.match(/\/evidence\/segments\/([^/]+)$/)
if (!m) return r.request().method() === 'GET' ? r.continue() : r.abort()
const body = structuredClone(fx.segments[m[1]])
body.chain[0] = { ...body.chain[0], hash_ok: false, sealed: false }
return r.fulfill({ json: body })
})
await bad.page.locator('#evidence-wide [data-seg="s8"]').click()
await bad.page.waitForSelector('[data-evidence-panel] [data-ev-integrity]', { timeout: 4000 }).catch(() => {})
const said = await bad.page.locator('[data-evidence-panel] [data-ev-integrity]').innerText().catch(() => '')
check('工件哈希校验失败、不在封存范围内:用失败色逐条写出', said.includes('哈希校验失败') && said.includes('不在封存范围内'), said)
await bad.page.keyboard.press('Escape')
await page.locator(`${wide} [data-seg="s8"]`).click()
await page.waitForSelector('[data-evidence-panel] [data-ev-substituted]')
check('全部复核通过时不多说一句', await page.locator('[data-evidence-panel] [data-ev-integrity]').count() === 0)
await page.keyboard.press('Escape')
await bad.ctx.close()
})
/**
* 组件预览页,一期夹具那份报告(#evidence-wide)的片段接口按 answer(segId, 夹具里那一份的拷贝) 回:返回对象就当 200,
* 返回数字就当那个状态码。它的证据图按 graph(夹具拷贝) 回。在打开页面之前挂上:报告一挂上就会取证据图(期 4 起正文
* 要它带的反方审稿标记),换答复晚了,取回来缓存住的就是换之前那一份。别的请求照 routed 答
*/
async function probe(answer, { graph = (g) => g } = {}) {
return open('/ui-harness.html?evidence=1', { before: (page) => page.route((u) => new URL(u).pathname.startsWith('/api/'), (route) => {
const req = route.request()
const path = new URL(req.url()).pathname
if (req.method() !== 'GET' || !path.startsWith(`/api/runs/${fx.run_id}/`)) return route.fallback()
const m = path.match(/\/evidence\/segments\/([^/]+)$/)
if (m) {
const body = answer(m[1], structuredClone(fx.segments[m[1]] ?? {}))
return typeof body === 'number'
? route.fulfill({ status: body, json: { detail: '伪造的错误', code: 'evidence_report_not_found' } })
: route.fulfill({ json: body })
}
if (/\/evidence$/.test(path)) return route.fulfill({ json: graph(structuredClone(fx.graph)) })
return route.fallback()
}) })
}
const sealOf = async (p) => ({
state: await p.locator('[data-evidence-panel] [data-ev-seal-state]').getAttribute('data-ev-seal-state').catch(() => null),
text: await p.locator('[data-evidence-panel]').innerText().catch(() => ''),
})
async function openSeg(p, id = 's8', wait = '[data-ev-seal-state]:not([data-ev-seal-state="idle"])') {
await p.locator(`#evidence-wide [data-seg="${id}"]`).click()
await p.waitForSelector(`[data-evidence-panel] ${wait}`, { timeout: 4000 }).catch(() => {})
await p.waitForTimeout(100)
}
await section('trust', '只信封存链:证据接口答的是另一份报告时,不能画成绿的', async () => {
// 正文是按成果上的 doc_artifact 取的(可改写),接口从封存的事件找文档。两边不是同一份:
// 接口报的文档工件 id 不同、这个位置的字也不同(封存的那份写的是 9.9%)
const other = await probe((id, body) => (id === 's8' ? {
...body, report: { ...body.report, doc_artifact: 'f'.repeat(64) },
segment: { ...body.segment, text: '9.9%' },
chain: [{ ...body.chain[0], value: 9.9, rendered: '9.9%' }, ...body.chain.slice(1)],
} : body))
await openSeg(other.page)
let got = await sealOf(other.page)
const integrity = await other.page.locator('[data-evidence-panel] [data-ev-integrity="doc"]').innerText().catch(() => '')
check('封存文档不是正文这份:封存那一行不是绿的「核对一致」,是失败', got.state === 'failed' && !got.text.includes('核对一致'),
`${got.state} ${got.text.replace(/\s+/g, ' ').slice(0, 120)}`)
check('醒目写出「正文不是封存的那一份」,并说封存的那份这里写的是 9.9%', integrity.includes('不是封存范围内的那一份')
&& integrity.includes('9.9%'), integrity.replace(/\s+/g, ' '))
check('另一份报告的算式和值不挂到正文上', await other.page.locator('[data-evidence-panel] [data-ev-substituted]').count() === 0
&& !(await other.page.locator('[data-evidence-panel] [data-ev-metric]').innerText()).includes('9.9%'))
check('标题还是正文上的字', (await other.page.locator('[data-evidence-panel] h3').innerText()) === '8.7%')
await other.ctx.close()
// 只有文档工件 id 不同(字碰巧一样)也不行
const idOnly = await probe((id, body) => ({ ...body, report: { ...body.report, doc_artifact: 'e'.repeat(64) } }))
await openSeg(idOnly.page)
got = await sealOf(idOnly.page)
check('只是文档工件 id 不同:一样判失败', got.state === 'failed'
&& await idOnly.page.locator('[data-evidence-panel] [data-ev-integrity="doc"]').count() === 1, got.state)
await idOnly.ctx.close()
// 片段接口取不到,退回证据图:证据图里封存的报告没有正文这份 → 失败;有 → 只说文档封存了
const noSeg = await probe(() => 500, { graph: (g) => ({ ...g, reports: g.reports.map((r) => ({ ...r, doc_artifact: 'd'.repeat(64) })) }) })
await openSeg(noSeg.page)
got = await sealOf(noSeg.page)
check('证据图兜底:封存的报告里没有正文这份,判失败', got.state === 'failed'
&& await noSeg.page.locator('[data-evidence-panel] [data-ev-integrity="doc"]').count() === 1, got.state)
await noSeg.ctx.close()
const viaGraph = await probe(() => 500)
await openSeg(viaGraph.page)
got = await sealOf(viaGraph.page)
check('证据图兜底、正文这份在封存的报告里:只说文档封存了,这个数的链没逐项核对', got.state === 'done'
&& got.text.includes('未能获取本段的证据链'), `${got.state} ${got.text.replace(/\s+/g, ' ').slice(-80)}`)
await viaGraph.ctx.close()
// 对照:接口答的就是正文这份时照常是绿的(上面那几条不是因为什么都判失败才过的)
await page.locator(`${wide} [data-seg="s8"]`).click()
await page.waitForSelector('[data-evidence-panel] [data-ev-seal-state="done"]', { timeout: 4000 }).catch(() => {})
got = await sealOf(page)
check('对照:同一份文档照常「已封存 · 核对一致」', got.state === 'done' && got.text.includes('已封存 · 核对一致')
&& await page.locator('[data-evidence-panel] [data-ev-integrity]').count() === 0, got.state)
await page.keyboard.press('Escape')
})
await section('seal', '封存状态:被改过的封存画成失败,没封存不说「事后补进来」', async () => {
// 后端 covered = 封存核对通过 && 每步在台账里:核对失败时 covered 必然 false、每步 sealed 也是 false
const cases = [
['封存被改过(sealed, ok=false)', { sealed: true, ok: false, covered: false }, 'failed', '已封存 · 核对不一致', false],
['还没封存', { sealed: false, ok: null, covered: false }, 'idle', '尚未封存', false],
['封存完好、这件证据不在台账里', { sealed: true, ok: true, covered: false }, 'waiting', '不在封存范围内', true],
]
for (const [name, seal, state, label, late] of cases) {
const r = await probe((id, body) => ({ ...body, seal, chain: body.chain?.map((st) => ('sealed' in st ? { ...st, sealed: false } : st)) }))
await openSeg(r.page, 's8', '[data-ev-substituted]')
const got = await sealOf(r.page)
check(`${name}:封存那一行是 ${state}「${label}」`, got.state === state && got.text.includes(label), `${got.state}`)
check(`${name}:${late ? '单独报「不在封存范围内」' : '不说「事后补充」(封存那一行已经说清了)'}`,
got.text.includes('事后补充') === late, got.text.replace(/\s+/g, ' ').slice(0, 120))
await r.ctx.close()
}
})
await section('fields', '成果字段取文档:别的运行写的、没记正文的都不画', async () => {
const r = await open('/ui-harness.html?evidence=1')
await r.page.route(/\/api\/artifacts\/fx-field-/, (route) => {
const id = new URL(route.request().url()).pathname.split('/').pop()
const doc = id === 'fx-field-other-run' ? { ...fx.doc, run_id: 'run-someone-else' }
: id === 'fx-field-no-markdown' ? (({ markdown: _m, ...d }) => d)(fx.doc) : fx.doc
return route.fulfill({ json: { id, content: doc } })
})
await r.page.reload({ waitUntil: 'networkidle' })
await r.page.waitForSelector('#fx-field-ok [data-evidence-doc]', { timeout: 5000 }).catch(() => {})
check('对照:正文这份照常逐段可点', await r.page.locator('#fx-field-ok [data-evidence-doc] [data-seg]').count() > 0)
const other = await r.page.locator('#fx-field-other-run [data-evidence-fallback]').getAttribute('data-evidence-fallback').catch(() => null)
check('文档的 run_id 和成果的运行不同:退回普通文本并说明', other === 'other-run'
&& (await r.page.locator('#fx-field-other-run').innerText()).includes('不属于本次运行')
&& await r.page.locator('#fx-field-other-run [data-seg]').count() === 0, String(other))
const bare = await r.page.locator('#fx-field-no-markdown [data-evidence-fallback]').getAttribute('data-evidence-fallback').catch(() => null)
check('文档没记正文(缺 markdown):当对不上处理,不画', bare === 'mismatch'
&& await r.page.locator('#fx-field-no-markdown [data-seg]').count() === 0, String(bare))
await r.ctx.close()
})
// ---------------------------------------------------------------------------
// 第二期:查询步骤、输入来源、整表、口径卡来源(夹具 evidence-query.json)
// ---------------------------------------------------------------------------
const Q = '#evidence-query'
const QN = '#evidence-query-narrow'
/** 夹具里的片段 id:按文字认,免得重新生成夹具后编号挪了这里全错 */
const qseg = (text, nth = 0) => Object.values(fxq.segments).filter((d) => d.segment.text === text)[nth]?.segment.id
const Q3 = fxq.snapshots.Q3.artifact
const qsteps = (text) => fxq.segments[qseg(text)].chain.filter((st) => st.step === 'query')
/** 第二期的组件预览:片段接口按 answer(segId, 夹具拷贝) 回(数字当状态码),其余照 routed */
async function probeQ(answer, opts = {}) {
const r = await open('/ui-harness.html?evidence=1', opts)
await r.page.route((u) => new URL(u).pathname.includes(`/runs/${fxq.run_id}/evidence/segments/`), (route) => {
const id = decodeURIComponent(new URL(route.request().url()).pathname.split('/').pop())
const body = answer(id, structuredClone(fxq.segments[id] ?? {}))
return typeof body === 'number'
? route.fulfill({ status: body, json: { detail: '伪造的错误', code: 'evidence_report_not_found' } })
: route.fulfill({ json: body })
})
return r
}
async function openQ(p, text, { box = Q, nth = 0, wait = '[data-ev-query]' } = {}) {
await p.locator(`${box} [data-seg="${qseg(text, nth)}"]`).click()
await p.waitForSelector(`[data-evidence-panel] ${wait}`, { timeout: 4000 }).catch(() => {})
await p.waitForTimeout(120)
}
/** 令牌算出来的颜色:拿一个探针元素量,不在脚本里写死色值 */
const tokenColor = (p, prop, value) => p.evaluate(([prop, value]) => {
const el = document.createElement('div')
el.style[prop] = value
document.body.appendChild(el)
const got = getComputedStyle(el)[prop]
el.remove()
return got
}, [prop, value])
await section('query', '查询步骤:SQL 带复制、被引用的行和格高亮、窗口说明、完整快照、遮罩', async () => {
await openQ(page, '1,288')
const part = panel(page).locator(`[data-ev-query="${Q3}"]`)
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(250)
await page.screenshot({ path: `${SHOTS}/evidence-query-step-${theme}.png` })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
check('点开单元格:面板里有这次查询的步骤', await part.count() === 1, String(await part.count()))
check('标题写查询编号和工具', (await part.innerText()).includes('Q3') && (await part.innerText()).includes('db_query__shop'))
check('SQL 原样摆出来', (await part.locator('[data-ev-sql]').innerText()).startsWith('SELECT order_id, region, amount, phone'))
check('SQL 带复制按钮', await part.locator('button', { hasText: '复制 SQL' }).count() === 1)
const rows = part.locator('tbody tr[data-highlight="row"]')
check('被引用的行加 data-highlight="row",只有这一行', await rows.count() === 1
&& (await rows.first().innerText()).includes('10006'), await rows.first().innerText().catch(() => ''))
const cell = part.locator('td[data-highlight="cell"]')
check('被引用的格加 data-highlight="cell",在 amount 列', await cell.count() === 1 && (await cell.innerText()) === '1288'
&& await cell.evaluate((td) => td.closest('table').querySelectorAll('th')[td.cellIndex]?.textContent === 'amount'),
await cell.innerText().catch(() => ''))
const [rowBg, cellLine] = await Promise.all([
rows.first().evaluate((el) => getComputedStyle(el).backgroundColor),
cell.evaluate((el) => getComputedStyle(el).outlineColor),
])
check('高亮只用令牌:行底是 --st-done-soft', rowBg === await tokenColor(page, 'backgroundColor', 'var(--st-done-soft)'), rowBg)
check('高亮只用令牌:格的描边是 --st-done', cellLine === await tokenColor(page, 'outlineColor', 'var(--st-done)'), cellLine)
check('只显示窗口:被引用的第 6 行加前后各 2 行,共 5 行', await part.locator('tbody tr').count() === 5)
const win = await part.locator('[data-ev-window]').innerText().catch(() => '')
check('窗口外的行写明「仅显示被引用的行及前后各 2 行」和位置', win.includes('仅显示被引用的行及前后各 2 行')
&& win.includes('第 4–8 行') && win.includes('共 12 行'), win)
const masked = await part.locator('td[data-masked]').allInnerTexts()
check('被遮罩的列(phone)每格写「已遮罩」', masked.length === 5 && masked.every((t) => t === '已遮罩'), masked.join('|'))
const note = await panel(page).locator('[data-ev-mask-note]').innerText().catch(() => '')
check('面板底部说明遮罩:列名 + 不是安全边界', note.includes('phone') && note.includes('遮罩只减少暴露,不是安全边界'), note)
check('遮罩说明在面板最后(封存那一行之后)', await panel(page).evaluate((p) => {
const note = p.querySelector('[data-ev-mask-note]')
const seal = p.querySelector('[data-ev-seal]')
return !!note && !!seal && !!(seal.compareDocumentPosition(note) & Node.DOCUMENT_POSITION_FOLLOWING)
}))
check('查询快照复验通过时不多说一句', await panel(page).locator('[data-ev-integrity]').count() === 0)
// Excel 导入期 4 之前的报告:查询步骤不带推断来源的提示,前端一个请求都不多发、三节都不画(P4-SPEC 4.4 第 14 条)。
// 断言请求记账,不只看 DOM:老服务端那种 404 也会让三节不画
check('期 4 之前的报告(查询步骤没有提示):不请求推断的来源,面板里没有数据版本、推断的来源',
!harness.hits.some((h) => h.startsWith('prov:'))
&& await panel(page).locator('[data-ev-provenance], [data-ev-prov-version], [data-ev-prov-checks]').count() === 0,
harness.hits.filter((h) => h.startsWith('prov:')).join(','))
const aria = await page.locator(`${Q} [data-seg="${qseg('1,288')}"]`).getAttribute('aria-label')
check('单元格的 aria-label 写明查询、第几行、哪一列', aria === '1,288,有出处:查询 Q3 · 第 6 行 · amount', aria)
const before = harness.hits.filter((h) => h === 'artifact:Q3').length
await part.locator('[data-ev-snapshot]').click()
await page.waitForSelector('[role="dialog"] [data-artifact] tbody tr', { timeout: 4000 }).catch(() => {})
const full = await page.locator('[role="dialog"] [data-artifact] tbody tr').count()
check('「打开完整快照」复用工件查看,12 行全在', full === 12 && harness.hits.filter((h) => h === 'artifact:Q3').length === before + 1, `${full} 行`)
const fullText = await page.locator('[role="dialog"] [data-artifact]').innerText().catch(() => '')
check('……完整快照里遮罩列同样写「已遮罩」,工件接口给的原值不画出来', !fullText.includes('RAW-PHONE')
&& await page.locator('[role="dialog"] [data-artifact] td[data-masked]').count() === 12,
fullText.includes('RAW-PHONE') ? '原值漏出来了' : String(await page.locator('[role="dialog"] [data-artifact] td[data-masked]').count()))
await page.keyboard.press('Escape')
await page.waitForTimeout(200)
check('在完整快照里按 Esc 只关快照,证据面板还开着', await page.locator('[role="dialog"]').count() === 0 && await panel(page).count() === 1)
await openQ(page, '华东')
const v = panel(page).locator('[data-ev-query]')
check('值片段(华东)也给查询步骤,高亮第 1 行 region 格', (await v.locator('td[data-highlight="cell"]').innerText().catch(() => '')) === '华东'
&& await v.locator('tbody tr').first().getAttribute('data-highlight') === 'row')
check('第 1 行:窗口是第 1–3 行(前面没有行可补),照实写', (await v.locator('[data-ev-window]').innerText().catch(() => ''))
.includes('第 1–3 行,共 4 行'))
check('没有遮罩列时不写遮罩说明', await panel(page).locator('[data-ev-mask-note]').count() === 0)
await page.keyboard.press('Escape')
await openQ(page, '45,678.5元')
const one = panel(page).locator('[data-ev-query]')
check('整份快照都在窗口里(1 行):不写窗口说明,完整快照照样能开', await one.count() === 1
&& await one.locator('[data-ev-window]').count() === 0 && await one.locator('[data-ev-snapshot]').count() === 1)
await page.keyboard.press('Escape')
// 被引用的两格隔得远(第 6、12 行):窗口不连续,行号那一列看得出跳过了哪几行,只标那两格
await openQ(page, '672.5元')
const gap = panel(page).locator(`[data-ev-query="${Q3}"]`)
const nums = await gap.locator('tbody tr td:first-child').allInnerTexts()
check('窗口不连续:最前面一列是快照里的行号(4–8、10–12)', nums.join(',') === '4,5,6,7,8,10,11,12', nums.join(','))
check('……窗口说明写成「第 4–8、10–12 行,共 12 行」', (await gap.locator('[data-ev-window]').innerText().catch(() => ''))
.includes('第 4–8、10–12 行,共 12 行'))
const hit2 = await gap.locator('td[data-highlight="cell"]').allInnerTexts()
check('……两格都高亮(1288、615.5)', hit2.join(',') === '1288,615.5', hit2.join(','))
await page.keyboard.press('Escape')
const exact = await probeQ((id, body) => (id === qseg('1,288') ? { ...body, chain: body.chain.map((st) => (st.step === 'query'
? { ...st, highlight: { rows: [5, 6], cols: ['amount', 'region'], cells: [[5, 'amount'], [6, 'region']] } } : st)) } : body))
await openQ(exact.page, '1,288')
const exactCells = await exact.page.locator('[data-evidence-panel] td[data-highlight="cell"]').allInnerTexts()
check('highlight 给了 cells:只标那几格,不按行 × 列全标', exactCells.join(',') === '1288,华北', exactCells.join(','))
await exact.ctx.close()
const unsealed = await probeQ((id, body) => (id === qseg('1,288') ? { ...body, chain: body.chain.map((st) => (st.step === 'query'
? { step: 'query', alias: st.alias, artifact: st.artifact, tool: st.tool, columns: [], rows: [], hash_ok: null, sealed: false,
highlight: st.highlight, note: '这份查询快照不属于封存范围内的任何事件(封存前没有任何查询返回过它),不能作为证据展示' } : st)) } : body))
await openQ(unsealed.page, '1,288')
const un = await unsealed.page.locator('[data-evidence-panel] [data-ev-query]').innerText().catch(() => '')
check('查询快照不在封存范围里:一行都不画,照接口原话说,并用失败色写「不在封存范围内」', un.includes('不能作为证据展示')
&& await unsealed.page.locator('[data-evidence-panel] [data-ev-query] [data-ev-integrity~="sealed"]').count() === 1
&& await unsealed.page.locator('[data-evidence-panel] [data-ev-query] tbody tr').count() === 0,
un.replace(/\s+/g, ' ').slice(0, 200))
// 后端这时照样带着工件 id:按钮一点就绕过证据接口把整份快照打开,还挂着「哈希已校验」
check('……不给「打开完整快照」(接口不给行,面板也不能开整份快照)',
await unsealed.page.locator('[data-evidence-panel] [data-ev-snapshot]').count() === 0)
await unsealed.ctx.close()
// 接口把遮罩列的原值带回来了(老接口、改错了的后端):列在遮罩里就一律不画原值
const leak = await probeQ((id, body) => (id === qseg('1,288') ? { ...body, chain: body.chain.map((st) => (st.step === 'query'
? { ...st, rows: st.rows.map((r) => r.map((v, j) => (st.columns[j] === 'phone' ? `RAW-PHONE-${j}` : v))) } : st)) } : body))
await openQ(leak.page, '1,288')
const leaked = await leak.page.locator('[data-evidence-panel]').innerText().catch(() => '')
check('遮罩列带回了原值也不画:表里一律写「已遮罩」', !leaked.includes('RAW-PHONE')
&& await leak.page.locator('[data-evidence-panel] td[data-masked]').count() === 5, leaked.includes('RAW-PHONE') ? '原值漏出来了' : '')
await leak.ctx.close()
const bad = await probeQ((id, body) => (id === qseg('1,288')
? { ...body, chain: body.chain.map((st) => (st.step === 'query' ? { ...st, hash_ok: false } : st)) } : body))
await openQ(bad.page, '1,288')
const said = await bad.page.locator('[data-evidence-panel] [data-ev-integrity]').innerText().catch(() => '')
const color = await bad.page.locator('[data-evidence-panel] [data-ev-integrity]').evaluate((el) => getComputedStyle(el).color).catch(() => '')
check('查询快照哈希校验失败:用失败色写出来', said.includes('查询快照') && said.includes('哈希校验失败')
&& color === await tokenColor(bad.page, 'color', 'var(--st-failed)'), said)
check('……哈希校验失败也不给「打开完整快照」', await bad.page.locator('[data-evidence-panel] [data-ev-snapshot]').count() === 0)
await bad.ctx.close()
})
await section('sources', '输入来源:与快照一致、模型报 X 快照是 Y、没查到记为空、代码节点、点标签跳到查询', async () => {
await openQ(page, '37.0元', { wait: '[data-ev-sources]' })
const src = (path) => panel(page).locator(`[data-ev-source="${path}"]`)
const gmv = await src('vars.kpi.gmv').innerText().catch(() => '')
check('agent 字段 verified:写「与快照一致」', gmv.includes('与快照一致')
&& await src('vars.kpi.gmv').getAttribute('data-ev-source-status') === 'verified', gmv)
const ord = await src('vars.kpi.order_cnt').innerText().catch(() => '')
check('mismatch:写「模型给出 1,240,快照值为 1,234,已采用快照值」', ord.includes('模型给出 1,240,快照值为 1,234,已采用快照值'), ord)
check('写明从哪个节点的哪个字段、哪一格来', gmv.includes('fetch') && gmv.includes('Q1') && gmv.includes('第 1 行'), gmv)
await page.keyboard.press('Escape')
await openQ(page, '—', { wait: '[data-ev-sources]' })
const refund = await src('vars.kpi.refund_cnt').innerText().catch(() => '')
check('from 为 null(missing):写「未查询到,记为空值(未按 0 处理)」', refund.includes('未查询到,记为空值(未按 0 处理)'), refund)
check('缺输入的指标照样给出输入来源,说得清缺的是哪一个', await panel(page).locator('[data-ev-missing]').count() === 1
&& await panel(page).locator('[data-ev-sources] [data-ev-source]').count() === 2)
await page.keyboard.press('Escape')
await openQ(page, '—', { nth: 1, wait: '[data-ev-sources]' })
const nu = await src('vars.kpi.new_users').innerText().catch(() => '')
check('unresolved:同样写「未查询到,记为空值(未按 0 处理)」,并带原因', nu.includes('未查询到,记为空值(未按 0 处理)')
&& nu.includes('没有列「new_users」'), nu)
await page.keyboard.press('Escape')
await openQ(page, '44,000.0元', { wait: '[data-ev-sources]' })
const compute = src('nodes.adjust.total')
const ct = await compute.innerText().catch(() => '')
check('来自 code 节点(计算):写警示语,说要进口径卡', ct.includes('代码节点') && ct.includes('口径卡')
&& await compute.getAttribute('data-ev-source-role') === 'compute', ct)
const computeColor = await compute.evaluate((el) => getComputedStyle(el).borderColor).catch(() => '')
await page.keyboard.press('Escape')
await openQ(page, '7.12', { wait: '[data-ev-sources]' })
const source = src('nodes.rate.value')
const st = await source.innerText().catch(() => '')
check('来自 code 节点(取数):写明标为取数、无法与快照核对', st.includes('取数') && st.includes('无法与查询快照核对')
&& await source.getAttribute('data-ev-source-role') === 'source', st)
const sourceColor = await source.evaluate((el) => getComputedStyle(el).borderColor).catch(() => '')
check('计算角色比取数角色醒目:失败色描边', computeColor === await tokenColor(page, 'borderColor', 'var(--st-failed)')
&& sourceColor !== computeColor, `${computeColor} / ${sourceColor}`)
await page.keyboard.press('Escape')
await openQ(page, '8.7%', { wait: '[data-ev-sources]' })
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(250)
await page.screenshot({ path: `${SHOTS}/evidence-sources-${theme}.png` })
await panel(page).evaluate((p) => { p.querySelector('.overflow-y-auto').scrollTop = 99999 })
await page.waitForTimeout(100)
await page.screenshot({ path: `${SHOTS}/evidence-sources-queries-${theme}.png` })
await panel(page).evaluate((p) => { p.querySelector('.overflow-y-auto').scrollTop = 0 })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
const prev = qsteps('8.7%').find((q) => q.alias === 'Q4')
const chip = panel(page).locator(`button[data-ev-input="cell(nodes.pull, 0, 'gmv')"]`)
check('代入式里的输入标签是按钮', await chip.count() === 1)
await chip.click()
await page.waitForTimeout(200)
const landed = await page.evaluate((a) => {
const part = document.querySelector(`[data-evidence-panel] [data-ev-query="${a}"]`)
const css = part ? getComputedStyle(part) : null
return { inside: !!part?.contains(document.activeElement), flash: part?.hasAttribute('data-flash') ?? false,
style: css?.outlineStyle, width: css?.outlineWidth, color: css?.outlineColor }
}, prev?.artifact)
// 只有属性没有样式等于没描:目标步骤已经在视野里时,点完什么都看不出来变了
check('点输入标签:焦点跳到对应的查询步骤(Q4),并描一下(看得见的强调色描边)', landed.inside && landed.flash
&& landed.style !== 'none' && parseFloat(landed.width) >= 1 && landed.color === await tokenColor(page, 'outlineColor', 'var(--accent)'),
JSON.stringify(landed))
// 键盘:焦点落在查询步骤的标题上,得有看得见的焦点框(WCAG 2.4.7)
await page.waitForTimeout(1700)
await page.keyboard.press('Shift')
await chip.focus()
await page.keyboard.press('Enter')
await page.waitForTimeout(150)
const ring = await page.evaluate(() => {
const el = document.activeElement
const css = el ? getComputedStyle(el) : null
return { head: el?.hasAttribute('data-ev-query-head') ?? false, style: css?.outlineStyle, width: css?.outlineWidth }
})
check('……键盘按回车跳过去:焦点落在查询步骤标题上,焦点框看得见', ring.head && ring.style !== 'none' && parseFloat(ring.width) >= 1,
JSON.stringify(ring))
const go = panel(page).locator('[data-ev-source="vars.kpi.gmv"] button[data-ev-goto]')
check('输入来源那一行也能跳到查询', await go.count() === 1)
await go.click()
await page.waitForTimeout(200)
check('……跳到 Q1 那一步', await page.evaluate((a) => !!document.querySelector(`[data-evidence-panel] [data-ev-query="${a}"]`)
?.contains(document.activeElement), qsteps('8.7%').find((q) => q.alias === 'Q1')?.artifact))
await page.keyboard.press('Escape')
await page.waitForTimeout(150)
check('Esc 关掉面板,焦点回到原片段', await panel(page).count() === 0 && await active(page) === qseg('8.7%'), await active(page))
})
await section('table', '整表:每格是可点的片段,方向键按行列走,值和数同一套线型', async () => {
const cells = page.locator(`${Q} table td [data-seg]`)
check('整表每个数据格都是可点的片段(3 行 × 3 列)', await cells.count() === 9, String(await cells.count()))
check('表头不是片段', await page.locator(`${Q} table th [data-seg]`).count() === 0)
const [val, num] = await Promise.all([qseg('华东', 1), qseg('18,230.5', 1)].map((id) => page.locator(`${Q} [data-seg="${id}"]`)
.evaluate((el) => { const cs = getComputedStyle(el); return `${cs.textDecorationLine}/${cs.textDecorationStyle}/${cs.textDecorationColor}` })))
check('值片段(华东)和数字片段(18,230.5)同一套线型', val === num && val.includes('solid'), `${val} vs ${num}`)
await page.locator(`${Q} [data-seg="${qseg('18,230.5', 1)}"]`).focus()
const walk = []
for (const key of ['ArrowDown', 'ArrowDown', 'ArrowUp', 'ArrowRight', 'ArrowDown', 'ArrowDown']) {
await page.keyboard.press(key)
walk.push(await active(page))
}
const want = [qseg('12,004', 1), qseg('9,876'), qseg('12,004', 1), qseg('311'), qseg('298'), qseg('1,288')]
check('表格里 ↓/↑ 按列走到下一行 / 上一行,→ 走到右边一格,出了最后一行接着往下', walk.join(',') === want.join(','),
`${walk.join(',')} ≠ ${want.join(',')}`)
await page.locator(`${Q} [data-seg="${qseg('华东', 1)}"]`).focus()
await page.keyboard.press('ArrowUp')
check('第一行按 ↑ 回到表格前面那一句', await active(page) === qseg('华东', 0), await active(page))
await page.locator(`${Q} [data-seg="${qseg('311')}"]`).focus()
await page.keyboard.press('Enter')
await page.waitForSelector('[data-evidence-panel] [data-ev-query]', { timeout: 4000 }).catch(() => {})
const hl = await panel(page).locator('td[data-highlight="cell"]').innerText().catch(() => '')
check('回车打开这一格的查询步骤,高亮 311 这一格', hl === '311', hl)
await page.keyboard.press('Escape')
const stops = await page.locator(`${Q} [data-seg][tabindex="0"]`).count()
check('整份报告(含表格)仍只占一个 Tab 位', stops === 1, String(stops))
})
await section('caliber', '口径卡来源和升版处置', async () => {
await openQ(page, '45,678.5元', { wait: '[data-ev-caliber-from]' })
const from = await panel(page).locator('[data-ev-caliber-from]').innerText().catch(() => '')
check('指标步骤写「口径卡「周报口径」v3,来自「销售周报」v5」', from.includes('口径卡「周报口径」v3,来自「销售周报」v5'), from)
const up = await panel(page).locator('[data-ev-caliber-upgrade]').innerText().catch(() => '')
check('有升版处置时写「上游已有 v6,按『…』处置」', up.includes('上游已有 v6') && up.includes('新旧口径并列展示') && up.includes('处置'), up)
await page.keyboard.press('Escape')
// 方案第 5 节的写法(caliber_from)也认;没给工作流名、没给 policy_label 时不出 undefined
const bare = await probeQ((id, body) => ({ ...body, chain: (body.chain ?? []).map((st) => (st.step === 'metric'
? { ...st, source: undefined, caliber_from: { workflow_id: 'wf-demo-weekly', workflow_version: 5 },
caliber_upgrade: { policy: 'recompute', latest: 6 } } : st)) }))
await openQ(bare.page, '45,678.5元', { wait: '[data-ev-caliber-from]' })
const t = await bare.page.locator('[data-evidence-panel] [data-ev-metric]').innerText()
check('没给工作流名:退回工作流 id,不出 undefined', t.includes('来自') && t.includes('wf-demo') && !/undefined|null/.test(t), t.replace(/\s+/g, ' ').slice(0, 160))
check('没给 policy_label:按策略代码说人话', t.includes('用新口径回算历史'), t.replace(/\s+/g, ' ').slice(0, 200))
await bare.ctx.close()
const none = await probeQ((id, body) => ({ ...body, chain: (body.chain ?? []).map((st) => (st.step === 'metric'
? { ...st, source: null, caliber_from: undefined, caliber_upgrade: null } : st)) }))
await openQ(none.page, '45,678.5元', { wait: '[data-ev-expression]' })
check('本地定义的口径卡、没有升版:两行都不出', await none.page.locator('[data-evidence-panel] [data-ev-caliber-from], [data-evidence-panel] [data-ev-caliber-upgrade]').count() === 0)
await none.ctx.close()
})
await section('incomplete', '截断的查询结果整组算出来的指标:标「结果不完整」并写明原因,完整的指标不标', async () => {
await openQ(page, '45,678.5元', { wait: '[data-ev-expression]' })
check('完整的指标不出不完整标识', await panel(page).locator('[data-ev-incomplete], [data-ev-incomplete-reason]').count() === 0)
check('……输入也不标「已截断」', !(await panel(page).locator('[data-ev-input]').allInnerTexts()).some((t) => t.includes('已截断')))
await page.keyboard.press('Escape')
// 接口的形状:backend/app/api/evidence.py 的指标步骤带 incomplete / incomplete_reason,输入带 Agent 字段证据里的 truncated
const REASON = '基于被截断的查询结果计算(只取回了前 1000 行),结果不完整'
const cut = await probeQ((id, body) => ({ ...body, chain: (body.chain ?? []).map((st) => (st.step === 'metric'
? { ...st, incomplete: true, incomplete_reason: REASON } : st.step === 'input' ? { ...st, truncated: true } : st)) }))
await openQ(cut.page, '45,678.5元', { wait: '[data-ev-incomplete]' })
const p = cut.page.locator('[data-evidence-panel]')
const chip = p.locator('[data-ev-incomplete]')
check('指标名旁有「结果不完整」标识,悬停看原因', (await chip.innerText().catch(() => '')) === '结果不完整'
&& await chip.getAttribute('title') === REASON, await chip.innerText().catch(() => ''))
const why = await p.locator('[data-ev-incomplete-reason]').innerText().catch(() => '')
check('原因写在指标下面', why === REASON, why)
const warn = await tokenColor(cut.page, 'color', 'var(--st-waiting)')
check('用提醒色(不是失败色:值是真算出来的,只是不全)', await chip.evaluate((el) => getComputedStyle(el).color) === warn)
const inputs = await p.locator('[data-ev-input]').allInnerTexts()
check('被截断切开的 Agent 字段输入标「已截断」', inputs.some((t) => t.includes('(已截断)')), inputs.join('|'))
const source = await p.locator('[data-ev-sources]').innerText().catch(() => '')
check('……输入来源写明与快照一致、但只含取回的部分行', source.includes('与快照一致;查询结果已截断,该字段只含取回的部分行'),
source.replace(/\s+/g, ' ').slice(0, 160))
await cut.ctx.close()
})
await section('sqlcheck', 'SQL 检查(数据目录阶段 4B):查询步骤列出级别、规则名、说明、表和列、SQL 片段;指标未通过时标出来并写明原因', async () => {
await openQ(page, '1,288')
check('没查出问题的查询步骤不出 SQL 检查', await panel(page).locator('[data-ev-sql-checks]').count() === 0)
check('没有冻结目录的查询步骤不写数据目录版本', await panel(page).locator('[data-ev-catalog]').count() === 0)
await page.keyboard.press('Escape')
await openQ(page, '45,678.5元', { wait: '[data-ev-expression]' })
check('通过的指标不出「SQL 检查未通过」', await panel(page).locator('[data-ev-sql-check-failed], [data-ev-sql-check-reason]').count() === 0)
await page.keyboard.press('Escape')
// 接口的形状:backend/app/api/evidence.py 的查询步骤带 checks(去掉了给模型的那句),指标步骤带 sql_check_failed / sql_check_reason
const CHECKS = [
{ code: 'join_unconfirmed', level: 'info', message: '「订单」与「地区」按推断的关系关联(o.region = r.code),这条关系尚未确认。', table: 'orders' },
{ code: 'fanout_sum', level: 'error', table: 'orders', column: 'amount', sql_excerpt: 'SUM(o.amount)',
message: '「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算。请先按订单汇总明细再关联。' },
{ code: 'missing_valid_filter', level: 'warning', message: '「订单」定义了有效记录条件「status <> 9」,查询中没有按它筛选。', table: 'orders' },
]
const REASON = '所依据的查询未通过 SQL 检查(「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算),结果不可靠'
// 查询步骤带着这次查询对照的数据目录版本(_catalog_of:表结构快照里冻结的那份)
const CATALOG = [{ table: 'orders', label: '订单', version: 3 }, { table: 'order_items', version: 1 }]
const bad = await probeQ((id, body) => ({ ...body, chain: (body.chain ?? []).map((st) => (st.step === 'query'
? { ...st, checks: CHECKS, schema_artifact: 'c'.repeat(64), catalog: CATALOG }
: st.step === 'metric' ? { ...st, sql_check_failed: true, sql_check_reason: REASON } : st)) }))
await openQ(bad.page, '1,288', { wait: '[data-ev-sql-checks]' })
const p = bad.page.locator('[data-evidence-panel]')
const cat = await p.locator('[data-ev-catalog]').innerText().catch(() => '')
check('查询步骤写明对照的数据目录版本:有中文名写中文名,没有写表名', cat === '数据目录:订单 第 3 版、order_items 第 1 版', cat)
const items = p.locator('[data-ev-sql-checks] [data-sql-check]')
const levels = await items.evaluateAll((els) => els.map((e) => e.getAttribute('data-level')))
check('三条都列在 SQL 下面,错误在前、提示在后', levels.join(',') === 'error,warning,info', levels.join(','))
const first = await items.first().innerText().catch(() => '')
check('一条里有级别、中文规则名、说明、涉及的表和列、SQL 片段', first.includes('错误') && first.includes('一对多关联后重复计算')
&& first.includes('会重复计算') && first.includes('orders.amount') && first.includes('SUM(o.amount)'), first.replace(/\s+/g, ' '))
check('级别四通道:图标 + 文字,三档文字各不相同', (await items.locator('[data-sql-level]').allInnerTexts()).map((t) => t.trim()).join(',')
=== '错误,提醒,提示' && await items.locator('[data-sql-level] svg').count() === 3)
const tones = await items.locator('[data-sql-level]').evaluateAll((els) => els.map((e) => getComputedStyle(e).color))
const want = [await tokenColor(bad.page, 'color', 'var(--st-failed)'), await tokenColor(bad.page, 'color', 'var(--st-waiting)'),
await tokenColor(bad.page, 'color', 'var(--accent)')]
check('级别的颜色走令牌:错误 --st-failed、提醒 --st-waiting、提示 --accent', tones.join('|') === want.join('|'), tones.join('|'))
check('悬停写明依据的确证程度', (await items.first().locator('[data-sql-level]').getAttribute('title')) === '依据已核实,结果很可能有误,需要修改')
check('不露规则编号', !/fanout_sum|missing_valid_filter|join_unconfirmed/.test(await p.innerText()))
await bad.page.keyboard.press('Escape')
await openQ(bad.page, '45,678.5元', { wait: '[data-ev-sql-check-failed]' })
const chip = p.locator('[data-ev-sql-check-failed]')
check('指标名旁有「SQL 检查未通过」标识,悬停看原因', (await chip.innerText().catch(() => '')) === 'SQL 检查未通过'
&& await chip.getAttribute('title') === REASON, await chip.innerText().catch(() => ''))
check('原因写在指标下面', (await p.locator('[data-ev-sql-check-reason]').innerText().catch(() => '')) === REASON)
check('和「结果不完整」同一种样式(提醒色的标识)', await chip.evaluate((el) => getComputedStyle(el).color)
=== await tokenColor(bad.page, 'color', 'var(--st-waiting)') && (await chip.getAttribute('class'))?.includes('chip'))
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await bad.page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await bad.page.waitForTimeout(200)
await bad.page.screenshot({ path: `${SHOTS}/evidence-sqlcheck-metric-${theme}.png` })
}
}
check('没有运行时报错', bad.errors.length === 0, bad.errors[0] ?? '')
await bad.ctx.close()
})
await section('absence', '缺失断言:证据目录有省略时写了「没有查到」,违规清单写明是什么、为什么依据不足,带着原句', async () => {
// 违规的形状:backend/app/engine/evidence.py 的 absence_claims(code、message、text、span、segment、unit、context)
const MESSAGE = '证据目录有省略时写了缺失断言:写作模型没有看到全部证据,「没有查到」「不存在」「缺失」这类结论依据不足'
const r = await probeQ((id, body) => body, {
patchQuery: (f) => {
const units = f.doc.blocks.flatMap((b) => b.units ?? [])
const unit = units.find((u) => (u.segments ?? []).some((s) => s.kind === 'text' && s.text.trim().length > 2))
const seg = unit.segments.find((s) => s.kind === 'text' && s.text.trim().length > 2)
f.doc.violations = [...(f.doc.violations ?? []), { code: 'absence_claim', message: MESSAGE, text: '没有查到',
span: [seg.span[0], seg.span[0] + 1], segment: seg.id, unit: unit.id, context: '核销数据缺失:本轮没有查到任何结果。' }]
return f
},
})
await openQ(r.page, '1,288')
await r.page.locator('[data-evidence-panel] [data-ev-open-violations]').click()
await r.page.waitForSelector('[data-ev-violations]')
const item = r.page.locator('[data-ev-violation="absence_claim"]')
const text = await item.innerText().catch(() => '')
check('违规清单里有这一条,标「缺失断言」,不是笼统的「文档核对未通过」', text.includes('缺失断言') && !text.includes('文档核对未通过'), text)
check('……写明为什么依据不足、该怎么写(文案在 terms.ts)', text.includes(MESSAGE)
&& (await item.locator('[data-ev-absence-hint]').innerText().catch(() => '')).includes('本报告未覆盖'), text.replace(/\s+/g, ' '))
check('……带着原句;说的是一整句,不给「定位」,也不说「正文里没有对应文本」', text.includes('本轮没有查到任何结果')
&& await item.getByRole('button', { name: '定位' }).count() === 0 && !text.includes('没有对应文本'), text.replace(/\s+/g, ' '))
check('没有运行时报错', r.errors.length === 0, r.errors.join(' | '))
await r.ctx.close()
})
await section('q-narrow','查询步骤在 360px:栏内展开、底部抽屉都不横向滚动;减少动效', async () => {
const overflow = () => page.evaluate((sel) => {
const el = document.querySelector(sel)
return { sw: el.scrollWidth, cw: el.clientWidth }
}, QN)
await openQ(page, '1,288', { box: QN })
const inline = await page.locator(`${QN} [data-evidence-panel="inline"] [data-ev-query]`).count()
const o = await overflow()
check('窄栏里查询步骤在栏内展开', inline === 1)
check('窄栏里没有横向滚动(表格自己在框里滚)', o.sw <= o.cw, JSON.stringify(o))
const hl = await page.locator(`${QN} td[data-highlight="cell"]`).evaluate((td) => {
const box = td.closest('.overflow-x-auto')
const a = td.getBoundingClientRect()
const b = box.getBoundingClientRect()
return a.left >= b.left - 1 && a.right <= b.right + 1
}).catch(() => false)
check('被引用的格滚到看得见的位置', hl)
await page.locator(`${QN} [data-evidence-panel] button`, { hasText: '回到正文' }).click()
const small = await open('/ui-harness.html?evidence=1', { w: 360, h: 780 })
await small.page.locator(`${Q} [data-seg="${qseg('1,288')}"]`).click()
await small.page.waitForSelector('[data-evidence-panel] [data-ev-query]', { timeout: 4000 }).catch(() => {})
const s = await small.page.evaluate(() => ({ sw: document.documentElement.scrollWidth, vw: innerWidth }))
check('360px 的屏幕:底部抽屉里有查询步骤', await small.page.locator('[data-evidence-panel="drawer"] [data-ev-query]').count() === 1)
check('360px 的屏幕:整页没有横向滚动', s.sw <= s.vw, JSON.stringify(s))
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await small.page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await small.page.waitForTimeout(250)
await small.page.screenshot({ path: `${SHOTS}/evidence-query-360-${theme}.png` })
}
}
check('没有运行时报错(360px)', small.errors.length === 0, small.errors.join(' | '))
await small.ctx.close()
const r = await open('/ui-harness.html?evidence=1', { reduced: true })
await r.page.locator(`${Q} [data-seg="${qseg('8.7%')}"]`).click()
await r.page.waitForSelector('[data-evidence-panel] [data-ev-sources]', { timeout: 4000 }).catch(() => {})
await r.page.locator(`[data-evidence-panel] button[data-ev-input="cell(nodes.pull, 0, 'gmv')"]`).click().catch(() => {})
await r.page.waitForTimeout(100)
const moving = await r.page.evaluate(() => [...(document.querySelector('[data-evidence-panel]')?.getAnimations({ subtree: true }) ?? [])]
.filter((a) => !(a instanceof CSSTransition && Number(a.effect?.getTiming().duration) <= 0.01)).length)
check('减少动效:面板和跳转都没有动画', moving === 0, String(moving))
await r.ctx.close()
})
await section('q-tolerant', '接口缺字段、取不到:降级显示,不白屏', async () => {
const miss = await probeQ(() => 404)
await openQ(miss.page, '1,288', { wait: '[data-ev-query-missing]' })
const t = await miss.page.locator('[data-evidence-panel]').innerText()
check('片段接口取不到:只显示文档里记的出处(Q3 · db_query__shop · 12 行),照实说取不到', t.includes('Q3')
&& t.includes('db_query__shop') && t.includes('12 行') && t.includes('未能获取查询结果的行'), t.replace(/\s+/g, ' ').slice(0, 200))
check('……没有假装高亮了什么', await miss.page.locator('[data-evidence-panel] [data-highlight]').count() === 0)
check('……没有运行时报错', miss.errors.length === 0, miss.errors.join(' | '))
await miss.ctx.close()
const thin = await probeQ((id, body) => ({ ...body, redacted: undefined,
chain: (body.chain ?? []).map((st) => (st.step === 'query' ? { step: 'query', alias: st.alias, columns: st.columns, rows: st.rows } : st)) }))
await openQ(thin.page, '1,288')
const part = thin.page.locator('[data-evidence-panel] [data-ev-query]')
check('查询步骤只有列和行(老形状):照样画表,不高亮、不写窗口位置', await part.locator('tbody tr').count() === 5
&& await part.locator('[data-highlight]').count() === 0 && !(await part.innerText()).includes('undefined'))
check('……没有工件 id 就不给「打开完整快照」', await part.locator('[data-ev-snapshot]').count() === 0)
const inRange = await probeQ((id, body) => ({ ...body, chain: (body.chain ?? []).map((st) => (st.step === 'query'
? { ...st, highlight: { rows: [2], cols: ['amount'] } } : st)) }))
await openQ(inRange.page, '1,288')
check('highlight 的行号落在窗口里时按窗口内下标认(row_offset 之前的号)', (await inRange.page.locator('[data-evidence-panel] td[data-highlight="cell"]').innerText().catch(() => '')) === '1288')
check('没有 mask_note 就不出那一句', await inRange.page.locator('[data-evidence-panel] [data-ev-query-mask-note]').count() === 0)
check('没有运行时报错(缺字段)', thin.errors.length === 0 && inRange.errors.length === 0, [...thin.errors, ...inRange.errors].join(' | '))
await thin.ctx.close()
await inRange.ctx.close()
// 查询之后数据源改名或删掉了(E2W-6):后端按查询当时记下的遮罩处理,行照样给,另带一句 mask_note。
// 这句写在窗口说明那一行、琥珀色:现在的数据源设置已经管不到这份快照了
const MASK_NOTE = '数据源「shop」已不存在(可能已改名或删除),按查询时记录的遮罩处理'
const gone = await probeQ((id, body) => ({ ...body, chain: (body.chain ?? []).map((st) => (st.step === 'query'
? { ...st, mask_note: MASK_NOTE } : st)) }))
await openQ(gone.page, '1,288')
const goneNote = gone.page.locator('[data-evidence-panel] [data-ev-query] [data-ev-query-mask-note]')
const [goneText, goneColor, amber, sameLine] = await Promise.all([
goneNote.innerText().catch(() => ''),
goneNote.evaluate((el) => getComputedStyle(el).color).catch(() => ''),
tokenColor(gone.page, 'color', 'var(--st-waiting)'),
goneNote.evaluate((el) => el.parentElement === el.closest('[data-ev-query]')?.querySelector('[data-ev-window]')?.parentElement).catch(() => false),
])
check('数据源找不到了:mask_note 原话写出来', goneText === MASK_NOTE, goneText)
check('……琥珀色(--st-waiting),和窗口说明同一行', goneColor === amber && sameLine, `${goneColor} / ${amber} · 同一行 ${sameLine}`)
check('……行照样画、按记下的遮罩照样写「已遮罩」', await gone.page.locator('[data-evidence-panel] [data-ev-query] tbody tr').count() === 5
&& await gone.page.locator('[data-evidence-panel] [data-ev-query] td[data-masked]').count() === 5)
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await gone.page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await gone.page.waitForTimeout(200)
await gone.page.locator('[data-evidence-panel]').screenshot({ path: `${SHOTS}/evidence-mask-note-${theme}.png` }).catch(() => {})
}
}
check('没有运行时报错(mask_note)', gone.errors.length === 0, gone.errors.join(' | '))
await gone.ctx.close()
// 窗口被截到 50 行(window_truncated):被引用的另一格(第 41 行)不在窗口里,窗口里那一格照样标
const cut = await probeQ((id, body) => ({ ...body, chain: (body.chain ?? []).map((st) => (st.step === 'query'
? { ...st, window_truncated: true, total_rows: 60,
highlight: { rows: [5, 40], cols: ['amount'], cells: [[5, 'amount'], [40, 'amount']] } } : st)) }))
await openQ(cut.page, '1,288')
const cutCells = await cut.page.locator('[data-evidence-panel] td[data-highlight="cell"]').allInnerTexts()
check('窗口截断、有的被引用行不在窗口里:窗口里的那一格照样高亮,不是一格都不标', cutCells.join(',') === '1288'
&& await cut.page.locator('[data-evidence-panel] tr[data-highlight="row"]').count() === 1, cutCells.join(',') || '一格都没标')
const cutRows = await probeQ((id, body) => ({ ...body, chain: (body.chain ?? []).map((st) => (st.step === 'query'
? { ...st, window_truncated: true, highlight: { rows: [5, 40], cols: ['amount'] } } : st)) }))
await openQ(cutRows.page, '1,288')
check('……只给 rows × cols 时同样只标窗口里的那一行', (await cutRows.page.locator('[data-evidence-panel] td[data-highlight="cell"]').allInnerTexts()).join(',') === '1288')
await cut.ctx.close()
await cutRows.ctx.close()
// 解析不了的单元格引用(行越界):后端不给链。上面已经说了原因,不能再摆一块「查询的行这一次没取到」
const REASON = 'Q3 请求第 99 行,但查询结果只有 12 行(行号从 0 开始)'
const broken = (seg) => ({ ...seg, text: '⟦?v:Q3.r99.amount⟧', state: 'none', issue: 'unresolved_ref', ref: 'v:Q3.r99.amount',
cite: { ref: 'Q3.r99.amount', alias: 'Q3', kind: 'cell', role: 'value', status: 'unresolved', reason: REASON } })
const target = qseg('1,288')
const bad = await probeQ((id, body) => (id === target
? { ...body, segment: broken(body.segment), chain: [], note: `引用无法解析:${REASON}` } : body), {
patchQuery: (f) => {
for (const b of f.doc.blocks) for (const u of b.units ?? []) u.segments = (u.segments ?? []).map((sg) => (sg.id === target ? broken(sg) : sg))
return f
},
})
await bad.page.locator(`${Q} [data-seg="${target}"]`).click()
await bad.page.waitForSelector('[data-evidence-panel] [data-ev-reason]', { timeout: 4000 }).catch(() => {})
await bad.page.waitForTimeout(150)
const said = await bad.page.locator('[data-evidence-panel]').innerText().catch(() => '')
check('无法解析的单元格引用:说清原因(行越界)', said.includes(REASON), said.replace(/\s+/g, ' ').slice(0, 200))
check('……不再摆「查询 Q3 · 未能获取」,那像是接口出了错', await bad.page.locator('[data-evidence-panel] [data-ev-query-missing]').count() === 0
&& !said.includes('未能获取'), said.replace(/\s+/g, ' ').slice(0, 200))
check('没有运行时报错(截断、解析不了)', cut.errors.length === 0 && bad.errors.length === 0, [...cut.errors, ...bad.errors].join(' | '))
await bad.ctx.close()
})
await section('pinned', '没有 _evidence 的旧回答:和 Markdown.tsx 改动前逐字一样', async () => {
// markdown-pinned.json 里的 html 是改动前(HEAD b418c0d)的 Markdown.tsx 渲染的。重新生成:把
// `git show <旧提交>:frontend/src/run/Markdown.tsx` 临时放进 src/run/,另起一个页面逐个渲染
// samples、取 outerHTML 写回 html 字段,然后删掉临时文件。唯一有意的改动列在 intended 里
const pinned = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/markdown-pinned.json`, 'utf8'))
const now = await page.evaluate(() => Object.fromEntries([...document.querySelectorAll('#md-pinned [data-pin]')]
.map((el) => [el.dataset.pin, el.firstElementChild?.outerHTML ?? ''])))
for (const sample of pinned.samples) {
let before = sample.html
for (const d of pinned.intended) before = before.split(d.from).join(d.to)
const cur = now[sample.id] ?? ''
let at = 0
while (at < before.length && before[at] === cur[at]) at++
check(`「${sample.id}」和改动前逐字一样(只差列表缩进 ml-4 → pl-4)`, !!cur && cur === before,
cur === before ? '' : `第 ${at} 个字起不同:改动前「${before.slice(at, at + 60)}」现在「${cur.slice(at, at + 60)}」`)
}
// ml-4 → pl-4:量一下列表项的位置和宽度,把类名换回 ml-4 再量一遍,必须一样
const moved = await page.evaluate(() => {
const out = []
for (const list of document.querySelectorAll('#md-pinned ul, #md-pinned ol')) {
const rects = () => [...list.children].map((li) => {
const r = li.getBoundingClientRect()
return [r.x, r.y, r.width, r.height].map((v) => Math.round(v * 10) / 10).join(',')
}).join('|')
// 类名得真是 pl-4 才有得换:换不了就算没量(别的缩进一律不认)
if (!list.classList.contains('pl-4') || list.classList.contains('ml-4')) { out.push(`类名是「${list.className}」`); continue }
const a = rects()
list.classList.replace('pl-4', 'ml-4')
const b = rects()
list.classList.replace('ml-4', 'pl-4')
if (a !== b) out.push(`${a} ≠ ${b}`)
}
return { lists: document.querySelectorAll('#md-pinned ul, #md-pinned ol').length, moved: out }
})
check('列表缩进 pl-4 和以前的 ml-4 看起来一样:每个列表项的位置、宽度都没变', moved.lists >= 6 && !moved.moved.length,
`${moved.lists} 个列表 ${moved.moved.join(' ; ')}`)
})
await section('studio', '报告撰写节点:画布认得、检查器能配(REQ-A-3)', async () => {
// 假的工作流,读写都在浏览器层拦下:GET 回假的,写一律 409,不落库
const at = (x, y) => ({ x, y })
const node = (id, type, x, label, config = {}) => ({ id, type, position: at(x, 80), data: { label, config } })
const graph = {
nodes: [
node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }),
node('caliber', 'metrics', 260, '周报口径', { caliber: '周报口径', caliber_version: 'v2',
metrics: [{ id: 'gmv', name: '销售额', unit: '元', expression: 'input.week' }] }),
node('write', 'report', 520, '写周报', { instructions: '为 {{ input.week }} 写周报', numbers: 'strict', max_repairs: 1 }),
node('late', 'metrics', 780, '事后口径', { caliber: '事后', metrics: [{ id: 'x', expression: '1' }] }),
node('out', 'output', 1040, '成果', { fields: [{ name: '周报', value: '{{ nodes.write.text }}' }] }),
],
edges: [['in', 'caliber'], ['caliber', 'write'], ['write', 'late'], ['late', 'out']]
.map(([source, target]) => ({ id: `${source}-${target}`, source, target })),
}
const wf = { id: 'fx-report', name: '报告节点检查', description: '', graph, tags: [], version: 1, is_template: false,
status: 'draft', published_version: null, run_count: 0, created_at: '2026-09-26T00:00:00Z', updated_at: '2026-09-26T00:00:00Z' }
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const p = await ctx.newPage()
const errors = []
p.on('pageerror', (e) => errors.push(e.message))
const json = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
await p.route(/\/api\/workflows(\?.*)?$/, (r) => (r.request().method() === 'GET' ? json(r, [wf]) : json(r, { detail: '检查脚本不写库' }, 409)))
await p.route(/\/api\/workflows\/fx-report(\/.*)?(\?.*)?$/, (r) =>
(r.request().method() === 'GET' && !new URL(r.request().url()).pathname.endsWith('/versions') ? json(r, wf) : json(r, [])))
await p.route(/\/api\/conversations(\/.*)?(\?.*)?$/, (r) =>
(r.request().method() === 'GET' ? json(r, []) : json(r, { id: 'fx-conv', kind: 'canvas', title: '', turns: [] })))
await p.route(/\/api\/runs(\/.*)?(\?.*)?$/, (r) => (r.request().method() === 'GET' ? r.continue() : json(r, { detail: '不发起运行' }, 409)))
await p.goto(`${WEB}/studio/fx-report`, { waitUntil: 'networkidle' })
await p.waitForFunction(() => window.__studio?.getState().workflow?.id === 'fx-report', null, { timeout: 15000 })
await p.waitForTimeout(600)
const card = await p.locator('.react-flow__node[data-id="write"]').innerText().catch(() => '')
check('画布上的报告节点有摘要(写作要求、指标从哪来),不是未知类型', card.includes('为 {{ input.week }} 写周报')
&& card.includes('上游全部口径卡'), card.replace(/\n/g, ' ').slice(0, 120))
const tint = await p.evaluate(() => {
const el = document.querySelector('.react-flow__node[data-id="write"] .nt-report')
return el ? getComputedStyle(el).getPropertyValue('--nt').trim() : ''
})
check('报告节点有自己的类型色(--nt-report)', !!tint, tint)
await p.evaluate(() => window.__studio.getState().select('write'))
await p.waitForSelector('[data-field="metrics_from"]', { timeout: 5000 })
check('检查器标题写「报告撰写」', (await p.locator('body').innerText()).includes('报告撰写'))
const fields = await p.evaluate(() => [...document.querySelectorAll('[data-field]')].map((el) => el.getAttribute('data-field')))
check('检查器的字段:写作要求、指标来自、裸数字、违规时、重写次数、模型',
['instructions', 'metrics_from', 'numbers', 'on_violation', 'max_repairs', 'model'].every((f) => fields.includes(f)), fields.join(','))
await p.locator('[data-field="metrics_from"] button', { hasText: '选择口径卡' }).click()
const opts = await p.locator('[data-field="metrics_from"] .max-h-52 button').allInnerTexts()
check('「指标来自」只列上游的口径卡(下游那张不列)', opts.length === 1 && opts[0].includes('周报口径'), opts.join(' | '))
const onViolation = await p.locator('[data-field="on_violation"] option').allInnerTexts()
check('违规时的默认写明按运行类别', onViolation[0]?.includes('探索运行正常产出'), onViolation.join(' | '))
// 结论句策略 claims(off / require_citation / judge,四期放开了 judge)和表名字段名核对 entities
// 表名字段名核对收在「高级选项」里(很少要改),先展开
await p.locator('button[aria-expanded]', { hasText: '高级选项' }).click().catch(() => {})
await p.waitForTimeout(100)
const all = await p.evaluate(() => [...document.querySelectorAll('[data-field]')].map((el) => el.getAttribute('data-field')))
check('检查器有结论句策略、表名字段名核对(高级选项里)两项', fields.includes('claims') && all.includes('entities')
&& !fields.includes('entities'), all.join(','))
const claimVals = await p.locator('[data-field="claims"] option').evaluateAll((els) => els.map((e) => e.value))
check('结论句策略三档:off / require_citation / judge(四期放开了模型裁判)', claimVals.join(',') === 'off,require_citation,judge',
claimVals.join(','))
const claimsLabel = await p.locator('[data-field="claims"] label').first().innerText().catch(() => '')
check('结论句策略有中文标签(发布前修复的预览也用它)', /结论句/.test(claimsLabel) && !/claims/.test(claimsLabel), claimsLabel)
const claimsHelp = await p.locator('[data-field="claims"]').innerText()
check('……说明写明模型逐句判断、受管级别要显式选并写金额上限,不再说「后续版本」', claimsHelp.includes('由模型逐句判断')
&& claimsHelp.includes('受管') && claimsHelp.includes('金额上限') && !claimsHelp.includes('后续版本'),
claimsHelp.replace(/\s+/g, ' ').slice(0, 200))
check('没写 claims 时显示默认的 off', await p.locator('[data-field="claims"] select').inputValue() === 'off'
|| (await p.locator('[data-field="claims"] select').evaluate((el) => el.options[el.selectedIndex]?.value)) === 'off')
const entityVals = await p.locator('[data-field="entities"] option').evaluateAll((els) => els.map((e) => e.value))
check('表名字段名核对:link / off', entityVals.join(',') === 'link,off', entityVals.join(','))
await p.locator('[data-field="claims"] select').selectOption('require_citation')
const cfgOf = () => p.evaluate(() => window.__studio.getState().nodes.find((n) => n.id === 'write')?.data.config)
check('选「要求挂依据」写进 config.claims', (await cfgOf())?.claims === 'require_citation')
// 手写或别处写进来的不认识的值:不能悄悄显示成 off,要照实写出来
await p.evaluate(() => {
const st = window.__studio.getState()
const n = st.nodes.find((x) => x.id === 'write')
st.updateNode('write', { config: { ...n.data.config, claims: 'sometimes' } })
})
await p.waitForTimeout(150)
const shown = await p.locator('[data-field="claims"] select').evaluate((el) => el.options[el.selectedIndex]?.textContent ?? '')
check('config 里写着无法识别的值:下拉照实显示,不装成 off', shown.includes('sometimes') && shown.includes('无法识别'), shown)
// 四期:选模型裁判,高级选项里出现结论句裁判的子配置(裁判模型、三项上限各自能设不限、改写一次、证据相矛盾时)
check('没选模型裁判时不出结论句裁判那一组', await p.locator('[data-field="judge"]').count() === 0)
await p.locator('[data-field="claims"] select').selectOption('judge')
check('选「请模型逐句判断」写进 config.claims = judge', (await cfgOf())?.claims === 'judge')
await p.waitForSelector('[data-field="judge"]', { timeout: 3000 }).catch(() => {})
const jf = p.locator('[data-field="judge"]')
const jlabel = await jf.locator('label').first().innerText().catch(() => '')
check('高级选项里出现「结论句裁判」一组(中文标签)', await jf.count() === 1 && jlabel.includes('结论句裁判'), jlabel)
const keys = await jf.locator('[data-judge-key]').evaluateAll((els) => els.map((e) => e.getAttribute('data-judge-key')))
check('……裁判模型、三项上限、改写一次、证据相矛盾时', keys.join(',') === 'model,max_claims,max_cost_usd,timeout_s,rewrite_once,on_unsupported',
keys.join(','))
const labels = await jf.innerText()
// 细节只报缺了哪几个:整段文字里有沙箱的接入名(下拉的选项),日志常被贴进报告。裁判拆档后 on_unsupported
// 管的是证据相矛盾(键名不变),标签跟着改
const missingLabels = ['裁判模型', '最多裁判句数', '金额上限(美元)', '时长上限(秒)', '退回改写一次', '证据相矛盾时'].filter((t) => !labels.includes(t))
check('……每一项都有中文标签', !missingLabels.length, `缺:${missingLabels.join('、')}`)
const onHint = await jf.locator('[data-judge-key="on_unsupported"] [data-judge-hint]').innerText().catch(() => '')
check('……「证据相矛盾时」下面写明证据不足的句子最多降档、不会因此不予出具', onHint.includes('证据不足的句子最多降档')
&& onHint.includes('不会因此不予出具'), onHint)
check('……没写的上限说明跟着设置里的默认值', (await jf.locator('[data-judge-key="max_cost_usd"] [data-judge-note]').innerText()).includes('默认值'))
const cost = jf.locator('[data-judge-key="max_cost_usd"]')
await cost.locator('input[type="checkbox"]').click()
check('金额上限勾「不限」:写 null(不是删掉,也不是 0)', (await cfgOf())?.judge && (await cfgOf()).judge.max_cost_usd === null
&& 'max_cost_usd' in (await cfgOf()).judge, JSON.stringify((await cfgOf())?.judge))
const off = await cost.locator('[data-judge-note]').innerText().catch(() => '')
check('……写明「不设上限,费用只受……约束」,数字框变灰', off.includes('不设上限') && off.includes('约束')
&& await cost.locator('input[type="number"]').isDisabled(), off)
await cost.locator('input[type="checkbox"]').click()
check('勾掉「不限」:回到没写(跟着设置里的默认值)', !((await cfgOf())?.judge && 'max_cost_usd' in (await cfgOf()).judge),
JSON.stringify((await cfgOf())?.judge))
await cost.locator('input[type="number"]').fill('0.2')
await jf.locator('[data-judge-key="max_claims"] input[type="number"]').fill('20')
await jf.locator('[data-judge-key="timeout_s"] input[type="checkbox"]').click()
await jf.locator('[data-judge-key="rewrite_once"] input[type="checkbox"]').click()
await jf.locator('[data-judge-key="on_unsupported"] select').selectOption('withhold')
const j = (await cfgOf())?.judge ?? {}
check('写数就是数、时长不限写 null、改写一次写 true、不支持时写 withhold',
j.max_cost_usd === 0.2 && j.max_claims === 20 && j.timeout_s === null && j.rewrite_once === true && j.on_unsupported === 'withhold',
JSON.stringify(j))
const provFirst = () => jf.locator('[data-judge-key="model"] select').evaluate((el) => el.options[0]?.textContent ?? '').catch(() => '')
check('接入和模型都没写:接入留空那一项叫「跟随设置」', await provFirst() === '跟随设置' && await jf.locator('[data-judge-by-model]').count() === 0)
await jf.locator('[data-judge-key="model"] input').fill('judge-model-b')
check('裁判模型写进 judge.model', (await cfgOf())?.judge?.model === 'judge-model-b')
// 节点上只写了模型:节点这一级整组生效(judge_model_spec 不跨级拼),按模型名找接入——已经不是「跟随设置」
const pf = await provFirst()
check('只写了模型:接入留空那一项不再叫「跟随设置」,改叫「按模型名找接入」', pf === '按模型名找接入', pf)
const byModel = jf.locator('[data-judge-by-model]')
const byText = await byModel.innerText().catch(() => '')
// 说明里有沙箱的接入名:细节只报认没认出来,不打印原文
check('……写明按模型名找接入、认不出就去调默认接入,两项都没写才跟随设置', await byModel.getAttribute('data-judge-by-model').catch(() => '') === 'default'
&& byText.includes('只填了模型') && byText.includes('默认接入') && byText.includes('接入和模型都未填写时,才跟随设置'),
`data-judge-by-model=${await byModel.getAttribute('data-judge-by-model').catch(() => '(没有)')}`)
await jf.locator('[data-judge-key="model"] input').fill('')
check('……模型清空:又回到「跟随设置」,说明也收起', await provFirst() === '跟随设置' && await byModel.count() === 0
&& !('model' in ((await cfgOf())?.judge ?? {})))
await jf.locator('[data-judge-key="model"] input').fill('judge-model-b')
const previews = await p.evaluate(async () => {
const url = performance.getEntriesByType('resource').map((e) => e.name)
.find((n) => { try { return new URL(n).pathname === '/src/canvas/issues.ts' } catch { return false } })
const m = await import(url ?? '/src/canvas/issues.ts')
return [m.fixFieldLabel('judge.max_cost_usd', 'report'), m.fixValueText(null, 'judge.max_cost_usd'),
m.fixValueText(0.05, 'judge.max_cost_usd'), (m.fixValueLines({ max_cost_usd: null, model: 'x' }, 'judge') ?? []).join(';'),
m.fixValueText('withhold', 'judge.on_unsupported'), m.fixFieldLabel('defaults.model', 'report')]
}).catch((e) => [String(e)])
check('发布前修复的预览:judge.max_cost_usd 写成「结论句裁判 · 金额上限(美元)」', previews[0] === '结论句裁判 · 金额上限(美元)', previews[0])
check('……null 写「不限」(没写才是「(空)」)、金额带单位', previews[1] === '不限' && previews[2] === '0.05 美元', JSON.stringify(previews.slice(1, 3)))
check('……补预算按整份 judge 记时一键一行', previews[3] === '裁判模型:x;金额上限(美元):不限', previews[3])
check('……证据相矛盾时写选项文字', previews[4] === '不予出具', previews[4])
// 不借「裁判模型」,写通用的「模型」(以前露键名 model;点名接入的修复会清掉全图默认里的模型,预览里要写得出叫法)
check('……别处的 model 不借用裁判的叫法', previews[5] === '工作流默认设置 · 模型', previews[5])
if (SHOTS) {
await jf.scrollIntoViewIfNeeded()
for (const theme of ['dark', 'light']) {
await p.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await p.waitForTimeout(250)
await p.screenshot({ path: `${SHOTS}/evidence4-inspector-judge-${theme}.png` })
}
}
const fixLabel = await p.evaluate(async () => {
// 按页面自己加载时的地址 import:改过的模块地址带 ?t=,直接写 /src/… 会拿到另一份实例
const url = performance.getEntriesByType('resource').map((e) => e.name)
.find((n) => { try { return new URL(n).pathname === '/src/canvas/issues.ts' } catch { return false } })
const m = await import(url ?? '/src/canvas/issues.ts')
return [m.fixFieldLabel('claims', 'report'), m.fixFieldLabel('contract.claims.on_uncited', 'output'),
m.fixValueText('ignore', 'contract.claims.on_uncited'), m.fixValueText('degrade', 'contract.claims.on_uncited')]
}).catch((e) => [String(e)])
check('发布前修复预览里 claims 这个字段写中文名', fixLabel[0] === claimsLabel.trim() && !/claims/.test(fixLabel[0]), fixLabel[0])
check('契约 claims 的 on_uncited(ignore 改成 degrade 那条修复):字段和值都写中文',
!/claims|on_uncited/.test(fixLabel[1] ?? 'claims') && fixLabel[2] === '仅标注,不计入缺口' && fixLabel[3] === '计入缺口、出具降档',
JSON.stringify(fixLabel.slice(1)))
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await p.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await p.waitForTimeout(250)
await p.screenshot({ path: `${SHOTS}/evidence-studio-report-${theme}.png` })
}
}
check('没有运行时报错(画布)', errors.length === 0, errors.join(' | '))
await ctx.close()
})
await section('config', '配置项:agent 的 cite_fields、code 的证据角色、口径卡钉住上游(caliber_from + upgrade_policy)', async () => {
// 假的工作流和上游,读写都在浏览器层拦下:GET 回假的,写一律 409,不落库
const node = (id, type, x, label, config = {}) => ({ id, type, position: { x, y: 80 }, data: { label, config } })
const graph = {
nodes: [
node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }),
node('fetch', 'agent', 240, '查数', { prompt: '查本周销售额', tools: ['db_query__shop'] }),
node('calc', 'code', 480, '换算', { language: 'python', code: 'print(1)' }),
node('caliber', 'metrics', 720, '周报口径', { caliber: '周报口径', caliber_version: 'v2',
metrics: [{ id: 'gmv', name: '销售额', unit: '元', expression: 'vars.kpi.gmv' }] }),
node('sub', 'subgraph', 960, '方法卡', { workflow_id: 'fx-weekly', input: {} }),
],
edges: [['in', 'fetch'], ['fetch', 'calc'], ['calc', 'caliber'], ['caliber', 'sub']].map(([source, target]) => ({ id: `${source}-${target}`, source, target })),
}
const stamp = { tags: [], is_template: false, status: 'draft', published_version: null, run_count: 0,
created_at: '2026-09-26T00:00:00Z', updated_at: '2026-09-26T00:00:00Z', description: '' }
const wf = { id: 'fx-config', name: '配置项检查', graph, version: 1, ...stamp }
const upstream = { id: 'fx-weekly', name: '销售周报', graph: { nodes: [], edges: [] }, version: 6, ...stamp, status: 'published', published_version: 6 }
const pinnedGraph = { nodes: [
node('caliber', 'metrics', 0, '周报口径', { caliber: '周报口径', caliber_version: 'v3',
metrics: [{ id: 'gmv', name: '销售额', expression: 'vars.kpi.gmv' }, { id: 'aov', name: '客单价', expression: 'vars.kpi.gmv / vars.kpi.order_cnt' }] }),
node('write', 'report', 240, '写周报', {}),
], edges: [] }
const versions = [5, 6].map((v) => ({ id: `v${v}`, version: v, note: v === 6 ? '改了客单价口径' : '', published: v === 6 }))
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const p = await ctx.newPage()
p.setDefaultTimeout(8000)
const errors = []
p.on('pageerror', (e) => errors.push(e.message))
const json = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
await p.route(/\/api\/workflows(\?.*)?$/, (r) => (r.request().method() === 'GET' ? json(r, [wf, upstream]) : json(r, { detail: '检查脚本不写库' }, 409)))
await p.route(/\/api\/workflows\/fx-config(\/.*)?(\?.*)?$/, (r) =>
(r.request().method() === 'GET' && !new URL(r.request().url()).pathname.endsWith('/versions') ? json(r, wf) : json(r, [])))
await p.route(/\/api\/workflows\/fx-weekly\/versions(\/\d+)?$/, (r) => {
const m = new URL(r.request().url()).pathname.match(/\/versions\/(\d+)$/)
return m ? json(r, { ...versions.find((v) => v.version === Number(m[1])), workflow_id: 'fx-weekly', graph: pinnedGraph }) : json(r, versions)
})
await p.route(/\/api\/conversations(\/.*)?(\?.*)?$/, (r) =>
(r.request().method() === 'GET' ? json(r, []) : json(r, { id: 'fx-conv', kind: 'canvas', title: '', turns: [] })))
await p.route(/\/api\/runs(\/.*)?(\?.*)?$/, (r) => (r.request().method() === 'GET' ? r.continue() : json(r, { detail: '不发起运行' }, 409)))
await p.goto(`${WEB}/studio/fx-config`, { waitUntil: 'networkidle' })
await p.waitForFunction(() => window.__studio?.getState().workflow?.id === 'fx-config', null, { timeout: 15000 })
await p.waitForTimeout(500)
const cfg = (id) => p.evaluate((id) => window.__studio.getState().nodes.find((n) => n.id === id)?.data.config, id)
const advanced = async () => {
const t = p.getByRole('button', { name: /高级选项/ })
if ((await t.getAttribute('aria-expanded')) !== 'true') await t.click()
}
await p.evaluate(() => window.__studio.getState().select('fetch'))
await advanced()
const cite = p.locator('[data-field="cite_fields"]')
check('agent 有「按出处核对字段」开关,没写 Schema 时用不了并说清差什么', await cite.locator('input[type="checkbox"]').isDisabled()
&& (await cite.innerText()).includes('需先填写上方的「结构化输出 Schema」'), (await cite.innerText()).replace(/\s+/g, ' ').slice(0, 120))
check('……说明会额外进行一次抽取调用', (await cite.innerText()).includes('额外进行一次抽取调用'))
check('output_schema 的说明写明要开 cite_fields 才生效', (await p.locator('[data-field="output_schema"]').innerText()).includes('按出处核对字段'))
await p.evaluate(() => {
const s = window.__studio.getState()
const n = s.nodes.find((x) => x.id === 'fetch')
s.updateNode('fetch', { config: { ...n.data.config, output_schema: { type: 'object', properties: { gmv: { type: 'number' } } } } })
})
await p.waitForTimeout(150)
check('写了 Schema:开关可用', !(await cite.locator('input[type="checkbox"]').isDisabled()) && await cite.locator('[id$="-off"]').count() === 0)
await cite.locator('input[type="checkbox"]').check()
check('……打开后写进 config.cite_fields', (await cfg('fetch'))?.cite_fields === true)
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await p.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await p.waitForTimeout(200)
await p.screenshot({ path: `${SHOTS}/evidence-config-agent-${theme}.png` })
}
}
// 开着开关又把 Schema 清掉:不能锁死在「开」上,得还能关
await p.evaluate(() => {
const s = window.__studio.getState()
const { output_schema: _drop, ...rest } = s.nodes.find((x) => x.id === 'fetch').data.config
s.updateNode('fetch', { config: rest })
})
await p.waitForTimeout(150)
check('开着又清掉 Schema:开关不锁死,还能关', !(await cite.locator('input[type="checkbox"]').isDisabled())
&& await cite.getAttribute('data-disabled') === null && await cite.locator('[id$="-off"]').count() === 0)
await cite.locator('input[type="checkbox"]').uncheck({ timeout: 2000 }).catch(() => {})
check('……关掉后 config.cite_fields 不再为真;关掉之后才锁上', !(await cfg('fetch'))?.cite_fields
&& await cite.locator('input[type="checkbox"]').isDisabled())
await p.evaluate(() => window.__studio.getState().select('calc'))
await advanced()
const role = p.locator('[data-field="evidence_role"]')
const opts = await role.locator('option').allInnerTexts()
check('code 有「证据角色」下拉:计算(默认)/ 取数', opts.join('|') === '计算(默认)|取数', opts.join('|'))
check('……说明报告不能直接引用代码节点的输出', (await role.innerText()).includes('报告不能直接引用代码节点的输出'))
await role.locator('select').selectOption('source')
check('……选「取数」写进 config.evidence_role', (await cfg('calc'))?.evidence_role === 'source')
await p.evaluate(() => window.__studio.getState().select('caliber'))
const from = p.locator('[data-field="caliber_from"]')
check('口径卡默认「在此定义」,本地的名称、版本、指标都在', await from.locator('[data-caliber-from="local"]').count() === 1
&& await p.locator('[data-field="metrics"]').count() === 1 && await p.locator('[data-field="upgrade_policy"]').count() === 0)
await from.getByRole('radio', { name: '引用其他工作流的口径卡' }).click()
await p.waitForTimeout(150)
check('切到引用:本地定义收起(后端也说它们不生效),升版处置出来', await p.locator('[data-field="metrics"]').count() === 0
&& await p.locator('[data-field="caliber"]').count() === 0 && await p.locator('[data-field="upgrade_policy"]').count() === 1)
const wfOpts = await from.locator('select').first().locator('option').allInnerTexts()
check('工作流下拉复用目录,不列自己', wfOpts.some((o) => o.includes('销售周报')) && !wfOpts.some((o) => o.includes('配置项检查')), wfOpts.join('|'))
await from.locator('select').first().selectOption('fx-weekly')
await p.waitForSelector('[data-field="caliber_from"] select[id$="-version"] option[value="5"]', { timeout: 4000 }).catch(() => {})
await from.locator('select[id$="-version"]').selectOption('5')
await p.waitForSelector('[data-field="caliber_from"] [data-caliber-preview]', { timeout: 4000 }).catch(() => {})
const c = await cfg('caliber')
check('选了工作流和版本:那一版只有一张口径卡时直接引用,写成 {workflow_id, workflow_version(数), node_id}',
JSON.stringify(c?.caliber_from) === JSON.stringify({ workflow_id: 'fx-weekly', workflow_version: 5, node_id: 'caliber' }), JSON.stringify(c?.caliber_from))
check('……本地的 metrics、caliber 不留在配置里', !('metrics' in (c ?? {})) && !('caliber' in (c ?? {})), JSON.stringify(Object.keys(c ?? {})))
const preview = await from.locator('[data-caliber-preview]').innerText().catch(() => '')
check('……预览引用的那张卡:口径、版本、几个指标', preview.includes('周报口径') && preview.includes('v3') && preview.includes('2 个指标')
&& preview.includes('aov'), preview.replace(/\s+/g, ' '))
const newer = await from.locator('[data-caliber-newer]').innerText().catch(() => '')
check('上游已有更新的版本:提醒正式运行前要声明升版处置', newer.includes('上游已有 v6') && newer.includes('升版处置'), newer)
const pol = await p.locator('[data-field="upgrade_policy"] option').allInnerTexts()
check('升版处置三选一,和子工作流同一套说法', pol.length === 4 && pol[0].includes('阻止正式运行') && pol.some((o) => o.includes('新旧口径并列展示')), pol.join('|'))
await p.locator('[data-field="upgrade_policy"] select').selectOption('dual')
check('……写进 config.upgrade_policy', (await cfg('caliber'))?.upgrade_policy === 'dual')
const card = await p.locator('.react-flow__node[data-id="caliber"]').innerText().catch(() => '')
check('画布卡片写「引用上游口径卡 v5」,不再写「0 个指标」', card.includes('引用上游口径卡 v5') && !card.includes('0 个指标'), card.replace(/\s+/g, ' ').slice(0, 120))
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await p.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await p.waitForTimeout(200)
await p.screenshot({ path: `${SHOTS}/evidence-config-caliber-${theme}.png` })
}
}
await from.getByRole('radio', { name: '在此定义' }).click()
await p.waitForTimeout(150)
const back = await cfg('caliber')
check('切回「在此定义」:去掉 caliber_from 和 upgrade_policy,给回一条空指标', !('caliber_from' in back) && !('upgrade_policy' in back)
&& Array.isArray(back.metrics) && back.metrics.length === 1 && await p.locator('[data-field="metrics"]').count() === 1, JSON.stringify(back))
// 子工作流钉住版本后的升版处置(E2W-7):和口径卡的是同一个下拉、同一套文案
await p.evaluate(() => window.__studio.getState().select('sub'))
await p.waitForTimeout(150)
check('子工作流没固定版本(跟随最新):不出升版处置', await p.locator('[data-field="upgrade_policy"]').count() === 0)
const subVersion = p.locator('[data-field="workflow_id"] select[id$="-version"]')
await p.waitForSelector('[data-field="workflow_id"] select[id$="-version"] option[value="5"]', { timeout: 4000 }).catch(() => {})
await subVersion.selectOption('5')
await p.waitForSelector('[data-field="upgrade_policy"]', { timeout: 3000 }).catch(() => {})
const subPol = await p.locator('[data-field="upgrade_policy"] option').allInnerTexts()
check('固定 v5:升版处置出来,选项和口径卡的那个一字不差', subPol.length === 4 && JSON.stringify(subPol) === JSON.stringify(pol), subPol.join('|'))
check('……紧跟在「工作流」后面', await p.evaluate(() => {
const fields = [...document.querySelectorAll('[data-field]')].map((el) => el.getAttribute('data-field'))
return fields[fields.indexOf('workflow_id') + 1] === 'upgrade_policy'
}))
const subHelp = await p.locator('[data-field="upgrade_policy"]').innerText()
check('……说明换成子工作流这一侧:规则与引用其他工作流的口径卡相同', subHelp.includes('规则与引用其他工作流的口径卡相同')
&& !subHelp.includes('规则与子工作流的升版处置相同'), subHelp.replace(/\s+/g, ' ').slice(0, 120))
const subNewer = await p.locator('[data-field="workflow_id"] [data-subgraph-newer]').innerText().catch(() => '')
check('上游已有 v6:提醒要声明升版处置,和口径卡同一句', subNewer === newer, subNewer)
await p.locator('[data-field="upgrade_policy"] select').selectOption('recompute')
check('……写进子工作流的 config.upgrade_policy', (await cfg('sub'))?.upgrade_policy === 'recompute')
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await p.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await p.waitForTimeout(200)
await p.screenshot({ path: `${SHOTS}/evidence-config-subgraph-${theme}.png` })
}
}
await subVersion.selectOption('')
await p.waitForTimeout(150)
const unpinned = await cfg('sub')
check('改回跟随最新:upgrade_policy 一起拿掉、下拉收起', !('upgrade_policy' in unpinned) && !('workflow_version' in unpinned)
&& await p.locator('[data-field="upgrade_policy"]').count() === 0, JSON.stringify(unpinned))
check('没有运行时报错(配置项)', errors.length === 0, errors.join(' | '))
await ctx.close()
})
// ------------------------------------------------------------------ 第三期:实体、引文、旧运行的猜测
const E = {
num: segOf(fxe.doc, '45,678.5'), orders: segOf(fxe.doc, 'orders'), week: segOf(fxe.doc, 'orders.week'),
refunds: segOf(fxe.doc, '`refunds`'), column: segOf(fxe.doc, '`refunds.refund_amount`'),
coupon: segOf(fxe.doc, '`orders.coupon_code`'), discount: segOf(fxe.doc, 'orders.discount'),
promo: segOf(fxe.doc, '`promo_events`'), customers: segOf(fxe.doc, 'customers'), bare: segOf(fxe.doc, '30'),
quote: segOf(fxe.doc, '退款金额以财务确认日为准,未确认的退款不计入当周'), miss: segOf(fxe.doc, '周报一律按自然周统计'),
}
const ent = '#evidence-entity'
await section('entity', '三期:表名字段名、可疑实体、逐字引文——四通道分得开', async () => {
check('夹具里认得出每一种片段', Object.values(E).every(Boolean), JSON.stringify(E))
const look = await page.evaluate(([sel, E]) => Object.fromEntries(Object.entries(E).map(([k, id]) => {
const el = document.querySelector(`${sel} [data-seg="${id}"]`)
if (!el) return [k, null]
const cs = getComputedStyle(el)
return [k, { style: cs.textDecorationStyle, color: cs.textDecorationColor, state: el.getAttribute('data-ev-state'),
kind: el.getAttribute('data-ev-kind'), label: el.getAttribute('aria-label') ?? '', text: el.innerText,
code: !!el.querySelector('code'), before: getComputedStyle(el, '::before').content }]
})), [ent, E])
check('有出处的实体和数字同一套「有出处」线型:实线、同一个颜色', look.orders?.state === 'deterministic'
&& look.orders.style === 'solid' && look.orders.color === look.num?.color, JSON.stringify([look.orders, look.num?.color]))
check('可疑实体用「无证据」的线型(点状),和有出处的实体线型不同', look.coupon?.state === 'suspect'
&& look.coupon.style === 'dotted' && look.coupon.style !== look.orders?.style, JSON.stringify(look.coupon))
check('标记写的可疑实体([[c:…]] 编造的字段)同样标成可疑', look.discount?.state === 'suspect' && look.discount.style === 'dotted',
JSON.stringify(look.discount))
check('可疑实体的 aria-label 写「疑似不存在的名称」,名字不带反引号', look.coupon?.label.startsWith('orders.coupon_code,')
&& look.coupon.label.includes('疑似不存在的名称'), look.coupon?.label)
check('无法核实的名字:点状线、自己的说法,不说「疑似不存在」', look.promo?.state === 'unverified' && look.promo.style === 'dotted'
&& look.promo.label.includes('无法核实') && !look.promo.label.includes('疑似不存在'), look.promo?.label)
check('核对不了和可疑实体颜色不同(它只是标注,不醒目)', look.promo?.color !== look.coupon?.color, `${look.promo?.color} / ${look.coupon?.color}`)
check('反引号里的实体按行内代码画,不露反引号', look.refunds?.code && !look.refunds.text.includes('`')
&& look.column?.code, JSON.stringify([look.refunds, look.column?.text]))
check('自动链接的名字(正文里的已知表名)也是有出处的实体', look.customers?.state === 'deterministic' && look.customers.kind === 'entity')
check('aria-label:实体说清是表还是字段、出现在哪几次查询', look.orders?.label === 'orders,有出处:表 orders · 出现在查询 Q1、Q2',
look.orders?.label)
check('引文片段用引用样式:前面挂引号、实线', look.quote?.kind === 'quote' && look.quote.before.includes('“')
&& look.quote.style === 'solid' && look.quote.state === 'deterministic', JSON.stringify(look.quote))
check('原话对不上的引文照样是引用样式,但线型换成点状(无证据)', look.miss?.kind === 'quote' && look.miss.before.includes('“')
&& look.miss.style === 'dotted' && look.miss.state === 'none', JSON.stringify(look.miss))
check('引文的 aria-label 写出出处', look.quote?.label.includes('有出处') && look.quote.label.includes('运营手册 · 退款'), look.quote?.label)
const tags = await page.locator(`${ent} .ev-tag`).allInnerTexts()
check('句末小标签:可疑名称挂「?!可疑名称」,和「?无证据」分开', tags.includes('?!可疑名称') && tags.includes('?无证据'), tags.join('|'))
const tally = await page.locator(`${ent} [data-evidence-tally]`).innerText().catch(() => '')
check('证据条另说可疑名称的个数', tally.includes('可疑名称 2'), tally)
const summary = await page.locator(`${ent} [data-evidence-summary]`).innerText().catch(() => '')
check('读屏摘要写出可疑实体、无法核实的个数', summary.includes('2 个疑似不存在的名称') && summary.includes('1 个名称无法核实'), summary)
// n / N:可疑实体进跳转(异常态),核对不了的不进(只是标注)
await page.locator(`${ent} [data-seg="${E.num}"]`).focus()
const jumps = []
for (let i = 0; i < 5; i++) {
await page.keyboard.press('n')
jumps.push(await active(page))
}
check('n 依次跳到裸数字、可疑实体、原话对不上的引文,跳过核对不了的名字',
jumps.join(',') === [E.bare, E.coupon, E.discount, E.miss, E.bare].join(','), jumps.join(','))
})
await section('e-panel', '三期:面板里的实体步骤、可疑实体的提示、引文步骤', async () => {
const p = panel(page)
await page.locator(`${ent} [data-seg="${E.week}"]`).click()
await page.waitForSelector('[data-evidence-panel] [data-ev-entity-type]', { timeout: 4000 }).catch(() => {})
check('面板标题是名字本身', (await p.locator('h3').innerText().catch(() => '')) === 'orders.week')
check('实体步骤写字段类型', (await p.locator('[data-ev-entity-type]').innerText().catch(() => '')).includes('VARCHAR(8)'))
check('实体步骤写表结构快照的同步时间', (await p.locator('[data-ev-entity-synced]').innerText().catch(() => '')).includes('2026-09-20'),
await p.locator('[data-ev-entity-synced]').innerText().catch(() => ''))
check('快照不全时照实说只包含部分表', (await p.innerText()).includes('表结构快照仅包含部分表'))
check('实体的来历:表结构快照', (await p.locator('[data-ev-entity-source]').allInnerTexts()).some((t) => t.includes('表结构快照')))
await page.locator(`${ent} [data-seg="${E.orders}"]`).click()
await page.waitForTimeout(250)
const queries = await p.locator('[data-ev-entity-queries]').innerText().catch(() => '')
check('表:出现在哪几次查询里', queries.includes('Q1') && queries.includes('Q2'), queries)
const srcs = await p.locator('[data-ev-entity-source]').allInnerTexts()
check('表的来历逐条列:表结构快照、查询 SQL', srcs.some((t) => t.includes('查询 Q2') && t.includes('SQL')), srcs.join(' | '))
check('实体步骤取自证据接口(按 runId:segId 取了一次)', harness.hits.includes(`e:${E.orders}`), harness.hits.filter((h) => h.startsWith('e:')).join(','))
await page.locator(`${ent} [data-seg="${E.discount}"]`).click()
// 提示要等片段接口回来(先显示「正在取」):等到名字出来再读
await page.waitForSelector('[data-evidence-panel] [data-ev-closest-name]', { timeout: 4000 }).catch(() => {})
const reason = await p.locator('[data-ev-reason]').innerText().catch(() => '')
check('可疑实体说清为什么:本次运行里哪里都没有这个名字', reason.includes('疑似不存在的名称'), reason.slice(0, 80))
const closest = await p.locator('[data-ev-closest-name]').allInnerTexts()
check('可疑实体给出最接近的已知名字(最多 3 个,不带 c: 前缀)', closest.length >= 1 && closest.length <= 3
&& closest.includes('orders.amount') && closest.every((n) => !n.startsWith('c:')), closest.join(','))
check('可疑实体的面板徽标:?! 疑似不存在的名称', (await p.locator('[data-ev-badge]').innerText().catch(() => '')).includes('疑似不存在的名称'))
await page.locator(`${ent} [data-seg="${E.promo}"]`).click()
await page.waitForTimeout(250)
const promo = await p.innerText()
check('无法核实的名字:说快照不全、无法核实,不说疑似不存在', promo.includes('无法核实') && !promo.includes('疑似不存在'), promo.slice(0, 120))
await page.locator(`${ent} [data-seg="${E.quote}"]`).click()
await page.waitForSelector('[data-evidence-panel] [data-ev-quote-hit]', { timeout: 4000 }).catch(() => {})
const src = await p.locator('[data-ev-quote-source]').innerText().catch(() => '')
check('引文步骤:原文所在的文档和片段', src.includes('运营手册 · 退款') && src.includes('第 3 段'), src)
const hit = await p.locator('[data-ev-quote-hit]').innerText().catch(() => '')
check('引文在原文里的位置高亮出来,就是那句原话', hit === '退款金额以财务确认日为准,未确认的退款不计入当周', hit)
const ctx = await p.locator('[data-ev-quote]').innerText().catch(() => '')
check('高亮前后带着原文的上下文', ctx.includes('第三章 退款口径') && ctx.includes('部分退款'), ctx.slice(0, 80))
await page.locator(`${ent} [data-seg="${E.miss}"]`).click()
await page.waitForTimeout(250)
const miss = await p.innerText()
check('原话对不上的引文:说清找不到这段引文的原文', miss.includes('找不到这段引文的原文'), miss.slice(0, 120))
await page.keyboard.press('Escape')
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
for (const [name, id] of [['entity', E.orders], ['suspect', E.discount], ['quote', E.quote]]) {
await page.locator(`${ent} [data-seg="${id}"]`).click()
await page.waitForTimeout(350)
await page.screenshot({ path: `${SHOTS}/evidence3-${name}-${theme}.png` })
}
await page.keyboard.press('Escape')
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
})
await section('e-narrow', '三期在 360px:栏内展开不横向滚动;减少动效', async () => {
const narrow = '#evidence-entity-narrow'
const overflow = () => page.evaluate((sel) => {
const el = document.querySelector(sel)
return { sw: el.scrollWidth, cw: el.clientWidth }
}, narrow)
for (const id of [E.quote, E.discount, E.orders]) {
await page.locator(`${narrow} [data-seg="${id}"]`).click()
await page.waitForSelector(`${narrow} [data-evidence-panel="inline"]`, { timeout: 4000 }).catch(() => {})
await page.waitForTimeout(250)
const o = await overflow()
check(`360px 栏内面板(${id})不出横向滚动`, o.sw <= o.cw, JSON.stringify(o))
}
await page.keyboard.press('Escape')
const r = await open('/ui-harness.html?evidence=1', { reduced: true, w: 360, h: 780 })
const scroll = () => r.page.evaluate(() => ({ sw: document.documentElement.scrollWidth, vw: innerWidth }))
await r.page.locator(`${ent} [data-seg="${E.quote}"]`).click()
await r.page.waitForSelector('[data-evidence-panel] [data-ev-quote]', { timeout: 4000 }).catch(() => {})
const s1 = await scroll()
check('360px 屏幕:引文面板(底部抽屉)整页不横向滚动', s1.sw <= s1.vw, JSON.stringify(s1))
const anim = await r.page.evaluate(() => document.querySelector('[data-evidence-panel]')?.getAnimations({ subtree: true })
.filter((a) => !(a instanceof CSSTransition && Number(a.effect?.getTiming().duration) <= 0.01)).length ?? -1)
check('减少动效:引文面板没有动画', anim === 0, String(anim))
await r.page.keyboard.press('Escape')
await r.page.locator('#evidence-guess [data-ev-guess-toggle]').click().catch(() => {})
await r.page.waitForTimeout(150)
const s2 = await scroll()
check('360px 屏幕:展开猜测之后整页不横向滚动', s2.sw <= s2.vw
&& await r.page.locator('#evidence-guess [data-ev-guess]').getAttribute('data-open').catch(() => null) === 'true', JSON.stringify(s2))
const guessAnim = await r.page.evaluate(() => document.querySelector('#evidence-guess [data-ev-guess]')?.getAnimations({ subtree: true })
.filter((a) => !(a instanceof CSSTransition && Number(a.effect?.getTiming().duration) <= 0.01)).length ?? -1)
check('减少动效:展开猜测没有动画', guessAnim === 0, String(guessAnim))
check('没有运行时报错(三期 360px)', r.errors.length === 0, r.errors.join(' | '))
await r.ctx.close()
})
await section('banner', '出具横幅那一行:数字都有出处时,可疑名字、没挂依据的结论句也写出来', async () => {
const at = (key) => page.locator(`#issuance-line [data-issuance-case="${key}"]`)
const line = await at('suspect').locator('[data-evidence-line]').innerText().catch(() => '')
check('数字那半句照旧:3 个数字都有出处', line.includes('3 个数字都有出处'), line)
check('可疑名称另起一句:?! 可疑名称 2', await at('suspect').locator('[data-evidence-line-suspect]').count() === 1
&& line.includes('?! 可疑名称 2'), line)
check('未附依据的结论句另起一句:未附依据的结论句 1', await at('suspect').locator('[data-evidence-line-claims]').count() === 1
&& line.includes('未附依据的结论句 1'), line)
const next = at('suspect').locator('[data-evidence-next]')
check('数字都有出处、只有可疑名称时也给「定位下一处」', await next.count() === 1)
await page.evaluate(() => { window.__issuanceNext = 0 })
await next.click()
check('点了它就去找下一处', await page.evaluate(() => window.__issuanceNext) === 1)
const clean = await at('clean').locator('[data-evidence-line]').innerText().catch(() => '')
check('都干净的那一份:只有数字那半句,不给「定位下一处」', clean.includes('3 个数字都有出处')
&& await at('clean').locator('[data-evidence-line-suspect], [data-evidence-line-claims], [data-evidence-next]').count() === 0, clean)
const names = await at('names').locator('[data-evidence-line]').innerText().catch(() => '')
check('一个数字都没有、只有可疑名称:这一行照样出来,开头不带「·」,也给「定位下一处」',
names.trim().startsWith('?! 可疑名称 1') && await at('names').locator('[data-evidence-next]').count() === 1, names)
})
await section('guess', '旧运行按数值猜的候选:默认折叠,展开写明「猜测」,淡点状线', async () => {
const g = '#evidence-guess'
const box = page.locator(`${g} [data-ev-guess]`)
check('默认折叠', await box.getAttribute('data-open').catch(() => null) === 'false')
check('折叠时看不到候选(不在页面上,不只是藏起来)', await page.locator(`${g} [data-ev-state="candidate"]`).count() === 0)
const head = await page.locator(`${g} [data-ev-guess-toggle]`).innerText().catch(() => '')
check('折叠的标题就写明「猜测」', head.includes('猜测'), head)
const toggle = page.locator(`${g} [data-ev-guess-toggle]`)
check('展开按钮是 button,带 aria-expanded=false', await toggle.evaluate((el) => el.tagName).catch(() => '') === 'BUTTON'
&& await toggle.getAttribute('aria-expanded').catch(() => null) === 'false')
const answer = await page.locator(`${g}`).innerText().catch(() => '')
check('折叠时答案本身照常显示', answer.includes('本周销售额 45,678.5 元'), answer.slice(0, 60))
await toggle.click()
await page.waitForTimeout(150)
check('展开后 aria-expanded=true', await toggle.getAttribute('aria-expanded').catch(() => null) === 'true')
const note = await page.locator(`${g} [data-ev-guess-note]`).innerText().catch(() => '')
check('展开后写明「猜测的来源,不能当证据」', note.includes('猜测的来源,不能当证据'), note)
const cands = await page.evaluate((sel) => [...document.querySelectorAll(`${sel} [data-ev-state="candidate"]`)].map((el) => {
const cs = getComputedStyle(el)
// 候选是 Markdown 里的行内标记(保留旧答案的版式),读屏靠标记里那句 sr-only 的「猜测的来源:…」
return { style: cs.textDecorationStyle, color: cs.textDecorationColor, line: el.getAttribute('data-ev-line'),
label: el.textContent ?? '', title: el.getAttribute('title') ?? '', text: el.firstChild?.textContent ?? '' }
}), g)
check('有候选的数字画成候选(4 个)', cands.length === 4, cands.map((c) => c.text).join(','))
check('候选用淡点状线,不用确定性的实线', cands.length > 0 && cands.every((c) => c.style === 'dotted' && c.line === 'dotted'),
JSON.stringify(cands[0]))
const detColor = await page.evaluate((sel) => {
const el = document.querySelector(`${sel} [data-ev-state="deterministic"]`)
return el ? getComputedStyle(el).textDecorationColor : ''
}, ent)
check('候选的线和「有出处」不是一个颜色', cands.length > 0 && cands.every((c) => c.color !== detColor), `${cands[0]?.color} / ${detColor}`)
check('候选给读屏写「猜测的来源」和候选出处,悬停也看得到', cands[0]?.label.includes('猜测的来源') && cands[0].label.includes('Q1')
&& cands[0].title.includes('Q1'), cands[0]?.label)
const list = await page.locator(`${g} [data-ev-candidates]`).innerText().catch(() => '')
check('候选清单:每个数字可能来自哪一格', list.includes('查询 Q1') && list.includes('gmv'), list.slice(0, 120))
const plain = await page.evaluate((sel) => [...document.querySelectorAll(`${sel} [data-ev-guess-body] [data-ev-state]`)]
.map((el) => el.firstChild?.textContent ?? ''), g)
check('没猜到候选的数字不画线(40%、50,000)', !plain.includes('40%') && !plain.includes('50,000'), plain.join(','))
await toggle.click()
await page.waitForTimeout(100)
check('再点一次收起', await box.getAttribute('data-open').catch(() => null) === 'false')
if (SHOTS) {
await toggle.click()
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(200)
await page.locator(g).screenshot({ path: `${SHOTS}/evidence3-guess-${theme}.png` })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
await toggle.click()
}
})
// ---------------------------------------------------------------------------
// 第四期:结论句裁判
// ---------------------------------------------------------------------------
const J = '#evidence-judge'
const JX = '#evidence-judge-explore'
const JP = '#evidence-judge-plain'
const badge = (pg, sel, unit) => pg.locator(`${sel} [data-ev-claim="${unit}"]`)
/** 令牌算出来的颜色(借查询步骤那节的 tokenColor 探针),和徽标的计算色比 */
const colorOf = (pg, token) => tokenColor(pg, 'color', `var(${token})`)
const activeKey = (pg) => pg.evaluate(() => document.activeElement?.getAttribute('data-seg')
?? (document.activeElement?.getAttribute('data-ev-claim') ? `claim:${document.activeElement.getAttribute('data-ev-claim')}` : null)
?? (document.activeElement?.closest('[data-evidence-panel]') ? 'panel' : document.activeElement?.tagName ?? null))
await section('judge', '四期:结论句的句末徽标——四通道、不用确定性的绿、整句不画线、n / N 也跳到证据不支持的句子', async () => {
const u = fxj.formal.units
const look = await page.evaluate(({ sel, units }) => Object.fromEntries(Object.entries(units).map(([k, id]) => {
const el = document.querySelector(`${sel} [data-ev-claim="${id}"]`)
if (!el) return [k, null]
const cs = getComputedStyle(el)
return [k, { verdict: el.getAttribute('data-ev-verdict'), glyph: el.textContent, color: cs.color, tag: el.tagName,
type: el.getAttribute('type'), label: el.getAttribute('aria-label'), deco: cs.textDecorationLine,
align: cs.verticalAlign }]
})), { sel: J, units: u })
check('有判定的结论句句末都挂徽标(有依据、证据不支持、部分有依据、未裁判)',
look.ok?.verdict === 'supported' && look.bad?.verdict === 'unsupported' && look.partial?.verdict === 'partial'
&& look.limit?.verdict === 'unjudged', JSON.stringify(Object.fromEntries(Object.entries(look).map(([k, v]) => [k, v?.verdict]))))
check('判成「不是结论句」的不挂徽标', look.not === null)
check('字形:◆ 有依据、◇ 部分、! 不支持、? 未裁判', [look.ok, look.partial, look.bad, look.limit].map((x) => x?.glyph).join('') === '◆◇!?',
[look.ok, look.partial, look.bad, look.limit].map((x) => x?.glyph).join(''))
const [accent, waiting, failed, dim, done] = await Promise.all(['--accent', '--st-waiting', '--st-failed', '--text-dim', '--st-done']
.map((t) => colorOf(page, t)))
check('颜色令牌:有依据 --accent、部分 --st-waiting、不支持 --st-failed、未裁判 --text-dim',
look.ok?.color === accent && look.partial?.color === waiting && look.bad?.color === failed && look.limit?.color === dim,
JSON.stringify({ got: [look.ok?.color, look.partial?.color, look.bad?.color, look.limit?.color], want: [accent, waiting, failed, dim] }))
check('概率性的判定一个都不用确定性的绿', [look.ok, look.partial, look.bad, look.limit].every((x) => x && x.color !== done), done)
check('徽标是原生 button type=button、上标、不画线', [look.ok, look.bad].every((x) => x?.tag === 'BUTTON' && x.type === 'button'
&& x.align === 'super' && x.deco === 'none'), JSON.stringify(look.ok))
check('aria-label 写全:句子和判定', look.bad?.label === '结论句「增长主要来自新客首单,老客复购持平。」,模型判断:证据不支持', look.bad?.label)
check('……未裁判(到上限)的说「已达上限」', look.limit?.label?.endsWith('未裁判:已达上限'), look.limit?.label)
const titles = await page.evaluate(({ a, b, c }) => [document.querySelector(a)?.getAttribute('title') ?? '',
document.querySelector(b)?.getAttribute('title') ?? '', document.querySelector(c)?.getAttribute('title') ?? ''],
{ a: `${J} [data-ev-claim="${u.bad}"]`, b: `${JX} [data-ev-claim="${fxj.explore.units.ok}"]`, c: `${J} [data-ev-claim="${u.limit}"]` })
check('悬停说明:模型判过的写明谁判的、非确定', titles[0] === `模型判断:证据不支持(${fxj.model} · 非确定)`, titles[0])
check('……还没有模型判过的(探索运行按需)不说谁判的,只说还没判', !titles[1].includes('非确定') && titles[1].startsWith('未裁判'),
titles[1])
check('……到上限没判的(记着裁判模型)也不写成「(模型 · 非确定)」,说已达上限、本句未经裁判',
titles[2].startsWith('未裁判:已达上限') && !titles[2].includes('非确定') && !titles[2].includes(fxj.model), titles[2])
const shade = await page.evaluate(({ sel, bad, ok }) => {
const box = document.querySelector(`${sel} [data-ev-claim-shade="${bad}"]`)
const other = document.querySelector(`${sel} [data-ev-claim-shade="${ok}"]`)
const probe = document.createElement('span')
probe.style.background = 'var(--st-failed-soft)'
document.body.appendChild(probe)
const soft = getComputedStyle(probe).backgroundColor
probe.remove()
if (!box) return { found: false }
const texts = [...box.querySelectorAll('span')].map((el) => getComputedStyle(el).textDecorationLine)
return { found: true, bg: getComputedStyle(box).backgroundColor, soft, deco: getComputedStyle(box).textDecorationLine,
texts, other: !!other, text: box.textContent }
}, { sel: J, bad: u.bad, ok: u.ok })
check('证据不支持的整句铺浅底(--st-failed-soft)', shade.found && shade.bg === shade.soft && shade.bg !== 'rgba(0, 0, 0, 0)',
JSON.stringify(shade))
check('……只有不支持的那句铺底', shade.found && !shade.other)
check('整句的文字不画下划线(浅底里的字、别的结论句的字都没有线)', shade.found && shade.deco === 'none'
&& shade.texts.every((d) => d === 'none'), JSON.stringify(shade.texts))
const plainText = await page.evaluate(({ sel }) => {
const b = document.querySelector(`${sel} [data-ev-claim]`)
const sentence = b?.previousElementSibling
return sentence ? getComputedStyle(sentence).textDecorationLine : ''
}, { sel: J })
check('……有依据的那句同样不画线(只有数字自己的线)', plainText === 'none' || plainText === '', plainText)
// 键盘:徽标在 roving 顺序里,句尾就是它;n / N 跳到证据不支持、部分有依据的句子
const stops = await page.locator(`${J} [data-seg][tabindex="0"], ${J} [data-ev-claim][tabindex="0"]`).count()
check('整份报告仍只有一个 Tab 位(徽标也在 roving 里)', stops === 1, String(stops))
const firstNum = fxj.formal.doc.blocks.flatMap((b) => b.units).find((x) => x.id === u.ok).segments.filter((s) => s.kind === 'number').at(-1).id
await page.locator(`${J} [data-seg="${firstNum}"]`).focus()
await page.keyboard.press('ArrowRight')
check('→ 从句子最后一个片段走到句末徽标', await activeKey(page) === `claim:${u.ok}`, await activeKey(page))
const jumps = []
for (const key of ['n', 'n', 'N']) {
await page.keyboard.press(key)
jumps.push(await activeKey(page))
}
check('n 跳到证据不支持、再到部分有依据的句子,N 回来', jumps.join(',') === `claim:${u.bad},claim:${u.partial},claim:${u.bad}`, jumps.join(','))
check('只是走动不打开面板', await panel(page).count() === 0)
await page.keyboard.press('Enter')
await page.waitForSelector('[data-evidence-panel] [data-ev-judge]', { timeout: 4000 })
check('回车打开这一句的「模型的解释」', (await panel(page).locator('[data-ev-judge]').getAttribute('data-ev-judge')) === 'unsupported')
check('徽标标着展开了面板', await badge(page, J, u.bad).getAttribute('aria-expanded') === 'true')
check('读屏从 live region 听到这一句的判定', (await page.locator(`${J} [aria-live="polite"]`).innerText()).includes('模型判断:证据不支持'))
await page.keyboard.press('Escape')
await page.waitForTimeout(150)
check('Esc 关掉,焦点回到徽标', await panel(page).count() === 0 && await activeKey(page) === `claim:${u.bad}`, await activeKey(page))
const tally = await page.locator(`${J} [data-evidence-claims]`).innerText().catch(() => '')
check('证据条接着数结论句,和数字那一段同一种说法', tally.includes('结论 4 句(有依据 1 · 部分有依据 1 · 证据不支持 1 · 未裁判 1)'), tally)
const summary = await page.locator(`${J} [data-evidence-summary]`).innerText()
check('读屏摘要也说结论句(写明是模型判断)', summary.includes('结论 4 句') && summary.includes('模型判断'), summary)
check('对照:一期的报告(没有判定)一个徽标都不挂', await page.locator(`${wide} [data-ev-claim], ${wide} [data-ev-claim-shade]`).count() === 0)
})
await section('j-panel', '四期:面板里「模型的解释」、「请模型判断这句」只在探索运行没判过时出现、触顶、封存后追加', async () => {
const r = await open('/ui-harness.html?evidence=1')
const pg = r.page
const p = pg.locator('[data-evidence-panel]')
const u = fxj.formal.units
await badge(pg, J, u.bad).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-judge="unsupported"]')
const chip = await p.locator('[data-ev-judge-badge]').innerText().catch(() => '')
check('带「模型判断 · 模型名 · 非确定」徽标', chip.trim() === `模型判断 · ${fxj.model} · 非确定`, chip)
const why = await p.locator('[data-ev-rationale]').evaluate((el) => ({ text: el.textContent, style: getComputedStyle(el).fontStyle }))
.catch(() => null)
check('理由用斜体', why?.style === 'italic' && why.text.includes('看不出新客、老客'), JSON.stringify(why))
const head = await p.locator('header [data-ev-badge]').evaluate((el) => ({ code: el.getAttribute('data-ev-badge'),
style: getComputedStyle(el).borderTopStyle, text: el.textContent })).catch(() => null)
check('面板标题的徽标:证据不支持、虚线框(概率性)', head?.code === 'unsupported' && head.style === 'dashed', JSON.stringify(head))
check('正式运行节点里判的:不写「封存后追加」,写明随报告封存', await p.locator('[data-ev-post-seal]').count() === 0
&& (await p.locator('[data-ev-judge-sealed]').innerText().catch(() => '')).includes('与报告一起封存'))
check('正式运行:没有「请模型判断本句」', await p.locator('[data-ev-judge-ask]').count() === 0)
const cites = await p.locator('[data-ev-cites]').innerText().catch(() => '')
check('整句的面板列出挂的依据', cites.includes('Q1') && cites.includes('db_query__shop'), cites)
await badge(pg, J, u.limit).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-judge-limit]')
const lim = await p.locator('[data-ev-judge-limit]').innerText()
check('到上限没判:写明「已达上限」和是哪个上限', lim.includes('已达上限') && lim.includes('这份报告的裁判金额上限 $0.05'), lim)
check('……写明怎么调(正式运行:报告撰写节点的结论句裁判,可以设为不限)', lim.includes('报告撰写节点') && lim.includes('设为不限'), lim)
check('……正式运行也不给按钮', await p.locator('[data-ev-judge-ask]').count() === 0)
// 到上限没判的句子也记着裁判模型(judge 字段),但模型没看过它:不能画成「模型判断 · 模型名 · 非确定」
await pg.waitForSelector('[data-evidence-panel] [data-ev-seal-state="done"]', { timeout: 3000 }).catch(() => {})
check('……模型没看过这句:不挂「模型判断 · 模型名 · 非确定」', await p.locator('[data-ev-judge-badge]').count() === 0)
check('……也不说这条判断「与报告一起封存」', await p.locator('[data-ev-judge-sealed]').count() === 0)
const limSeal = await p.locator('[data-ev-seal]').innerText().catch(() => '')
check('……封存那一节只说报告文档封存了', limSeal.includes('报告文档已封存') && !limSeal.includes('这条判断随报告一起封存'), limSeal)
const limModel = await p.locator('[data-ev-judge-model]').innerText().catch(() => '')
check('……中性地说一句裁判模型是谁、未裁判本句', limModel.includes(fxj.model) && limModel.includes('未裁判本句'), limModel)
// 点开结论句里的一个数字:片段面板里也有「模型的解释」(只对结论句)
const num = fxj.formal.doc.blocks.flatMap((b) => b.units).find((x) => x.id === u.ok).segments.find((s) => s.kind === 'number').id
await pg.locator(`${J} [data-seg="${num}"]`).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-judge="supported"]')
check('结论句里的片段:面板里有这一句的模型的解释', (await p.locator('[data-ev-verdict]').innerText()).includes('模型判断:有依据'))
// 一期的报告:没有判定、没开裁判、不知道运行类别也取不到——不画这一节
await pg.locator(`${wide} [data-seg="s8"]`).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-substituted]')
await pg.waitForTimeout(300)
check('对照:没有判定的老报告不画「模型的解释」', await p.locator('[data-ev-judge]').count() === 0)
await pg.keyboard.press('Escape')
// 探索运行、开了裁判(按需):还没判过的句子给按钮
const x = fxj.explore.units
await badge(pg, JX, x.bad).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-judge-ask]')
check('探索运行、还没判过:有「请模型判断本句」', (await p.locator('[data-ev-judge-ask]').innerText()).includes('请模型判断本句'))
check('……还没判过:没有模型徽标(还没有模型判过)', await p.locator('[data-ev-judge-badge]').count() === 0)
await p.locator('[data-ev-judge-ask]').click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-judge="unsupported"]').catch(() => {})
const sent = r.hits.filter((h) => h.startsWith(`judge:${fxj.explore.run_id}:`))
check('请求体带着句子和报告节点', sent.length === 1 && sent[0].endsWith(`:${x.bad}:write`), sent.join(' | '))
check('判完:写「封存后追加」', (await p.locator('[data-ev-post-seal]').innerText().catch(() => '')).trim() === '封存后追加')
check('……答复没带话(判成了):不多写一行', await p.locator('[data-ev-judge-message]').count() === 0)
check('……封存那一节也写明封存后追加(报告文档仍是封存的)', (await p.locator('[data-ev-seal-late]').innerText().catch(() => '')).includes('封存后追加')
&& (await p.locator('[data-ev-seal]').innerText()).includes('报告文档已封存'))
check('……判过了就不再给按钮', await p.locator('[data-ev-judge-ask]').count() === 0)
const after = await badge(pg, JX, x.bad).evaluate((el) => ({ v: el.getAttribute('data-ev-verdict'), late: el.hasAttribute('data-ev-post-seal') }))
check('正文的句末徽标跟着变:证据不支持、标着封存后追加', after.v === 'unsupported' && after.late, JSON.stringify(after))
check('同一份报告在窄栏里的那份也跟着变(按运行和报告记)',
await badge(pg, '#evidence-judge-explore-narrow', x.bad).getAttribute('data-ev-verdict') === 'unsupported')
check('证据条的结论句计数跟着变', (await pg.locator(`${JX} [data-evidence-claims]`).innerText()).includes('证据不支持 1'))
await badge(pg, JX, x.bad).click()
await pg.waitForTimeout(100)
await badge(pg, JX, x.bad).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-judge="unsupported"]')
check('同一句再点开:直接用已有的判定,不再请求', r.hits.filter((h) => h.startsWith(`judge:${fxj.explore.run_id}:`)).length === 1)
// 触顶:不报错,说明「已达上限」和怎么调(每次点击的上限),还能再点一次
await badge(pg, JX, x.limit).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-judge-ask]')
await p.locator('[data-ev-judge-ask]').click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-judge-limit]')
const top = await p.locator('[data-ev-judge-limit]').innerText()
check('按需裁判触顶:写「已达上限」和这次点击的上限', top.includes('已达上限') && top.includes('这次点击的裁判金额上限 $0.01'), top)
check('……写明怎么调:设置里「每次点击的金额上限」、可以设为不限', top.includes('每次点击的金额上限') && top.includes('设为不限')
&& await p.locator('[data-ev-judge-settings]').count() === 1, top)
check('……不是报错', await p.locator('[data-ev-judge-error]').count() === 0)
check('……还能再次请模型判断', (await p.locator('[data-ev-judge-ask]').innerText().catch(() => '')).includes('再次请模型判断'))
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await pg.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await badge(pg, JX, x.bad).click()
await pg.waitForTimeout(100)
if (await p.count() === 0) await badge(pg, JX, x.bad).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-judge="unsupported"]')
await pg.waitForTimeout(250)
await pg.screenshot({ path: `${SHOTS}/evidence4-explore-judged-${theme}.png` })
await badge(pg, J, u.limit).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-judge-limit]')
await pg.waitForTimeout(250)
await pg.screenshot({ path: `${SHOTS}/evidence4-formal-limit-${theme}.png` })
await pg.keyboard.press('Escape')
}
await pg.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
// 没开裁判的探索运行:文档里不记运行类别,面板按运行 id 取一次;结论句照样能请模型判断
const pu = fxj.plain.doc.blocks.flatMap((b) => b.units).find((x2) => x2.id === 'u3')
const pnum = pu.segments.find((s) => s.kind === 'number').id
await pg.locator(`${JP} [data-seg="${pnum}"]`).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-judge-ask]').catch(() => {})
check('没开裁判的报告:按运行 id 认出是探索运行,给按钮', r.hits.includes(`run:${fxj.plain.run_id}`)
&& await p.locator('[data-ev-judge-ask]').count() === 1)
check('……判之前正文没有徽标', await pg.locator(`${JP} [data-ev-claim]`).count() === 0)
await p.locator('[data-ev-judge-ask]').click().catch(() => {})
await pg.waitForSelector(`${JP} [data-ev-claim="u3"]`).catch(() => {})
check('……判完正文挂上徽标(封存后追加)', await badge(pg, JP, 'u3').getAttribute('data-ev-verdict') === 'supported'
&& await badge(pg, JP, 'u3').getAttribute('data-ev-post-seal') === '')
const ptally = await pg.locator(`${JP} [data-evidence-claims]`).innerText().catch(() => '')
check('……证据条多出结论句:挂了依据没判的算未裁判、没挂的算无证据', ptally.includes('结论 5 句(有依据 1 · 未裁判 3 · 无证据 1)'), ptally)
check('没有运行时报错(裁判面板)', r.errors.length === 0, r.errors.join(' | '))
await r.ctx.close()
// 片段接口说暂时判不了(运行还没跑完封存):不给按钮,照原话说为什么
const un = await open('/ui-harness.html?evidence=1', { segPatch: (set, body) => (set === fxj.explore
? { ...body, unit: { ...body.unit, on_demand: { available: false, reason: 'unsealed', message: '运行尚未结束并封存(仍在运行、停在人工审批或正在继续运行),请在运行结束后再请模型裁判' } } } : body) })
const tnum = fxj.explore.doc.blocks.flatMap((b) => b.units).find((x2) => x2.id === x.ok).segments.find((s) => s.kind === 'number').id
await un.page.locator(`${JX} [data-seg="${tnum}"]`).click()
await un.page.waitForSelector('[data-evidence-panel] [data-ev-judge]')
await un.page.waitForTimeout(300)
check('接口说运行还没封存:不给按钮,照原话说', await un.page.locator('[data-ev-judge-ask]').count() === 0
&& (await un.page.locator('[data-ev-judge-blocked]').innerText()).includes('请在运行结束后再请模型裁判'))
await un.ctx.close()
// 接口 409(正式运行、封存被改过):不白屏,照后端原话说
const e409 = await open('/ui-harness.html?evidence=1', { judge: () => ({ status: 409, json: { detail: '封存核对未通过:封存后有事件被修改、删除或插入,报告和证据已不可信,无法再请模型裁判', code: 'evidence_seal_broken' } }) })
await badge(e409.page, JX, x.bad).click()
await e409.page.locator('[data-ev-judge-ask]').click()
await e409.page.waitForSelector('[data-evidence-panel] [data-ev-judge-error]')
check('按需裁判回 409:照后端原话说,不白屏', (await e409.page.locator('[data-ev-judge-error]').innerText()).includes('封存核对未通过'))
check('……徽标还是未裁判', await badge(e409.page, JX, x.bad).getAttribute('data-ev-verdict') === 'unjudged')
await e409.ctx.close()
// 答复里的那句话(message):判定照样交回来,但运行已经接着跑了、没记进运行记录——得照原话写出来,
// 不能只挂一枚「封存后追加」让人以为记进去了。触顶的那句已经由上限那一框说了,不重复
const lostText = '运行已继续执行,本次裁判结果未写入运行记录,刷新后将不再显示。请在运行结束并封存后重新裁判'
const lost = await open('/ui-harness.html?evidence=1', { judge: (set, body) => {
const unit = body?.units?.[0]
if (set === fxj.explore && unit === x.limit) {
return { json: { ...fxj.explore.judge.limit, message: '已达上限(这次点击的裁判金额上限 $0.01):1 句未裁判,已裁判的结果保留' } }
}
return { json: { report: { node_id: 'write', doc_artifact: set.doc_artifact }, model: fxj.model, limits_hit: [], post_seal: true,
message: lostText, event: null, calls: 1,
verdicts: { [unit]: { status: 'supported', rationale: '引用的证据支持这句话', judge: fxj.model, post_seal: true, used: [] } } } }
} })
const lp = lost.page.locator('[data-evidence-panel]')
await badge(lost.page, JX, x.ok).click()
await lp.locator('[data-ev-judge-ask]').click().catch(() => {})
await lost.page.waitForSelector('[data-evidence-panel] [data-ev-judge="supported"]').catch(() => {})
const lostMsg = await lp.locator('[data-ev-judge-message]').innerText().catch(() => '')
check('答复带着「未写入运行记录」:照原话写在判定下面', lostMsg.includes('未写入运行记录') && lostMsg.includes('刷新后将不再显示'), lostMsg || '(没写)')
check('……判定照样显示(有依据)', (await lp.locator('[data-ev-verdict]').innerText().catch(() => '')).includes('模型判断:有依据'))
await badge(lost.page, JX, x.limit).click()
await lp.locator('[data-ev-judge-ask]').click().catch(() => {})
await lost.page.waitForSelector('[data-evidence-panel] [data-ev-judge-limit]').catch(() => {})
check('触顶那句话已经由上限那一框说了:不再另写一遍', await lp.locator('[data-ev-judge-limit]').count() === 1
&& await lp.locator('[data-ev-judge-message]').count() === 0)
check('没有运行时报错(答复里的那句话)', lost.errors.length === 0, lost.errors.join(' | '))
await lost.ctx.close()
})
await section('j-narrow', '四期在 360px:徽标和「模型的解释」栏内展开不横向滚动;减少动效', async () => {
const narrow = '#evidence-judge-narrow'
const overflow = () => page.evaluate((sel) => {
const el = document.querySelector(sel)
return { sw: el.scrollWidth, cw: el.clientWidth, w: el.getBoundingClientRect().width }
}, narrow)
const before = await overflow()
await badge(page, narrow, fxj.formal.units.bad).click()
await page.waitForSelector(`${narrow} [data-evidence-panel="inline"] [data-ev-judge]`)
const after = await overflow()
check('窄栏里徽标照样挂在句末', await page.locator(`${narrow} [data-ev-claim]`).count() === 4)
check('窄栏里没有横向滚动(面板打开前后)', Math.round(before.w) === 360 && before.sw <= before.cw && after.sw <= after.cw,
JSON.stringify({ before, after }))
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(200)
await page.locator(narrow).screenshot({ path: `${SHOTS}/evidence4-360-inline-${theme}.png` })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
await page.locator(`${narrow} [data-evidence-panel] button`, { hasText: '回到正文' }).click()
await page.waitForTimeout(150)
const small = await open('/ui-harness.html?evidence=1', { w: 360, h: 780 })
const scroll = () => small.page.evaluate(() => ({ sw: document.documentElement.scrollWidth, vw: innerWidth }))
const a = await scroll()
await badge(small.page, J, fxj.formal.units.bad).click()
await small.page.waitForSelector('[data-evidence-panel] [data-ev-judge]')
const b = await scroll()
check('360px 宽的屏幕:整句的面板从底部抽出、整页没有横向滚动',
await small.page.locator('[data-evidence-panel]').getAttribute('data-evidence-panel') === 'drawer' && a.sw <= a.vw && b.sw <= b.vw,
JSON.stringify({ a, b }))
await small.ctx.close()
const r = await open('/ui-harness.html?evidence=1', { reduced: true })
await badge(r.page, J, fxj.formal.units.bad).click()
await r.page.waitForSelector('[data-evidence-panel] [data-ev-judge]').catch(() => {})
const still = await r.page.evaluate(() => {
const p = document.querySelector('[data-evidence-panel]')
return { n: p ? p.getAnimations({ subtree: true })
.filter((a2) => !(a2 instanceof CSSTransition && Number(a2.effect?.getTiming().duration) <= 0.01)).length : -1,
name: p ? getComputedStyle(p).animationName : '' }
})
check('减少动效时整句的面板没有任何动画', still.n === 0 && still.name === 'none', JSON.stringify(still))
await r.ctx.close()
})
await section('j-banner', '四期:出具横幅多一段「结论 N 句(支持 a · 无证据 b)」', async () => {
const at = (key) => page.locator(`#issuance-claims [data-issuance-case="${key}"]`)
const judged = await at('judged').locator('[data-evidence-line]').innerText().catch(() => '')
check('结论句那一段:和数字那一段同一种说法', judged.includes('3 个数字都有出处') && judged.includes('结论 4 句(有依据 1 · 部分有依据 1 · 证据不支持 1 · 未裁判 1)'), judged)
check('有证据不支持的结论句:给「定位下一处」', await at('judged').locator('[data-evidence-next]').count() === 1)
await page.evaluate(() => { window.__claimsNext = 0 })
await at('judged').locator('[data-evidence-next]').click()
check('……点了就去找下一处', await page.evaluate(() => window.__claimsNext) === 1)
const cited = await at('cited').locator('[data-evidence-line]').innerText().catch(() => '')
check('方案的例子:结论 4 句(有依据 3 · 无证据 1)', cited.includes('结论 4 句(有依据 3 · 无证据 1)'), cited)
check('……没附依据的算进「无证据」,不再另起「未附依据的结论句」', await at('cited').locator('[data-evidence-line-claims]').count() === 0)
const all = at('all').locator('[data-evidence-claims]')
const [color, done] = [await all.evaluate((el) => getComputedStyle(el).color).catch(() => ''), await colorOf(page, '--st-done')]
check('全都支持:不给「定位下一处」,也不用确定性的绿', await at('all').locator('[data-evidence-next]').count() === 0 && !!color && color !== done,
`${color} / ${done}`)
check('对照:三期那一份(没有判定)照旧写「未附依据的结论句」', await page.locator('#issuance-line [data-issuance-case="suspect"] [data-evidence-line-claims]').count() === 1)
// 开了裁判的文档:没挂依据的那句送了裁判、按判定数进了「支持 3」,出具照样按没挂依据记 1 句——不能被吞掉
const ju = await at('judge-uncited').locator('[data-evidence-line]').innerText().catch(() => '')
check('开了裁判、出具还记着未附依据的结论句:结论句那一段旁边另起「未附依据的结论句 1」',
ju.includes('结论 3 句(有依据 3)') && await at('judge-uncited').locator('[data-evidence-line-claims]').count() === 1
&& ju.includes('· 未附依据的结论句 1'), ju)
const juTitle = await at('judge-uncited').locator('[data-evidence-line-claims]').getAttribute('title').catch(() => '')
check('……悬停说明:判定不能替代依据,出具照样计缺口', !!juTitle && juTitle.includes('不能替代依据') && juTitle.includes('缺口'), juTitle ?? '')
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(200)
await page.locator('#issuance-claims').screenshot({ path: `${SHOTS}/evidence4-banner-${theme}.png` })
await page.locator(J).screenshot({ path: `${SHOTS}/evidence4-doc-${theme}.png` })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
})
await section('j-stream', '四期:问数据页、画布右栏的横幅(Output 这条路)——按需判过的结论句数进横幅,开了裁判的报告没挂依据的照样写出来', async () => {
// 按需裁判把「退款 29 单,其中大多数是部分退款」判成证据不支持(这句有数字,点得开)
const pu = fxj.plain.doc.blocks.flatMap((b) => b.units).find((x2) => x2.id === 'u3')
const pnum = pu.segments.find((x2) => x2.kind === 'number').id
const s = await open('/preview.html?syn=evidence-judge', { judge: (set, body) => (set === fxj.plain ? { json: {
report: { node_id: 'write', doc_artifact: set.doc_artifact }, model: fxj.model, limits_hit: [], post_seal: true,
verdicts: { [body.units[0]]: { status: 'unsupported', rationale: 'Q2 里部分退款 11 单,说不上「大多数」', judge: fxj.model, post_seal: true, used: ['Q2'] } },
} } : judgeReply(set, body)) })
const p = s.page
const turn = (id) => p.locator(`[data-turn="${id}"]`)
await p.waitForSelector('[data-turn="judge-plain"] [data-evidence-line]', { timeout: 6000 }).catch(() => {})
await p.waitForSelector('[data-turn="judge-formal"] [data-evidence-line]', { timeout: 6000 }).catch(() => {})
const plainLine = () => turn('judge-plain').locator('[data-evidence-line]').innerText().catch(() => '')
const before = await plainLine()
check('没开裁判、还没判过:横幅照旧写「未附依据的结论句 1」,没有结论句那一段',
before.includes('未附依据的结论句 1') && await turn('judge-plain').locator('[data-evidence-claims]').count() === 0, before)
// 点开那一句的数字,请模型判断(Output 没给运行类别:按运行 id 认出探索运行)
await turn('judge-plain').locator(`[data-seg="${pnum}"]`).click().catch(() => {})
const ask = p.locator('[data-evidence-panel] [data-ev-judge-ask]')
await ask.waitFor({ timeout: 6000 }).catch(() => {})
await ask.click().catch(() => {})
await p.waitForSelector('[data-turn="judge-plain"] [data-ev-claim="u3"]', { timeout: 6000 }).catch(() => {})
await p.waitForSelector('[data-turn="judge-plain"] [data-evidence-line] [data-evidence-claims]', { timeout: 3000 }).catch(() => {})
check('……请模型判断那一下发出去了(按运行、报告、句子)', s.hits.includes(`judge:${fxj.plain.run_id}:u3:write`),
s.hits.filter((h) => h.startsWith('judge:')).join(' '))
const claims = await turn('judge-plain').locator('[data-evidence-line] [data-evidence-claims]').innerText().catch(() => '')
check('按需判过一句:横幅多出结论句那一段(EvidenceField 叠好判定交出的文档,docTally 照样数)',
claims.includes('结论 5 句(证据不支持 1 · 未裁判 3 · 无证据 1)'), claims || '(没有这一段)')
const after = await plainLine()
check('……没挂依据的那句已经数在「无证据」里,不再另起一句', !!claims && await turn('judge-plain').locator('[data-evidence-line-claims]').count() === 0, after)
check('……有证据不支持的句子:横幅给「定位下一处」', !!claims && await turn('judge-plain').locator('[data-evidence-line] [data-evidence-next]').count() === 1, after)
await p.keyboard.press('Escape')
const formal = await turn('judge-formal').locator('[data-evidence-line]').innerText().catch(() => '')
check('开了裁判的正式运行:结论句那一段照节点里的判定数', formal.includes('结论 4 句(有依据 1 · 部分有依据 1 · 证据不支持 1 · 未裁判 1)'), formal)
check('……没附依据的那句(送了裁判、到上限没判)出具照样计缺口:另起「未附依据的结论句 1」',
await turn('judge-formal').locator('[data-evidence-line-claims]').count() === 1 && formal.includes('未附依据的结论句 1'), formal)
check('没有运行时报错(问数据页 · 结论句裁判)', s.errors.length === 0, s.errors.join(' | '))
if (SHOTS) {
// 侧面板还开着会压住横幅的右半边:先关掉
await p.locator('[data-evidence-panel] button[aria-label="关闭证据"]').click().catch(() => {})
for (const theme of ['dark', 'light']) {
await p.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await p.waitForTimeout(200)
await turn('judge-plain').locator('[data-issuance-banner]').screenshot({ path: `${SHOTS}/evidence4-stream-plain-${theme}.png` })
await turn('judge-formal').locator('[data-issuance-banner]').screenshot({ path: `${SHOTS}/evidence4-stream-formal-${theme}.png` })
}
}
await s.ctx.close()
})
// ---------------------------------------------------------------------------
// 裁判拆档(JR):证据相矛盾 / 证据不足、缺什么、裁判模型的两条提醒、理由 120 字、字段清单、讲方法的句子
// ---------------------------------------------------------------------------
const V = '#evidence-verdict'
const VN = '#evidence-verdict-narrow'
const VX = '#evidence-verdict-explore'
/** 一组句末徽标的样子:判定、字形、计算色、读屏名、悬停说明,外加整句的浅底(没有浅底是 null) */
const badgeLook = (pg, sel, units) => pg.evaluate(({ sel, units }) => Object.fromEntries(Object.entries(units).map(([k, id]) => {
const el = document.querySelector(`${sel} [data-ev-claim="${id}"]`)
if (!el) return [k, null]
const shade = document.querySelector(`${sel} [data-ev-claim-shade="${id}"]`)
return [k, { verdict: el.getAttribute('data-ev-verdict'), glyph: el.textContent, color: getComputedStyle(el).color,
label: el.getAttribute('aria-label'), title: el.getAttribute('title'),
shade: shade ? getComputedStyle(shade).backgroundColor : null }]
})), { sel, units })
const softOf = (pg, token) => tokenColor(pg, 'backgroundColor', `var(${token})`)
const firstNumber = (doc, uid) => doc.blocks.flatMap((b) => b.units).find((x) => x.id === uid).segments.find((s) => s.kind === 'number').id
await section('jr', '裁判拆档:证据相矛盾、证据不足四通道分得开,证据不足不用红、不和未裁判混;n / N 先走相矛盾、再走证据不足', async () => {
const u = fxv.formal.units
await page.waitForSelector(`${V} [data-ev-claim="${u.insuff}"]`, { timeout: 4000 }).catch(() => {})
const look = await badgeLook(page, V, u)
check('新取值各挂各的徽标:证据相矛盾、证据不足(讲方法的那句也判成证据相矛盾)',
look.contra?.verdict === 'contradicted' && look.insuff?.verdict === 'insufficient' && look.method?.verdict === 'contradicted',
JSON.stringify(Object.fromEntries(Object.entries(look).map(([k, v]) => [k, v?.verdict]))))
check('字形:证据相矛盾 !、证据不足 ○,和未裁判的 ? 分得开', look.contra?.glyph === '!' && look.insuff?.glyph === '○'
&& look.limit?.glyph === '?' && look.insuff.glyph !== look.limit.glyph,
[look.contra?.glyph, look.insuff?.glyph, look.limit?.glyph].join(' '))
const [failed, waiting, neutral, done] = await Promise.all(['--st-failed', '--st-waiting', '--st-cancelled', '--st-done']
.map((t) => colorOf(page, t)))
check('颜色令牌:证据相矛盾 --st-failed(同原来的证据不支持),证据不足 --st-cancelled(中性)',
look.contra?.color === failed && look.method?.color === failed && look.insuff?.color === neutral,
JSON.stringify({ got: [look.contra?.color, look.insuff?.color], want: [failed, neutral] }))
check('证据不足不用红,也不用提醒色和确定性的绿', !!look.insuff && ![failed, waiting, done].includes(look.insuff.color), look.insuff?.color)
const soft = await softOf(page, '--st-failed-soft')
check('证据相矛盾的整句铺浅底(--st-failed-soft)', look.contra?.shade === soft && look.method?.shade === soft && soft !== 'rgba(0, 0, 0, 0)',
JSON.stringify({ contra: look.contra?.shade, method: look.method?.shade, soft }))
check('证据不足不铺底(中性样式)', look.insuff?.shade === null, String(look.insuff?.shade))
check('读屏名:证据相矛盾', look.contra?.label === '结论句「各渠道的订单数相加与总订单数一致。」,模型判断:证据相矛盾', look.contra?.label)
check('读屏名:证据不足接着说缺什么', look.insuff?.label === `结论句「增长主要来自新客首单,老客复购持平。」,模型判断:证据不足,缺少:${fxv.missing}`,
look.insuff?.label)
check('悬停说明:证据不足写明谁判的、非确定、缺什么', look.insuff?.title === `模型判断:证据不足(${fxv.model} · 非确定)。缺少:${fxv.missing}`,
look.insuff?.title)
const tally = await page.locator(`${V} [data-evidence-claims]`).innerText().catch(() => '')
check('证据条的结论句计数带上证据相矛盾、证据不足', tally.includes('结论 6 句(有依据 1 · 部分有依据 1 · 证据相矛盾 2 · 证据不足 1 · 未裁判 1)'), tally)
const summary = await page.locator(`${V} [data-evidence-summary]`).innerText()
check('读屏摘要的按键说明:证据有问题的句子 n 也跳到,证据不足的排在最后', summary.includes('证据有问题的句子(证据不足的排在最后)'), summary)
// n / N:第 1 轮是证据相矛盾、部分有依据(按正文顺序),第 2 轮是证据不足——它在正文里排第二,照样最后才到
await page.locator(`${V} [data-seg="${fxv.formal.segs.gmv}"]`).focus()
const jumps = []
for (let i = 0; i < 5; i++) {
await page.keyboard.press('n')
jumps.push(await activeKey(page))
}
const want = [u.contra, u.partial, u.method, u.insuff, u.contra].map((x) => `claim:${x}`)
check('n:先按正文顺序走证据相矛盾、部分有依据,再走证据不足,走完接回开头', jumps.join(',') === want.join(','), jumps.join(','))
const back = []
for (let i = 0; i < 2; i++) {
await page.keyboard.press('N')
back.push(await activeKey(page))
}
check('N:倒着走,从第一处退到证据不足(最后一处),再退到第 1 轮的最后一处', back.join(',') === `claim:${u.insuff},claim:${u.method}`,
back.join(','))
check('只是走动不打开面板', await panel(page).count() === 0)
// 拆档之前的取值:探索运行证据图里的 unsupported 照旧显示「证据不支持」,样式同证据相矛盾
const x = fxv.explore.units
await page.waitForSelector(`${VX} [data-ev-claim="${x.contra}"][data-ev-verdict="unsupported"]`, { timeout: 4000 }).catch(() => {})
const old = (await badgeLook(page, VX, { contra: x.contra, insuff: x.insuff }))
check('旧取值 unsupported:照旧叫「证据不支持」(读屏名、悬停说明)', old.contra?.verdict === 'unsupported'
&& old.contra.label?.endsWith(',模型判断:证据不支持,封存后追加') && old.contra.title?.startsWith('模型判断:证据不支持 · 封存后追加'),
JSON.stringify(old.contra))
check('……样式同证据相矛盾:!、--st-failed、整句浅底', old.contra?.glyph === '!' && old.contra.color === failed && old.contra.shade === soft,
JSON.stringify(old.contra))
check('……封存后追加的证据不足同样是 ○、中性色', old.insuff?.verdict === 'insufficient' && old.insuff.glyph === '○' && old.insuff.color === neutral,
JSON.stringify(old.insuff))
const xt = await page.locator(`${VX} [data-evidence-claims]`).innerText().catch(() => '')
check('……计数里旧取值照样算进去,和证据不足并列', xt.includes('结论 7 句(证据不支持 1 · 证据不足 1 · 未裁判 5)'), xt)
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(200)
await page.locator(V).screenshot({ path: `${SHOTS}/jr-doc-${theme}.png` })
await page.locator(VX).screenshot({ path: `${SHOTS}/jr-doc-explore-${theme}.png` })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
})
await section('jr-panel', '裁判拆档:面板写明缺什么、裁判模型的两条提醒、理由 120 字不截断、字段清单、讲方法的句子直接看到 SQL 和字段清单', async () => {
const r = await open('/ui-harness.html?evidence=1')
const pg = r.page
const p = pg.locator('[data-evidence-panel]')
const u = fxv.formal.units
const [failed, waiting, neutral] = await Promise.all(['--st-failed', '--st-waiting', '--st-cancelled'].map((t) => colorOf(pg, t)))
await badge(pg, V, u.insuff).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-judge="insufficient"]')
check('面板:证据不足(字形 ○)', (await p.locator('[data-ev-verdict]').innerText()).trim() === '○模型判断:证据不足',
await p.locator('[data-ev-verdict]').innerText())
const head = await p.locator('header [data-ev-badge]').evaluate((el) => ({ code: el.getAttribute('data-ev-badge'), color: getComputedStyle(el).color }))
.catch(() => null)
check('……面板标题的徽标是中性色,不用红', head?.code === 'insufficient' && head.color === neutral && head.color !== failed, JSON.stringify(head))
const missing = await p.locator('[data-ev-judge] [data-ev-missing]').innerText().catch(() => '')
check('……「模型的解释」里写明缺什么', missing === `缺少:${fxv.missing}`, missing)
const same = await p.locator('[data-ev-judge] [data-ev-judge-same]').innerText().catch(() => '')
const unpriced = await p.locator('[data-ev-judge] [data-ev-judge-unpriced]').innerText().catch(() => '')
check('裁判模型和写作模型相同:「模型的解释」下写「裁判模型和写作模型相同,结果仅供参考」', same === '裁判模型和写作模型相同,结果仅供参考', same)
check('模型不在价格表里:写「该模型不在价格表中,金额上限不生效」', unpriced === '该模型不在价格表中,金额上限不生效', unpriced)
const caution = await p.locator('[data-ev-judge-caution]').evaluate((el) => {
const cs = getComputedStyle(el)
return { border: cs.borderLeftColor, width: cs.borderLeftWidth, style: cs.borderLeftStyle, color: cs.color, bg: cs.backgroundColor,
icon: getComputedStyle(el.querySelector('svg')).color }
}).catch(() => null)
check('……醒目但不刺眼:左侧一道 2px 提醒色竖线、图标用提醒色,字不用红、不铺底', !!caution && caution.border === waiting
&& caution.width === '2px' && caution.style === 'solid' && caution.icon === waiting && caution.color !== failed
&& caution.bg === 'rgba(0, 0, 0, 0)', JSON.stringify(caution))
const sameTitle = await p.locator('[data-ev-judge-same]').getAttribute('title').catch(() => '')
check('……悬停写明是哪个模型', (sameTitle ?? '').includes(fxv.model), sameTitle ?? '')
// 没判的句子、别的报告不挂
await badge(pg, V, u.limit).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-judge="unjudged"]')
check('到上限没判的句子不挂这两条提醒(模型没看过这句)', await p.locator('[data-ev-judge-caution]').count() === 0)
await badge(pg, J, fxj.formal.units.bad).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-judge="unsupported"]')
check('对照:裁判模型和写作模型不同、模型在价格表里的报告不挂提醒', await p.locator('[data-ev-judge-caution]').count() === 0)
check('……旧取值的面板照旧写「证据不支持」', (await p.locator('[data-ev-verdict]').innerText()).includes('模型判断:证据不支持'))
// 理由最长 120 字:整句显示,侧边面板里折行、不溢出
await badge(pg, V, u.contra).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-judge="contradicted"]')
const why = await p.locator('[data-ev-rationale]').evaluate((el) => {
const cs = getComputedStyle(el)
return { text: el.textContent, sw: el.scrollWidth, cw: el.clientWidth, ellipsis: cs.textOverflow, clamp: cs.webkitLineClamp,
overflow: cs.overflow, lines: Math.round(el.getBoundingClientRect().height / parseFloat(cs.lineHeight)) }
}).catch(() => null)
check('理由 120 字整句显示,不截断', why?.text === fxv.long_rationale && [...fxv.long_rationale].length === 120, why?.text)
check('……在侧边面板里折成几行、不溢出、不加省略号', !!why && why.sw <= why.cw && why.ellipsis !== 'ellipsis'
&& (why.clamp === 'none' || why.clamp === '') && why.lines >= 3, JSON.stringify(why))
check('证据相矛盾:面板写「模型判断:证据相矛盾」,不写缺什么', (await p.locator('[data-ev-verdict]').innerText()).trim() === '!模型判断:证据相矛盾'
&& await p.locator('[data-ev-missing]').count() === 0)
const orders = fxv.formal.segments[fxv.formal.segs.orders].chain.find((c) => c.step === 'entity')
const wantFields = orders.fields.map((f) => [f.name, f.type])
// 讲方法的句子:「挂的依据」下直接列出 SQL 和字段清单(先于点开句中的表名:取数只能来自这一块)
const hitsBefore = r.hits.length
check('……此前没取过句中表名的片段', !r.hits.includes(`j:${fxv.formal.segs.orders}`))
await badge(pg, V, u.method).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-cites] [data-ev-basis]')
// SQL、字段清单没画出来时也往下走:让下面几条断言自己报没通过
await pg.waitForSelector('[data-evidence-panel] [data-ev-cites] [data-ev-basis] [data-ev-sql]', { timeout: 3000 }).catch(() => {})
await pg.waitForSelector('[data-evidence-panel] [data-ev-basis-table] [data-ev-field]', { timeout: 3000 }).catch(() => {})
const basis = p.locator('[data-ev-cites] [data-ev-basis]')
const sqls = await basis.locator('[data-ev-basis-sql]').evaluateAll((els) => els.map((e) =>
[e.getAttribute('data-ev-basis-sql'), e.querySelector('[data-ev-sql]')?.textContent ?? '']))
check('讲方法的句子:「挂的依据」下直接列出 SQL(直接挂的 Q1,再补表名出现过的 Q2)',
JSON.stringify(sqls) === JSON.stringify([['Q1', fxv.snapshots.Q1.content.sql], ['Q2', fxv.snapshots.Q2.content.sql]]), JSON.stringify(sqls))
const bf = await basis.locator('[data-ev-basis-table="t:orders"] [data-ev-field]').evaluateAll((els) => els.map((e) => e.getAttribute('data-ev-field')))
check('……表的字段清单也直接列出', JSON.stringify(bf) === JSON.stringify(wantFields.map(([n]) => n)), JSON.stringify(bf))
const bhead = await basis.locator('[data-ev-basis-table="t:orders"]').innerText().catch(() => '')
check('……写明是哪张表的字段清单', bhead.startsWith(`表 orders 的字段清单(${wantFields.length} 个)`), bhead.split('\n')[0])
const bc = await basis.locator('[data-ev-basis-column]').allInnerTexts()
check('……挂的字段写类型', bc.some((t) => t.includes('orders.status') && t.includes('VARCHAR(12)'))
&& bc.some((t) => t.includes('orders.created_at') && t.includes('TIMESTAMP')), bc.join(' | '))
check('……SQL 带复制', await basis.locator('[data-ev-basis-sql="Q1"] button', { hasText: '复制 SQL' }).count() === 1)
// 这一页只有「挂的依据」会取查询快照工件(完整快照要点按钮);这之前没点开过句中的表名,字段清单那次片段请求只能是
// 它自己发的。Q1、Q2 的 SQL 在前面几句(只挂了整份查询)打开时已经取过,缓存着不再取
const fresh = r.hits.slice(hitsBefore)
check('……SQL 取自查询快照工件,字段清单取自表名片段的证据链', r.hits.includes('artifact:V:Q1') && r.hits.includes('artifact:V:Q2')
&& fresh.includes(`j:${fxv.formal.segs.orders}`), fresh.join(','))
check('……判定的理由里提到的 created_at 在字段清单里看得到', bf.includes('created_at'))
await badge(pg, V, u.insuff).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-judge="insufficient"]')
await pg.waitForSelector('[data-evidence-panel] [data-ev-basis] [data-ev-sql]').catch(() => {})
const insuffSql = await p.locator('[data-ev-basis] [data-ev-basis-sql]').evaluateAll((els) => els.map((e) => e.getAttribute('data-ev-basis-sql')))
check('只挂了整份查询的句子(证据不足的那句):同样列出 SQL,读的人看得出缺的是什么', insuffSql.join(',') === 'Q1', insuffSql.join(','))
await badge(pg, V, u.ok).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-judge="supported"]')
await pg.waitForTimeout(200)
check('对照:挂了数字的结论句只列依据的编号,不另列 SQL', await p.locator('[data-ev-basis]').count() === 0
&& (await p.locator('[data-ev-cites]').innerText()).includes('Q1'))
// 表名实体步骤:字段清单和类型
await pg.locator(`${V} [data-seg="${fxv.formal.segs.orders}"]`).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-entity] [data-ev-entity-queries]')
// 字段清单没画出来时也往下走:让下面两条断言自己报没通过,而不是整节在等待上出错
await pg.waitForSelector('[data-evidence-panel] [data-ev-entity] [data-ev-fields]', { timeout: 3000 }).catch(() => {})
const fields = await p.locator('[data-ev-entity] [data-ev-field]').evaluateAll((els) => els.map((e) =>
[e.getAttribute('data-ev-field'), e.querySelector('[data-ev-field-type]')?.textContent ?? '']))
check('表名实体步骤列出字段清单和类型(照证据接口的 fields,顺序不变)', JSON.stringify(fields) === JSON.stringify(wantFields), JSON.stringify(fields))
const fhead = await p.locator('[data-ev-entity] [data-ev-fields]').innerText().catch(() => '')
check('……写明一共几个字段;没有多出来的不写「另有」', fhead.startsWith(`字段清单(${wantFields.length} 个)`)
&& await p.locator('[data-ev-fields-more]').count() === 0, fhead.split('\n')[0])
check('没有运行时报错(裁判拆档 · 面板)', r.errors.length === 0, r.errors.join(' | '))
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await pg.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
for (const [name, unit] of [['insufficient', u.insuff], ['contradicted', u.contra], ['method', u.method]]) {
await badge(pg, V, unit).click()
await pg.waitForTimeout(100)
if (await p.count() === 0) await badge(pg, V, unit).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-judge]')
await pg.waitForTimeout(300)
await pg.screenshot({ path: `${SHOTS}/jr-panel-${name}-${theme}.png` })
}
await pg.locator(`${V} [data-seg="${fxv.formal.segs.orders}"]`).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-fields]')
await pg.waitForTimeout(300)
await pg.screenshot({ path: `${SHOTS}/jr-panel-fields-${theme}.png` })
await pg.keyboard.press('Escape')
}
await pg.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
await r.ctx.close()
// 字段多于 60 个、表结构快照只存了部分表
const more = await open('/ui-harness.html?evidence=1', { segPatch: (set, body) => (set === fxv.formal && body?.segment?.id === fxv.formal.segs.orders
? { ...body, chain: body.chain.map((c) => (c.step === 'entity' ? { ...c, fields: c.fields.slice(0, 5), fields_more: 55, snapshot_truncated: true,
fields_masked: true } : c)) }
: body) })
const mp = more.page.locator('[data-evidence-panel]')
await more.page.locator(`${V} [data-seg="${fxv.formal.segs.orders}"]`).click()
await more.page.waitForSelector('[data-evidence-panel] [data-ev-fields-more]').catch(() => {})
check('字段多于 60 个:列出的之外写「另有 N 个」', (await mp.locator('[data-ev-fields-more]').innerText().catch(() => '')) === '另有 55 个字段未列出',
await mp.locator('[data-ev-fields-more]').innerText().catch(() => '(没写)'))
check('……总数按列出的加另有的算', (await mp.locator('[data-ev-entity] [data-ev-fields]').innerText().catch(() => '')).startsWith('字段清单(60 个)')
&& await mp.locator('[data-ev-entity] [data-ev-field]').count() === 5)
check('有列按遮罩没列出(fields_masked):照实说一句', (await mp.locator('[data-ev-entity] [data-ev-fields-masked]').innerText().catch(() => ''))
=== '部分字段已按数据源的遮罩设置隐藏,未列出')
await badge(more.page, V, u.method).click()
await more.page.waitForSelector('[data-evidence-panel] [data-ev-basis-table] [data-ev-fields-partial]').catch(() => {})
check('讲方法的句子:表结构快照只存了部分表时,字段清单下照实注明', (await mp.locator('[data-ev-basis-table] [data-ev-fields-partial]')
.innerText().catch(() => '')).includes('表结构快照仅包含部分表'))
check('……另有的字段也写出来', (await mp.locator('[data-ev-basis-table] [data-ev-fields-more]').innerText().catch(() => '')) === '另有 55 个字段未列出')
await more.ctx.close()
// 探索运行:封存后追加的证据不足、按需裁判答复里的 same_model / priced
const x = fxv.explore.units
const ex = await open('/ui-harness.html?evidence=1')
const xp = ex.page.locator('[data-evidence-panel]')
await ex.page.waitForSelector(`${VX} [data-ev-claim="${x.insuff}"][data-ev-verdict="insufficient"]`, { timeout: 4000 }).catch(() => {})
await badge(ex.page, VX, x.insuff).click()
await ex.page.waitForSelector('[data-evidence-panel] [data-ev-judge="insufficient"]')
await ex.page.waitForTimeout(300)
check('封存后追加的证据不足:同样写明缺什么', (await xp.locator('[data-ev-missing]').innerText().catch(() => '')) === `缺少:${fxv.missing}`)
check('……证据图里最近一次按需裁判的模型和写作模型不同、在价格表里:不挂提醒', await xp.locator('[data-ev-judge-caution]').count() === 0)
await badge(ex.page, VX, x.method).click()
await xp.locator('[data-ev-judge-ask]').click()
await ex.page.waitForSelector('[data-evidence-panel] [data-ev-judge="contradicted"]').catch(() => {})
check('按需裁判回来:讲方法的那句判成证据相矛盾,正文徽标跟着变', await badge(ex.page, VX, x.method).getAttribute('data-ev-verdict') === 'contradicted')
check('……这一次的答复说裁判模型和写作模型相同、不在价格表里:两条提醒都挂上',
(await xp.locator('[data-ev-judge-same]').innerText().catch(() => '')) === '裁判模型和写作模型相同,结果仅供参考'
&& (await xp.locator('[data-ev-judge-unpriced]').innerText().catch(() => '')) === '该模型不在价格表中,金额上限不生效')
await ex.page.keyboard.press('Escape')
await ex.page.waitForTimeout(150)
await ex.page.locator(`${VX} [data-seg="${firstNumber(fxv.explore.doc, x.ok)}"]`).focus()
const xj = []
for (let i = 0; i < 3; i++) {
await ex.page.keyboard.press('n')
xj.push(await activeKey(ex.page))
}
check('n:旧的证据不支持、证据相矛盾在前,证据不足在后(它在正文里更靠前也一样)',
xj.join(',') === [x.contra, x.method, x.insuff].map((k) => `claim:${k}`).join(','), xj.join(','))
// 封存后追加的旧判定(拆档之前的 unsupported):片段接口给 on_demand.reason = outdated、available 为真——照常给按钮,
// 旧判定旁边注明「按旧规则判定」;按当前规则重判之后注明和按钮都收起
const outdatedMsg = fxv.explore.segments[fxv.explore.doc.blocks.flatMap((b) => b.units).find((u2) => u2.id === x.contra)
.segments.find((sg) => sg.kind !== 'structural').id].unit.on_demand
// 面板可能还开着、正停在这一句上(上面 n 跟着走过来):点一下是收起,没开出来就再点一次
await badge(ex.page, VX, x.contra).click()
await ex.page.waitForSelector('[data-evidence-panel] [data-ev-judge="unsupported"]', { timeout: 1500 }).catch(() => {})
if (await xp.locator('[data-ev-judge="unsupported"]').count() === 0) await badge(ex.page, VX, x.contra).click()
await ex.page.waitForSelector('[data-evidence-panel] [data-ev-judge="unsupported"]', { timeout: 4000 }).catch(() => {})
await ex.page.waitForSelector('[data-evidence-panel] [data-ev-judge-outdated]').catch(() => {})
const chip = xp.locator('[data-ev-judge-outdated]')
check('旧判定(片段接口说 outdated):旁边注明「按旧规则判定」,悬停是接口的原话', outdatedMsg?.reason === 'outdated'
&& (await chip.innerText().catch(() => '')) === '按旧规则判定' && await chip.getAttribute('title').catch(() => '') === outdatedMsg.message,
JSON.stringify(outdatedMsg))
check('……照常给按钮(再次请模型判断),按钮旁写明为什么', (await xp.locator('[data-ev-judge-ask]').innerText().catch(() => '')).includes('再次请模型判断')
&& (await xp.locator('[data-ev-judge-outdated-why]').innerText().catch(() => '')) === outdatedMsg.message)
await xp.locator('[data-ev-judge-ask]').click().catch(() => {})
await ex.page.waitForSelector('[data-evidence-panel] [data-ev-judge="contradicted"]').catch(() => {})
check('……按当前规则重判:判成证据相矛盾,「按旧规则判定」和按钮都收起', await badge(ex.page, VX, x.contra).getAttribute('data-ev-verdict') === 'contradicted'
&& await xp.locator('[data-ev-judge-outdated]').count() === 0 && await xp.locator('[data-ev-judge-ask]').count() === 0
&& ex.hits.some((h) => h === `judge:${fxv.explore.run_id}:${x.contra}:write`), ex.hits.filter((h) => h.startsWith('judge:')).join(' '))
if (SHOTS) {
// 重新开一页截「按旧规则判定」那一刻(上面已经重判掉了)
const shot = await open('/ui-harness.html?evidence=1')
await shot.page.waitForSelector(`${VX} [data-ev-claim="${x.contra}"][data-ev-verdict="unsupported"]`).catch(() => {})
for (const theme of ['dark', 'light']) {
await shot.page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await badge(shot.page, VX, x.contra).click()
await shot.page.waitForTimeout(100)
if (await shot.page.locator('[data-evidence-panel]').count() === 0) await badge(shot.page, VX, x.contra).click()
await shot.page.waitForSelector('[data-evidence-panel] [data-ev-judge-outdated]').catch(() => {})
await shot.page.waitForTimeout(300)
await shot.page.screenshot({ path: `${SHOTS}/jr-panel-outdated-${theme}.png` })
}
await shot.ctx.close()
}
check('没有运行时报错(裁判拆档 · 探索运行)', ex.errors.length === 0, ex.errors.join(' | '))
await ex.ctx.close()
})
await section('jr-narrow', '裁判拆档在 360px:理由 120 字、字段清单、SQL 栏内展开不横向滚动;底部抽屉整页不横向滚动', async () => {
const overflow = () => page.evaluate((sel) => {
const el = document.querySelector(sel)
return { sw: el.scrollWidth, cw: el.clientWidth, w: el.getBoundingClientRect().width }
}, VN)
const before = await overflow()
await badge(page, VN, fxv.formal.units.contra).click()
await page.waitForSelector(`${VN} [data-evidence-panel="inline"] [data-ev-rationale]`)
const why = await page.locator(`${VN} [data-ev-rationale]`).evaluate((el) => ({ text: el.textContent, sw: el.scrollWidth, cw: el.clientWidth }))
const after = await overflow()
check('窄栏里理由 120 字整句显示、在栏内折行', why.text === fxv.long_rationale && why.sw <= why.cw, JSON.stringify({ sw: why.sw, cw: why.cw }))
check('……面板打开前后都没有横向滚动', Math.round(before.w) === 360 && before.sw <= before.cw && after.sw <= after.cw, JSON.stringify({ before, after }))
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(200)
await page.locator(VN).screenshot({ path: `${SHOTS}/jr-360-rationale-${theme}.png` })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
await badge(page, VN, fxv.formal.units.method).click()
await page.waitForSelector(`${VN} [data-evidence-panel="inline"] [data-ev-basis] [data-ev-sql]`).catch(() => {})
await page.waitForSelector(`${VN} [data-evidence-panel="inline"] [data-ev-basis-table] [data-ev-field]`).catch(() => {})
const basis = await overflow()
const pre = await page.locator(`${VN} [data-ev-basis] [data-ev-sql]`).first().evaluate((el) => ({ sw: el.scrollWidth, cw: el.clientWidth }))
.catch(() => null)
check('窄栏里讲方法的句子:SQL、字段清单都在栏内,不横向滚动', basis.sw <= basis.cw && !!pre && pre.sw <= pre.cw
&& await page.locator(`${VN} [data-ev-basis-table] [data-ev-field]`).count() > 0, JSON.stringify({ basis, pre }))
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(200)
await page.locator(VN).screenshot({ path: `${SHOTS}/jr-360-basis-${theme}.png` })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
await page.locator(`${VN} [data-evidence-panel] button`, { hasText: '回到正文' }).click()
await page.waitForTimeout(150)
const small = await open('/ui-harness.html?evidence=1', { w: 360, h: 780 })
const scroll = () => small.page.evaluate(() => ({ sw: document.documentElement.scrollWidth, vw: innerWidth }))
await badge(small.page, V, fxv.formal.units.contra).click()
await small.page.waitForSelector('[data-evidence-panel] [data-ev-rationale]')
const d = await scroll()
const dwhy = await small.page.locator('[data-evidence-panel] [data-ev-rationale]').evaluate((el) => ({ text: el.textContent, sw: el.scrollWidth, cw: el.clientWidth }))
check('360px 宽的屏幕:底部抽屉里理由整句显示、整页没有横向滚动',
await small.page.locator('[data-evidence-panel]').getAttribute('data-evidence-panel') === 'drawer' && d.sw <= d.vw
&& dwhy.text === fxv.long_rationale && dwhy.sw <= dwhy.cw, JSON.stringify({ d, sw: dwhy.sw, cw: dwhy.cw }))
await small.ctx.close()
})
await section('jr-banner', '裁判拆档:出具横幅的结论句计数带上证据相矛盾、证据不足和旧的证据不支持', async () => {
const at = (key) => page.locator(`#issuance-claims [data-issuance-case="${key}"]`)
const rework = await at('rework').locator('[data-evidence-line]').innerText().catch(() => '')
check('计数:证据相矛盾、旧的证据不支持、证据不足各写各的', rework.includes('结论 7 句(有依据 1 · 部分有依据 1 · 证据相矛盾 2 · 证据不支持 1 · 证据不足 1 · 未裁判 1)'),
rework)
const [color, waiting] = [await at('rework').locator('[data-evidence-claims]').evaluate((el) => getComputedStyle(el).color).catch(() => ''),
await colorOf(page, '--st-waiting')]
check('……有问题的计数用提醒色', color === waiting, `${color} / ${waiting}`)
check('只有证据不足一种问题:照样给「定位下一处」', await at('insufficient').locator('[data-evidence-next]').count() === 1)
const only = await at('insufficient').locator('[data-evidence-claims]').evaluate((el) => getComputedStyle(el).color).catch(() => '')
check('……计数也用提醒色(证据不足也算问题)', only === waiting, only)
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(200)
await page.locator('#issuance-claims').screenshot({ path: `${SHOTS}/jr-banner-${theme}.png` })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
})
// ---------------------------------------------------------------------------
// Excel 导入期 4:推断的来源(夹具 evidence-provenance.json,P4-SPEC 4.4)
// ---------------------------------------------------------------------------
const PV = '#evidence-provenance'
const PVN = '#evidence-provenance-narrow'
/** 场景的片段 id:按夹具 cases 里记的片段文字认,重新生成夹具后编号挪了也不用改 */
const pseg = (name) => segOf(fxp.doc, fxp.cases[name].text)
/** 这个场景的片段请求过几次推断来源 */
const provHits = (hits, name) => hits.filter((h) => h === `prov:${fxp.run_id}:${pseg(name)}`).length
/** 点开一个场景,等推断的来源那一节定下来(不再是「正在推断」);不该有这一节的场景改等查询步骤、指标步骤 */
async function openP(p, name, { box = PV, wait = '[data-ev-provenance]:not([data-ev-provenance="loading"])' } = {}) {
await p.locator(`${box} [data-seg="${pseg(name)}"]`).click()
await p.waitForSelector(`[data-evidence-panel] ${wait}`, { timeout: 4000 }).catch(() => {})
await p.waitForTimeout(120)
}
const pv = (p, sel = '') => p.locator(`[data-evidence-panel] ${sel}`)
/** 元素的文字(空白合成一个空格);没有这个元素时是空串 */
const textOf = async (loc) => ((await loc.count()) ? (await loc.first().innerText()).replace(/\s+/g, ' ').trim() : '')
const fromOf = (p) => p.locator('[data-evidence-panel] [data-ev-provenance] [data-ev-prov-from] li').allInnerTexts()
const partsOf = (p) => p.locator('[data-evidence-panel] [data-ev-prov-version] [data-ev-prov-part]')
.evaluateAll((els) => els.map((e) => `${e.getAttribute('data-ev-prov-part')}${e.hasAttribute('data-ev-prov-has-row') ? '*' : ''}`))
/** 只给表级来历的场景:table_only、一句原因、没有格子和相关核对、数据版本照样有 */
async function tableOnly(p, name, want, what) {
await openP(p, name)
const reason = await textOf(pv(p, '[data-ev-provenance="table_only"] [data-ev-prov-reason]'))
check(what, (typeof want === 'string' ? reason === want : want.test(reason))
&& await pv(p, '[data-ev-prov-cell], [data-ev-prov-checks], [data-ev-prov-alert]').count() === 0
&& await pv(p, '[data-ev-prov-version]').count() === 1, reason)
}
const noSections = async (p) => (await pv(p, '[data-ev-provenance], [data-ev-prov-version], [data-ev-prov-checks]').count()) === 0
const activeHas = (p, attr) => p.evaluate((a) => document.activeElement?.hasAttribute(a) ?? false, attr)
await section('provenance', '期 4:推断的来源——数据版本、格子与逐条、相关核对,只在上传表格的单元格上请求', async () => {
const ids = Object.fromEntries(Object.keys(fxp.cases).map((k) => [k, pseg(k)]))
check('夹具的场景都能按片段文字认出来(和 cases 记的编号一致)', Object.entries(fxp.cases).every(([k, c]) => ids[k] === c.segment),
JSON.stringify(ids))
// 前面各节点开的都是期 4 之前的报告、没有提示的查询步骤:一个推断来源请求都不该有(4.4 第 14 条的全局版)
const stray = harness.hits.filter((h) => h.startsWith('prov:'))
check('其他夹具(期 4 之前的报告)点开过的片段一个推断来源请求都没发', stray.length === 0, stray.join(','))
// ---- 1. 宽表的一格:格子、逐条、相关核对、位置、标题
await openP(page, 'wide')
const sec = pv(page, '[data-ev-provenance="inferred"]')
check('满足条件:有「推断的来源」一节(inferred)', await sec.count() === 1, String(await pv(page, '[data-ev-provenance]').getAttribute('data-ev-provenance').catch(() => null)))
check('……标题「推断的来源」,旁边标「推断,不属于已封存的证据」', await textOf(sec.locator('h4')) === '推断的来源'
&& await textOf(sec.locator('[data-ev-prov-badge]')) === '推断,不属于已封存的证据')
const cell = await textOf(sec.locator('[data-ev-prov-cell]'))
check('……第一行醒目地写格子:工作表「客流汇总」G5', cell === '工作表「客流汇总」G5', cell)
const items = await fromOf(page)
check('……逐条:日期取自表头格 G4 → 年份取自统计期 → 行标签 B5(指标名)', items.join('|')
=== '日期取自表头格 G4|年份取自统计期(客流汇总!B2)|行标签 B5「全日客流(人次)」', items.join('|'))
check('相关核对:R1「这一行成立」', (await textOf(pv(page, '[data-ev-prov-checks] [data-ev-prov-check="R1"]'))).includes('这一行成立'))
// 规格 4.1 的散文写「口径说明:…」;R2 的标题本身是「口径不同:…」,再套一层冒号读不顺,所以和别的核对同一种
// 写法「R2 标题 — 口径说明」(口径说明是 4.2 partStatus.info 的原文)。改写法要连同 CheckRow 一起改
check('……口径不同(R2)只写口径说明,不判行', (await textOf(pv(page, '[data-ev-prov-check="R2"]'))).endsWith('— 口径说明')
&& await pv(page, '[data-ev-prov-check="R2"][data-ev-prov-row]').count() === 0)
const order = await page.evaluate(() => {
const p = document.querySelector('[data-evidence-panel]')
const q = p?.querySelector('[data-ev-query]')
const seq = ['[data-ev-prov-version]', '[data-ev-provenance]', '[data-ev-prov-checks]', '[data-ev-judge]', '[data-ev-seal]']
.map((s) => p?.querySelector(s)).filter(Boolean)
if (!q || seq.length < 4) return `缺:${seq.length}`
let prev = q
for (const el of seq) {
if (!(prev.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)) return `顺序不对:${el.outerHTML.slice(0, 60)}`
prev = el
}
return 'ok'
})
check('三节在查询步骤之后、结论句裁判和封存之前:数据版本 → 推断的来源 → 相关核对', order === 'ok', order)
const heads = await Promise.all(['数据版本', '推断的来源', '相关核对']
.map((name) => panel(page).getByRole('heading', { name, exact: true }).evaluateAll((els) => els.map((e) => e.tagName).join(','))))
check('三节的标题都是真正的标题(getByRole heading,h4)', heads.every((t) => t === 'H4'), heads.join(' | '))
check('推断的来源用虚线左边框,和其余几节区分', await sec.evaluate((el) => getComputedStyle(el).borderLeftStyle) === 'dashed')
check('相关核对也挂「推断」标签;数据版本来自封存链上的清单,不挂', await pv(page, '[data-ev-prov-checks] [data-ev-prov-badge]').count() === 1
&& await pv(page, '[data-ev-prov-version] [data-ev-prov-badge]').count() === 0)
check('封存那一行照旧只按片段接口的链算:「已封存 · 核对一致」', (await textOf(pv(page, '[data-ev-seal]'))).includes('已封存 · 核对一致'))
const integrityBase = await pv(page, '[data-ev-integrity]').count()
const ver = pv(page, '[data-ev-prov-version]')
const vtext = await textOf(ver)
const p1 = fxp.provenance[ids.wide].version.parts[0]
check('数据版本:首行「数据源「flow_demo」· 每期替换」(每期替换不写期数)', await textOf(ver.locator('[data-ev-prov-head]')) === '数据源「flow_demo」· 每期替换',
vtext)
check('……每期一行:第几次导入 · 统计期;文件名、sha256 前 12 位、区域、原件状态、配方第几版',
vtext.includes('第 1 次导入 · 统计期 2026-08-01 至 2026-08-31') && vtext.includes(`文件「${p1.file_name}」· sha256 ${p1.raw_sha256.slice(0, 12)}`)
&& !vtext.includes(p1.raw_sha256.slice(0, 13)) && vtext.includes('区域 客流汇总!B4:AG30') && vtext.includes('原件已保存')
&& vtext.includes('配方第 1 版') && vtext.includes('涉及的表:日客流'), vtext)
check('……这一格所在的一期有标记;已封存的运行不写「尚未封存」', (await partsOf(page)).join(',') === '1*'
&& vtext.includes('这一格所在的一期') && await ver.locator('[data-ev-prov-unsealed]').count() === 0)
const det = sec.locator('[data-ev-prov-details]')
check('末尾一行小字「已按主键回查数据文件,值一致」,回查 SQL 收在「技术细节」里(默认收起)',
await textOf(sec.locator('[data-ev-prov-recheck]')) === '已按主键回查数据文件,值一致'
&& await det.getAttribute('aria-expanded') === 'false' && await sec.locator('[data-ev-prov-tech]').isHidden()
&& await det.getAttribute('aria-controls') === await sec.locator('[data-ev-prov-tech]').getAttribute('id'))
await det.focus()
await page.keyboard.press('Enter')
const tech = await textOf(sec.locator('[data-ev-prov-tech]'))
check('……「技术细节」是按钮,回车展开:回查 SQL 和参数,带复制', await det.getAttribute('aria-expanded') === 'true'
&& await sec.locator('[data-ev-prov-tech]').isVisible()
&& tech.includes('SELECT rowid, "全日客流" FROM "日客流" WHERE "日期" = ?') && tech.includes('2026-08-05')
&& await sec.locator('[data-ev-prov-tech] button', { hasText: '复制 SQL' }).count() === 1, tech)
// ---- 16、28:键盘打开导入清单,对话框是结构化的清单渲染,Esc 只关对话框
await pv(page, '[data-ev-query] [data-ev-query-head]').focus()
let reached = false
for (let i = 0; i < 30 && !reached; i++) {
await page.keyboard.press('Tab')
reached = await activeHas(page, 'data-ev-prov-manifest')
}
check('键盘:从查询步骤 Tab 走得到「查看导入清单」', reached)
const arts = harness.hits.filter((h) => h.startsWith('artifact:P:')).length
await page.keyboard.press('Enter')
await page.waitForSelector('[role="dialog"] [data-manifest-block]', { timeout: 4000 }).catch(() => {})
const dlg = page.locator('[role="dialog"]')
const dtext = await dlg.innerText().catch(() => '')
check('回车打开导入清单对话框,按清单的工件 id 经工件接口取', await dlg.count() === 1
&& harness.hits.filter((h) => h.startsWith('artifact:P:')).length === arts + 1 && harness.hits.includes(`artifact:P:${p1.manifest}`)
&& dtext.includes('第 1 次导入的导入清单'), dtext.slice(0, 80))
check('……正文是版本页的清单渲染(结构化区块),不是整份 JSON(展开的文字里没有键名 "acceptances")',
await dlg.locator('[data-manifest-block]').count() >= 5 && !dtext.includes('"acceptances"') && dtext.includes(p1.file_name)
&& await dlg.locator('[data-manifest-raw]').evaluate((el) => !el.open).catch(() => false), String(await dlg.locator('[data-manifest-block]').count()))
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(250)
await page.screenshot({ path: `${SHOTS}/evidence-p4-manifest-${theme}.png` })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
await page.keyboard.press('Escape')
await page.waitForTimeout(200)
check('Esc 只关清单对话框:证据面板还开着,焦点回到「查看导入清单」', await dlg.count() === 0 && await panel(page).count() === 1
&& await activeHas(page, 'data-ev-prov-manifest'))
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(250)
await panel(page).screenshot({ path: `${SHOTS}/evidence-p4-inferred-${theme}.png` })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
await page.keyboard.press('Escape')
await page.waitForTimeout(200)
// ---- 18:缓存只在面板这次打开期间有效
const c0 = provHits(harness.hits, 'wide')
await openP(page, 'wide')
await openP(page, 'long')
await openP(page, 'wide')
check('同一次打开里同一片段点两次,推断的来源只取一次', provHits(harness.hits, 'wide') === c0 + 1 && provHits(harness.hits, 'long') >= 1,
`${c0} → ${provHits(harness.hits, 'wide')}`)
await page.keyboard.press('Escape')
await page.waitForTimeout(200)
check('……面板关掉了', await panel(page).count() === 0)
await openP(page, 'wide')
check('关闭面板再打开、再点同一片段:重新取(回答里有当前状态)', provHits(harness.hits, 'wide') === c0 + 2, String(provHits(harness.hits, 'wide')))
// ---- 2、3:长表的分段标题、全角标签的规范写法、合计表
await openP(page, 'long')
const longItems = await fromOf(page)
check('长表一格:分段标题 B9(按配方中的标题「…」定位),不说它是格子原文;行标签 B16「13-14」',
longItems.includes('分段标题 B9(按配方中的标题「日间时段客流(人次)」定位)') && longItems.includes('行标签 B16「13-14」'), longItems.join('|'))
await openP(page, 'fullwidth')
const fw = await fromOf(page)
check('全角标签那一期的维度列:原文「8-9」,按规范写法存为「8-9」', fw.includes('原文「8-9」,按规范写法存为「8-9」'), fw.join('|'))
await openP(page, 'total')
const tItems = await fromOf(page)
check('合计表一格:合计标签 B28「18-22 时合计」(原文),口径「原表写明的合计:不要彼此相加…」', tItems.includes('合计标签 B28「18-22 时合计」')
&& tItems.includes('原表写明的合计:不要彼此相加,也不要与明细相加') && tItems.includes('原文「18-22 时合计」,按规范写法存为「18-22时合计」'),
tItems.join('|'))
check('……相关核对里 K1「这一格一致」', (await textOf(pv(page, '[data-ev-prov-check="K1"]'))).includes('这一格一致'))
// ---- 4:累积的两期
await openP(page, 'union')
const un = fxp.provenance[ids.union]
check('按期累积:首行写「按期累积 · 共 2 期」,两期都列出,第 2 期标「这一格所在的一期」',
await textOf(pv(page, '[data-ev-prov-head]')) === `数据源「${un.version.source}」· 按期累积 · 共 2 期`
&& (await partsOf(page)).join(',') === '1,2*', (await partsOf(page)).join(','))
const ufrom = await textOf(pv(page, '[data-ev-prov-from-part]'))
check('……格子是第 2 期(9 月)文件的坐标', await textOf(pv(page, '[data-ev-prov-cell]')) === `工作表「${un.cell_source.sheet}」${un.cell_source.cell}`
&& ufrom.includes('第 2 次导入') && ufrom.includes(un.version.parts[1].file_name), ufrom)
// ---- 5–8:只给表级来历
await tableOnly(page, 'alias', /改了名/, '改名:table_only,原因写「改了名」,没有格子,数据版本照样有')
await tableOnly(page, 'selfjoin', /同一张表多次/, '自连接:table_only,原因写「同一张表多次」')
await tableOnly(page, 'null', '这一格是空值,不推断来源', '空值:table_only,「这一格是空值,不推断来源」')
await tableOnly(page, 'aggregate', /计算、聚合/, '聚合:table_only,原因写「计算、聚合」')
check('……聚合的格子数据版本照样列出涉及的表', (await textOf(pv(page, '[data-ev-prov-tables]'))) === '涉及的表:日客流')
// ---- 9、10、21:标红
const failedColor = await tokenColor(page, 'color', 'var(--st-failed)')
const look = (code) => pv(page, `[data-ev-prov-alert="${code}"]`).evaluate((el) => {
const cs = getComputedStyle(el)
return { color: cs.color, border: cs.borderLeftColor, text: el.innerText }
}).catch(() => null)
await openP(page, 'tampered')
const tam = await look('db_tampered')
check('数据文件被改:标红(data-ev-prov-alert="db_tampered"),写「可能被修改过」,边框和字都是 --st-failed',
!!tam && tam.text.includes('可能被修改过') && tam.color === failedColor && tam.border === failedColor, JSON.stringify({ tam, failedColor }))
check('……不给格子、不写原因那一句(有 alert 时只画 alert),数据版本照样有', await pv(page, '[data-ev-prov-cell], [data-ev-prov-reason], [data-ev-prov-checks]').count() === 0
&& await pv(page, '[data-ev-prov-version]').count() === 1 && await pv(page, '[data-ev-provenance="table_only"]').count() === 1)
check('……标红用自己的属性:面板里 data-ev-integrity 的个数和没有 alert 时相同', await pv(page, '[data-ev-integrity]').count() === integrityBase)
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(250)
await panel(page).screenshot({ path: `${SHOTS}/evidence-p4-tampered-${theme}.png` })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
await openP(page, 'chain')
check('登记哈希与清单不符:table_only,标红 chain_mismatch,数据版本照样有,没有原因那一句',
await pv(page, '[data-ev-provenance="table_only"] [data-ev-prov-alert="chain_mismatch"]').count() === 1
&& await pv(page, '[data-ev-prov-version]').count() === 1 && await pv(page, '[data-ev-prov-reason]').count() === 0)
await openP(page, 'unreadable')
const unr = await look('manifest_unreadable')
check('清单无法读取:none 里只有红色的 manifest_unreadable,没有数据版本、没有原因那一句',
await pv(page, '[data-ev-provenance="none"] [data-ev-prov-alert="manifest_unreadable"]').count() === 1 && unr?.color === failedColor
&& await pv(page, '[data-ev-prov-version], [data-ev-prov-reason], [data-ev-prov-cell], [data-ev-prov-checks]').count() === 0, JSON.stringify(unr))
// ---- 11、12:当前状态
await openP(page, 'purged')
const pver = await textOf(pv(page, '[data-ev-prov-version]'))
const purgedLine = await textOf(pv(page, '[data-ev-prov-purged]'))
check('原件已清除:数据版本里写「原件已清除 · 清除时间 · 理由 · 署名(未认证)」,标「当前状态」',
/^原件已清除 · \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \(UTC[+-]\d{2}:\d{2}\) · 理由:合成理由:原件不再保留 · 署名(未认证):录入员甲/.test(purgedLine)
&& await pv(page, '[data-ev-prov-purged] [data-ev-prov-current]').count() === 1, purgedLine)
check('……格子照样有,旁边注明「原件已清除:坐标来自导入时的记录,无法再对照原件」', await pv(page, '[data-ev-prov-cell]').count() === 1
&& await textOf(pv(page, '[data-ev-prov-raw]')) === '原件已清除:坐标来自导入时的记录,无法再对照原件')
await openP(page, 'revoked')
const rver = await textOf(pv(page, '[data-ev-prov-version]'))
check('作废接受:数据版本里写「这一期的接受已作废 · 作废时间 · 理由」(当前状态),接受理由照旧列出',
/^这一期的接受已作废 · \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \(UTC[+-]\d{2}:\d{2}\) · 理由:合成理由:接受依据有误 · 署名(未认证):未填写/
.test(await textOf(pv(page, '[data-ev-prov-revoked]')))
&& await pv(page, '[data-ev-prov-revoked] [data-ev-prov-current]').count() === 1
&& rver.includes('已接受:「日客流」:全日客流 = 分区甲 + 分区乙 · 合成理由:分区合计口径调整 · 署名(未认证):未填写'), rver)
// ---- 13:简单导入
await openP(page, 'simple')
check('简单导入:none 里只有一句「这份表格按表头行直接导入…」,没有数据版本',
(await textOf(pv(page, '[data-ev-provenance="none"] [data-ev-prov-reason]'))).startsWith('这份表格按表头行直接导入')
&& await pv(page, '[data-ev-prov-version], [data-ev-prov-alert], [data-ev-prov-checks]').count() === 0)
// ---- 15、20:不该请求的两种
check('夹具自检:手工源那一格的查询步骤没有提示,指标片段链上的查询步骤误带了提示',
fxp.segments[ids.manual].chain.every((st) => st.provenance !== true)
&& fxp.segments[ids.metric].chain.some((st) => st.step === 'query' && st.provenance === true))
await openP(page, 'manual', { wait: '[data-ev-query]' })
await page.waitForTimeout(250)
check('手工源(查询步骤不带提示):不请求推断的来源,三节都不画', provHits(harness.hits, 'manual') === 0 && await noSections(page))
await openP(page, 'metric', { wait: '[data-ev-metric]' })
await page.waitForTimeout(250)
check('指标片段:链上的查询步骤带了提示也不请求(只认单元格引用)', provHits(harness.hits, 'metric') === 0 && await noSections(page)
&& await pv(page, '[data-ev-query]').count() >= 1)
// ---- 22、23、25、26:回收、遮罩、未封存、本期不成立并接受
await tableOnly(page, 'gone', /已回收或不存在/, '数据文件已回收:table_only,原因写「已回收或不存在」,不标红')
await tableOnly(page, 'masked', '这一格涉及设置了遮罩的列,不推断来源', '遮罩:table_only,「这一格涉及设置了遮罩的列,不推断来源」')
check('……数据源设有遮罩:数据版本里没有「查看导入清单」,改写一句去向', await pv(page, '[data-ev-prov-manifest]').count() === 0
&& await textOf(pv(page, '[data-ev-prov-manifest-masked]')) === '数据源设置了遮罩,导入清单请到数据源卡片上的「版本」查看')
await openP(page, 'unsealed')
check('运行未封存:照常推断,数据版本一节写「本次运行尚未封存…」,不标红', await pv(page, '[data-ev-provenance="inferred"]').count() === 1
&& (await textOf(pv(page, '[data-ev-prov-version] [data-ev-prov-unsealed]'))).startsWith('本次运行尚未封存或封存核对未通过')
&& await pv(page, '[data-ev-prov-alert]').count() === 0)
// 接受理由挂在本期结论上(接受针对的是这一期的核对结果,不是这一行),写法取 4.2 的 accepted 原文「已接受,理由:…」,
// 不是 4.1 散文里的「已接受:理由」;行级结果单独一行,本期结论另起一行——d26_other 要同时看得到「这一行成立」和
// 「本期不成立 · 已接受」(4.4 第 26 条)
await openP(page, 'd26_bad')
const bad = await textOf(pv(page, '[data-ev-prov-check="R1"]'))
check('不成立的那一天:R1「这一行不成立」,接着写「本期不成立 · 已接受,理由:…」', bad.includes('这一行不成立')
&& bad.includes('本期不成立 · 已接受,理由:合成理由:分区合计口径调整(署名(未认证):未填写)'), bad)
await openP(page, 'd26_other')
const other = await textOf(pv(page, '[data-ev-prov-check="R1"]'))
check('同一期的另一天:「这一行成立」,本期结论照样写「本期不成立 · 已接受」', other.includes('这一行成立') && other.includes('本期不成立 · 已接受'), other)
// ---- 人工录入的统计期、列表
await openP(page, 'human')
const hItems = await fromOf(page)
check('统计期人工录入:年份取自人工录入的统计期(署名(未认证):录入员甲)', hItems.includes('年份取自人工录入的统计期(署名(未认证):录入员甲)')
&& (await textOf(pv(page, '[data-ev-prov-version]'))).includes('人工录入(署名(未认证):录入员甲)'), hItems.join('|'))
await openP(page, 'list')
check('列表:格子在列表头下面(列表头 F5),统计期未记录', await textOf(pv(page, '[data-ev-prov-cell]')) === '工作表「月报」F8'
&& (await fromOf(page)).includes('列表头 F5') && (await textOf(pv(page, '[data-ev-prov-version]'))).includes('第 1 次导入 · 统计期未记录'))
// ---- 27:多于 3 期的折叠
await openP(page, 'many')
const more = pv(page, '[data-ev-prov-more]')
check('四期:首行「按期累积 · 共 4 期」,只展开这一格所在的一期和最近一期,其余收进「另有 2 期」',
(await textOf(pv(page, '[data-ev-prov-head]'))).endsWith('按期累积 · 共 4 期') && (await partsOf(page)).join(',') === '2*,4'
&& await textOf(more) === '另有 2 期' && await more.getAttribute('aria-expanded') === 'false', (await partsOf(page)).join(','))
await more.focus()
await page.keyboard.press('Enter')
check('……「另有 2 期」是带 aria-expanded 的按钮:回车展开(true),四期按统计期列全,焦点留在按钮上',
await more.getAttribute('aria-expanded') === 'true' && (await partsOf(page)).join(',') === '1,2*,3,4' && await activeHas(page, 'data-ev-prov-more'),
(await partsOf(page)).join(','))
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(250)
await panel(page).screenshot({ path: `${SHOTS}/evidence-p4-many-${theme}.png` })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
await page.keyboard.press('Escape')
await page.waitForTimeout(200)
// ---- 17:360 宽的画布右栏
const wrap = async () => page.evaluate((sel) => {
const box = document.querySelector(sel)
const p = box?.querySelector('[data-evidence-panel]')
return { mode: p?.getAttribute('data-evidence-panel') ?? null, box: [box?.scrollWidth, box?.clientWidth], panel: p ? [p.scrollWidth, p.clientWidth] : null }
}, PVN)
const fits = (w) => w.mode === 'inline' && w.box[0] <= w.box[1] && !!w.panel && w.panel[0] <= w.panel[1]
await openP(page, 'wide', { box: PVN })
await page.locator(`${PVN} [data-ev-prov-details]`).click().catch(() => {})
const w1 = await wrap()
await openP(page, 'many', { box: PVN })
await page.locator(`${PVN} [data-ev-prov-more]`).click().catch(() => {})
const w2 = await wrap()
await openP(page, 'tampered', { box: PVN })
const w3 = await wrap()
check('360 宽的画布右栏:栏内展开;宽表一格(技术细节展开)、四期(全部展开)、标红三种都不出横向滚动',
fits(w1) && fits(w2) && fits(w3), JSON.stringify({ w1, w2, w3 }))
if (SHOTS) {
await openP(page, 'wide', { box: PVN })
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(250)
await page.locator(PVN).screenshot({ path: `${SHOTS}/evidence-p4-360-${theme}.png` })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
await page.locator(`${PVN} [data-evidence-panel] button`, { hasText: '回到正文' }).click().catch(() => {})
await page.waitForTimeout(150)
// ---- 24:加载态
const wideId = ids.wide
const slow = await open('/ui-harness.html?evidence=1', {
prov: async (run, sid, reply) => {
if (sid === wideId) await new Promise((res) => setTimeout(res, 300))
return reply
},
})
await slow.page.locator(`${PV} [data-seg="${wideId}"]`).click()
await slow.page.waitForSelector('[data-evidence-panel] [data-ev-prov-loading]', { timeout: 4000 }).catch(() => {})
const loading = await textOf(pv(slow.page, '[data-ev-provenance="loading"] [data-ev-prov-loading]'))
check('取的过程中:「推断的来源」一节写「正在推断来源…」,三节不先画空壳', loading === '正在推断来源…'
&& await pv(slow.page, '[data-ev-prov-version], [data-ev-prov-checks], [data-ev-prov-cell]').count() === 0, loading)
await slow.page.waitForSelector('[data-evidence-panel] [data-ev-provenance="inferred"]', { timeout: 4000 }).catch(() => {})
check('……答复到了以后加载态消失,三节画出来', await pv(slow.page, '[data-ev-prov-loading]').count() === 0
&& await pv(slow.page, '[data-ev-prov-version]').count() === 1 && await pv(slow.page, '[data-ev-prov-checks]').count() === 1)
check('没有运行时报错(加载态)', slow.errors.length === 0, slow.errors.join(' | '))
await slow.ctx.close()
// ---- 19:出错、老服务端。另守渲染矩阵本身:服务端误把数据版本带在 none · manifest_unreadable、table_only 的答复里
// 时照样按矩阵画(前者不画数据版本,后者不画格子)——不能只靠夹具里那些字段恰好为空。
// 清除、作废、导入的时间没记(契约里 at 可以是 null,夹具里恰好都有):略去这一项,不留孤立的「—」
const longId = ids.long
const untimed = (ans) => ({
...ans,
version: {
...ans.version,
parts: ans.version.parts.map((pt) => ({
...pt,
committed_at: null,
purged: pt.purged && { ...pt.purged, at: null },
revoked: pt.revoked && { ...pt.revoked, at: null },
})),
},
})
const sloppy = {
[ids.unreadable]: { ...fxp.provenance[ids.unreadable], version: fxp.provenance[wideId].version },
[ids.alias]: { ...fxp.provenance[ids.alias], cell_source: fxp.provenance[wideId].cell_source, checks: fxp.provenance[wideId].checks },
[ids.purged]: untimed(fxp.provenance[ids.purged]),
[ids.revoked]: untimed(fxp.provenance[ids.revoked]),
}
const broken = await open('/ui-harness.html?evidence=1', {
prov: (run, sid, reply) => (sid === wideId ? fxp.errors.server : sid === longId ? fxp.errors.old_server
: sloppy[sid] ? { json: sloppy[sid] } : reply),
})
await openP(broken.page, 'wide')
check('推断来源接口回 500:「推断的来源」一节只写「未能获取推断的来源」', await textOf(pv(broken.page, '[data-ev-provenance="error"] [data-ev-prov-failed]'))
=== '未能获取推断的来源' && await pv(broken.page, '[data-ev-prov-version], [data-ev-prov-checks], [data-ev-prov-cell]').count() === 0)
check('……其余几节照常:查询步骤、封存都在', await pv(broken.page, '[data-ev-query]').count() === 1
&& (await textOf(pv(broken.page, '[data-ev-seal]'))).includes('已封存 · 核对一致'))
await openP(broken.page, 'long', { wait: '[data-ev-query]' })
await broken.page.waitForTimeout(300)
check('回 404 且没有机读码(老服务端):请求发了,三节都不画', provHits(broken.hits, 'long') === 1 && await noSections(broken.page),
String(provHits(broken.hits, 'long')))
await openP(broken.page, 'unreadable')
check('渲染矩阵:清单无法读取时,答复里哪怕带着数据版本也不画,只画红色提示',
await pv(broken.page, '[data-ev-prov-alert="manifest_unreadable"]').count() === 1 && await pv(broken.page, '[data-ev-prov-version]').count() === 0)
await openP(broken.page, 'alias')
check('渲染矩阵:只给表级来历时,答复里哪怕带着格子和核对也不画', await pv(broken.page, '[data-ev-prov-reason]').count() === 1
&& await pv(broken.page, '[data-ev-prov-cell], [data-ev-prov-checks]').count() === 0)
await openP(broken.page, 'purged')
const purgedUntimed = await textOf(pv(broken.page, '[data-ev-prov-purged]'))
const verUntimed = await textOf(pv(broken.page, '[data-ev-prov-version]'))
check('清除时间、导入时间没记:写「原件已清除 · 理由:… · 署名(未认证):…」,不留孤立的「—」,也不写「导入于」',
/^原件已清除 · 理由:合成理由:原件不再保留 · 署名(未认证):录入员甲/.test(purgedUntimed)
&& !verUntimed.includes('—') && !verUntimed.includes('导入于')
&& await pv(broken.page, '[data-ev-prov-purged] [data-ev-prov-current]').count() === 1, verUntimed)
await openP(broken.page, 'revoked')
const revokedUntimed = await textOf(pv(broken.page, '[data-ev-prov-revoked]'))
check('作废时间没记:写「这一期的接受已作废 · 理由:… · 署名(未认证):未填写」,不留孤立的「—」',
/^这一期的接受已作废 · 理由:合成理由:接受依据有误 · 署名(未认证):未填写/.test(revokedUntimed)
&& !(await textOf(pv(broken.page, '[data-ev-prov-version]'))).includes('—'), revokedUntimed)
check('没有运行时报错(出错、老服务端)', broken.errors.length === 0, broken.errors.join(' | '))
await broken.ctx.close()
})
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.locator(`${wide} [data-seg="s8"]`).click()
await page.waitForTimeout(350)
await page.screenshot({ path: `${SHOTS}/evidence-harness-${theme}.png`, fullPage: true })
await page.locator('#evidence-narrow [data-seg="s26"]').click()
await page.waitForTimeout(350)
await page.locator('#evidence-narrow').screenshot({ path: `${SHOTS}/evidence-360-inline-${theme}.png` })
await page.keyboard.press('Escape')
}
}
// ---------------------------------------------------------------------------
// 合并查询:片段接口的查询步骤带 merge(表级来历、逐格来历),后面接着追到的输入的查询步骤(merged_into);
// 推断来源的答复带 merge(经过的每一次合并)。答复按 backend/app/api/evidence.py 的 _merge_view、_query_steps 的形状改
// ---------------------------------------------------------------------------
const MERGE_SQL = 'SELECT o.order_id, o.region, o.amount, t.target FROM o JOIN t ON o.region = t.region ORDER BY o.order_id'
// 合并输入自己的 SQL 检查(_merge_view 的 inputs[].checks,形状同查询步骤的 checks)
const INPUT_CHECKS = [{ code: 'fanout_sum', level: 'error', table: 'orders', column: 'amount',
message: '「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算。请先按订单汇总明细再关联。' }]
function asMerge(body, traced, { withInput = true, inputChecks = false } = {}) {
const q = body.chain.find((st) => st.step === 'query')
const merged = {
...q, tool: undefined, source: '合并查询', sql: MERGE_SQL,
merge: {
sql: MERGE_SQL,
inputs: [
{ alias: 'o', node_id: 'fetch', label: '订单明细', query: 'Q1', rows: 12, source: 'shop', artifact: 'a'.repeat(64), sealed: true,
...(inputChecks ? { checks: INPUT_CHECKS } : {}) },
{ alias: 't', node_id: 'targets', label: '区域目标', query: null, rows: 4, source: 'plan', artifact: 'b'.repeat(64), sealed: true },
],
warnings: [{ code: 'rows_grew', message: '合并结果有 14 行,多于行数最多的输入「o」(12 行):合并键可能不唯一,同一行被重复匹配' }],
traced,
},
}
const input = { ...q, alias: 'Q1', merged_into: q.alias }
return { ...body, chain: body.chain.flatMap((st) => (st.step === 'query' ? (withInput ? [merged, input] : [merged]) : [st])) }
}
await section('merge', '合并查询:表级来历(输入、合并 SQL、警告)、逐格来历、输入步骤标明是哪次合并的输入', async () => {
const traced = await probeQ((id, body) => (id === qseg('1,288')
? asMerge(body, [{ cell: [5, 'amount'], input: 'o', query: 'Q1', row: 5, column: 'amount' }]) : body))
await openQ(traced.page, '1,288')
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await traced.page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await traced.page.waitForTimeout(250)
await traced.page.screenshot({ path: `${SHOTS}/evidence-merge-${theme}.png`, fullPage: true })
}
await traced.page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
const p = traced.page.locator('[data-evidence-panel]')
const parts = p.locator('[data-ev-query]')
check('合并步骤和它追到的输入步骤各一块', await parts.count() === 2, String(await parts.count()))
const head = await parts.first().locator('[data-ev-query-head]').innerText().catch(() => '')
check('合并步骤的标题写「合并查询」,不写工具名', head.includes('Q3') && head.includes('合并查询') && !head.includes('db_query__'), head)
const inputs = p.locator('[data-ev-merge]')
check('表级来历:合并自 2 个输入', (await inputs.innerText().catch(() => '')).includes('合并自 2 个输入'))
const o = await p.locator('[data-ev-merge-input="o"]').innerText().catch(() => '')
check('……每个输入写别名、目录编号、节点名、行数、数据源', ['o', '查询 Q1', '订单明细', '12 行', '数据源 shop'].every((t) => o.includes(t)), o)
const t = await p.locator('[data-ev-merge-input="t"]').innerText().catch(() => '')
check('……不在本报告目录里的输入照实说', t.includes('不在本报告的证据目录中'), t)
check('合并 SQL 有标签、带复制', (await parts.first().innerText()).includes('合并 SQL')
&& await parts.first().locator('button', { hasText: '复制合并 SQL' }).count() === 1)
const warn = await p.locator('[data-ev-merge-warnings]').innerText().catch(() => '')
check('执行时的警告写出来', warn.includes('执行时的警告') && warn.includes('不唯一'), warn)
const line = await p.locator('[data-ev-merge-trace="cell"]').innerText().catch(() => '')
check('逐格来历:被引用的格追到输入的哪一格(行号从 1 数)', line.includes('Q3 第 6 行「amount」') && line.includes('Q1 第 6 行「amount」'), line)
const into = await parts.nth(1).locator('[data-ev-merged-into]').innerText().catch(() => '')
check('输入步骤标明是哪次合并的输入,高亮追到的那一格', into.includes('合并查询 Q3 的输入')
&& await parts.nth(1).locator('td[data-highlight="cell"]').count() === 1, into)
check('合并查询不是数据源:不说它「已不存在」', await p.locator('[data-ev-query-mask-note]').count() === 0)
check('输入没查出问题:合并步骤里不出输入的 SQL 检查', await p.locator('[data-ev-merge-input-checks]').count() === 0)
await traced.ctx.close()
const flat = await probeQ((id, body) => (id === qseg('1,288') ? asMerge(body, [{
cell: [5, 'amount'], input: null, query: null, row: null, column: null, note: '没有逐格来历',
}], { withInput: false, inputChecks: true }) : body))
await openQ(flat.page, '1,288')
const none = await flat.page.locator('[data-evidence-panel] [data-ev-merge-trace="none"]').innerText().catch(() => '')
check('追不到的格:写明没有逐格来历、只有表级来历,不接输入步骤', none.includes('没有逐格来历')
&& none.includes('表级来历') && await flat.page.locator('[data-evidence-panel] [data-ev-query]').count() === 1, none)
// 没有输入步骤时,输入查询的 SQL 检查结果要在合并步骤里看得到(指标正是因为它标了「存疑」)
const inChecks = flat.page.locator('[data-evidence-panel] [data-ev-merge-input-checks="o"]')
const inText = await inChecks.innerText().catch(() => '')
check('追不到逐格来历时,合并步骤的输入下面列出它自己的 SQL 检查(级别、规则名、说明)', inText.includes('输入 o 的 SQL 检查')
&& inText.includes('错误') && inText.includes('一对多关联后重复计算') && inText.includes('会重复计算')
&& await inChecks.locator('[data-sql-check="fanout_sum"][data-level="error"]').count() === 1, inText.replace(/\s+/g, ' '))
check('……没查出问题的输入不出', await flat.page.locator('[data-evidence-panel] [data-ev-merge-input-checks="t"]').count() === 0)
check('……不露规则编号', !/fanout_sum/.test(await flat.page.locator('[data-evidence-panel] [data-ev-merge]').innerText()))
await flat.ctx.close()
const wideId = pseg('wide')
const hop = { alias: 'Q3', node_id: 'merge', input: 'f', query: 'Q1', row: 5, column: '全日客流' }
const lost = { alias: 'Q3', node_id: 'merge', input: null, query: null, row: null, column: null }
const noLineage = {
...fxp.provenance[wideId], status: 'none', version: null, cell_source: null, checks: [], merge: [lost],
reason: { code: 'merge_no_lineage', detail: '', text: '这一格来自合并查询,无法确定它对应哪个输入的哪一格' },
}
const hops = await open('/ui-harness.html?evidence=1', {
prov: (run, sid, reply) => (sid === wideId ? { json: { ...reply.json, merge: [hop] } }
: sid === pseg('long') ? { json: noLineage } : reply),
})
await openP(hops.page, 'wide')
const said = await textOf(pv(hops.page, '[data-ev-prov-merge]'))
check('推断的来源:先说经过的合并、追到的那一格,下面的原表格子照常', said === '经合并查询 Q3 追到 Q1 第 6 行「全日客流」'
&& await pv(hops.page, '[data-ev-prov-cell]').count() === 1, said)
await openP(hops.page, 'long')
check('追不到逐格来历:写原因和没追到的那一跳,不画数据版本和格子', (await textOf(pv(hops.page, '[data-ev-prov-reason="merge_no_lineage"]')))
.includes('合并查询') && (await textOf(pv(hops.page, '[data-ev-prov-merge-hop="none"]'))).includes('没有逐格来历')
&& await pv(hops.page, '[data-ev-prov-version], [data-ev-prov-cell]').count() === 0)
await hops.ctx.close()
})
await section('copy-r2','按后端原文匹配的地方:后端现在的原文、整改前的原文都认得(文案整改第二轮)', async () => {
// 画布问题落到哪一栏(canvas/issues.ts)、检查器的分支标识过滤(Inspector 用 issues.ts 的 isCaseKeyIssue)、
// 证据面板的裁判触顶(lib/evidence.ts)都按后端原文判断。开发库里历史运行、旧版本存下的文字是整改前的,
// 新的是整改后的:两种各喂一遍。「新」样本和 backend/app 现在的写法一致(先核对源码片段),
// 「旧」样本是整改前(提交 68fb353)的原话,只证明旧文字仍认得,不要跟着后端改
const src = (f) => readFileSync(new URL(`../backend/app/${f}`, import.meta.url), 'utf8')
const SOURCE = [
['engine/schema.py', '条件循环没有设置「继续条件」'],
['engine/schema.py', '「数据整形」节点还没有填写表达式'],
['engine/schema.py', '「调用工具」节点还没有选择工具'],
['engine/schema.py', '「子工作流」节点还没有选择要嵌套的工作流'],
['engine/schema.py', '有两个分支的标识都是「{key}」'],
['engine/schema.py', '提示词要求用「{tool}」,但节点没有绑定它'],
['engine/schema.py', '团队目标要求用「{tool}」,但'],
['engine/judge.py', '已达上限({_limit_label(reason, budget, click=click)}),这句未裁判'],
['api/evidence.py', 'f"已达上限({labels}):'],
['engine/evidence.py', 'UNKNOWN_ENTITY_REASON = "疑似不存在的名称:'],
['engine/upgrade.py', 'HINT = "可以升级为可追溯结构"'],
]
const gone = SOURCE.filter(([f, frag]) => !src(f).includes(frag))
check('「新」样本的片段在后端源码里都还在(后端改了说法就同步这里的样本和前端的正则)', gone.length === 0,
gone.map(([f, frag]) => `${f}:${frag}`).join(' | '))
const got = await page.evaluate(async () => {
const at = (path) => performance.getEntriesByType('resource').map((e) => e.name)
.find((n) => { try { return new URL(n).pathname === path } catch { return false } }) ?? path
const issues = await import(at('/src/canvas/issues.ts'))
const ev = await import(at('/src/lib/evidence.ts'))
const terms = await import(at('/src/lib/terms.ts'))
const node = (nodeType, config = {}) => ({ id: 'n', data: { nodeType, config } })
const field = (nodeType, message, config) => JSON.stringify(issues.fieldOfIssue({ level: 'error', message, node_id: 'n' }, node(nodeType, config)))
const cases = { cases: [{ key: 'a', condition: 'x > 1' }, { key: 'a', condition: 'x > 2' }] }
const PAIRS = [
// [说明, 节点类型, 后端现在的原文, 整改前的原文, 配置]
['条件循环没写继续条件 → 循环条件', 'loop', '条件循环没有设置「继续条件」,循环体不会执行', 'while 循环没有写条件,循环体一次都不会跑'],
['数据整形没写表达式 → 表达式', 'transform', '「数据整形」节点还没有填写表达式', '整形节点没有填表达式'],
['调用工具没选工具 → 工具', 'tool', '「调用工具」节点还没有选择工具', '「调用工具」节点还没选工具'],
['子工作流没选工作流 → 工作流', 'subgraph', '「子工作流」节点还没有选择要嵌套的工作流', '「子工作流」节点还没选要嵌套的工作流'],
['两个分支标识重复 → 后一个分支的标识', 'branch',
'「条件分支」中有两个分支的标识都是「a」:路由只按标识区分,后一个分支不会被选中。请换一个不重复的标识',
"「条件分支」里有两个分支的标识都是 'a':路由只认标识,后一个永远轮不到。给它换一个不重复的标识", cases],
['分支没有连线 → 那个分支', 'branch', '分支「a」还没有连线', "分支 'a' 没有连出去的边", cases],
['分支条件有误 → 那个分支的条件', 'branch', '分支「a」的条件有误:语法错误(原文:x >)', '分支「a」的条件写错了:语法错误(原文:x >)', cases],
]
return {
fields: PAIRS.map(([what, type, now, before, config]) => [what, field(type, now, config), field(type, before, config)]),
unbound: [
issues.unboundToolOf('提示词要求用「db_query__shop」,但节点没有绑定它'),
issues.unboundToolOf('团队目标要求用「crm_lookup」,但没有任何成员绑定该工具:调度者分派的成员无法调用它'),
issues.unboundToolOf('协作目标要求用「crm_lookup」,但没有哪个成员绑定了它:调度者派出去的人用不了'),
],
bareAdvice: [
issues.isBareUpgradeAdvice('可以升级为可追溯结构'),
issues.isBareUpgradeAdvice('可升级为可追溯结构'),
issues.isBareUpgradeAdvice('可以升级为可追溯结构:「写周报」的数字来自代码节点,先预览改动'),
],
keyIssue: [
issues.isCaseKeyIssue('分支「default」的标识用了「default」:这是「其他」出口的保留名,两者会合并成一个出口'),
issues.isCaseKeyIssue("「条件分支」里有两个分支的标识都是 'a':路由只认标识,后一个永远轮不到。给它换一个不重复的标识"),
issues.isCaseKeyIssue('分支「a」还没有连线'),
],
limit: [
ev.isJudgeLimit('已达上限(每次点击的金额上限 $0.01):1 句未裁判,已裁判的结果保留'),
ev.isJudgeLimit('已到上限(每次点击的金额上限 $0.01):1 句没判,记为未裁判;已判的保留'),
ev.isJudgeLimit('裁判失败:超时'),
ev.judgeLimitWords('已达上限(这份报告的裁判金额上限 $0.05),这句未裁判'),
ev.judgeLimitWords('已到上限(这份报告的裁判金额上限 $0.05),这句没判'),
],
settings: [
Object.values(terms.JUDGE_TEXT.limitHow.click).every((h) => ev.pointsToSettings(h)),
ev.pointsToSettings(terms.JUDGE_TEXT.limitHow.report.max_cost_usd),
ev.pointsToSettings('到「设置 → 偏好设置 → 证据裁判」调高每日上限'),
],
suspect: [
ev.segmentLabel({ kind: 'entity', text: '`orders.coupon_code`', code: true, name: 'orders.coupon_code', state: 'none', issue: 'unknown_entity',
cite: { reason: '疑似不存在的名称:本次运行的表结构快照、查询用到的表和查询结果列中都没有这个名称' } }),
ev.segmentLabel({ kind: 'entity', text: '`orders.coupon_code`', code: true, name: 'orders.coupon_code', state: 'none', issue: 'unknown_entity',
cite: { reason: '本次运行的表结构快照、查询用到的表、查询结果列里都没有这个名字,可能是编造的名字' } }),
],
}
}).catch((e) => ({ error: String(e) }))
if (got.error) { check('按原文匹配的函数都拿得到', false, got.error); return }
const want = {
'条件循环没写继续条件 → 循环条件': '{"key":"condition"}',
'数据整形没写表达式 → 表达式': '{"key":"expression"}',
'调用工具没选工具 → 工具': '{"key":"tool"}',
'子工作流没选工作流 → 工作流': '{"key":"workflow_id"}',
'两个分支标识重复 → 后一个分支的标识': '{"key":"cases","index":1,"sub":"key"}',
'分支没有连线 → 那个分支': '{"key":"cases","index":0}',
'分支条件有误 → 那个分支的条件': '{"key":"cases","index":0,"sub":"condition"}',
}
for (const [what, now, before] of got.fields) {
check(`画布问题落到字段:${what}(后端现在的原文)`, now === want[what], now)
check(`画布问题落到字段:${what}(整改前的原文)`, before === want[what], before)
}
check('提示词点名的工具:节点、团队目标(新旧两种说法)都取得出工具名',
got.unbound.join(',') === 'db_query__shop,crm_lookup,crm_lookup', got.unbound.join(','))
check('问题面板的升级建议只有一句标题时才补说明:后端原话「可以升级…」和界面的「可升级…」都认,带了具体说明的不补',
got.bareAdvice.join(',') === 'true,true,false', got.bareAdvice.join(','))
check('检查器不重复列的「标识」问题:保留名、重复标识(新旧)都认得,别的分支问题不误认',
got.keyIssue.join(',') === 'true,true,false', got.keyIssue.join(','))
check('证据面板的裁判触顶:「已达上限」「已到上限」都认得,别的失败不误认', got.limit.slice(0, 3).join(',') === 'true,true,false',
got.limit.slice(0, 3).join(','))
check('……触顶理由去掉前缀(新旧两种)', got.limit[3] === '这份报告的裁判金额上限 $0.05,这句未裁判'
&& got.limit[4] === '这份报告的裁判金额上限 $0.05,这句没判', `${got.limit[3]} | ${got.limit[4]}`)
check('……调法指向设置页的才给直达入口(现在写「前往「设置」,以前写「到「设置」)',
got.settings.join(',') === 'true,false,true', got.settings.join(','))
check('可疑名称的读屏名:原因里已带状态名的(现在「疑似不存在的名称:」、以前「可能是编造的名字」)不再重复',
got.suspect[0] === 'orders.coupon_code,疑似不存在的名称:本次运行的表结构快照、查询用到的表和查询结果列中都没有这个名称'
&& got.suspect[1] === 'orders.coupon_code,本次运行的表结构快照、查询用到的表、查询结果列里都没有这个名字,可能是编造的名字',
got.suspect.join(' | '))
})
// ---------------------------------------------------------------- 期 4:核查状态
/**
* 双盲复算的几种状态,按片段挂到一期夹具的指标步骤上。形状照服务端(api/evidence.py 的 _dual_check_of):一致、不一致
* (审核人采用原流程 / 采用盲算 / 没选)、未能完成(带原因)、已跳过(带原因代码)。DUAL_MORE 是第二轮:跳过的另几种原因
* (关闭了、没有来自查询的输入、认不出的代码)和「两边都不对」
*/
const DUAL = {
s6: { status: 'agreed', node_id: 'dual', blind_rendered: '45,678.5元' },
s8: { status: 'disagreed', node_id: 'dual', choice: 'a', blind_rendered: '9.1%' },
s10: { status: 'disagreed', node_id: 'dual', choice: 'b', blind_rendered: '1,301单' },
s45: { status: 'disagreed', node_id: 'dual', blind_rendered: '45,900.0元' },
s12: { status: 'unchecked', node_id: 'dual', reason: '盲算没有交出可用的数:查询全部失败' },
s23: { status: 'skipped', node_id: 'dual', reason: 'not_targeted' },
}
const DUAL_MORE = {
s23: { status: 'skipped', node_id: 'dual', reason: 'disabled' },
s49: { status: 'skipped', node_id: 'dual', reason: 'no_inputs' },
s45: { status: 'skipped', node_id: 'dual', reason: 'no_such_reason' },
s10: { status: 'disagreed', node_id: 'dual', choice: 'neither', blind_rendered: '1,301单' },
}
/** 双盲复算那一行标签上的说明(DUAL_CHECK_TEXT.description):悬停、读屏都说得出是什么 */
const DUAL_ABOUT = '另起一路独立计算,与原结果逐项比对'
/** 虚构的反方审稿记录(challenge_artifact):整句面板里点开那一句,按它取理由 */
const CH_ART = 'c4'.repeat(32)
const CH_RATIONALE = '扣除退款后新客首单只占增长的一小部分,老客复购反而下降。'
/** 整份报告的审稿计数(服务端从节点产出里取):审了 6 句,经受住 3、被削弱 1、被推翻 2 */
const CH_COUNTS = { stands: 3, weakened: 1, refuted: 2, inconclusive: 0 }
/** 出具预告的几行(审批卡上说「出具会降档」那一套):证据面板看的是已经结束的运行,一行都不该有 */
const ISSUANCE_LINES = '[data-challenge-degrade], [data-challenge-degrade-note], [data-challenge-degrade-off], '
+ '[data-challenge-flag], [data-challenge-runclass]'
/** WCAG 对比度:rgb()/rgba()/color(srgb …) 都认 */
function contrastOf(fg, bg) {
const parse = (c) => {
const m = c.match(/color\(srgb ([\d.]+) ([\d.]+) ([\d.]+)/)
if (m) return [m[1], m[2], m[3]].map((x) => Number(x) * 255)
return (c.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number)
}
const lum = (c) => {
const [r, g, b] = parse(c).map((v) => {
const x = v / 255
return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4
})
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
const [a, b] = [lum(fg), lum(bg)].sort((x, y) => y - x)
return (a + 0.05) / (b + 0.05)
}
await section('verify', '期 4:指标步骤的双盲复算、取自遮罩列的输入、反方审稿的判断', async () => {
// ---- 指标步骤多一行双盲复算的结果;没复核过的不画
const r = await probe((id, body) => {
const metric = body.chain?.find((st) => st.step === 'metric')
if (metric && DUAL[id]) metric.dual_check = DUAL[id]
if (id === 's12' && metric) {
// 服务端遮罩之后的样子:订单数取自遮罩列,标签和代入式里都写「已遮罩」
metric.substituted = metric.substituted.replace('1234', '已遮罩')
for (const st of body.chain) {
if (st.step === 'input' && st.path === 'vars.kpi.order_cnt') Object.assign(st, { value: '已遮罩', masked: ['order_cnt'] })
}
body.redacted = { columns: ['order_cnt'], note: '这些列在数据源中设置了遮罩' }
}
if (id === 's23' && metric) {
// 遮没遮核对不了(快照读不出来):同样不显示值,值那里写「无法核对」,原因是服务端写好的一句,不进遮罩列
for (const st of body.chain) {
if (st.step === 'input' && st.path === 'vars.kpi.refund_cnt') {
Object.assign(st, { value: '无法核对', mask_unverified: '查询快照无法读取,核对不了这一格是否取自设置了遮罩的列' })
}
}
}
return body
})
const pg = r.page
const p = panel(pg)
const line = p.locator('[data-ev-dual-check]')
const lineText = async () => (await line.innerText().catch(() => '')).replace(/\s+/g, ' ')
for (const [id, status, words, token, none = []] of [
['s6', 'agreed', ['双盲复算', '与盲算结果一致', '盲算 45,678.5元'], '--st-done', ['缺口']],
['s8', 'disagreed', ['与盲算结果不一致', '盲算 9.1%', '审核人选择「采用原流程的结果」'], '--st-waiting', ['缺口']],
['s10', 'disagreed', ['与盲算结果不一致', '盲算 1,301单', '审核人选择「采用盲算的结果」',
'报告中的数仍取自原流程,这个指标已记为缺口'], '--st-failed', ['审核人采用盲算结果', '出具缺口', '降档']],
['s45', 'disagreed', ['与盲算结果不一致', '盲算 45,900.0元', '审核人没有作出选择', '报告中的数仍取自原流程'], '--st-failed'],
['s12', 'unchecked', ['未能完成', '查询全部失败', '这个指标已记为缺口'], '--st-waiting', ['无法复算']],
['s23', 'skipped', ['已跳过', '这个指标不在复算范围内'], '--text-dim', ['缺口']],
]) {
await openSeg(pg, id, '[data-ev-dual-check]')
const text = await lineText()
const got = await line.getAttribute('data-ev-dual-check').catch(() => null)
const color = await line.locator('span.font-medium').evaluate((el) => getComputedStyle(el).color).catch(() => '')
check(`双盲复算 ${status}(${words.slice(1).join('、') || words[0]}):指标步骤写成一行,颜色按要不要多看一眼`,
got === status && words.every((w) => text.includes(w)) && !none.some((w) => text.includes(w))
&& color === await tokenColor(pg, 'color', `var(${token})`), `${got}:${text}(${color})`)
await pg.keyboard.press('Escape')
await pg.waitForTimeout(80)
}
// 「双盲复算」和同一节里按代入式重算的「复算」是两件事:标签悬停、读屏都说明是什么,状态词也不同
await openSeg(pg, 's12', '[data-ev-dual-check]')
const label = p.locator('[data-ev-dual-check-label]')
const recompute = await p.locator('[data-ev-recompute]').innerText().catch(() => '')
check('双盲复算的标签悬停写明是什么(另起一路独立计算),读屏同样读得到', await label.getAttribute('title') === DUAL_ABOUT
&& (await label.locator('.sr-only').innerText().catch(() => '')).includes(DUAL_ABOUT))
check('双盲复算的状态词和「复算」那一行分得开:未能完成 ≠ 无法复算', (await lineText()).includes('未能完成')
&& !(await lineText()).includes('无法复算') && !!recompute, `${await lineText()} | ${recompute}`)
await pg.keyboard.press('Escape')
for (const theme of ['light', 'dark']) {
await pg.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
let worst = Infinity
for (const id of ['s6', 's10', 's8', 's23']) {
await openSeg(pg, id, '[data-ev-dual-check]')
const pair = await line.evaluate((el) => {
const back = (node) => {
for (let n = node; n; n = n.parentElement) {
const c = getComputedStyle(n).backgroundColor
if (c && !/rgba\(0, 0, 0, 0\)|transparent/.test(c)) return c
}
return getComputedStyle(document.body).backgroundColor
}
return [...el.querySelectorAll('span')].filter((sp) => !sp.classList.contains('sr-only'))
.map((sp) => [getComputedStyle(sp).color, back(sp)])
}).catch(() => [])
for (const [fg, bg] of pair) worst = Math.min(worst, contrastOf(fg, bg))
await pg.keyboard.press('Escape')
await pg.waitForTimeout(80)
}
check(`${theme}:双盲复算那一行的字对比度都 ≥ 4.5`, worst >= 4.5, worst.toFixed(2))
}
await pg.evaluate(() => document.documentElement.removeAttribute('data-theme'))
await openSeg(pg, 's49', '[data-ev-substituted]')
check('没有双盲复算复核过的指标:不画这一行', await line.count() === 0)
await pg.keyboard.press('Escape')
// ---- 取自遮罩列的输入:标签写「已遮罩」(遮罩格的样子),代入式里也是,底下写明遮罩不是安全边界
await openSeg(pg, 's12', '[data-ev-substituted]')
const masked = p.locator('[data-ev-input="vars.kpi.order_cnt"]')
const chip = (await masked.innerText().catch(() => '')).replace(/\s+/g, ' ')
const italic = await masked.locator('.italic').evaluate((el) => getComputedStyle(el).fontStyle).catch(() => '')
check('遮罩列的输入:标签写「已遮罩 ← 路径」,按遮罩格的样子画,不露出原值',
await masked.getAttribute('data-ev-input-masked') === '' && chip.includes('已遮罩 ← vars.kpi.order_cnt')
&& !chip.includes('1,234') && italic === 'italic', `${chip}(${italic})`)
const sub = await p.locator('[data-ev-substituted]').innerText().catch(() => '')
check('遮罩列的输入:代入式里那一处写「已遮罩」', sub === 'round(45678.5 / 已遮罩, 1)', sub)
check('没用到遮罩列的输入照常写值', (await p.locator('[data-ev-input="vars.kpi.gmv"]').innerText()).includes('45,678.5 ← vars.kpi.gmv')
&& await p.locator('[data-ev-input="vars.kpi.gmv"]').getAttribute('data-ev-input-masked') === null)
check('面板底下写明遮罩的列和遮罩不是安全边界', (await p.locator('[data-ev-mask-note]').innerText().catch(() => '')).includes('order_cnt'))
await pg.keyboard.press('Escape')
// 遮没遮核对不了:不显示值,写「无法核对」(不写「已遮罩」:数据源可能根本没设遮罩),悬停说明原因(快照读不出来),
// 不算进遮罩列
await openSeg(pg, 's23', '[data-ev-substituted]')
const unsure = p.locator('[data-ev-input="vars.kpi.refund_cnt"]')
const unsureTitle = await unsure.getAttribute('title').catch(() => '')
const unsureText = await unsure.innerText().catch(() => '')
check('遮没遮核对不了的输入:标签写「无法核对」、不写「已遮罩」,悬停写明快照读不出来、不显示值',
await unsure.getAttribute('data-ev-input-masked') === '' && unsureText.includes('无法核对')
&& !unsureText.includes('已遮罩')
&& (unsureTitle ?? '').includes('查询快照无法读取,核对不了这一格是否取自设置了遮罩的列,不显示值'),
`${unsureText} / ${unsureTitle ?? ''}`)
check('……面板底下的遮罩说明不把它算进遮罩列', await p.locator('[data-ev-mask-note]').count() === 0)
await pg.keyboard.press('Escape')
check('核查状态那几页没有运行时报错', r.errors.length === 0, r.errors.join(' | '))
await r.ctx.close()
// ---- 第二轮:跳过的另几种原因、「两边都不对」。原因代码认不出来的不写进句子;关闭了的不说两遍「双盲复算」
const r2 = await probe((id, body) => {
const metric = body.chain?.find((st) => st.step === 'metric')
if (metric && DUAL_MORE[id]) metric.dual_check = DUAL_MORE[id]
return body
})
const line2 = panel(r2.page).locator('[data-ev-dual-check]')
for (const [id, words, none] of [
['s23', ['已跳过', '节点或设置中已关闭'], ['双盲复算已关闭']],
['s49', ['已跳过', '这个指标没有来自查询的输入'], ['口径卡上没有']],
['s45', ['已跳过'], ['no_such_reason', ',']],
['s10', ['审核人选择「两边都不对」', '报告中的数仍取自原流程,这个指标已记为缺口'], ['审核人判断']],
]) {
await openSeg(r2.page, id, '[data-ev-dual-check]')
const text = (await line2.innerText().catch(() => '')).replace(/\s+/g, ' ').replace(`(${DUAL_ABOUT})`, '')
check(`双盲复算 ${id}:${words.join('、')}${none.length ? `,不写「${none.join('」「')}」` : ''}`,
words.every((w) => text.includes(w)) && !none.some((w) => text.includes(w)), text)
await r2.page.keyboard.press('Escape')
await r2.page.waitForTimeout(80)
}
check('第二轮没有运行时报错', r2.errors.length === 0, r2.errors.join(' | '))
await r2.ctx.close()
// ---- 输入来源:遮罩列的 Agent 字段核对不一致时只说不一致,不写两边的值
const q = await probeQ((id, body) => {
if (body.segment?.text !== '37.0元') return body
for (const st of body.chain) {
if (st.step === 'input' && st.path === 'vars.kpi.order_cnt') {
delete st.model_value
Object.assign(st, { value: '已遮罩', masked: ['order_cnt'] })
}
}
return body
})
await openQ(q.page, '37.0元', { wait: '[data-ev-sources]' })
const src = await panel(q.page).locator('[data-ev-source="vars.kpi.order_cnt"]').innerText().catch(() => '')
check('输入来源:遮罩列的字段核对不一致,只说不一致、不写值', src.includes('与快照不一致;取自设置了遮罩的列,不显示值')
&& !src.includes('1,240') && !src.includes('1,234'), src.replace(/\s+/g, ' '))
const verified = await panel(q.page).locator('[data-ev-source="vars.kpi.gmv"]').innerText().catch(() => '')
check('输入来源:没遮罩的字段照旧', verified.includes('与快照一致') && !verified.includes('遮罩'), verified.replace(/\s+/g, ' '))
await q.ctx.close()
// ---- 反方审稿:整句面板、片段面板都看得到这一句的判断。用审批卡那一块的单句展示:只画这一句的判断,不画报告级的
// 标题和计数,不预告出具
const u = fxj.formal.units
const challenge = (verdict) => ({ verdict, node_id: 'chal', artifact: CH_ART, on_refuted: 'degrade', report_label: '写周报',
counts: CH_COUNTS })
const segPatch = (jset, body) => {
if (jset === fxj.formal && body.unit?.id === u.bad) body.unit.challenge = challenge('refuted')
if (jset === fxj.formal && body.unit?.id === u.ok) {
body.unit.challenge = challenge('weakened')
// 正式运行里的一个指标:双盲复算不一致、审核人采用盲算。记的是出具缺口
if (body.segment?.id === 's6') {
body.chain = [{ step: 'metric', metric: 'gmv', name: '销售额', value: 45678.5, rendered: '45,678.5', unit: '元',
expression: 'vars.kpi.gmv', substituted: '45678.5', recompute_ok: true,
dual_check: { status: 'disagreed', node_id: 'dual', choice: 'b', blind_rendered: '45,900.0元' } }]
}
}
// 计数缺了(格式不对):不画,免得审批卡的展示按这一句拼一个「被推翻 1 句、其余 0 句」
if (jset === fxj.formal && body.unit?.id === u.limit) body.unit.challenge = { verdict: 'refuted', node_id: 'chal' }
// 没开结论句裁判的报告:没有句末徽标,点句中的数字也要看得到
if (jset === fxj.plain && body.unit?.id === 'u3') body.unit.challenge = challenge('refuted')
return body
}
for (const [w, h] of [[1280, 1000], [390, 844]]) {
const c = await open('/ui-harness.html?evidence=1', { segPatch, w, h })
const artifactHits = []
await c.page.route((url) => new URL(url).pathname === `/api/artifacts/${CH_ART}`, (route) => {
artifactHits.push(route.request().url())
return route.fulfill({ json: { id: CH_ART, content: {
report: 'write', doc_artifact: fxj.formal.doc_artifact, model: 'challenger-x',
claims: [{ unit: u.bad, text: '增长主要来自新客首单,老客复购持平。', verdict: 'refuted', rationale: CH_RATIONALE,
rationale_doc: null, queries: [] }],
} } })
})
const cp = panel(c.page)
const block = cp.locator('[data-ev-challenge]')
const blockText = async () => (await block.innerText().catch(() => '')).replace(/\s+/g, ' ')
await badge(c.page, J, u.bad).click()
// 单句展示没有逐句的条目(data-challenge-item):等的是片段接口答回来之后的单句那一块和它的判断标签
await c.page.waitForSelector('[data-evidence-panel] [data-ev-challenge] [data-challenge-scope="claim"]:not([data-challenge-pending]) '
+ '[data-challenge-tag]', { timeout: 4000 }).catch(() => {})
const text = await blockText()
if (w === 1280) {
check('整句面板:「反方审稿」一节写这一句的判断(被推翻),用单句展示', await block.getAttribute('data-ev-challenge', { timeout: 3000 }).catch(() => null) === 'refuted'
&& text.startsWith('反方审稿') && await block.locator('[data-challenge-scope="claim"]').count() === 1
&& (await block.locator('[data-challenge-tag="refuted"]').innerText().catch(() => '')).trim() === '被推翻', text)
check('……单句只画这一句:不画报告级的标题、四种判断的计数、别的句子,也不再抄一遍这一句的原文',
await block.locator('[data-challenge-counts], [data-challenge-items], [data-challenge-more], [data-challenge-show-all]').count() === 0
&& !text.includes('报告「写周报」的反方审稿') && !/\d+ 句/.test(text) && !text.includes('增长主要来自新客首单'), text)
check('……运行已经结束:不预告「出具会降档」,降没降档看报告的出具横幅', await block.locator(ISSUANCE_LINES).count() === 0
&& !text.includes('降档'), text)
check('……判断由反方模型给出,并非系统核对', text.includes('并非系统核对'), text)
await block.locator('[data-challenge-toggle]').click()
await c.page.waitForSelector('[data-evidence-panel] [data-challenge-detail-state="ok"]', { timeout: 4000 }).catch(() => {})
const why = await block.locator('[data-challenge-rationale]').innerText().catch(() => '')
check('……点开看得到反方的理由(按审稿记录取)', why.includes(CH_RATIONALE) && artifactHits.length === 1, why)
// 被削弱的那一句:只写它自己的判断,不写整份报告的计数(以前借报告级的展示,读着像这一句「被推翻 0 句」)
await badge(c.page, J, u.ok).click()
await c.page.waitForSelector('[data-evidence-panel] [data-ev-challenge="weakened"]', { timeout: 4000 }).catch(() => {})
const weak = await blockText()
check('被削弱的那一句:只写这一句的判断(被削弱),不写整份报告的计数', weak.includes('被削弱') && !weak.includes('被推翻')
&& !/\d+ 句/.test(weak), weak)
// 片段面板:点这一句里的数字,同样看得到判断;正式运行里双盲复算记的是出具缺口
await c.page.locator(`${J} [data-seg="s6"]`).click()
await c.page.waitForSelector('[data-evidence-panel] [data-ev-challenge="weakened"]', { timeout: 4000 }).catch(() => {})
check('片段面板:点句中的数字,看得到这一句的反方审稿判断', await block.getAttribute('data-ev-challenge', { timeout: 3000 }).catch(() => null) === 'weakened'
&& (await blockText()).includes('被削弱'), await blockText())
const dual = (await cp.locator('[data-ev-dual-check]').innerText().catch(() => '')).replace(/\s+/g, ' ')
check('……正式运行:审核人采用盲算的,写明报告中的数仍取自原流程、已记为出具缺口',
dual.includes('审核人选择「采用盲算的结果」') && dual.includes('报告中的数仍取自原流程,这个指标已记为出具缺口'), dual)
// 计数格式不对的:不画
await badge(c.page, J, u.limit).click()
await c.page.waitForSelector('[data-evidence-panel] [data-ev-judge]', { timeout: 4000 }).catch(() => {})
await c.page.waitForTimeout(200)
check('服务端没给计数的判断:不画(不拼一个只数这一句的概况)', await block.count() === 0)
await badge(c.page, J, u.partial).click()
await c.page.waitForSelector('[data-evidence-panel] [data-ev-judge="partial"]', { timeout: 4000 }).catch(() => {})
await c.page.waitForTimeout(200)
check('没审到的句子:不画反方审稿', await block.count() === 0)
// 没开结论句裁判的报告:没有句末徽标,点句中的数字照样看得到判断
await c.page.keyboard.press('Escape')
await c.page.locator(`${JP} [data-seg="s14"]`).click()
await c.page.waitForSelector('[data-evidence-panel] [data-ev-challenge]', { timeout: 4000 }).catch(() => {})
check('没开结论句裁判的报告:点句中的数字看得到反方审稿判断', await block.getAttribute('data-ev-challenge', { timeout: 3000 }).catch(() => null) === 'refuted'
&& (await block.locator('[data-challenge-tag="refuted"]').count()) === 1, await blockText())
} else {
const wide = await c.page.evaluate(() => {
const el = document.querySelector('[data-evidence-panel]')
return { page: document.documentElement.scrollWidth - document.documentElement.clientWidth,
panel: el ? el.scrollWidth - el.clientWidth : 99 }
})
check('390px:整句面板带着反方审稿不横向溢出', !!text && wide.page <= 0 && wide.panel <= 1, JSON.stringify(wide))
}
check(`${w}px:没有运行时报错`, c.errors.length === 0, c.errors.join(' | '))
await c.ctx.close()
}
})
// ---------------------------------------------------------------- 期 4:正文里被反方审过的句子
/**
* 证据图带的被反方审过的句子(服务端 _challenge_verdicts,{句子: 判断}):没开裁判的那份挂在纯文字的结论句(u2、u4、u6
* 一个可点的片段都没有)和带数字的句子(u3)上;正式运行那份和裁判的徽标并在一起;探索运行那份的证据图说的是另一稿
* (工件 id 对不上),一个都不该挂
*/
const MARKS = {
plain: { u2: 'refuted', u3: 'weakened', u4: 'inconclusive', u6: 'stands' },
formal: { [fxj.formal.units.bad]: 'refuted', [fxj.formal.units.ok]: 'stands', [fxj.formal.units.limit]: 'inconclusive' },
explore: { [fxj.explore.units.ok]: 'refuted' },
}
/**
* 片段接口答的 unit.challenge 里整份报告的计数:没开裁判的那份无法判断 1 句、未能反查 1 句(无法判断的就是没反查成的);
* 正式运行那份无法判断 2 句、未能反查 1 句(说不准是哪一句)
*/
const MARK_COUNTS = {
plain: { counts: { stands: 1, weakened: 1, refuted: 1, inconclusive: 1 }, unreviewed: 1 },
formal: { counts: { stands: 1, weakened: 0, refuted: 1, inconclusive: 2 }, unreviewed: 1 },
}
/**
* 片段接口答回来之后的完整单句展示(面板里的「反方审稿」一节)。答回来之前那一节先按标记写判断(data-challenge-pending),
* 等的是之后这一块
*/
const FULL_CLAIM = '[data-evidence-panel] [data-ev-challenge] [data-challenge-scope="claim"]:not([data-challenge-pending])'
const markSet = (jset) => (jset === fxj.plain ? 'plain' : jset === fxj.formal ? 'formal' : jset === fxj.explore ? 'explore' : '')
const markGraph = (jset, g) => {
const key = markSet(jset)
if (!key) return g
g.reports[0].challenge_verdicts = MARKS[key]
if (key === 'explore') g.reports[0].doc_artifact = 'e0'.repeat(32)
return g
}
const markSeg = (jset, body) => {
const key = markSet(jset)
const verdict = MARKS[key]?.[body.unit?.id]
if (verdict && MARK_COUNTS[key]) {
body.unit.challenge = { verdict, node_id: 'chal', artifact: CH_ART, on_refuted: 'degrade', report_label: '写周报', ...MARK_COUNTS[key] }
}
return body
}
/** 半透明的前景叠在底色上之后的颜色(rgb 字符串),量对比度用 */
function blendOver(fg, bg, alpha) {
const nums = (c) => (c.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number)
const [f, b] = [nums(fg), nums(bg)]
return `rgb(${f.map((v, i) => Math.round(v * alpha + b[i] * (1 - alpha))).join(', ')})`
}
/** getComputedStyle 给的颜色(rgb / rgba / color(srgb …))→ [r, g, b, a],r g b 取 0–255 */
function rgbaOf(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])] : [0, 0, 0, 0]
}
/**
* 句末标记里那两个字(醒目的标记)的对比度:标记自己的底色(悬停、展开时铺的浅底)一层层叠到最近的不透明底色上再算。
* 返回比值、各层底色(从标记往外),以及量的时候它是不是展开态、是不是悬停着
*/
async function loudStack(pg, sel) {
const got = await pg.evaluate((s) => {
const btn = document.querySelector(s)
const word = btn?.querySelector('[data-ev-challenge-look="loud"] span')
if (!btn || !word) return null
const layers = []
for (let n = btn; n; n = n.parentElement) {
const c = getComputedStyle(n).backgroundColor
if (c && !/rgba\(0, 0, 0, 0\)|transparent/.test(c)) layers.push(c)
if (/^rgb\(/.test(c) || (c.startsWith('color(srgb') && !c.includes('/'))) break
}
layers.push(getComputedStyle(document.body).backgroundColor)
return { fg: getComputedStyle(word).color, layers, expanded: btn.getAttribute('aria-expanded') === 'true', hover: btn.matches(':hover') }
}, sel)
if (!got) return { ratio: 0, layers: [], expanded: false, hover: false }
let under = [255, 255, 255]
for (const c of [...got.layers].reverse()) {
const [r, g, b, a] = rgbaOf(c)
under = [r, g, b].map((v, i) => v * a + under[i] * (1 - a))
}
return { ratio: contrastOf(got.fg, `rgb(${under.map((v) => Math.round(v)).join(', ')})`), layers: got.layers,
expanded: got.expanded, hover: got.hover }
}
await section('marks', '期 4:被反方审过的句子在正文里挂句末标记——和裁判徽标并在一起、点开是整句面板、不逐句请求', async () => {
const r = await open('/ui-harness.html?evidence=1', { graph: markGraph, segPatch: markSeg })
const pg = r.page
await pg.waitForSelector(`${JP} [data-ev-challenge-mark]`, { timeout: 5000 }).catch(() => {})
// 正文一挂上、一处都没点:不请求片段接口;证据图每次运行只取一回(正式运行那份宽窄各摆一份也一样)
const segHits = () => r.hits.filter((h) => h.startsWith('j:'))
const graphs = (run) => r.hits.filter((h) => h === `graph:${run}`).length
check('一处都没点:没有逐句请求片段接口,证据图每次运行只取一回', segHits().length === 0
&& graphs(fxj.plain.run_id) === 1 && graphs(fxj.formal.run_id) === 1,
`${segHits().join(',')} | ${r.hits.filter((h) => h.startsWith('graph:')).join(',')}`)
const look = await pg.evaluate((sel) => [...document.querySelectorAll(`${sel} [data-ev-claim]`)].map((el) => {
const mark = el.querySelector('[data-ev-challenge-look]')
return { unit: el.getAttribute('data-ev-claim'), verdict: el.getAttribute('data-ev-verdict'),
mark: el.getAttribute('data-ev-challenge-mark'), look: mark?.getAttribute('data-ev-challenge-look') ?? null,
text: el.textContent ?? '', color: mark ? getComputedStyle(mark).color : '', icon: !!mark?.querySelector('svg'),
label: el.getAttribute('aria-label'), title: el.getAttribute('title'), tag: el.tagName, type: el.getAttribute('type') }
}), JP)
const by = Object.fromEntries(look.map((x) => [x.unit, x]))
check('没开裁判的报告:被审过的四句都挂了标记(纯文字的结论句、带数字的句子都有),没被审的不挂',
JSON.stringify(Object.keys(by).sort()) === JSON.stringify(['u2', 'u3', 'u4', 'u6'])
&& look.every((x) => x.mark === MARKS.plain[x.unit] && x.verdict === null && x.tag === 'BUTTON' && x.type === 'button'),
JSON.stringify(look.map((x) => [x.unit, x.mark, x.verdict])))
const [failedC, waitingC, faintC] = await Promise.all(['--st-failed', '--st-waiting', '--text-faint'].map((t) => colorOf(pg, t)))
check('被推翻、被削弱醒目:盾牌图标加判断两个字,失败色、提醒色', by.u2?.look === 'loud' && by.u2.text.includes('被推翻')
&& by.u2.color === failedC && by.u2.icon && by.u3?.look === 'loud' && by.u3.text.includes('被削弱') && by.u3.color === waitingC
&& by.u3.icon, JSON.stringify([by.u2, by.u3]))
check('经受住、无法判断低调:只有淡色的盾牌图标,不写字', ['u4', 'u6'].every((k) => by[k]?.look === 'quiet' && by[k].icon
&& !by[k].text.trim() && by[k].color === faintC), JSON.stringify([by.u4, by.u6]))
// 低调的两种图标形状不同(经受住是勾、无法判断是省略号):句末已经有裁判的「?」和「质询」的问号,不再添一个问号
const shapes = await pg.evaluate((sel) => ['u4', 'u6'].map((u) => document.querySelector(`${sel} [data-ev-claim="${u}"] svg`)
?.getAttribute('class') ?? ''), JP)
check('……经受住、无法判断的图标形状不同,无法判断不用问号', shapes[0].includes('lucide-shield-ellipsis')
&& shapes[1].includes('lucide-shield-check') && !shapes.some((s) => s.includes('question')), shapes.join(' | '))
check('读屏名写明句子和反方审稿的判断,悬停写明是反方模型的判断',
by.u2?.label === '结论句「增长主要来自新客首单,老客复购持平。」,反方审稿:被推翻'
&& by.u2?.title === '反方审稿:被推翻(反方模型的判断,并非系统核对)', `${by.u2?.label} | ${by.u2?.title}`)
// 未能反查的句子也记为无法判断,正文里分不出是哪一种:无法判断的悬停说明不说成反方模型的判断
check('无法判断的悬停说明:不说成反方模型的判断(可能是未能反查时记下的)',
by.u4?.title === '反方审稿:无法判断(可能是反方模型的判断,也可能是未能反查时记下的,并非系统核对)', by.u4?.title)
const both = await pg.evaluate(({ sel, unit }) => {
const els = document.querySelectorAll(`${sel} [data-ev-claim="${unit}"]`)
const el = els[0]
return { n: els.length, verdict: el?.getAttribute('data-ev-verdict'), mark: el?.getAttribute('data-ev-challenge-mark'),
text: el?.textContent ?? '', label: el?.getAttribute('aria-label'), title: el?.getAttribute('title') ?? '' }
}, { sel: J, unit: fxj.formal.units.bad })
check('正式运行:有裁判徽标的句子并在一个徽标里,不挂两个', both.n === 1 && both.verdict === 'unsupported' && both.mark === 'refuted'
&& both.text.startsWith('!') && both.text.includes('被推翻'), JSON.stringify(both))
check('……读屏名、悬停说明两样都说', both.label === '结论句「增长主要来自新客首单,老客复购持平。」,模型判断:证据不支持,反方审稿:被推翻'
&& both.title.startsWith('模型判断:证据不支持') && both.title.endsWith('反方审稿:被推翻(反方模型的判断,并非系统核对)'),
`${both.label} | ${both.title}`)
check('……两份报告仍各只占一个 Tab 位', await pg.locator(`${JP} [data-seg][tabindex="0"], ${JP} [data-ev-claim][tabindex="0"]`).count() === 1
&& await pg.locator(`${J} [data-seg][tabindex="0"], ${J} [data-ev-claim][tabindex="0"]`).count() === 1)
check('证据图说的是另一稿(工件 id 对不上):一个标记都不挂', await pg.locator(`${JX} [data-ev-challenge-mark]`).count() === 0)
// 键盘:没有可点片段的那句,标记就是它在键盘顺序里的位置;回车打开整句面板,Esc 回到标记
await pg.locator(`${JP} [data-seg="s8"]`).focus()
await pg.keyboard.press('ArrowDown')
check('↓ 从上一句走到只有标记的那一句', await activeKey(pg) === 'claim:u2', await activeKey(pg))
await pg.keyboard.press('Enter')
await pg.waitForSelector(FULL_CLAIM, { timeout: 4000 }).catch(() => {})
const p = panel(pg)
const block = p.locator('[data-ev-challenge]')
const blockText = (await block.innerText().catch(() => '')).replace(/\s+/g, ' ')
check('回车打开整句面板:标题是这一句,「反方审稿」一节写被推翻(单句展示)',
(await p.locator('h3').innerText().catch(() => '')).trim() === '增长主要来自新客首单,老客复购持平。'
&& await block.getAttribute('data-ev-challenge').catch(() => null) === 'refuted'
&& await block.locator('[data-challenge-scope="claim"]').count() === 1, blockText)
check('……只请求了这一句的一个片段', JSON.stringify(segHits()) === JSON.stringify(['j:s11']), segHits().join(','))
check('……标记标着展开了面板', await pg.locator(`${JP} [data-ev-claim="u2"]`).getAttribute('aria-expanded') === 'true')
// 只有反方标记的句子:读屏名本身已经说了「反方审稿:被推翻」,前面不再加一个「反方审稿:」
const said = await pg.locator(`${JP} [aria-live="polite"]`).first().innerText()
check('……读屏从 live region 听到这一句和反方审稿的判断,「反方审稿」只说一次',
said === '结论句「增长主要来自新客首单,老客复购持平。」,反方审稿:被推翻', said)
check('……判断不是无法判断:不说未能反查', await p.locator('[data-challenge-unreviewed]').count() === 0)
// 面板开着时这枚标记一直是展开态(悬停同理):带字的醒目标记不铺浅底——10px 的失败色、提醒色字叠在 14% 的浅底上,
// 浅色主题里不到 4.5。按标记自己的底色叠在外面的底色上算,深浅两套都量;悬停的是被削弱那一枚,和正式运行里和裁判徽标
// 并在一起的那一枚(浅底是裁判判定的颜色,字是反方的失败色)
for (const theme of ['light', 'dark']) {
await pg.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await pg.waitForTimeout(100)
const open = await loudStack(pg, `${JP} [data-ev-claim="u2"]`)
await pg.locator(`${JP} [data-ev-claim="u3"]`).hover()
const hover = await loudStack(pg, `${JP} [data-ev-claim="u3"]`)
await pg.locator(`${J} [data-ev-claim="${fxj.formal.units.bad}"]`).hover()
const both = await loudStack(pg, `${J} [data-ev-claim="${fxj.formal.units.bad}"]`)
check(`${theme}:展开态、悬停时带字的标记对比度 ≥ 4.5`, open.expanded && hover.hover && both.hover
&& Math.min(open.ratio, hover.ratio, both.ratio) >= 4.5,
`展开 ${open.ratio.toFixed(2)}(${open.layers.join(' / ')})· 悬停 ${hover.ratio.toFixed(2)} · 并在一起的 ${both.ratio.toFixed(2)}`)
}
await pg.mouse.move(1, 1)
await pg.evaluate(() => document.documentElement.removeAttribute('data-theme'))
await pg.locator(`${JP} [data-ev-claim="u2"]`).focus()
await pg.keyboard.press('Escape')
await pg.waitForTimeout(150)
check('Esc 关掉,焦点回到标记', await panel(pg).count() === 0 && await activeKey(pg) === 'claim:u2', await activeKey(pg))
// 判为无法判断的句子:这份报告无法判断的全是没反查成的,就说这一句未能反查;还有查过了、数据不够的,说可能在其中
await pg.locator(`${JP} [data-ev-claim="u4"]`).click()
await pg.waitForSelector('[data-evidence-panel] [data-challenge-unreviewed]', { timeout: 4000 }).catch(() => {})
const sure = p.locator('[data-challenge-unreviewed]')
const sureText = (await sure.innerText().catch(() => '')).replace(/\s+/g, ' ')
check('无法判断、而无法判断的全是没反查成的:说这一句未能反查', await sure.getAttribute('data-challenge-unreviewed').catch(() => null) === 'claim'
&& sureText.startsWith('这一句未能反查,已记为无法判断。反查没有完成'), sureText)
// 没反查成的句子是服务端按规则记的无法判断,反方模型根本没看过这一句:不能再写「判断由反方模型给出」
const sureBlock = (await p.locator('[data-ev-challenge]').innerText().catch(() => '')).replace(/\s+/g, ' ')
check('……这一句根本没反查:不写「判断由反方模型给出」', await p.locator('[data-ev-challenge] [data-challenge-judged]').count() === 0
&& !sureBlock.includes('反方模型'), sureBlock)
await badge(pg, J, fxj.formal.units.limit).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-challenge="inconclusive"] [data-challenge-unreviewed]', { timeout: 4000 })
.catch(() => {})
const maybe = p.locator('[data-challenge-unreviewed]')
const maybeText = (await maybe.innerText().catch(() => '')).replace(/\s+/g, ' ')
check('……无法判断的还有查过了、数据不够的:说这份报告几句未能反查、这一句可能在其中',
await maybe.getAttribute('data-challenge-unreviewed').catch(() => null) === 'maybe'
&& maybeText.startsWith('这份报告有 1 句未能反查,已记为无法判断,这一句可能在其中'), maybeText)
check('……说不准的照旧写明判断出自反方模型、并非系统核对', await p.locator('[data-ev-challenge] [data-challenge-judged]').count() === 1)
await pg.keyboard.press('Escape')
await pg.waitForTimeout(120)
for (const theme of ['light', 'dark']) {
await pg.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await pg.waitForTimeout(100)
const pairs = await pg.evaluate((sel) => [...document.querySelectorAll(`${sel} [data-ev-challenge-look]`)].map((el) => {
const back = (node) => {
for (let n = node; n; n = n.parentElement) {
const c = getComputedStyle(n).backgroundColor
if (c && !/rgba\(0, 0, 0, 0\)|transparent/.test(c)) return c
}
return getComputedStyle(document.body).backgroundColor
}
return { loud: el.getAttribute('data-ev-challenge-look') === 'loud', fg: getComputedStyle(el).color, bg: back(el) }
}), JP)
const worst = (loud) => Math.min(...pairs.filter((x) => x.loud === loud).map((x) => contrastOf(x.fg, x.bg)))
check(`${theme}:醒目的标记(带字)对比度 ≥ 4.5,低调的图标 ≥ 3`, pairs.length === 4 && worst(true) >= 4.5 && worst(false) >= 3,
`${worst(true).toFixed(2)} / ${worst(false).toFixed(2)}`)
}
await pg.evaluate(() => document.documentElement.removeAttribute('data-theme'))
const narrow = await pg.evaluate(() => {
const el = document.querySelector('#evidence-judge-narrow')
return el ? el.scrollWidth - el.clientWidth : 99
})
check('360px 画布右栏那份(并在一起的徽标)不撑出横向滚动', narrow <= 0, `${narrow}px`)
check('没有运行时报错', r.errors.length === 0, r.errors.join(' | '))
await r.ctx.close()
const n = await open('/ui-harness.html?evidence=1', { graph: markGraph, segPatch: markSeg, w: 390, h: 844 })
await n.page.waitForSelector(`${JP} [data-ev-challenge-mark]`, { timeout: 5000 }).catch(() => {})
await n.page.locator(`${JP} [data-ev-claim="u2"]`).click()
await n.page.waitForSelector(FULL_CLAIM, { timeout: 4000 }).catch(() => {})
const wide390 = await n.page.evaluate(() => {
const doc = document.querySelector('#evidence-judge-plain')
const el = document.querySelector('[data-evidence-panel]')
return { doc: doc ? doc.scrollWidth - doc.clientWidth : 99, panel: el ? el.scrollWidth - el.clientWidth : 99 }
})
check('390px:带标记的报告、点开的整句面板都不横向溢出', wide390.doc <= 0 && wide390.panel <= 1, JSON.stringify(wide390))
check('390px:没有运行时报错', n.errors.length === 0, n.errors.join(' | '))
await n.ctx.close()
})
await section('mark-panel', '期 4:从只有反方标记的句子打开整句面板——片段接口还没答、答不了时,「反方审稿」一节照样写出判断,取不到给重试', async () => {
// 正文的标记来自证据图(challenge_verdicts),面板里的理由、整份报告的计数要等这一句第一个片段的片段接口(unit.challenge)。
// 只认片段接口的话:还在取时这一节什么都不写,答 500 时整节消失、也不说为什么——用户点的可是醒目的「被推翻」,没开裁判的
// 报告里这几句也没有别的入口。u2 的第一个片段 s11 先压着不答、放行时答 500,重试时照常答;u4(无法判断)的 s17 一律 500
const plainSeg = (id) => `/api/runs/${fxj.plain.run_id}/evidence/segments/${id}`
let release = () => {}
const gate = new Promise((res) => { release = res })
const st = { s11: 0, mode: 'hold' }
const r = await open('/ui-harness.html?evidence=1', {
graph: markGraph, segPatch: markSeg,
before: (page) => page.route((u) => [plainSeg('s11'), plainSeg('s17')].includes(new URL(u).pathname), async (route) => {
if (new URL(route.request().url()).pathname === plainSeg('s17')) {
return route.fulfill({ status: 500, json: { detail: '服务暂时不可用' } })
}
st.s11++
if (st.mode === 'hold') {
await gate
return route.fulfill({ status: 500, json: { detail: '服务暂时不可用' } })
}
return route.fallback()
}),
})
const pg = r.page
await pg.waitForSelector(`${JP} [data-ev-claim="u2"][data-ev-challenge-mark="refuted"]`, { timeout: 5000 }).catch(() => {})
await pg.locator(`${JP} [data-ev-claim="u2"]`).click()
const p = panel(pg)
const block = p.locator('[data-ev-challenge]')
const text = async () => (await block.innerText().catch(() => '')).replace(/\s+/g, ' ')
await pg.waitForSelector('[data-evidence-panel] [data-ev-challenge]', { timeout: 3000 }).catch(() => {})
check('片段接口还在取:「反方审稿」一节已经按标记写出判断(被推翻),说正在获取理由与查询',
await block.getAttribute('data-ev-challenge').catch(() => null) === 'refuted'
&& (await block.locator('[data-challenge-tag="refuted"]').innerText().catch(() => '')).trim() === '被推翻'
&& await block.locator('[data-challenge-pending="loading"]').count() === 1 && (await text()).includes('正在获取反方的理由与查询'),
await text())
check('……判断出自反方模型、并非系统核对,同样写明', (await text()).includes('判断由反方模型给出,并非系统核对'), await text())
release()
await pg.waitForSelector('[data-evidence-panel] [data-challenge-pending="error"]', { timeout: 4000 }).catch(() => {})
const failedText = await text()
check('片段接口答 500:判断还在,写明未能获取理由与查询,给重试', await block.locator('[data-challenge-tag="refuted"]').count() === 1
&& failedText.includes('未能获取反方的理由与查询') && await block.locator('button', { hasText: '重试' }).count() === 1, failedText)
// 重试:这回照常答(带 unit.challenge),换成完整的单句展示;按过的「重试」没了,焦点不掉到页面上
st.mode = 'ok'
await block.locator('button', { hasText: '重试' }).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-challenge] [data-challenge-scope="claim"]:not([data-challenge-pending]) '
+ '[data-challenge-toggle]', { timeout: 4000 }).catch(() => {})
check('重试:片段接口再请求一次,答回来换成完整的单句展示(能展开理由与查询)', st.s11 === 2
&& await block.locator('[data-challenge-pending]').count() === 0 && await block.locator('[data-challenge-toggle]').count() === 1
&& await block.locator('[data-challenge-tag="refuted"]').count() === 1, `${st.s11} 次 | ${await text()}`)
check('……焦点留在面板里', await pg.evaluate(() => !!document.activeElement?.closest('[data-evidence-panel]')),
await pg.evaluate(() => document.activeElement?.outerHTML.slice(0, 80) ?? ''))
// 无法判断的那一句:片段接口答不了,说不准它是不是根本没反查成,不说成反方模型的判断
await pg.keyboard.press('Escape')
await pg.locator(`${JP} [data-ev-claim="u4"]`).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-challenge="inconclusive"] [data-challenge-pending="error"]', { timeout: 4000 })
.catch(() => {})
const unsure = await text()
check('无法判断的那一句、片段接口答不了:写出判断和未能获取,不说成反方模型的判断', unsure.includes('无法判断')
&& unsure.includes('未能获取反方的理由与查询') && !unsure.includes('判断由反方模型') && unsure.includes('也可能是未能反查时记下的'), unsure)
check('没有运行时报错', r.errors.length === 0, r.errors.join(' | '))
await r.ctx.close()
// 390px(底部抽屉):取不到时那一节(判断、未能获取、重试)不横向溢出
const n = await open('/ui-harness.html?evidence=1', {
graph: markGraph, segPatch: markSeg, w: 390, h: 844,
before: (page) => page.route((u) => new URL(u).pathname === plainSeg('s11'),
(route) => route.fulfill({ status: 500, json: { detail: '服务暂时不可用' } })),
})
await n.page.waitForSelector(`${JP} [data-ev-claim="u2"][data-ev-challenge-mark]`, { timeout: 5000 }).catch(() => {})
await n.page.locator(`${JP} [data-ev-claim="u2"]`).click()
await n.page.waitForSelector('[data-evidence-panel] [data-challenge-pending="error"]', { timeout: 4000 }).catch(() => {})
const over = await n.page.evaluate(() => {
const el = document.querySelector('[data-evidence-panel]')
return { pending: !!el?.querySelector('[data-challenge-pending="error"]'),
page: document.documentElement.scrollWidth - document.documentElement.clientWidth, panel: el ? el.scrollWidth - el.clientWidth : 99 }
})
check('390px:取不到时那一节(判断、未能获取、重试)不横向溢出', over.pending && over.page <= 0 && over.panel <= 1, JSON.stringify(over))
check('390px:没有运行时报错', n.errors.length === 0, n.errors.join(' | '))
await n.ctx.close()
})
await section('mark-tab', '期 4:一个可点的片段都没有的报告,标记随证据图晚到:标记进 Tab 顺序', async () => {
// 用例 evidence-marks-text(dev/fixtures/evidence_marks.tsx):挂上时整份报告没有键盘目标(没有可点片段、没开裁判、没给
// 质询),拿 Tab 位的是空的;证据图晚 0.7 秒到,带着被审过的三句。只在「拿着 Tab 位的那个没了」时补的话,空的从来不补:
// 三枚标记都是 tabindex=-1,Tab 直接跳过整份报告
const RUN_T = 'run-evidence-marks-text'
const r = await open('/preview.html?syn=evidence-marks-text', {
before: (page) => page.route((u) => new URL(u).pathname.startsWith(`/api/runs/${RUN_T}/`), async (route) => {
if (new URL(route.request().url()).pathname !== `/api/runs/${RUN_T}/evidence`) {
return route.fulfill({ status: 404, json: { detail: 'Not Found' } })
}
await new Promise((res) => setTimeout(res, 700))
const g = structuredClone(fxj.plain.graph)
g.run_id = RUN_T
g.reports[0].challenge_verdicts = { u2: 'refuted', u4: 'weakened', u6: 'stands' }
return route.fulfill({ json: g })
}),
})
const pg = r.page
await pg.waitForSelector('[data-marks-doc] [data-ev-challenge-mark]', { timeout: 5000 }).catch(() => {})
const tabs = await pg.evaluate(() => [...document.querySelectorAll('[data-marks-doc] [data-ev-claim]')]
.map((el) => `${el.getAttribute('data-ev-claim')}:${el.tabIndex}`))
check('标记到了:三枚都挂上,第一枚拿着这份报告唯一的 Tab 位', JSON.stringify(tabs) === JSON.stringify(['u2:0', 'u4:-1', 'u6:-1'])
&& await pg.locator('[data-marks-doc] [tabindex="0"]').count() === 1, tabs.join(', '))
await pg.locator('[data-marks-before]').focus()
await pg.keyboard.press('Tab')
check('从报告前面的按钮按 Tab:落到第一枚标记上', await activeKey(pg) === 'claim:u2', await activeKey(pg))
await pg.keyboard.press('ArrowRight')
check('……→ 走到下一枚标记', await activeKey(pg) === 'claim:u4', await activeKey(pg))
check('没有运行时报错', r.errors.length === 0, r.errors.join(' | '))
await r.ctx.close()
})
await section('stale', '期 4:手里那份证据图过期了(报告改写过、运行收尾之前取的):正文、证据页重取一次,对上了就不再取', async () => {
// 正文有运行就取证据图,同一次运行只取一回、之后一直用缓存。审批卡里展开过报告的话,缓存的是运行停在审批上那一刻的图:
// 之后报告改写了(第二次审批、同一张卡换稿),新稿在那份图里找不到,一个标记都不挂;审批之后才审完的反方审稿,成果区
// 也看不到;证据页挂上时只在「运行已结束、图没封存」时重取,停在第二次审批上的运行、别处正在取的那份都不管,画的是
// 旧稿,审计表却是新取的。用例 evidence-marks-stale(dev/fixtures/evidence_marks.tsx)按场景重新挂,证据图在这里按
// st.answer 答(请求进来那一刻的答复和延迟),数得出取了几回
const RUN_S = 'run-evidence-marks-stale'
const OLD = fxj.plain.doc_artifact
const NEW = 'b7'.repeat(32)
const SEALED = { sealed: true, ok: true, manifest_seq: 64, legacy: false }
const OPEN = { sealed: false, ok: null, manifest_seq: null, legacy: false }
const graphOf = (art, marks, seal) => ({
...structuredClone(fxj.plain.graph), run_id: RUN_S, seal,
reports: [{ ...structuredClone(fxj.plain.graph.reports[0]), doc_artifact: art, doc_sealed: seal.sealed, challenge_verdicts: marks }],
})
// 第一次审批时:初稿,没封存;改写后第二次审批:新稿,没封存;运行结束:审批之后的反方审稿也审完了,封存了
const G1 = graphOf(OLD, { u2: 'refuted' }, OPEN)
const G2 = graphOf(NEW, { u4: 'weakened' }, OPEN)
const G3 = graphOf(NEW, { u2: 'refuted', u4: 'weakened' }, SEALED)
const start = async () => {
const st = { answer: G1, delay: 0, n: 0 }
const r = await open('/preview.html?syn=evidence-marks-stale', {
before: (page) => page.route((u) => new URL(u).pathname.startsWith(`/api/runs/${RUN_S}/`), async (route) => {
if (new URL(route.request().url()).pathname !== `/api/runs/${RUN_S}/evidence`) {
// 审计接口答「老服务端」(证据页按页面上的文档拼审计表),片段、问答串一律没有
return route.fulfill({ status: 404, json: { detail: 'Not Found' } })
}
st.n++
const { answer, delay } = st
if (delay) await new Promise((res) => setTimeout(res, delay))
return route.fulfill({ json: structuredClone(answer) })
}),
})
return { ...r, st }
}
const stage = async (pg, name) => {
await pg.locator(`[data-marks-stage="${name}"]`).click()
await pg.waitForSelector(`[data-marks-at="${name}"]`)
}
const marksIn = (pg) => pg.evaluate(() => JSON.stringify(Object.fromEntries([...document.querySelectorAll('[data-marks-at] [data-ev-challenge-mark]')]
.map((el) => [el.getAttribute('data-ev-claim'), el.getAttribute('data-ev-challenge-mark')]).sort())))
// 审批卡 → 第二次审批 → 成果区:每处重新挂上
const a = await start()
await a.page.waitForSelector('[data-marks-at="old"] [data-ev-challenge-mark="refuted"]', { timeout: 5000 }).catch(() => {})
check('审批卡里展开初稿:证据图取一回,标记照它挂', a.st.n === 1 && await marksIn(a.page) === '{"u2":"refuted"}',
`${a.st.n} 回 | ${await marksIn(a.page)}`)
a.st.answer = G2
await stage(a.page, 'new')
await a.page.waitForSelector('[data-marks-at="new"] [data-ev-claim="u4"][data-ev-challenge-mark="weakened"]', { timeout: 3000 })
.catch(() => {})
check('改写后第二次审批的新稿:手里那份图里没有这一稿,重取一次,标记按新图挂', a.st.n === 2
&& await marksIn(a.page) === '{"u4":"weakened"}', `${a.st.n} 回 | ${await marksIn(a.page)}`)
await stage(a.page, 'new')
await a.page.waitForTimeout(500)
check('……再挂一次:手里那份对上了,不再取', a.st.n === 2 && await marksIn(a.page) === '{"u4":"weakened"}', `${a.st.n} 回`)
a.st.answer = G3
await stage(a.page, 'output')
await a.page.waitForSelector('[data-marks-at="output"] [data-ev-claim="u2"][data-ev-challenge-mark="refuted"]', { timeout: 3000 })
.catch(() => {})
check('运行结束后的成果区:手里那份是收尾之前取的(没封存),重取一次,审批之后才审完的标记也挂上', a.st.n === 3
&& await marksIn(a.page) === '{"u2":"refuted","u4":"weakened"}', `${a.st.n} 回 | ${await marksIn(a.page)}`)
await stage(a.page, 'output')
await a.page.waitForTimeout(500)
check('……再挂一次:封存了的图不再取', a.st.n === 3, `${a.st.n} 回`)
check('没有运行时报错', a.errors.length === 0, a.errors.join(' | '))
await a.ctx.close()
// 同一个实例从初稿换到新稿(画布的审批浮层换审批,不重新挂)
const b = await start()
await b.page.waitForSelector('[data-marks-at="old"] [data-ev-challenge-mark]', { timeout: 5000 }).catch(() => {})
await stage(b.page, 'switch')
await b.page.waitForTimeout(400)
check('同一张卡:初稿重新挂上,手里那份对得上,不重取', b.st.n === 1, `${b.st.n} 回`)
b.st.answer = G2
await b.page.locator('[data-marks-switch]').click()
await b.page.waitForSelector(`[data-marks-card="${NEW}"] [data-ev-claim="u4"][data-ev-challenge-mark="weakened"]`, { timeout: 3000 })
.catch(() => {})
check('……同一个实例换成新稿:重取一次,标记按新图挂', b.st.n === 2 && await marksIn(b.page) === '{"u4":"weakened"}',
`${b.st.n} 回 | ${await marksIn(b.page)}`)
await b.ctx.close()
// 证据页,运行停在第二次审批上:手里那份是第一次审批时的初稿
const c = await start()
await c.page.waitForSelector('[data-marks-at="old"] [data-ev-challenge-mark]', { timeout: 5000 }).catch(() => {})
c.st.answer = G2
// 证据页挂上之后画没画过旧稿,哪怕只画了一帧又收起来:证据页里加进来、拿下去的节点带着旧稿的句子就记下来(画的是旧稿、
// 审计表却是新取的,点一行会打开旧稿里同号的片段)。按变动记录里的节点认,不按回调时的页面认——先画旧稿、紧接着换成
// 占位的话,回调时页面上已经没有了
await c.page.evaluate(() => {
window.__sawOld = false
new MutationObserver((muts) => {
for (const m of muts) {
const at = m.target.nodeType === 1 ? m.target : m.target.parentElement
const nodes = [...m.addedNodes, ...m.removedNodes]
// 证据页里面的变动看全部节点;外面的(换场景时拿下审批卡那一层)只看证据页那一层自己
const suspects = at?.closest?.('[data-marks-at^="pane"]') ? nodes
: nodes.filter((n) => n.nodeType === 1 && n.matches('[data-marks-at^="pane"]'))
if (suspects.some((n) => n.textContent?.includes('增长主要来自新客首单'))) window.__sawOld = true
}
}).observe(document.body, { subtree: true, childList: true, characterData: true })
})
await stage(c.page, 'pane-interrupted')
await c.page.waitForSelector('[data-marks-at="pane-interrupted"] [data-evidence-report="write"] [data-ev-claim="u4"]', { timeout: 4000 })
.catch(() => {})
const report = (await c.page.locator('[data-marks-at] [data-evidence-report="write"]').innerText().catch(() => '')).replace(/\s+/g, ' ')
check('证据页(运行停在第二次审批上):手里那份没封存,重取一次,画的是新稿,标记按新图挂', c.st.n === 2
&& report.includes('增长主要来自老客复购') && !report.includes('增长主要来自新客首单') && await marksIn(c.page) === '{"u4":"weakened"}',
`${c.st.n} 回 | ${report.slice(0, 80)}`)
check('……重取回来之前先占位,旧稿一眼都没画', !(await c.page.evaluate(() => window.__sawOld)))
const table = (await c.page.locator('[data-marks-at] [data-evidence-audit]').innerText().catch(() => '')).replace(/\s+/g, ' ')
check('……审计表也是新稿的(退款 31 单)', table.includes('退款 31 单') && !table.includes('退款 29 单'), table.slice(0, 160))
await c.ctx.close()
// 证据页,运行已经结束:挂上时别处正在取证据图(新稿重新挂上触发的那一次,慢),取回来的那份没封存,再取一次
const d = await start()
await d.page.waitForSelector('[data-marks-at="old"] [data-ev-challenge-mark]', { timeout: 5000 }).catch(() => {})
Object.assign(d.st, { answer: G2, delay: 1200 })
await stage(d.page, 'new')
for (let i = 0; i < 30 && d.st.n < 2; i++) await d.page.waitForTimeout(50)
Object.assign(d.st, { answer: G3, delay: 0 })
await stage(d.page, 'pane-succeeded')
await d.page.waitForSelector('[data-marks-at="pane-succeeded"] [data-evidence-seal="done"]', { timeout: 5000 }).catch(() => {})
const seal = (await d.page.locator('[data-marks-at] [data-evidence-seal]').innerText().catch(() => '')).replace(/\s+/g, ' ')
check('证据页(运行已结束):挂上时别处正在取,取回来的那份没封存,再取一次,顶上写已封存', d.st.n === 3
&& seal.includes('已封存'), `${d.st.n} 回 | ${seal}`)
await d.page.waitForTimeout(500)
check('……之后不再取', d.st.n === 3, `${d.st.n} 回`)
check('没有运行时报错', d.errors.length === 0, d.errors.join(' | '))
await d.ctx.close()
})
await section('ask', '期 4:句末「质询」图标平时的对比度——深浅两套、页面底色和面板底色上都 ≥ 3:1,显出来时不撑开排版', async () => {
// 平时是淡淡的一个小图标(字色压一层不透明度),可 WCAG 1.4.11 要求界面组件对背景 ≥ 3:1(以前最淡的字色压到 60%,深色
// 约 2.75、浅色约 2.33)。用例 evidence-ask(dev/fixtures/evidence_ask.tsx):同一份报告摆在页面底色(同审批卡)和面板
// 底色(同证据页)上,深浅两套各量一遍,按叠上不透明度之后的颜色算
const r = await open('/preview.html?syn=evidence-ask')
const pg = r.page
await pg.waitForSelector('[data-ask-on="panel"] [data-inquiry-ask]')
await pg.mouse.move(1, 1)
await pg.evaluate(() => document.activeElement?.blur?.())
for (const theme of ['dark', 'light']) {
await pg.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await pg.waitForTimeout(120)
for (const [on, name] of [['bg', '页面底色'], ['panel', '面板底色']]) {
const m = await pg.evaluate((box) => [...document.querySelectorAll(`[data-ask-on="${box}"] [data-inquiry-ask]`)].map((b) => {
const back = (node) => {
for (let n = node; n; n = n.parentElement) {
const c = getComputedStyle(n).backgroundColor
if (c && !/rgba\(0, 0, 0, 0\)|transparent/.test(c)) return c
}
return getComputedStyle(document.body).backgroundColor
}
const cs = getComputedStyle(b)
return { fg: cs.color, opacity: Number(cs.opacity), bg: back(b) }
}), on)
const ratios = m.map((x) => contrastOf(blendOver(x.fg, x.bg, x.opacity), x.bg))
const worst = Math.min(...ratios)
check(`${theme} · ${name}:平时的「质询」图标对比度 ≥ 3:1`, m.length >= 3 && worst >= 3 && m.every((x) => x.opacity < 1),
`${worst.toFixed(2)}(${m[0]?.fg} × ${m[0]?.opacity} on ${m[0]?.bg})`)
}
}
await pg.evaluate(() => document.documentElement.removeAttribute('data-theme'))
// 悬停一句时显出来:只换颜色、描边和不透明度,宽度、位置不变
const btn = pg.locator('[data-ask-on="panel"] [data-inquiry-ask]').first()
const rest = await btn.boundingBox()
await pg.locator('[data-ask-on="panel"] [data-ev-unit]').first().hover()
await pg.waitForTimeout(150)
const shown = await btn.boundingBox()
const opacity = await btn.evaluate((el) => getComputedStyle(el).opacity)
check('悬停这一句:图标显出来(不透明),宽度、位置不变,不撑开排版', opacity === '1' && !!rest && !!shown
&& Math.abs(rest.width - shown.width) < 0.5 && Math.abs(rest.x - shown.x) < 0.5, JSON.stringify({ rest, shown, opacity }))
check('没有运行时报错', r.errors.length === 0, r.errors.join(' | '))
await r.ctx.close()
})
await section('ledger', '口径账本:来自口径的数字挂小标记(受管 / 已发布),面板出处链最上面「口径」一步分三种情况', async () => {
// 来自口径的数加一个小标记、写明受管还是已发布;相关口径的数不加标记,只在面板里说。面板「口径」一步:来自口径——「X」v3
//(受管)· 参数 · 怎么认的;相关口径——差在哪 + 看差多少;这次的推理——没有相关的已发布口径(02 做法 3、11)
const LED = ledgerOf(fx.run_id)
let asked = 0
const r = await open('/ui-harness.html?evidence=1', { before: (page) => page.route(
(u) => new URL(u).pathname === `/api/runs/${fx.run_id}/ledger`, (route) => { asked += 1; return route.fulfill({ json: LED }) }) })
const p = r.page
await p.waitForSelector('#evidence-wide [data-ev-caliber-of="s6"]', { timeout: 6000 }).catch(() => {})
const mark = (id) => p.evaluate((sid) => {
const el = document.querySelector(`#evidence-wide [data-ev-caliber-of="${sid}"]`)
const seg = document.querySelector(`#evidence-wide [data-seg="${sid}"]`)
return el ? { text: el.textContent, title: el.getAttribute('title'), level: el.getAttribute('data-ev-caliber'),
hidden: el.getAttribute('aria-hidden'), aria: seg?.getAttribute('aria-label') ?? '',
after: seg?.nextElementSibling === el } : null
}, id)
const s6 = await mark('s6')
check('受管口径的数挂「受管」小标记,紧跟在数字后面', s6?.text === '受管' && s6.level === 'governed' && s6.after, JSON.stringify(s6))
check('……悬停写是哪一份口径、怎么认的', s6?.title === '来自口径「周报口径」v2(受管) · 等价已证明', s6?.title)
check('……标记不进读屏(读屏念在数字上)', s6?.hidden === 'true' && s6.aria.includes('来自口径「周报口径」v2(受管),等价已证明'), s6?.aria)
const s10 = await mark('s10')
check('已发布口径、助手选用的数挂「已发布」', s10?.text === '已发布' && s10.title.includes('助手选用'), JSON.stringify(s10))
check('相关口径、这次的推理、没比对的数都不挂标记', !(await mark('s8')) && !(await mark('s12')) && !(await mark('s23')))
check('标记只挂在面向读者的正文上:窄栏那份同样挂', await p.locator('#evidence-narrow [data-ev-caliber-of="s6"]').count() === 1)
const part = () => p.locator('[data-evidence-panel] [data-ev-ledger]')
await openSeg(p, 's6', '[data-ev-ledger]')
const caliber = await part().innerText().catch(() => '')
check('来自口径:「口径」一步写口径、级别、怎么认的', await part().getAttribute('data-ev-ledger').catch(() => null) === 'caliber'
&& caliber.includes('来自口径「周报口径」v2(受管)') && caliber.includes('等价已证明'), caliber.replace(/\s+/g, ' '))
check('……写参数', caliber.includes('week = 2026-W37'), caliber.replace(/\s+/g, ' '))
check('……脚注写明封存后比对、不在封存范围内', caliber.includes('封存后比对,不在封存范围内'))
const order = await p.evaluate(() => {
const body = document.querySelector('[data-evidence-panel] [data-ev-body]')
const kids = [...(body?.children ?? [])]
const at = (sel) => kids.findIndex((k) => k.matches(sel) || !!k.querySelector(sel))
return { sentence: at('[data-ev-sentence]'), ledger: at('[data-ev-ledger]'), metric: at('[data-ev-metric], [data-ev-metric-step]'),
seal: at('[data-ev-seal]') }
})
check('「口径」一步在出处链最上面:紧跟句子,在指标步骤、封存之前', order.ledger === order.sentence + 1
&& (order.metric < 0 || order.ledger < order.metric) && order.ledger < order.seal, JSON.stringify(order))
check('整题不等价:这个数上没有「按口径正式出具」', await p.locator('[data-evidence-panel] [data-ledger-issue-button]').count() === 0)
await openSeg(p, 's10', '[data-ev-ledger="caliber"]')
const composed = await part().innerText().catch(() => '')
check('助手选用:写「助手选用」和由人确认的说明', composed.includes('助手选用') && composed.includes('要由人确认'), composed.replace(/\s+/g, ' '))
check('……绑定的遮罩值只写「已遮罩」', composed.includes('store = 已遮罩'), composed.replace(/\s+/g, ' '))
await openSeg(p, 's8', '[data-ev-ledger="related"]')
const related = await part().innerText().catch(() => '')
check('相关口径:写哪一份、差在哪', related.includes('相关口径「订单口径」v1(已发布)') && related.includes('差在哪')
&& related.includes('口径只保留状态为已支付的行,这次没有这个条件'), related.replace(/\s+/g, ' '))
check('……给「看差多少」(服务端给了对账这一项)', await p.locator('[data-evidence-panel] [data-ledger-reconcile-open]').count() === 1)
await openSeg(p, 's12', '[data-ev-ledger="reasoning"]')
const reasoning = await part().innerText().catch(() => '')
check('这次的推理:写「没有相关的已发布口径」', reasoning.includes('这次的推理') && reasoning.includes('没有相关的已发布口径'), reasoning)
await openSeg(p, 's23', '[data-ev-ledger="unknown"]')
const unknown = await part().innerText().catch(() => '')
check('没有比对:写原因', unknown.includes('没有比对') && unknown.includes('比对超过时限,没有做完'), unknown)
await openSeg(p, 's4', '[data-ev-seal-state]')
check('不是数的片段(运行输入)不画「口径」一步', await part().count() === 0)
check('同一次运行只取一次账本(正文两份、面板开了几次都共用一份,比对过了不再轮询)', asked === 1, `${asked} 次`)
check('没有运行时报错', r.errors.length === 0, r.errors.join(' | '))
await r.ctx.close()
})
check('没有运行时报错(组件预览)', harness.errors.length === 0, harness.errors.join(' | '))
await harness.ctx.close()
await browser.close()
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 可点击证据全部通过')
process.exit(failed ? 1 : 0)