mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:31:58 +00:00
6a54ab06c2
证据图缓存会过期:正文有运行就取证据图,之后一直用缓存。审批卡里展开过报告的话,缓存的是运行停在审批上那一刻的图:报告 改写后(第二次审批、同一张卡换稿)新稿在那份图里找不到,一个标记都挂不上;审批之后才审完的反方审稿,成果区也看不到;证据页 挂上时只管「运行已结束、图没封存」,停在第二次审批上的运行、别处正在取的那份都不管,画的是旧稿、审计表却是新取的。改成: - EvidenceDoc(useRunGraph)挂上、换稿那一刻手里已有的那份,找不到正文这一稿,或者正文是运行的成果(EvidenceField 给 settled)而那份没封存,就按(运行、报告、工件)重取一次;重取回来还是这样就作罢,挂上时还没有、正在取的那份不算过期; - 证据页挂上时手里那份不是「封存了、封存的就是这一版(manifest_seq)」就重取,不看运行停在哪;别处正在取的取回来再看一眼; 重取回来之前占位,第一次渲染就按挂上那一刻手里的那份定,不先画一帧旧稿。 整句面板的反方审稿:只有反方标记的句子(没有可点片段、也没有裁判判定)只能从标记点进来,面板却只认片段接口的 unit.challenge,还在取时这一节空着,答 500 时整节消失、也不说为什么。EvidencePanel 收下正文的标记(marks),片段接口还没答、 答不了、答了却没有可用内容时先按标记写出判断(ChallengeSummary 导出的 ClaimVerdictPending),说正在获取、未能获取(带重试, 重试后焦点落到「反方的理由与查询」或新的「重试」上)、无法查看;拿到 unit.challenge 换成完整的单句展示。 没反查成的句子:单句展示里一定没反查成的那一句不再写「判断由反方模型给出」(那是服务端按规则记的无法判断,反方模型没看过); 无法判断的句末标记悬停说明、先行版的落款写「可能是反方模型的判断,也可能是未能反查时记下的」,说不准的仍写判断出自反方模型。 其余几处: - 挂上时一个键盘目标都没有、反方标记随证据图后来才到:Tab 位交给第一枚标记(以前三枚都是 tabindex=-1,Tab 跳过整份报告); - 只有反方标记的句子,读屏不再把「反方审稿」念两遍; - 带字的醒目标记悬停、展开时不铺浅底(10px 的失败色、提醒色字叠在 14% 的浅底上,浅色主题只有 4.0 左右),改描 1px 内边, 和裁判徽标并在一起的同样;无法判断换成省略号盾牌,不再和裁判的「?」、质询的问号挤成三个问号,也和经受住的勾分得开。 检查:check-evidence 新增 mark-panel(片段接口压着、答 500、重试,390px 不溢出)、mark-tab(标记晚到进 Tab 顺序)、stale(审批卡 换稿、同一张卡换稿、成果区、证据页停在审批上和别处正在取,各重取一次、对上了不再取、旧稿一帧都不画)三节,marks 一节补读屏原文 逐字比、展开和悬停时的对比度、图标形状、无法判断的说法,verify 一节改等单句展示(原来等的选择器单句模式里没有,每遍白等 4 秒); 新预览用例 evidence-marks-text、evidence-marks-stale(dev/fixtures/evidence_marks.tsx)。check-challenge 新增 challenge-claim-quiet: 单句、不谈出具,给齐正式运行、运行 id、degrades 也不写降档、不取运行类别(以前这个开关在哪个用例里都观察不到)。
926 lines
67 KiB
JavaScript
926 lines
67 KiB
JavaScript
// 反方审稿的回归检查:审批卡上的反方审稿(run/ChallengeSummary.tsx)和检查器里反方审稿节点的配置(canvas/nodeDefs.ts 的
|
||
// challenge、canvas/verifyFields.tsx)。
|
||
//
|
||
// 守的是这些「看着没坏、其实用不了」的地方:概况的句数和条目对不上、有被推翻的结论却不提醒降档(或者原因只藏在悬停里)、
|
||
// 没有被推翻的结论也写降档、只标注时也写降档、探索运行里说出具、运行类别取不到时说起出具(取不到就不知道是不是正式
|
||
// 运行,一句和出具有关的话都不说,窗口重新拿到焦点时再取)、审批带着运行类别时还另取一次(取失败了降档提示就没了;
|
||
// 整张审批卡得把 approval.run_class 交给反方审稿那一块)、
|
||
// 反方审稿不在出具路径上(服务端的 degrades 为假)还写降档、说明里用「本节点」(画在审批卡上,读着像审批节点)、
|
||
// 反查没完成的几句混在「无法判断」里看不出来(或者比「无法判断」还多、插在降档标记和它的说明中间)、
|
||
// 没经过反方审稿的报告也冒出一块空框(或者白发一次请求)、点开一句不取工件(或者每点一句取一次)、理由里的数字看不出引用了哪次查询、
|
||
// 裸数字没被标出来、理由文档是另一个工件时不去取、查询的 SQL / 行数 / 截断不显示、工件取不到时卡死在加载中、
|
||
// 审稿记录没存上时每句还能点开、条目只列前十句时看不到其余的(或者补的时候吞掉原文相同的另一句、键盘焦点掉到页面上);
|
||
// 检查器里的字段写不进节点配置(或者写进去的不是约定的形状:跟随设置要把这一项拿掉,不能写成空字符串)、下拉里列出了
|
||
// 下游的报告、留空时认的报告和服务端不一样(「初稿 → 终稿 → 反方审稿」审终稿)、选中的报告被删了还看着像「未选择」、
|
||
// 选中的不是报告撰写节点却说成不在上游、反方模型和撰写报告的模型相同不提醒、画布校验已经报了的又在字段下面说一遍、
|
||
// 节点卡片的摘要还写着功能开发中、检查器上的叫法和服务端报错里的(engine/labels.py 的 CHALLENGE_LABEL、
|
||
// CHALLENGE_ON_REFUTED_LABEL)对不上;窄屏(390px)被撑出横向滚动、浅色主题下标签看不清。
|
||
//
|
||
// 跑在预览页上:/preview.html?syn=challenge-*(用例见 frontend/src/dev/fixtures/challenge.tsx)。夹具在
|
||
// frontend/src/dev/fixtures/challenge.json,理由文档是后端 run_verify_agent + compose_doc 真跑出来的(虚构的库和数字)。
|
||
// 运行类别(GET /api/runs/{id})、工件(GET /api/artifacts/{id})、画布校验(两个纯计算的 POST)在这里伪造,别的非 GET
|
||
// 一律拦掉并记下来,别的 GET 放行。叫法对照那一节不开浏览器:读 backend/app/engine/labels.py,terms.ts 借 vite 转译后
|
||
// 直接 import。
|
||
//
|
||
// node scripts/check-challenge.mjs
|
||
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-challenge.mjs
|
||
// CHALLENGE_SHOTS=<目录> 时亮暗两套各截几张,供人眼复核。
|
||
import { mkdirSync, readFileSync } from 'node:fs'
|
||
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
|
||
|
||
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
|
||
const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
||
const SHOTS = process.env.CHALLENGE_SHOTS ?? ''
|
||
const root = new URL('..', import.meta.url).pathname
|
||
const fx = JSON.parse(readFileSync(`${root}frontend/src/dev/fixtures/challenge.json`, 'utf8'))
|
||
const ART = fx.challenge.artifact
|
||
const DOC = fx.doc_u8
|
||
const MANY = fx.many.artifact
|
||
const BROKEN = 'e'.repeat(64)
|
||
// challenge-approval-dup 的工件:同 many,只把补出来的 u30(被推翻)的原文改成和条目 u20 一字不差
|
||
const DUP = 'd'.repeat(64)
|
||
const DUP_CONTENT = {
|
||
...fx.artifacts[MANY],
|
||
claims: fx.artifacts[MANY].claims.map((c) => (c.unit === 'u30' ? { ...c, text: fx.many.items.find((it) => it.unit === 'u20').text } : c)),
|
||
}
|
||
const RUNS = fx.runs
|
||
// RUNS.given 不在这里:那次运行的 GET 一律答 500,审批带着的运行类别得照样管用
|
||
const RUN_CLASS = {
|
||
[RUNS.formal]: 'formal', [RUNS.flag]: 'formal', [RUNS.explore]: 'exploratory', [RUNS.none]: 'formal',
|
||
[RUNS.many]: 'formal', [RUNS.broken]: 'formal', [RUNS.clear]: 'formal', [RUNS.clearFlag]: 'formal',
|
||
[RUNS.exploreFlag]: 'exploratory', [RUNS.runfail]: 'formal', [RUNS.dup]: 'formal', [RUNS.norecord]: 'formal',
|
||
[RUNS.unreviewed]: 'formal', [RUNS.offpath]: 'formal', [RUNS.over]: 'formal',
|
||
}
|
||
// challenge-approval-unreviewed 的工件:同第一个用例,被削弱(u5)、经受住(u8)的两句换成数据源已停用、没查成
|
||
// (服务端 unreached 的写法:记为无法判断,理由是系统说的,没有理由文档和查询)
|
||
const UNREVIEWED_ART = 'f'.repeat(64)
|
||
const UNREVIEWED_UNITS = ['u5', 'u8']
|
||
const UNREVIEWED_CONTENT = {
|
||
...fx.artifacts[ART],
|
||
claims: fx.artifacts[ART].claims.map((c) => (UNREVIEWED_UNITS.includes(c.unit) ? {
|
||
...c, verdict: 'inconclusive', rationale_doc: null, queries: [],
|
||
rationale: '这句引用的数据源「北仓库」已不存在或已停用,没有可以反查的数据源,这句未反查',
|
||
} : c)),
|
||
}
|
||
const LABEL = { stands: '经受住', weakened: '被削弱', refuted: '被推翻', inconclusive: '无法判断' }
|
||
const FLAG_NOTE = '按节点设置,被推翻的结论只做标注,不影响出具档位'
|
||
const DEGRADE_NOTE = '被推翻的结论计入出具缺口,本次正式运行将降档出具'
|
||
// 主语写明是反方审稿节点:这句画在人工审批的卡上,「本节点」读着像审批节点
|
||
const DEGRADE_OFF = '反方审稿节点没有接在带出具契约的成果节点上游,被推翻的结论不计入出具缺口,不影响出具档位'
|
||
const UNREVIEWED = (n) => `${n} 句未能反查,已记为无法判断。反查没有完成:可能是核查模型不可用、达到上限、数据源已不存在或已停用,`
|
||
+ '或报告未引用数据库查询,原因见运行时间线'
|
||
const FAILED = '无法获取反方审稿的详细内容'
|
||
// F3a 画布校验(schema._check_challenge)在同样情形下的说法:检查器按 field 挂在字段下面
|
||
const SERVER_MANY = '「反方审稿」的上游有多个「报告撰写」节点(「写月报」、「写周报」),请在「要审的报告」中选择要审的那一份'
|
||
const SERVER_GHOST = '「反方审稿」的「要审的报告」选择了「ghost」,但工作流中不存在这个节点'
|
||
const SERVER_SAME = '反方模型与报告「写月报」的写作模型相同(「writer-large」),等于自己审自己,难以发现写作模型自身的错误。'
|
||
+ '请在「反方模型」中选择另一个模型'
|
||
|
||
let failed = 0
|
||
const check = (name, cond, detail = '') => {
|
||
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
|
||
if (!cond) failed++
|
||
}
|
||
async function section(name, fn) {
|
||
console.log(`\n=== ${name} ===`)
|
||
try {
|
||
await fn()
|
||
} catch (e) {
|
||
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
|
||
}
|
||
}
|
||
|
||
// terms.ts 借 vite 转译成 data: URL 再 import(同 check-verify、check-decode):依赖也各自转好替换进去
|
||
const loaded = new Map()
|
||
async function load(path) {
|
||
if (loaded.has(path)) return loaded.get(path)
|
||
const res = await fetch(`${WEB}${path}?t=${Date.now()}`)
|
||
if (!res.ok) throw new Error(`${path} → HTTP ${res.status}`)
|
||
let code = await res.text()
|
||
const deps = new Set([...code.matchAll(/from\s+["'](\/src\/[^"'?]+)(?:\?[^"']*)?["']/g)].map((m) => m[1]))
|
||
for (const dep of deps) {
|
||
const url = await load(dep)
|
||
const pattern = new RegExp(`(["'])${dep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\?[^"']*)?\\1`, 'g')
|
||
code = code.replace(pattern, JSON.stringify(url))
|
||
}
|
||
const url = 'data:text/javascript;base64,' + Buffer.from(code).toString('base64')
|
||
loaded.set(path, url)
|
||
return url
|
||
}
|
||
|
||
await section('检查器的叫法和服务端报错一致', async () => {
|
||
// 校验和执行报错写的是 engine/labels.py 的 CHALLENGE_LABEL(字段)、CHALLENGE_ON_REFUTED_LABEL(「结论被推翻时」的
|
||
// 取值),报错让人去改某一项时,检查器上得是同一个名字,下拉里得找得到那个选项(选项只多一个「(默认)」)
|
||
const { CHALLENGE_TEXT: C } = await import(await load('/src/lib/terms.ts'))
|
||
const py = readFileSync(`${root}backend/app/engine/labels.py`, 'utf8')
|
||
const dict = (name) => {
|
||
const block = py.match(new RegExp(`^${name}: dict\\[str, str\\] = \\{([\\s\\S]*?)\\}$`, 'm'))?.[1]
|
||
return block == null ? null : Object.fromEntries([...block.matchAll(/"([^"]+)":\s*"([^"]+)"/g)].map((m) => [m[1], m[2]]))
|
||
}
|
||
const fields = dict('CHALLENGE_LABEL')
|
||
const options = dict('CHALLENGE_ON_REFUTED_LABEL')
|
||
check('从 labels.py 读出了 CHALLENGE_LABEL、CHALLENGE_ON_REFUTED_LABEL', !!fields && !!options
|
||
&& Object.keys(fields).length === 8 && Object.keys(options).length === 3, `${JSON.stringify(fields)} / ${JSON.stringify(options)}`)
|
||
const wrong = Object.entries(fields ?? {}).filter(([k, v]) => C.field?.[k] !== v)
|
||
check('检查器的字段名(CHALLENGE_TEXT.field)和 CHALLENGE_LABEL 一字不差、一项不多', !wrong.length
|
||
&& Object.keys(C.field ?? {}).length === Object.keys(fields ?? {}).length, wrong.map(([k, v]) => `${k}:${C.field?.[k]} ≠ ${v}`).join(';'))
|
||
const off = Object.entries(options ?? {}).filter(([k, v]) => (C.onRefuted?.[k] ?? '').replace(/(默认)$/, '') !== v)
|
||
check('「结论被推翻时」的下拉选项去掉「(默认)」后和 CHALLENGE_ON_REFUTED_LABEL 一字不差', !off.length
|
||
&& Object.keys(C.onRefuted ?? {}).length === 3, off.map(([k, v]) => `${k}:${C.onRefuted?.[k]} ≠ ${v}`).join(';'))
|
||
check('……只有缺省的那一项(出具降档)带「(默认)」', Object.entries(C.onRefuted ?? {}).filter(([, v]) => v.endsWith('(默认)'))
|
||
.map(([k]) => k).join(',') === 'degrade', JSON.stringify(C.onRefuted))
|
||
check('帮助里提到的选项名就是下拉里的名字', Object.values(options ?? {}).every((v) => C.help?.on_refuted?.includes(`${v}:`)), C.help?.on_refuted)
|
||
})
|
||
|
||
const browser = await chromium.launch({ executablePath: CHROME })
|
||
|
||
/**
|
||
* 开一个预览用例。工件按夹具答(BROKEN 先答 404,state.broken 置 false 后答正常内容),运行类别按 RUN_CLASS 答
|
||
* (runfail 先答 500,state.runFail 置 false 后照常答),画布校验答 state.issues;
|
||
* 记下取了哪些工件、问了哪些运行、拦下了哪些写请求
|
||
*/
|
||
async function open(syn, { width = 1100, height = 900, theme = 'dark' } = {}) {
|
||
const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, timezoneId: 'Asia/Shanghai' })
|
||
ctx.setDefaultTimeout(6000)
|
||
const page = await ctx.newPage()
|
||
const hits = { artifacts: [], runs: [], writes: [] }
|
||
const state = { broken: true, runFail: true, issues: [] }
|
||
const errors = []
|
||
page.on('pageerror', (e) => errors.push(e.message))
|
||
page.on('dialog', (d) => { errors.push(`原生对话框:${d.message()}`); void d.dismiss() })
|
||
await page.route((u) => new URL(u).pathname.startsWith('/api/'), async (route) => {
|
||
const req = route.request()
|
||
const path = new URL(req.url()).pathname
|
||
if (req.method() === 'POST' && path === '/api/workflows/validate') {
|
||
return route.fulfill({ json: { ok: !state.issues.some((i) => i.level === 'error'), issues: state.issues } })
|
||
}
|
||
if (req.method() === 'POST' && path === '/api/workflows/variables') return route.fulfill({ json: { variables: [], issues: [] } })
|
||
// 画布载入一张图时会找(没有就建)它的助手会话:答一份假的,不碰真实会话
|
||
if (path === '/api/conversations') {
|
||
return route.fulfill({ json: req.method() === 'GET' ? [] : { id: 'conv-challenge', kind: 'canvas', title: '', turns: [] } })
|
||
}
|
||
if (req.method() !== 'GET') {
|
||
hits.writes.push(`${req.method()} ${path}`)
|
||
return route.abort()
|
||
}
|
||
const art = path.match(/^\/api\/artifacts\/([^/]+)$/)
|
||
if (art) {
|
||
const id = decodeURIComponent(art[1])
|
||
hits.artifacts.push(id)
|
||
if (id === BROKEN && !state.broken) return route.fulfill({ json: { id, content: fx.artifacts[ART] } })
|
||
if (id === DUP) return route.fulfill({ json: { id, content: DUP_CONTENT } })
|
||
if (id === UNREVIEWED_ART) return route.fulfill({ json: { id, content: UNREVIEWED_CONTENT } })
|
||
const content = fx.artifacts[id]
|
||
return content ? route.fulfill({ json: { id, content } })
|
||
: route.fulfill({ status: 404, json: { detail: '工件不存在,可能已被删除' } })
|
||
}
|
||
const run = path.match(/^\/api\/runs\/([^/]+)$/)
|
||
if (run) {
|
||
const id = decodeURIComponent(run[1])
|
||
hits.runs.push(id)
|
||
if (id === RUNS.runfail && state.runFail) return route.fulfill({ status: 500, json: { detail: '服务暂时不可用' } })
|
||
if (id === RUNS.given) return route.fulfill({ status: 500, json: { detail: '服务暂时不可用' } })
|
||
return RUN_CLASS[id] ? route.fulfill({ json: { id, run_class: RUN_CLASS[id], status: 'interrupted' } })
|
||
: route.fulfill({ status: 404, json: { detail: '运行不存在' } })
|
||
}
|
||
return route.continue()
|
||
})
|
||
await page.goto(`${WEB}/preview.html?syn=${syn}&theme=${theme}`, { waitUntil: 'networkidle' })
|
||
return { ctx, page, hits, state, errors }
|
||
}
|
||
|
||
const text = async (loc) => (await loc.innerText()).replace(/\s+/g, ' ').trim()
|
||
const shot = async (page, name) => {
|
||
if (!SHOTS) return
|
||
mkdirSync(SHOTS, { recursive: true })
|
||
await page.screenshot({ path: `${SHOTS}/${name}.png`, fullPage: true })
|
||
}
|
||
/**
|
||
* 等界面画定:否定的断言(不写降档)得在这之后做。审批带着运行类别时卡上照它说、不另取,画上就是定了;老服务端的审批
|
||
* 不带运行类别(fetches 为真),要等卡上按运行 id 取的那一次答完
|
||
*/
|
||
async function runClassSettled(page, hits, runId, { fetches = false } = {}) {
|
||
if (fetches) for (let i = 0; i < 50 && !hits.runs.includes(runId); i++) await page.waitForTimeout(100)
|
||
await page.waitForTimeout(400)
|
||
}
|
||
const item = (page, unit) => page.locator(`[data-challenge-item="${unit}"]`)
|
||
const toggle = (page, unit) => item(page, unit).locator('[data-challenge-toggle]')
|
||
const detail = (page, unit) => page.locator(`[data-challenge-detail="${unit}"]`)
|
||
const config = (page) => page.evaluate(() => window.__studio.getState().nodes.find((n) => n.id === 'critic')?.data.config ?? null)
|
||
|
||
await section('审批卡:概况与降档提示', async () => {
|
||
const { ctx, page, hits, errors } = await open('challenge-approval')
|
||
const block = page.locator('[data-challenge="write"]')
|
||
await block.waitFor()
|
||
check('审批卡里有这份报告的反方审稿', (await text(block.locator('div').first())).includes('报告「写月报」的反方审稿'))
|
||
const counts = {}
|
||
for (const v of Object.keys(LABEL)) counts[v] = await text(block.locator(`[data-challenge-count="${v}"]`))
|
||
check('一行概况:四种判断各几句', counts.stands === '经受住 1 句' && counts.weakened === '被削弱 1 句'
|
||
&& counts.refuted === '被推翻 1 句' && counts.inconclusive === '无法判断 1 句', JSON.stringify(counts))
|
||
await page.waitForSelector('[data-challenge-degrade]')
|
||
check('正式运行、没写处理方式(按降档)、有被推翻的:醒目提示「出具会降档」',
|
||
(await text(page.locator('[data-challenge-degrade]'))) === '出具会降档')
|
||
// 原因不能只放在悬停提示和读屏文字里:触屏、键盘用户看不到(只标注那一档反而有常显的说明)
|
||
const note = page.locator('[data-challenge-degrade-note]')
|
||
check('降档的原因常显在概况下面,不靠悬停', await note.count() === 1 && await note.isVisible() && (await text(note)) === DEGRADE_NOTE
|
||
&& (await page.locator('[data-challenge-degrade]').getAttribute('title')) === null, await note.count() ? await text(note) : '没有')
|
||
check('不是只标注:没有「只做标注」那句', await page.locator('[data-challenge-flag]').count() === 0)
|
||
// 老服务端不给 degrades:按旧规则(不是只标注就降档),不写「不在出具路径上」
|
||
check('没给 degrades:按旧规则写降档,不写「不影响出具档位」', await page.locator('[data-challenge-degrade-off]').count() === 0)
|
||
const rows = await block.locator('[data-challenge-item]').evaluateAll((els) => els.map((el) => ({
|
||
unit: el.getAttribute('data-challenge-item'),
|
||
tag: el.querySelector('[data-challenge-tag]')?.textContent?.trim(),
|
||
text: el.querySelector('[data-challenge-text]')?.textContent?.trim(),
|
||
})))
|
||
const expected = fx.challenge.items.map((it) => ({ unit: it.unit, tag: LABEL[it.verdict], text: it.text }))
|
||
check('逐句列出结论原文和判断标签,顺序照审批的条目', JSON.stringify(rows) === JSON.stringify(expected), JSON.stringify(rows))
|
||
check('每句是一个收起的按钮', await block.locator('[data-challenge-toggle][aria-expanded="false"]').count() === 4)
|
||
check('没点开之前也说清判断出自反方模型', (await text(block.locator('[data-challenge-judged]'))) === '判断由反方模型给出,并非系统核对')
|
||
check('没点开之前不取工件', hits.artifacts.length === 0, hits.artifacts.join('、'))
|
||
check('审批带着运行类别:照它说,不另取', !hits.runs.includes(RUNS.formal), hits.runs.join('、'))
|
||
await shot(page, 'approval-dark')
|
||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||
await ctx.close()
|
||
})
|
||
|
||
await section('点开一句:取工件,显示理由与查询', async () => {
|
||
const { ctx, page, hits, errors } = await open('challenge-approval')
|
||
await toggle(page, 'u3').click()
|
||
await detail(page, 'u3').locator('[data-challenge-doc]').waitFor()
|
||
check('点开后标为展开、指向详情', (await toggle(page, 'u3').getAttribute('aria-expanded')) === 'true'
|
||
&& await page.locator(`#${CSS_ESC(await toggle(page, 'u3').getAttribute('aria-controls'))}[data-challenge-detail="u3"]`).count() === 1)
|
||
check('按 challenge_artifact 取了一次工件', JSON.stringify(hits.artifacts) === JSON.stringify([ART]), hits.artifacts.join('、'))
|
||
const u3 = detail(page, 'u3')
|
||
const rationale = await text(u3.locator('[data-challenge-doc]'))
|
||
check('显示理由正文(数字是查询结果里的值)', rationale.includes('9 月新客销售额 151,000') && rationale.includes('1,135,000'), rationale)
|
||
const cites = await u3.locator('[data-challenge-cite]').evaluateAll((els) => els.map((el) => [el.getAttribute('data-challenge-cite'),
|
||
el.querySelector('sup')?.textContent, el.getAttribute('aria-label'), el.getAttribute('data-ev-line')]))
|
||
check('有出处的数字照证据的样子画实线,后面挂查询编号', cites.length === 4
|
||
&& cites.every(([alias, sup, , line]) => alias === sup && line === 'solid')
|
||
&& JSON.stringify(cites.map((c) => c[0])) === JSON.stringify(['Q1', 'Q2', 'Q2', 'Q1']), JSON.stringify(cites.map((c) => c.slice(0, 2))))
|
||
check('读屏听得到引用的是哪次查询、哪一格', (cites[0]?.[2] ?? '').includes('出处:查询 Q1 · 第 1 行 · amount'), cites[0]?.[2])
|
||
const queries = await u3.locator('[data-challenge-query]').evaluateAll((els) => els.map((el) => ({
|
||
alias: el.getAttribute('data-challenge-query'), sql: el.querySelector('[data-challenge-sql]')?.textContent ?? '',
|
||
rows: el.querySelector('[data-challenge-rows]')?.textContent ?? '', cut: !!el.querySelector('[data-challenge-truncated]'),
|
||
})))
|
||
check('列出反方执行的查询:SQL、行数', queries.length === 2 && queries[0].alias === 'Q1' && queries[1].alias === 'Q2'
|
||
&& queries.every((q) => q.sql.includes('GROUP BY customer_type') && q.rows === '2 行' && !q.cut), JSON.stringify(queries.map((q) => [q.alias, q.rows])))
|
||
check('查询一节写明共几次', (await text(u3.locator('[data-challenge-queries] > div').first())) === '反方执行的查询(2 次)')
|
||
check('写明判断出自反方模型、不是系统核对(取到工件后带上模型名,整块只说一次)',
|
||
(await text(page.locator('[data-challenge="write"] [data-challenge-judged]'))) === '判断由反方模型「critic-large」给出,并非系统核对'
|
||
&& await page.locator('[data-challenge-judged]').count() === 1)
|
||
|
||
// 点理由里的数字:焦点落到那次查询上,描一下边
|
||
await u3.locator('[data-challenge-cite="Q2"]').first().click()
|
||
await page.waitForTimeout(100)
|
||
const jumped = await page.evaluate(() => {
|
||
const head = document.activeElement
|
||
const card = head?.closest('[data-challenge-query]')
|
||
return { head: head?.hasAttribute('data-challenge-query-head'), alias: card?.getAttribute('data-challenge-query'), flash: card?.getAttribute('data-flash') }
|
||
})
|
||
check('点有出处的数字:跳到它引用的那次查询并描边', jumped.head && jumped.alias === 'Q2' && jumped.flash === 'focus', JSON.stringify(jumped))
|
||
|
||
// 裸数字被标出来
|
||
await toggle(page, 'u5').click()
|
||
const u5 = detail(page, 'u5')
|
||
await u5.locator('[data-challenge-doc]').waitFor()
|
||
const bare = await u5.locator('.ev-mark[data-ev-state="none"]').allInnerTexts()
|
||
check('理由里的裸数字画点状线,句末挂「无证据」', JSON.stringify(bare.map((t) => t.replace(/\s*(无证据)$/, ''))) === JSON.stringify(['三', '70000', '一半'])
|
||
&& (await u5.locator('[data-ev-tag="none"]').innerText()).includes('无证据'), JSON.stringify(bare))
|
||
check('有出处的那个照样挂编号', await u5.locator('[data-challenge-cite="Q1"]').count() === 1)
|
||
check('同一张卡点第二句不再取工件', hits.artifacts.filter((a) => a === ART).length === 1, hits.artifacts.join('、'))
|
||
|
||
// 理由文档是另一个工件
|
||
await toggle(page, 'u8').click()
|
||
const u8 = detail(page, 'u8')
|
||
await u8.locator('[data-challenge-doc]').waitFor()
|
||
const u8Text = await text(u8.locator('[data-challenge-doc]'))
|
||
check('理由文档是工件 id 时去取它,按文档显示', hits.artifacts.includes(DOC) && u8Text.includes('9 月退款金额 21,500')
|
||
&& await u8.locator('[data-challenge-cite]').count() === 2, `${hits.artifacts.length} 次取工件 · ${u8Text}`)
|
||
|
||
// 没有理由文档:按渲染后的正文显示;查询截断照实说
|
||
await toggle(page, 'u11').click()
|
||
const u11 = detail(page, 'u11')
|
||
await u11.locator('[data-challenge-rationale-text]').waitFor()
|
||
check('没有理由文档时显示渲染后的正文', (await text(u11.locator('[data-challenge-rationale-text]'))) === '数据源中没有区分线上与门店的字段,无法判断这句结论。')
|
||
check('查询结果截断时写明', (await text(u11.locator('[data-challenge-rows]'))) === '50 行'
|
||
&& (await text(u11.locator('[data-challenge-truncated]'))) === '结果已截断,数据库中还有更多数据')
|
||
await shot(page, 'approval-open-dark')
|
||
|
||
// 收起;键盘开合
|
||
await toggle(page, 'u3').click()
|
||
check('再点一下收起', await detail(page, 'u3').count() === 0 && (await toggle(page, 'u3').getAttribute('aria-expanded')) === 'false')
|
||
await toggle(page, 'u3').focus()
|
||
await page.keyboard.press('Enter')
|
||
await detail(page, 'u3').locator('[data-challenge-doc]').waitFor()
|
||
check('键盘:回车展开', (await toggle(page, 'u3').getAttribute('aria-expanded')) === 'true')
|
||
await page.keyboard.press('Space')
|
||
await page.waitForTimeout(100)
|
||
check('键盘:空格收起', await detail(page, 'u3').count() === 0)
|
||
check('没有发出任何写请求', hits.writes.length === 0, hits.writes.join('、'))
|
||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||
await ctx.close()
|
||
})
|
||
|
||
// 下面几段各开各的页面、各自成段:一段中途出错(比如等一个不该出现的元素超时)不遮住其余几段
|
||
await section('只标注:不写降档', async () => {
|
||
const { ctx, page, hits, errors } = await open('challenge-approval-flag')
|
||
await page.locator('[data-challenge="write"]').waitFor()
|
||
await runClassSettled(page, hits, RUNS.flag)
|
||
const all = await text(page.locator('[data-challenge="write"]'))
|
||
check('只标注:不写「出具会降档」', await page.locator('[data-challenge-degrade], [data-challenge-degrade-note]').count() === 0
|
||
&& !all.includes('降档'), all)
|
||
check('只标注:写明被推翻的结论只做标注', (await text(page.locator('[data-challenge-flag]'))) === FLAG_NOTE)
|
||
check('只标注:页面没有报错', errors.length === 0, errors.join(';'))
|
||
await ctx.close()
|
||
})
|
||
|
||
await section('探索运行:不说出具', async () => {
|
||
const { ctx, page, hits, errors } = await open('challenge-approval-explore')
|
||
await page.locator('[data-challenge="write"]').waitFor()
|
||
await runClassSettled(page, hits, RUNS.explore)
|
||
const all = await text(page.locator('[data-challenge="write"]'))
|
||
check('探索运行:反方审稿里不说任何和出具有关的话', !all.includes('出具')
|
||
&& await page.locator('[data-challenge-degrade], [data-challenge-degrade-note], [data-challenge-flag]').count() === 0, all)
|
||
check('探索运行:概况和条目照常显示', (await text(page.locator('[data-challenge-count="refuted"]'))) === '被推翻 1 句'
|
||
&& await page.locator('[data-challenge-item]').count() === 4)
|
||
check('探索运行:页面没有报错', errors.length === 0, errors.join(';'))
|
||
await ctx.close()
|
||
})
|
||
|
||
await section('探索运行配只标注:同样不说出具', async () => {
|
||
const { ctx, page, hits, errors } = await open('challenge-approval-explore-flag')
|
||
await page.locator('[data-challenge="write"]').waitFor()
|
||
await runClassSettled(page, hits, RUNS.exploreFlag)
|
||
const all = await text(page.locator('[data-challenge="write"]'))
|
||
check('探索运行、只标注:不写「只做标注、不影响出具档位」', await page.locator('[data-challenge-flag]').count() === 0 && !all.includes('出具'), all)
|
||
check('探索运行、只标注:也不写降档', await page.locator('[data-challenge-degrade], [data-challenge-degrade-note]').count() === 0)
|
||
check('探索运行、只标注:页面没有报错', errors.length === 0, errors.join(';'))
|
||
await ctx.close()
|
||
})
|
||
|
||
await section('正式运行、没有被推翻的结论:不写降档', async () => {
|
||
for (const [syn, run, what] of [['challenge-approval-clear', RUNS.clear, '按降档'], ['challenge-approval-clear-flag', RUNS.clearFlag, '只标注']]) {
|
||
const { ctx, page, hits, errors } = await open(syn)
|
||
await page.locator('[data-challenge="write"]').waitFor()
|
||
await runClassSettled(page, hits, run)
|
||
const all = await text(page.locator('[data-challenge="write"]'))
|
||
check(`正式运行、${what}、被推翻 0 句:概况照实写 0 句`, (await text(page.locator('[data-challenge-count="refuted"]'))) === '被推翻 0 句'
|
||
&& await page.locator('[data-challenge-item]').count() === 3, all)
|
||
check(`正式运行、${what}、被推翻 0 句:既不写降档,也不写只做标注`,
|
||
await page.locator('[data-challenge-degrade], [data-challenge-degrade-note], [data-challenge-flag]').count() === 0
|
||
&& !all.includes('降档') && !all.includes('只做标注'), all)
|
||
check(`正式运行、${what}、被推翻 0 句:页面没有报错`, errors.length === 0, errors.join(';'))
|
||
await ctx.close()
|
||
}
|
||
})
|
||
|
||
await section('运行类别取不到:不说任何和出具有关的话,重新拿到焦点时再取', async () => {
|
||
// 老服务端的审批不带运行类别,只能按运行 id 取。取不到就不知道是不是正式运行:以前写「暂时无法确认运行类别;如为
|
||
// 正式运行……会降档」外加重试。期 4 起取不到时和出具有关的话一句不说,窗口重新拿到焦点时再取,取到了正式运行降档
|
||
// 提示才出现。审批带着运行类别时照它说、不另取,见下一节
|
||
const { ctx, page, hits, state, errors } = await open('challenge-approval-runfail')
|
||
const block = page.locator('[data-challenge="write"]')
|
||
await block.waitFor()
|
||
await runClassSettled(page, hits, RUNS.runfail, { fetches: true })
|
||
const quiet = async () => {
|
||
const all = await text(block)
|
||
return await page.locator('[data-challenge-degrade], [data-challenge-degrade-note], [data-challenge-degrade-off], '
|
||
+ '[data-challenge-flag], [data-challenge-runclass]').count() === 0 && !all.includes('出具') && !all.includes('运行类别')
|
||
}
|
||
check('取不到:不写降档、不写只标注,也不提运行类别', await quiet(), await text(block))
|
||
check('取不到:概况和条目照常显示', (await text(page.locator('[data-challenge-count="refuted"]'))) === '被推翻 1 句'
|
||
&& await page.locator('[data-challenge-item]').count() === 4)
|
||
check('取不到:不再给「重试」(重试的是运行类别,不是审稿内容)', await block.locator('button', { hasText: '重试' }).count() === 0)
|
||
// 窗口重新拿到焦点时再取一次(store 对出错的槽会重发):还是取不到,照样不说
|
||
await page.evaluate(() => window.dispatchEvent(new Event('focus')))
|
||
for (let i = 0; i < 30 && hits.runs.filter((r) => r === RUNS.runfail).length < 2; i++) await page.waitForTimeout(100)
|
||
check('窗口重新拿到焦点:再取一次运行类别', hits.runs.filter((r) => r === RUNS.runfail).length === 2, hits.runs.join('、'))
|
||
await page.waitForTimeout(300)
|
||
check('……还是取不到:照样不说出具', await quiet(), await text(block))
|
||
state.runFail = false
|
||
await page.evaluate(() => window.dispatchEvent(new Event('focus')))
|
||
await page.waitForSelector('[data-challenge-degrade]')
|
||
check('取到了正式运行:写「出具会降档」和原因', (await text(page.locator('[data-challenge-degrade]'))) === '出具会降档'
|
||
&& (await text(page.locator('[data-challenge-degrade-note]'))) === DEGRADE_NOTE && hits.runs.filter((r) => r === RUNS.runfail).length === 3,
|
||
hits.runs.join('、'))
|
||
check('取不到:页面没有报错', errors.length === 0, errors.join(';'))
|
||
await ctx.close()
|
||
})
|
||
|
||
await section('审批带着运行类别:照它说出具,不另取(另取失败也不影响)', async () => {
|
||
// 审批自带 approval.run_class(卡顶写的就是它)。以前反方审稿那一块只拿运行 id 另取一次,这一次失败了,正式运行里
|
||
// 「出具会降档」整块消失,卡顶却照样写着正式运行。用例把反方审稿那一块单独画,运行类别由调用方传进来,这次运行的
|
||
// GET 一律答 500
|
||
const { ctx, page, hits, errors } = await open('challenge-summary-given')
|
||
const block = page.locator('[data-challenge="write"]')
|
||
await block.waitFor()
|
||
await page.waitForTimeout(500)
|
||
check('给了运行类别(正式运行):写「出具会降档」和原因', (await text(page.locator('[data-challenge-degrade]'))) === '出具会降档'
|
||
&& (await text(page.locator('[data-challenge-degrade-note]'))) === DEGRADE_NOTE, await text(block))
|
||
check('……不另取运行类别', !hits.runs.includes(RUNS.given), hits.runs.join('、'))
|
||
check('……页面没有报错', errors.length === 0, errors.join(';'))
|
||
await ctx.close()
|
||
})
|
||
|
||
await section('整张审批卡:把审批带着的运行类别交给反方审稿那一块,运行的 GET 失败也照样写降档、不另取', async () => {
|
||
// RunPanel 的 ApprovalCard 得把 approval.run_class 传给 ChallengeSummary(同 ApprovalReports)。不传的话那一块按运行 id
|
||
// 另取一次:这里那次 GET 一律答 500,正式运行里「出具会降档」整块消失,卡顶却照样写着正式运行
|
||
const { ctx, page, hits, errors } = await open('challenge-approval-given')
|
||
const block = page.locator('[data-challenge="write"]')
|
||
await block.waitFor()
|
||
await page.waitForTimeout(500)
|
||
check('卡顶照审批写正式运行', await page.locator('[data-approval-run-class="formal"]').count() === 1)
|
||
const degrade = page.locator('[data-challenge-degrade]')
|
||
check('……反方审稿那一块照样写「出具会降档」和原因', await degrade.count() === 1 && (await text(degrade)) === '出具会降档'
|
||
&& (await text(page.locator('[data-challenge-degrade-note]'))) === DEGRADE_NOTE, await text(block))
|
||
check('……不另取这次运行的类别(那次 GET 答 500)', !hits.runs.includes(RUNS.given), hits.runs.join('、') || '一次都没取')
|
||
check('……页面没有报错', errors.length === 0, errors.join(';'))
|
||
await ctx.close()
|
||
})
|
||
|
||
await section('证据面板的单句展示、不谈出具:给了正式运行、服务端说会降档,也一句和出具有关的话都不说,不为此取运行类别', async () => {
|
||
// 证据面板看的是已经结束的运行,降没降档以报告的出具横幅为准,单句那一块传 issuance={false}。以前证据面板既不传运行 id
|
||
// 也不传运行类别,这个开关在哪个用例里都观察不到:改成忽略它,「出具会降档」悄悄回到单句面板也没有检查会发现。用例
|
||
// challenge-claim-quiet 把审批卡上写降档要的几样(正式运行、运行 id、被推翻、degrades)都给齐了;那次运行的类别要是
|
||
// 去取,答的是正式运行
|
||
const { ctx, page, hits, errors } = await open('challenge-claim-quiet')
|
||
const block = page.locator('[data-challenge="write"][data-challenge-scope="claim"]')
|
||
await block.waitFor()
|
||
await page.waitForTimeout(500)
|
||
const all = await text(block)
|
||
check('单句:只画这一句的判断(被推翻),不画报告级的标题和计数', (await text(block.locator('[data-challenge-tag]'))) === '被推翻'
|
||
&& await page.locator('[data-challenge-counts], [data-challenge-items]').count() === 0, all)
|
||
check('……不谈出具:不写降档、不在出具路径上、只标注', await page.locator('[data-challenge-degrade], [data-challenge-degrade-note], '
|
||
+ '[data-challenge-degrade-off], [data-challenge-flag]').count() === 0 && !all.includes('降档') && !all.includes('出具'), all)
|
||
check('……也不为此取这次运行的类别', !hits.runs.includes(RUNS.formal), hits.runs.join('、') || '一次都没取')
|
||
check('……页面没有报错', errors.length === 0, errors.join(';'))
|
||
await ctx.close()
|
||
})
|
||
|
||
await section('有句子反查没完成:单独说一行,不混在「无法判断」里,不插在降档标记和它的说明中间', async () => {
|
||
// 服务端的 unreviewed:核查模型不可用、触顶没给出结论、数据源不在了……这几句记为无法判断,可反查没有完成。「无法判断
|
||
// 3 句」分不出是查了、数据不够,还是没查成,审批卡单独说。未能反查的每一句都记为无法判断,unreviewed 不会多于它
|
||
const { ctx, page, hits, errors } = await open('challenge-approval-unreviewed')
|
||
const block = page.locator('[data-challenge="write"]')
|
||
await block.waitFor()
|
||
await runClassSettled(page, hits, RUNS.unreviewed)
|
||
const line = block.locator('[data-challenge-unreviewed]')
|
||
check('写明几句未能反查、记为无法判断,原因去时间线看', await line.count() === 1 && (await text(line)) === UNREVIEWED(2)
|
||
&& (await line.getAttribute('data-challenge-unreviewed')) === '2', await line.count() ? await text(line) : '没有')
|
||
const inconclusive = await text(block.locator('[data-challenge-count="inconclusive"]'))
|
||
check('……概况照服务端的计数写(无法判断 3 句:两句未能反查、一句查过了数据不够),未能反查的不多于无法判断',
|
||
inconclusive === '无法判断 3 句' && 2 <= Number(inconclusive.match(/\d+/)?.[0]), inconclusive)
|
||
check('……用「未能」,不说「没能」「真正」', !/没能|真正/.test(await text(block)))
|
||
// degrades 为真、正式运行、有被推翻的:照常写降档
|
||
check('服务端说会降档(degrades 为真):写「出具会降档」和原因', (await text(page.locator('[data-challenge-degrade]'))) === '出具会降档'
|
||
&& (await text(page.locator('[data-challenge-degrade-note]'))) === DEGRADE_NOTE && await page.locator('[data-challenge-degrade-off]').count() === 0)
|
||
// 降档的说明紧跟在概况那一行(「出具会降档」的标记)下面,未能反查的那一行在它后面
|
||
const order = await block.evaluate((el) => [...el.children].map((c) => (c.hasAttribute('data-challenge-counts') ? 'counts'
|
||
: c.hasAttribute('data-challenge-degrade-note') ? 'note' : c.hasAttribute('data-challenge-unreviewed') ? 'unreviewed' : '')).filter(Boolean))
|
||
check('……降档的说明紧跟在降档标记那一行下面,未能反查的那一行排在后面', order.join(',') === 'counts,note,unreviewed', order.join(','))
|
||
// 点开一句未能反查的:理由是系统说的原因,没有查询
|
||
await toggle(page, 'u5').click()
|
||
await detail(page, 'u5').locator('[data-challenge-rationale-text]').waitFor()
|
||
check('点开未能反查的一句:理由写明数据源已停用、这句未反查', (await text(detail(page, 'u5').locator('[data-challenge-rationale-text]')))
|
||
.includes('这句未反查'), await text(detail(page, 'u5')))
|
||
check('未能反查:页面没有报错', errors.length === 0, errors.join(';'))
|
||
await ctx.close()
|
||
// 老服务端不给 unreviewed(第一个用例)、给了 0(offpath 用例):都不写这一行
|
||
for (const [syn, run] of [['challenge-approval', RUNS.formal], ['challenge-approval-offpath', RUNS.offpath]]) {
|
||
const other = await open(syn)
|
||
await other.page.locator('[data-challenge="write"]').waitFor()
|
||
await runClassSettled(other.page, other.hits, run)
|
||
check(`${syn}:没有未能反查的句子时不写这一行`, await other.page.locator('[data-challenge-unreviewed]').count() === 0
|
||
&& !(await text(other.page.locator('[data-challenge="write"]'))).includes('未能反查'))
|
||
await other.ctx.close()
|
||
}
|
||
// 服务端给的 unreviewed 比「无法判断」还多(不该出现):按「无法判断」的句数说,两个数不打架
|
||
const over = await open('challenge-approval-unreviewed-over')
|
||
await over.page.locator('[data-challenge="write"]').waitFor()
|
||
await runClassSettled(over.page, over.hits, RUNS.over)
|
||
const capped = over.page.locator('[data-challenge-unreviewed]')
|
||
check('unreviewed 比「无法判断」多:按「无法判断」的句数说', (await capped.getAttribute('data-challenge-unreviewed')) === '1'
|
||
&& (await text(capped)).startsWith('1 句未能反查') && (await text(over.page.locator('[data-challenge-count="inconclusive"]'))) === '无法判断 1 句',
|
||
await text(capped))
|
||
await over.ctx.close()
|
||
})
|
||
|
||
await section('按降档处置、却不在出具路径上(degrades 为假):不写降档,说清为什么不影响', async () => {
|
||
// 反方审稿接在侧枝上(或成果节点没带出具契约):被推翻的结论记了缺口也进不了出具。以前照 on_refuted 写「出具会降档」,
|
||
// 审核人以为这次会降档,实际照常出具
|
||
const { ctx, page, hits, errors } = await open('challenge-approval-offpath')
|
||
const block = page.locator('[data-challenge="write"]')
|
||
await block.waitFor()
|
||
await runClassSettled(page, hits, RUNS.offpath)
|
||
check('不写「出具会降档」', await page.locator('[data-challenge-degrade], [data-challenge-degrade-note]').count() === 0
|
||
&& !(await text(block)).includes('出具会降档'), await text(block))
|
||
const off = page.locator('[data-challenge-degrade-off]')
|
||
check('写明反方审稿节点不在带出具契约的成果节点上游、不影响出具档位(不说「本节点」)', await off.count() === 1 && (await text(off)) === DEGRADE_OFF
|
||
&& !(await text(off)).includes('本节点'), await off.count() ? await text(off) : '没有')
|
||
check('不是只标注:没有「只做标注」那句', await page.locator('[data-challenge-flag]').count() === 0)
|
||
check('不在出具路径上:页面没有报错', errors.length === 0, errors.join(';'))
|
||
await ctx.close()
|
||
})
|
||
|
||
await section('没有反方审稿:什么都不画', async () => {
|
||
const { ctx, page, hits, errors } = await open('challenge-approval-none')
|
||
await page.locator('[data-approval-report="write"]').waitFor()
|
||
await page.waitForTimeout(500)
|
||
check('报告没有经过反方审稿:什么都不画', await page.locator('[data-challenge]').count() === 0)
|
||
check('报告没有经过反方审稿:不问运行类别、不取工件', !hits.runs.includes(RUNS.none) && hits.artifacts.length === 0,
|
||
`${hits.runs.join('、')} / ${hits.artifacts.join('、')}`)
|
||
check('报告核对概况照常显示', (await text(page.locator('[data-approval-report="write"]'))).includes('报告「写月报」的核对'))
|
||
check('没有反方审稿:页面没有报错', errors.length === 0, errors.join(';'))
|
||
await ctx.close()
|
||
})
|
||
|
||
await section('工件取不到:说明并可重试', async () => {
|
||
const { ctx, page, state, hits, errors } = await open('challenge-approval-broken')
|
||
await toggle(page, 'u3').click()
|
||
await page.waitForSelector('[data-challenge-detail="u3"] [data-challenge-detail-state="error"]')
|
||
check('取不到:写明无法获取,给重试', (await text(detail(page, 'u3'))).includes(FAILED)
|
||
&& await detail(page, 'u3').locator('button', { hasText: '重试' }).count() === 1)
|
||
check('取不到:不停在「正在加载」', await page.locator('[data-challenge-detail-state="loading"]').count() === 0)
|
||
state.broken = false
|
||
// 用键盘按重试:按钮在加载时就拿掉了,取到之后焦点得接到理由上
|
||
await detail(page, 'u3').locator('button', { hasText: '重试' }).focus()
|
||
await page.keyboard.press('Enter')
|
||
await detail(page, 'u3').locator('[data-challenge-doc]').waitFor()
|
||
check('重试:再取一次,取到后显示理由', hits.artifacts.filter((a) => a === BROKEN).length === 2
|
||
&& (await text(detail(page, 'u3').locator('[data-challenge-doc]'))).includes('151,000'))
|
||
await page.waitForTimeout(100)
|
||
check('键盘按重试:取到后焦点落到这一句的理由上,不掉到页面上', await page.evaluate(() =>
|
||
document.activeElement?.matches('[data-challenge-detail="u3"] [data-challenge-rationale]') ?? false),
|
||
await page.evaluate(() => document.activeElement?.tagName))
|
||
check('取不到:页面没有报错', errors.length === 0, errors.join(';'))
|
||
await ctx.close()
|
||
})
|
||
|
||
await section('超过十句:显示全部', async () => {
|
||
const { ctx, page, hits, errors } = await open('challenge-approval-many')
|
||
await page.locator('[data-challenge="write"]').waitFor()
|
||
check('条目只列前十句', await page.locator('[data-challenge-item]').count() === 10)
|
||
check('计数是全部十二句', (await text(page.locator('[data-challenge-count="stands"]'))) === '经受住 7 句'
|
||
&& (await text(page.locator('[data-challenge-count="refuted"]'))) === '被推翻 2 句')
|
||
check('写明另有几句未列出', (await text(page.locator('[data-challenge-more]'))).startsWith('另有 2 句未列出'))
|
||
check('没要之前不取工件', hits.artifacts.length === 0)
|
||
// 用键盘按:按钮所在那一行补齐后会拿掉,焦点得落到补出来的第一句上,不能掉到页面上、下一个 Tab 跳过新补的几句
|
||
await page.locator('[data-challenge-show-all]').focus()
|
||
await page.keyboard.press('Enter')
|
||
await page.waitForFunction(() => document.querySelectorAll('[data-challenge-item]').length === 12)
|
||
check('显示全部:从工件里补齐其余两句,排在后面', JSON.stringify(await page.locator('[data-challenge-item]').evaluateAll((els) =>
|
||
els.slice(10).map((el) => el.getAttribute('data-challenge-item')))) === JSON.stringify(['u30', 'u31']) && hits.artifacts.includes(MANY))
|
||
await page.waitForTimeout(100)
|
||
const focused = await page.evaluate(() => ({
|
||
toggle: document.activeElement?.hasAttribute('data-challenge-toggle') ?? false,
|
||
unit: document.activeElement?.closest('[data-challenge-item]')?.getAttribute('data-challenge-item') ?? document.activeElement?.tagName,
|
||
}))
|
||
check('键盘按「显示全部」:焦点落到补出来的第一句上', focused.toggle && focused.unit === 'u30', JSON.stringify(focused))
|
||
check('显示全部之后不再写「另有」', await page.locator('[data-challenge-more]').count() === 0)
|
||
check('补出来的一句带着自己的判断标签', (await text(item(page, 'u30').locator('[data-challenge-tag]'))) === '被推翻')
|
||
await toggle(page, 'u30').click()
|
||
await detail(page, 'u30').locator('[data-challenge-rationale-text]').waitFor()
|
||
const u30 = await text(detail(page, 'u30'))
|
||
check('补出来的那几句也能点开', u30.includes('白汀区的退款金额高于云屿区这一说法与查询结果不符'), u30)
|
||
check('超过十句:页面没有报错', errors.length === 0, errors.join(';'))
|
||
await ctx.close()
|
||
})
|
||
|
||
await section('显示全部:原文相同、unit 不同的一句不被吞掉', async () => {
|
||
// 同一句结论在摘要和正文各出现一次、各自被选中审稿:补的时候按 unit 认,不按原文
|
||
const { ctx, page, hits, errors } = await open('challenge-approval-dup')
|
||
await page.locator('[data-challenge="write"]').waitFor()
|
||
await page.locator('[data-challenge-show-all]').click()
|
||
await item(page, 'u31').waitFor()
|
||
const units = await page.locator('[data-challenge-item]').evaluateAll((els) => els.map((el) => el.getAttribute('data-challenge-item')))
|
||
check('补齐十二句,原文和 u20 相同的 u30 也在', units.length === 12 && units.includes('u30') && hits.artifacts.includes(DUP), JSON.stringify(units))
|
||
check('补出来的 u30 带着自己的判断(被推翻)', await item(page, 'u30').count() === 1
|
||
&& (await text(item(page, 'u30').locator('[data-challenge-tag]'))) === '被推翻')
|
||
await toggle(page, 'u20').click()
|
||
await detail(page, 'u20').locator('[data-challenge-rationale-text]').waitFor()
|
||
check('点开原文相同的 u20:显示的是 u20 自己的理由', !(await text(detail(page, 'u20'))).includes('白汀区的退款金额高于云屿区这一说法与查询结果不符'),
|
||
await text(detail(page, 'u20')))
|
||
check('原文相同:页面没有报错', errors.length === 0, errors.join(';'))
|
||
await ctx.close()
|
||
})
|
||
|
||
await section('审稿记录没存上:不能展开,说明原因', async () => {
|
||
const { ctx, page, hits, errors } = await open('challenge-approval-norecord')
|
||
const block = page.locator('[data-challenge="write"]')
|
||
await block.waitFor()
|
||
await page.waitForTimeout(300)
|
||
check('概况、判断照常列出', (await text(block.locator('[data-challenge-count="stands"]'))) === '经受住 7 句'
|
||
&& await block.locator('[data-challenge-item]').count() === 10)
|
||
check('每句不再是能点开的按钮(点开也看不到东西)', await block.locator('[data-challenge-toggle]').count() === 0
|
||
&& await block.locator('[data-challenge-item] [data-challenge-tag]').count() === 10)
|
||
const norecord = block.locator('[data-challenge-norecord]')
|
||
check('整块说一句审稿记录没存上、看不了理由和查询', await norecord.count() === 1
|
||
&& (await text(norecord)) === '反方审稿记录未保存,无法查看理由和查询', await norecord.count() ? await text(norecord) : '没有')
|
||
check('「另有 N 句未列出」照样交代,只是没有「显示全部」', (await text(block.locator('[data-challenge-more]'))) === '另有 2 句未列出'
|
||
&& await block.locator('[data-challenge-show-all]').count() === 0)
|
||
check('没有工件 id:不发取工件的请求', hits.artifacts.length === 0, hits.artifacts.join('、'))
|
||
check('审稿记录没存上:页面没有报错', errors.length === 0, errors.join(';'))
|
||
await ctx.close()
|
||
})
|
||
|
||
await section('检查器:字段写进节点配置', async () => {
|
||
const { ctx, page, hits, errors } = await open('challenge-inspector', { width: 1280, height: 1000 })
|
||
await page.locator('[data-field="report"]').waitFor()
|
||
const panel = page.locator('[data-challenge-inspector]')
|
||
check('节点说明不再写功能开发中', !(await text(panel)).includes('功能开发中'))
|
||
const card = page.locator('.react-flow__node[data-id="critic"]')
|
||
await card.waitFor()
|
||
check('节点卡片摘要:没配时写审上游报告、句数跟随设置', (await text(card)).includes('审上游报告 · 句数跟随设置'), await text(card))
|
||
|
||
// 要审的报告:只列上游的报告撰写节点
|
||
const report = page.locator('[data-field="report"] select')
|
||
const options = await report.locator('option').evaluateAll((els) => els.map((o) => [o.value, o.textContent]))
|
||
check('要审的报告:只列上游的报告撰写节点,下游的不列', JSON.stringify(options) === JSON.stringify([
|
||
['', '未选择:审上游的报告撰写节点'], ['write', '写月报(write)'], ['write2', '写周报(write2)']]), JSON.stringify(options))
|
||
check('上游有两个、又没选:提醒选一个', (await text(page.locator('[data-node-pick-hint="many"]'))) === '上游有 2 个报告撰写节点,请选择要审的那一个')
|
||
await report.selectOption('write')
|
||
check('选了报告:写进 report', (await config(page))?.report === 'write')
|
||
check('选了之后不再提醒', await page.locator('[data-node-pick-hint]').count() === 0)
|
||
|
||
await page.locator('[data-field="max_claims"] input').fill('3')
|
||
check('最多审几句:写进 max_claims(数)', (await config(page))?.max_claims === 3)
|
||
await page.waitForTimeout(150)
|
||
check('节点卡片摘要写清审哪份报告、最多几句', (await text(card)).includes('审报告节点 write · 最多审 3 句结论'), await text(card))
|
||
|
||
// 反方模型
|
||
const provider = page.locator('[data-field="provider"] select')
|
||
const providers = await provider.locator('option').evaluateAll((els) => els.map((o) => o.textContent))
|
||
check('反方模型 · 接入:跟随设置在第一项,停用的接入标出来', JSON.stringify(providers) === JSON.stringify(['跟随设置', '主力接入', '备用接入(已停用)']),
|
||
JSON.stringify(providers))
|
||
await provider.selectOption('主力接入')
|
||
const model = page.locator('[data-field="model"] input')
|
||
await model.fill('writer-large')
|
||
let c = await config(page)
|
||
check('接入、模型写进 provider、model', c?.provider === '主力接入' && c?.model === 'writer-large', JSON.stringify(c))
|
||
check('和撰写报告的模型相同:提醒', (await text(page.locator('[data-verify-same-model]'))).includes('「writer-large」'))
|
||
await model.fill('critic-large')
|
||
check('换一个模型:不再提醒', await page.locator('[data-verify-same-model]').count() === 0)
|
||
check('模型框给出所选接入的模型', JSON.stringify(await page.locator('[data-field="model"] datalist option').evaluateAll((els) => els.map((o) => o.value)))
|
||
=== JSON.stringify(['writer-large', 'critic-large']))
|
||
await provider.selectOption('')
|
||
c = await config(page)
|
||
check('接入改回留空:配置里拿掉 provider', !('provider' in (c ?? {})) || c.provider === undefined, JSON.stringify(c))
|
||
check('只填了模型:接入一项写「按模型名找接入」,并说明会用哪个接入',
|
||
(await provider.locator('option').first().textContent()) === '按模型名找接入'
|
||
&& (await text(page.locator('[data-verify-by-model="found"]'))).includes('「主力接入」'))
|
||
|
||
// 被推翻时怎么办
|
||
const onRefuted = page.locator('[data-field="on_refuted"] select')
|
||
check('结论被推翻时:没写时落在「出具降档(默认)」', (await onRefuted.evaluate((s) => s.options[s.selectedIndex]?.textContent)) === '出具降档(默认)')
|
||
await onRefuted.selectOption('flag')
|
||
check('选只标注:写进 on_refuted', (await config(page))?.on_refuted === 'flag')
|
||
await onRefuted.selectOption('fail')
|
||
check('选报错:写进 on_refuted', (await config(page))?.on_refuted === 'fail')
|
||
|
||
// 启用:三态
|
||
const group = page.locator('[data-field="enabled"] [role="radiogroup"]')
|
||
check('启用:一组单选,组名是「启用」,默认跟随设置', (await group.getAttribute('aria-label')) === '启用'
|
||
&& (await group.getAttribute('data-tri-state')) === 'follow' && !('enabled' in (await config(page))))
|
||
// 指到真实存在的入口:设置页的标签是 模型接入 / 偏好设置 / 运行环境,「核查」一节在偏好设置里
|
||
check('跟随设置时说明跟的是哪一项(入口写全:设置 → 偏好设置 → 核查)',
|
||
(await text(page.locator('[data-tri-hint]'))) === '使用「设置 → 偏好设置 → 核查」中「反方审稿 · 启用」的值')
|
||
check('检查器里提到设置的地方都写全入口', !(await text(panel)).includes('「设置 → 核查」'))
|
||
await group.locator('[data-tri="off"]').click()
|
||
check('点关闭:写进 enabled: false', (await config(page))?.enabled === false && (await group.getAttribute('data-tri-state')) === 'off')
|
||
await page.keyboard.press('ArrowLeft')
|
||
check('键盘:方向键在组内移动并选中(关闭 → 开启)', (await config(page))?.enabled === true
|
||
&& await page.evaluate(() => document.activeElement?.getAttribute('data-tri')) === 'on')
|
||
await group.locator('[data-tri="follow"]').click()
|
||
check('点跟随设置:配置里拿掉 enabled,不写 null 也不写空字符串', !('enabled' in (await config(page))) || (await config(page)).enabled === undefined,
|
||
JSON.stringify(await config(page)))
|
||
await group.locator('[data-tri="off"]').click()
|
||
await page.waitForTimeout(150)
|
||
check('关掉的节点:卡片摘要写已关闭', (await text(card)).includes('已关闭 · 审报告节点 write'), await text(card))
|
||
|
||
// 高级选项里的两项上限
|
||
await panel.locator('button', { hasText: '高级选项' }).click()
|
||
await page.locator('[data-field="max_queries"] input').fill('2')
|
||
await page.locator('[data-field="timeout_s"] input').fill('60')
|
||
c = await config(page)
|
||
check('每句最多查询次数、每句时长上限写进 max_queries、timeout_s', c?.max_queries === 2 && c?.timeout_s === 60, JSON.stringify(c))
|
||
await page.locator('[data-field="max_queries"] input').fill('')
|
||
check('清空上限:配置里拿掉这一项(跟随设置)', (await config(page))?.max_queries === undefined)
|
||
|
||
// 选中的报告挪到下游、被删掉:照实列出来,不像「未选择」
|
||
await page.evaluate(() => {
|
||
const s = window.__studio.getState()
|
||
const n = s.nodes.find((x) => x.id === 'critic')
|
||
s.updateNode('critic', { config: { ...n.data.config, report: 'later' } })
|
||
})
|
||
await page.waitForTimeout(100)
|
||
check('选中的报告在下游:下拉里照实标「不在上游」,并提醒', (await report.evaluate((s) => s.options[s.selectedIndex]?.textContent)) === '写摘要(later)(不在上游)'
|
||
&& await page.locator('[data-node-pick-hint="notUpstream"]').count() === 1)
|
||
await page.evaluate(() => {
|
||
const s = window.__studio.getState()
|
||
const n = s.nodes.find((x) => x.id === 'critic')
|
||
s.updateNode('critic', { config: { ...n.data.config, report: 'ghost' } })
|
||
})
|
||
await page.waitForTimeout(100)
|
||
check('选中的报告已不在画布上:照实标出,并提醒', (await report.evaluate((s) => s.options[s.selectedIndex]?.textContent)) === 'ghost(已不在画布上)'
|
||
&& await page.locator('[data-node-pick-hint="missing"]').count() === 1)
|
||
// 选中的是上游的输入节点(助手改图、导入 JSON 会写出这种配置):它在上游,错在类型,不能说成「不在上游」
|
||
await page.evaluate(() => {
|
||
const s = window.__studio.getState()
|
||
const n = s.nodes.find((x) => x.id === 'critic')
|
||
s.updateNode('critic', { config: { ...n.data.config, report: 'input' } })
|
||
})
|
||
await page.waitForTimeout(100)
|
||
const notReport = page.locator('[data-node-pick-hint="wrongType"]')
|
||
check('选中的是上游的非报告节点:下拉照实标「不是报告撰写节点」,提示说清它是哪类节点',
|
||
(await report.evaluate((s) => s.options[s.selectedIndex]?.textContent)) === '输入(input)(不是报告撰写节点)'
|
||
&& await notReport.count() === 1 && (await text(notReport)) === '所选节点是「输入」节点,不是报告撰写节点,请重新选择'
|
||
&& await page.locator('[data-node-pick-hint="notUpstream"]').count() === 0,
|
||
`${await report.evaluate((s) => s.options[s.selectedIndex]?.textContent)} · ${await notReport.count() ? await text(notReport) : '没有提示'}`)
|
||
await shot(page, 'inspector-dark')
|
||
check('检查器:没有发出任何写请求', hits.writes.length === 0, hits.writes.join('、'))
|
||
check('检查器:页面没有报错', errors.length === 0, errors.join(';'))
|
||
await ctx.close()
|
||
})
|
||
|
||
await section('检查器:链式接法按服务端的认法定要审的报告', async () => {
|
||
// schema.challenge_report:留空时先看直接连着的报告撰写节点,恰好一个就审它(「初稿 → 终稿 → 反方审稿」审终稿),
|
||
// 否则才看整个上游是否只有一个
|
||
const { ctx, page, errors } = await open('challenge-inspector-chain', { width: 1280, height: 1000 })
|
||
await page.locator('[data-field="report"]').waitFor()
|
||
const report = page.locator('[data-field="report"] select')
|
||
const options = await report.locator('option').evaluateAll((els) => els.map((o) => [o.value, o.textContent]))
|
||
check('留空那一项写出实际会审的那份(终稿),两份上游报告都可选', JSON.stringify(options) === JSON.stringify([
|
||
['', '未选择:审上游的「终稿」'], ['draft', '初稿(draft)'], ['final', '终稿(final)']]), JSON.stringify(options))
|
||
check('服务端照常审终稿、不报错:检查器也不要求选择', await page.locator('[data-node-pick-hint]').count() === 0,
|
||
await page.locator('[data-node-pick-hint]').count() ? await text(page.locator('[data-node-pick-hint]')) : '')
|
||
check('反方模型和终稿的模型相同:提醒', await page.locator('[data-verify-same-model]').count() === 1
|
||
&& (await text(page.locator('[data-verify-same-model]'))).includes('「writer-large」'))
|
||
// 改成审初稿(模型不同):同模型提醒随之消失
|
||
await report.selectOption('draft')
|
||
check('改审初稿:配置写进 report,同模型提醒消失', (await config(page))?.report === 'draft'
|
||
&& await page.locator('[data-verify-same-model]').count() === 0)
|
||
await shot(page, 'inspector-chain-dark')
|
||
check('链式接法:页面没有报错', errors.length === 0, errors.join(';'))
|
||
await ctx.close()
|
||
})
|
||
|
||
await section('检查器:画布校验报了就不重复说', async () => {
|
||
// 服务端(F3a 的 _check_challenge)在同样情形下会发 report / model 字段上的问题,检查器把它们挂在字段下面;
|
||
// 本地那句提示再画一遍就是同一件事说两遍,还一句琥珀一句红(同结论句裁判「校验也会报,报了就不重复说」)
|
||
const { ctx, page, state, errors } = await open('challenge-inspector', { width: 1280, height: 1000 })
|
||
await page.locator('[data-field="report"]').waitFor()
|
||
const reportField = page.locator('[data-field="report"]')
|
||
const settle = async (issues, wait) => {
|
||
state.issues = issues
|
||
await page.evaluate(() => window.__studio.getState().analyzeNow())
|
||
if (wait) await page.getByText(wait, { exact: false }).first().waitFor()
|
||
await page.waitForTimeout(150)
|
||
}
|
||
check('校验没报时:本地提醒照常(上游有两个)', await page.locator('[data-node-pick-hint="many"]').count() === 1)
|
||
await settle([{ level: 'error', node_id: 'critic', field: 'report', code: 'challenge.report_invalid', message: SERVER_MANY }], SERVER_MANY)
|
||
check('校验报了「上游有多个」:字段下只剩服务端那一句', await page.locator('[data-node-pick-hint]').count() === 0
|
||
&& (await text(reportField)).includes(SERVER_MANY), await text(reportField))
|
||
await page.evaluate(() => {
|
||
const s = window.__studio.getState()
|
||
const n = s.nodes.find((x) => x.id === 'critic')
|
||
s.updateNode('critic', { config: { ...n.data.config, report: 'ghost' } })
|
||
})
|
||
await settle([{ level: 'error', node_id: 'critic', field: 'report', code: 'challenge.report_invalid', message: SERVER_GHOST }], SERVER_GHOST)
|
||
check('校验报了「不存在这个节点」:不再画本地的「已不在画布上」提示,下拉里照实标出的那一项保留',
|
||
await page.locator('[data-node-pick-hint]').count() === 0
|
||
&& (await reportField.locator('select').evaluate((s) => s.options[s.selectedIndex]?.textContent)) === 'ghost(已不在画布上)',
|
||
await text(reportField))
|
||
await reportField.locator('select').selectOption('write')
|
||
await page.locator('[data-field="model"] input').fill('writer-large')
|
||
await settle([], null)
|
||
check('校验没报同模型时:本地提醒照常', await page.locator('[data-verify-same-model]').count() === 1)
|
||
await settle([{ level: 'warning', node_id: 'critic', field: 'model', code: 'challenge.same_model', message: SERVER_SAME }], SERVER_SAME)
|
||
const modelField = page.locator('[data-field="model"]')
|
||
check('校验报了「模型相同」:字段下只剩服务端那一句', await page.locator('[data-verify-same-model]').count() === 0
|
||
&& (await text(modelField)).includes(SERVER_SAME), await text(modelField))
|
||
await shot(page, 'inspector-dedupe-dark')
|
||
check('校验报重复:页面没有报错', errors.length === 0, errors.join(';'))
|
||
await ctx.close()
|
||
})
|
||
|
||
/** 页面和某个容器都没有横向滚动(SQL 框自己的滚动条不算) */
|
||
async function overflow(page, sel) {
|
||
return page.evaluate((s) => {
|
||
const doc = document.documentElement
|
||
const box = document.querySelector(s)
|
||
return { page: doc.scrollWidth - doc.clientWidth, box: box ? box.scrollWidth - box.clientWidth : -1 }
|
||
}, sel)
|
||
}
|
||
|
||
/** 两个颜色的对比度(WCAG):颜色是 getComputedStyle 给的 rgb / rgba / color(srgb …),半透明的叠在底色上 */
|
||
function contrast(fg, bg, base) {
|
||
const parse = (c) => {
|
||
const m = c.match(/color\(srgb ([\d.]+) ([\d.]+) ([\d.]+)(?: \/ ([\d.]+))?\)/)
|
||
if (m) return [m[1] * 255, m[2] * 255, m[3] * 255, m[4] == null ? 1 : Number(m[4])]
|
||
const n = c.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/)
|
||
return n ? [Number(n[1]), Number(n[2]), Number(n[3]), n[4] == null ? 1 : Number(n[4])] : null
|
||
}
|
||
const over = (top, under) => top.slice(0, 3).map((v, i) => v * top[3] + under[i] * (1 - top[3]))
|
||
const lum = ([r, g, b]) => {
|
||
const f = (v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 }
|
||
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b)
|
||
}
|
||
const b0 = parse(base); const b1 = parse(bg); const f0 = parse(fg)
|
||
if (!b0 || !b1 || !f0) return 0
|
||
const under = over(b1, b0.slice(0, 3))
|
||
const a = lum(over(f0, under)); const b = lum(under)
|
||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
|
||
}
|
||
|
||
await section('窄屏与深浅色', async () => {
|
||
for (const theme of ['dark', 'light']) {
|
||
const { ctx, page, errors } = await open('challenge-approval', { width: 390, height: 844, theme })
|
||
await page.waitForSelector('[data-challenge-degrade]')
|
||
for (const unit of ['u3', 'u5', 'u11']) {
|
||
await toggle(page, unit).click()
|
||
await detail(page, unit).locator('[data-challenge-queries]').waitFor()
|
||
}
|
||
const o = await overflow(page, '[data-challenge="write"]')
|
||
check(`390px(${theme}):展开三句也没有横向溢出`, o.page <= 0 && o.box <= 1, JSON.stringify(o))
|
||
const colors = await page.evaluate(() => {
|
||
const block = document.querySelector('[data-challenge="write"]')
|
||
const base = getComputedStyle(document.body).backgroundColor
|
||
const bg = getComputedStyle(block).backgroundColor
|
||
const pick = (el) => ({ fg: getComputedStyle(el).color, weight: Number(getComputedStyle(el).fontWeight) })
|
||
return {
|
||
base, bg,
|
||
degrade: pick(document.querySelector('[data-challenge-degrade]')),
|
||
tags: [...document.querySelectorAll('[data-challenge-tag]')].slice(0, 4).map((el) => ({ v: el.getAttribute('data-challenge-tag'), ...pick(el) })),
|
||
counts: [...document.querySelectorAll('[data-challenge-count]')].map((el) => ({ v: el.getAttribute('data-challenge-count'), ...pick(el) })),
|
||
sup: pick(document.querySelector('[data-challenge-cite] sup')),
|
||
}
|
||
})
|
||
const ratio = (c) => contrast(c.fg, colors.bg, colors.base)
|
||
check(`${theme}:「出具会降档」对比度 ≥ 4.5、加粗`, ratio(colors.degrade) >= 4.5 && colors.degrade.weight >= 600,
|
||
`${ratio(colors.degrade).toFixed(2)} · ${colors.degrade.weight}`)
|
||
const tagRatios = colors.tags.map((t) => [t.v, ratio(t).toFixed(2)])
|
||
check(`${theme}:四种判断标签对比度都 ≥ 4.5`, colors.tags.length === 4 && colors.tags.every((t) => ratio(t) >= 4.5), JSON.stringify(tagRatios))
|
||
check(`${theme}:概况里的句数对比度都 ≥ 4.5`, colors.counts.every((t) => ratio(t) >= 4.5),
|
||
JSON.stringify(colors.counts.map((t) => [t.v, ratio(t).toFixed(2)])))
|
||
check(`${theme}:数字后面的查询编号对比度 ≥ 4.5`, ratio(colors.sup) >= 4.5, ratio(colors.sup).toFixed(2))
|
||
await shot(page, `narrow-${theme}`)
|
||
check(`390px(${theme}):页面没有报错`, errors.length === 0, errors.join(';'))
|
||
await ctx.close()
|
||
}
|
||
// 几行说明(降档原因、未能反查、不在出具路径上、审稿记录没存上):窄屏不撑宽,深浅色都看得清
|
||
for (const theme of ['dark', 'light']) {
|
||
const notes = [
|
||
['challenge-approval', '[data-challenge-degrade-note]'],
|
||
['challenge-approval-unreviewed', '[data-challenge-unreviewed] > span:first-child'],
|
||
['challenge-approval-unreviewed', '[data-challenge-unreviewed] > span:last-child'],
|
||
['challenge-approval-offpath', '[data-challenge-degrade-off]'],
|
||
['challenge-approval-norecord', '[data-challenge-norecord]'],
|
||
]
|
||
for (const [syn, sel] of notes) {
|
||
const { ctx, page, errors } = await open(syn, { width: 390, height: 844, theme })
|
||
await page.locator(sel).waitFor()
|
||
const o = await overflow(page, '[data-challenge="write"]')
|
||
const c = await page.evaluate((s) => ({
|
||
fg: getComputedStyle(document.querySelector(s)).color,
|
||
bg: getComputedStyle(document.querySelector('[data-challenge="write"]')).backgroundColor,
|
||
base: getComputedStyle(document.body).backgroundColor,
|
||
}), sel)
|
||
const r = contrast(c.fg, c.bg, c.base)
|
||
check(`390px(${theme})${syn}:说明不横向溢出、对比度 ≥ 4.5`, o.page <= 0 && o.box <= 1 && r >= 4.5, `${JSON.stringify(o)} · ${r.toFixed(2)}`)
|
||
await shot(page, `${syn.replace('challenge-', '')}-narrow-${theme}`)
|
||
check(`390px(${theme})${syn}:页面没有报错`, errors.length === 0, errors.join(';'))
|
||
await ctx.close()
|
||
}
|
||
}
|
||
{
|
||
const { ctx, page } = await open('challenge-approval-dense', { width: 1100, height: 900 })
|
||
await page.locator('[data-challenge="write"]').waitFor()
|
||
await toggle(page, 'u3').click()
|
||
await detail(page, 'u3').locator('[data-challenge-queries]').waitFor()
|
||
const o = await overflow(page, '[data-fixture="challenge-approval-dense"]')
|
||
check('360px 右栏:展开后不撑宽右栏', o.page <= 0 && o.box <= 0, JSON.stringify(o))
|
||
await shot(page, 'dense-dark')
|
||
await ctx.close()
|
||
}
|
||
for (const theme of ['dark', 'light']) {
|
||
const { ctx, page, errors } = await open('challenge-inspector', { width: 390, height: 844, theme })
|
||
await page.locator('[data-field="report"]').waitFor()
|
||
await page.locator('[data-field="model"] input').fill('writer-large')
|
||
await page.waitForSelector('[data-verify-by-model]')
|
||
const o = await overflow(page, '[data-challenge-inspector]')
|
||
check(`390px(${theme}):检查器不横向溢出`, o.page <= 0 && o.box <= 0, JSON.stringify(o))
|
||
await shot(page, `inspector-narrow-${theme}`)
|
||
check(`390px 检查器(${theme}):页面没有报错`, errors.length === 0, errors.join(';'))
|
||
await ctx.close()
|
||
}
|
||
})
|
||
|
||
function CSS_ESC(id) {
|
||
return String(id ?? '').replace(/([^a-zA-Z0-9_-])/g, '\\$1')
|
||
}
|
||
|
||
await browser.close()
|
||
console.log(failed ? `\n✗ 反方审稿:${failed} 项未通过` : '\n✓ 反方审稿:全部通过')
|
||
process.exit(failed ? 1 : 0)
|