mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 23:41:58 +00:00
审批质询(前端)复核整改(第 1 轮):同一张卡挂两处、换了审批、报告改写前的问答各归其位,句末入口不再挤动正文
- 提问框记下打开它的那一处(host)和那张审批:画布的审批浮层和右栏同时挂着同一张卡时,提问框只画在点的那一处,焦点不跳到另一处;另一处写明「提问框已在另一处打开」,可「在这里继续」;浮层关了由右栏接过去,不抢焦点。换了审批(同一份报告的下一级审批、驳回改写后再审)旧的提问框不再显示,提交一律带页面此刻的审批 id,不会再拿旧 id 一直 409 - 问答串按每条问的是哪一稿报告分开:别的审批里问的,按审批列表里那张审批审的文档对一下,对上了才按句子编号挂;改写前的、对不上的单独一组「报告其他版本上的质询」,不拿当前报告同编号的句子当抬头,回答里引用报告出处的数也不去当前这一稿里找。运行详情同理(封存之后问的认封存的那一稿) - 句末的「质询」改成固定宽度的淡色图标,悬停、聚焦只换颜色和描边,不再从宽 0 撑开:后文不右移、表格列不跳;表格格子不挂(问表里的数划选或就整份报告提问) - Esc、「取消」之后焦点回到那一句的「质询」并滚回视野(页面和限高的报告框都滚) - 回答期间另开的提问框不再被失败的那一条覆盖:失败的问题和原因挂在原来那一句下面,提问框关着时可以放回提问框重问 - 写进运行记录后:画布正显示这次运行、流已收尾时按已有的最大序号补接一次;运行详情补拉一次事件(pages/runs/useRunDetail.ts 订阅一个计数)。时间线因此出现「审批人质询」 - 运行详情对所有运行只读列出已有问答(正式运行审批时问过的随运行封存,审批处理后也看得到查过的 SQL、行数、快照),入口仍只给封存了的探索运行;设置里关了审批质询就不给入口,已有问答照列并写明已关闭 - 小项:读屏连续两次回答都播报;「已等待 N 秒」挪出播报区;花费照 runCostText 写(估不出不写 $0);查询列表收着时也写有几次被截断;报告没有逐段证据时提示只说可以就整份报告提问;列表取回来之前不出「还没有问答」的提示;回答里核对不了的名称、猜测的来源照报告的线型画;回答期间批准 / 驳回下面那句原因在浅色主题下对比度够了 - RunPanel 改传这张卡的审批 id 和是否待处理(处理过的卡也认得哪几条问的是卡上这一稿);check-all 里 check-inquiry 的预计时长按实测改成 60 秒 - check-inquiry 补上各项:悬停前后正文和表格不移位、焦点回去后在视野里、同一张卡挂两处、先后两张审批、改写前的问答单独一组、失败不覆盖新写的、连续播报、花费、截断、无文档、设置关闭、遮罩原值不外露(含导出的 CSV)、补接事件流;新增预览用例 inquiry-approval-twice / -steps / -steps-same / -nodoc
This commit is contained in:
@@ -98,7 +98,7 @@ const ESTIMATE = {
|
||||
stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87,
|
||||
evidence: 91, versions: 60, catalog: 45, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
|
||||
// 占位,换成真检查时按实测改
|
||||
schedules: 60, compare: 45, verify: 71, inquiry: 22, bundle: 16, whatif: 33,
|
||||
schedules: 60, compare: 45, verify: 71, inquiry: 60, bundle: 16, whatif: 33,
|
||||
}
|
||||
// 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段
|
||||
// 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。
|
||||
|
||||
+558
-20
@@ -16,13 +16,14 @@
|
||||
//
|
||||
// node scripts/check-inquiry.mjs
|
||||
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-inquiry.mjs
|
||||
// INQUIRY_SHOTS=<目录> 时截几张图,供人眼复核。
|
||||
// INQUIRY_SHOTS=<目录> 时截几张图,供人眼复核。INQUIRY_ONLY=<字> 只跑名字里带这几个字的段(改代码时用)。
|
||||
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.INQUIRY_SHOTS ?? ''
|
||||
const ONLY = process.env.INQUIRY_ONLY ?? ''
|
||||
const root = new URL('..', import.meta.url).pathname
|
||||
const fx = JSON.parse(readFileSync(`${root}frontend/src/dev/fixtures/inquiry.json`, 'utf8'))
|
||||
const RUNS = fx.runs
|
||||
@@ -44,6 +45,7 @@ const check = (name, cond, detail = '') => {
|
||||
if (!cond) failed++
|
||||
}
|
||||
async function section(name, fn) {
|
||||
if (ONLY && !name.includes(ONLY)) return
|
||||
console.log(`\n=== ${name} ===`)
|
||||
try {
|
||||
await fn()
|
||||
@@ -63,18 +65,26 @@ const answerFor = (body, extra = {}) => ({
|
||||
|
||||
/**
|
||||
* 开一个预览用例。列表按运行答夹具里的问答(state.lists 可改),提问按 state.reply 答({status, json} 或按请求体算的函数;
|
||||
* state.gate 是个没兑现的 Promise 时先挂着,测「回答期间」)。记下列表、提问、取快照、取工件的请求和拦下的写请求
|
||||
* state.gate 是个没兑现的 Promise 时先挂着,测「回答期间」;state.listGate 同样挂着列表)。审批列表按 state.approvals
|
||||
* (运行 → 审批数组)答,设置按 state.settings 答;state.docs、state.snaps 盖过夹具里的文档、快照。setup 在打开页面之前
|
||||
* 改 state(进入时就要取的列表、设置得先备好);ws 接管运行的事件流。记下列表、提问、取快照、取工件、取审批列表的请求和拦下的写请求
|
||||
*/
|
||||
async function open(syn, { width = 1100, height = 900, theme = 'dark' } = {}) {
|
||||
async function open(syn, { width = 1100, height = 900, theme = 'dark', setup, ws, waitUntil = 'networkidle' } = {}) {
|
||||
const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, timezoneId: 'Asia/Shanghai' })
|
||||
ctx.setDefaultTimeout(6000)
|
||||
const page = await ctx.newPage()
|
||||
const hits = { lists: [], asks: [], snapshots: [], artifacts: [], segments: [], writes: [], other: [] }
|
||||
const hits = { lists: [], asks: [], snapshots: [], artifacts: [], segments: [], approvals: [], writes: [], other: [] }
|
||||
const state = {
|
||||
lists: { [RUNS.approve]: fx.items, [RUNS.formal]: fx.items, [RUNS.explore]: fx.explore_items },
|
||||
reply: (body) => ({ status: 200, json: answerFor(body) }),
|
||||
gate: null,
|
||||
listGate: null,
|
||||
approvals: {},
|
||||
settings: {},
|
||||
docs: {},
|
||||
snaps: {},
|
||||
}
|
||||
setup?.(state)
|
||||
const errors = []
|
||||
page.on('pageerror', (e) => errors.push(e.message))
|
||||
page.on('dialog', (d) => { errors.push(`原生对话框:${d.message()}`); void d.dismiss() })
|
||||
@@ -98,12 +108,21 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark' } = {}) {
|
||||
if (inq) {
|
||||
const run = decodeURIComponent(inq[1])
|
||||
hits.lists.push({ run, report: url.searchParams.get('report'), unit: url.searchParams.get('unit') })
|
||||
if (state.listGate) await state.listGate
|
||||
return route.fulfill({ json: { items: state.lists[run] ?? [] } })
|
||||
}
|
||||
if (path === '/api/approvals') {
|
||||
const run = url.searchParams.get('run_id')
|
||||
hits.approvals.push({ run, status: url.searchParams.get('status') })
|
||||
const list = state.approvals[run]
|
||||
return list ? route.fulfill({ json: list }) : route.fulfill({ status: 404, json: { detail: '检查脚本没有伪造这个接口' } })
|
||||
}
|
||||
if (path === '/api/settings') return route.fulfill({ json: state.settings })
|
||||
const snap = path.match(/^\/api\/runs\/([^/]+)\/inquiries\/artifacts\/([^/]+)$/)
|
||||
if (snap) {
|
||||
const id = decodeURIComponent(snap[2])
|
||||
hits.snapshots.push(id)
|
||||
if (state.snaps[id]) return route.fulfill({ json: state.snaps[id] })
|
||||
const body = fx.snapshots[id]
|
||||
if (!body) return route.fulfill({ status: 404, json: { detail: '不是这次运行的质询引用过的工件' } })
|
||||
// 约定没定死外面包不包一层:一份包在 {id, content} 里,其余是快照本身,两种界面都得认
|
||||
@@ -113,7 +132,8 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark' } = {}) {
|
||||
if (art) {
|
||||
const id = decodeURIComponent(art[1])
|
||||
hits.artifacts.push(id)
|
||||
return fx.docs[id] ? route.fulfill({ json: { id, content: fx.docs[id] } })
|
||||
const doc = state.docs[id] ?? fx.docs[id]
|
||||
return doc ? route.fulfill({ json: { id, content: doc } })
|
||||
: route.fulfill({ status: 404, json: { detail: '工件不存在,可能已被删除' } })
|
||||
}
|
||||
const seg = path.match(/^\/api\/runs\/([^/]+)\/evidence\/segments\/([^/]+)$/)
|
||||
@@ -138,7 +158,9 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark' } = {}) {
|
||||
hits.other.push(path)
|
||||
return route.fulfill({ status: 404, json: { detail: '检查脚本没有伪造这个接口' } })
|
||||
})
|
||||
await page.goto(`${WEB}/preview.html?syn=${syn}&theme=${theme}`, { waitUntil: 'networkidle' })
|
||||
// 事件流:Playwright 靠注入页面的脚本接管 WebSocket,得在打开页面之前挂上
|
||||
if (ws) await page.routeWebSocket(/\/api\/runs\/[^/]+\/stream/, ws)
|
||||
await page.goto(`${WEB}/preview.html?syn=${syn}&theme=${theme}`, { waitUntil })
|
||||
return { ctx, page, hits, state, errors }
|
||||
}
|
||||
|
||||
@@ -170,17 +192,59 @@ async function expand(page) {
|
||||
await page.locator('[data-approval-evidence]').click()
|
||||
await page.locator('[data-approval-evidence-doc] [data-evidence-doc] [data-block]').first().waitFor()
|
||||
}
|
||||
/** 像人一样点某一句的「质询」:先把鼠标移到这一句的字上(「质询」这才出现),再点它 */
|
||||
async function clickAsk(page, unit) {
|
||||
// 句子会折行:包住它的框的左上角可能落在上一句上,鼠标要放到这一句第一行的字上
|
||||
const at = await page.locator(`[data-ev-unit="${unit}"]`).evaluate((el) => {
|
||||
/** 鼠标放到某一句第一行的字上(句子会折行:包住它的框的左上角可能落在上一句上) */
|
||||
async function hoverUnit(page, unit, scope = page) {
|
||||
const at = await scope.locator(`[data-ev-unit="${unit}"]`).evaluate((el) => {
|
||||
el.scrollIntoView({ block: 'center' })
|
||||
const r = el.getClientRects()[0]
|
||||
return { x: r.left + Math.min(8, r.width / 2), y: r.top + r.height / 2 }
|
||||
})
|
||||
await page.mouse.move(at.x, at.y)
|
||||
await askBtn(page, unit).click()
|
||||
}
|
||||
/** 像人一样点某一句的「质询」:先把鼠标移到这一句的字上(「质询」这才显出来),再点它。scope:同一页有两份报告时限定在哪一份 */
|
||||
async function clickAsk(page, unit, scope = page) {
|
||||
await hoverUnit(page, unit, scope)
|
||||
await scope.locator(`[data-inquiry-ask="${unit}"]`).click()
|
||||
}
|
||||
/** 一次审批(审批列表接口的一行):审的是哪一稿报告 */
|
||||
const approvalRow = (id, run, doc) => ({ id, run_id: run, node_id: 'review', mode: 'approve', status: 'approved', response: {},
|
||||
payload: { kind: 'human_node', reports: [{ node_id: 'write', doc_artifact: doc }] }, created_at: '2026-10-05T06:00:00' })
|
||||
const clone = (x) => JSON.parse(JSON.stringify(x))
|
||||
/** 问答串的分组:每组是哪一句、抬头写的什么、有哪几条 */
|
||||
const groupsOf = (page, scope = '[data-inquiry="write"]') => page.locator(`${scope} [data-inquiry-group]`).evaluateAll((els) => els.map((el) => ({
|
||||
unit: el.getAttribute('data-inquiry-group'), about: el.querySelector('[data-inquiry-about]')?.textContent?.trim() ?? '',
|
||||
entries: [...el.querySelectorAll('[data-inquiry-entry]')].map((x) => x.getAttribute('data-inquiry-entry')),
|
||||
})))
|
||||
/** 报告正文里每个片段、每句话、每个「质询」的位置(第一个框),悬停前后对比用 */
|
||||
const layout = (page, root = '[data-evidence-doc]') => page.evaluate((r) => {
|
||||
const out = []
|
||||
for (const el of document.querySelector(r)?.querySelectorAll('[data-seg], [data-ev-unit], [data-inquiry-ask], td, th') ?? []) {
|
||||
const box = el.getClientRects()[0] ?? el.getBoundingClientRect()
|
||||
const key = el.getAttribute('data-seg') ?? (el.hasAttribute('data-inquiry-ask') ? `ask:${el.getAttribute('data-inquiry-ask')}`
|
||||
: el.getAttribute('data-ev-unit') ?? `${el.tagName}${out.length}`)
|
||||
out.push([key, { x: box.left, y: box.top, w: box.width, h: box.height }])
|
||||
}
|
||||
return out
|
||||
}, root).then((list) => new Map(list))
|
||||
/** 两次位置里挪了的(超过半个像素) */
|
||||
const moved = (a, b) => [...a].filter(([k, v]) => {
|
||||
const w = b.get(k)
|
||||
return !w || Math.abs(w.x - v.x) > 0.5 || Math.abs(w.y - v.y) > 0.5 || Math.abs(w.w - v.w) > 0.5
|
||||
}).map(([k, v]) => `${k} ${Math.round(v.x)},${Math.round(v.y)}→${Math.round(b.get(k)?.x)},${Math.round(b.get(k)?.y)}`)
|
||||
/** 焦点所在的元素在不在视口里、在不在会裁切它的祖先(限高滚动的报告框)的可见范围里 */
|
||||
const focusShown = (page) => page.evaluate(() => {
|
||||
const el = document.activeElement
|
||||
if (!el || el === document.body) return { ok: false, why: 'body' }
|
||||
const r = el.getBoundingClientRect()
|
||||
if (r.bottom <= 0 || r.top >= innerHeight || r.width === 0) return { ok: false, why: `视口外 ${Math.round(r.top)}` }
|
||||
for (let p = el.parentElement; p; p = p.parentElement) {
|
||||
const s = getComputedStyle(p)
|
||||
if (s.overflowY === 'visible' && s.overflowX === 'visible') continue
|
||||
const b = p.getBoundingClientRect()
|
||||
if (r.bottom <= b.top || r.top >= b.bottom) return { ok: false, why: `被 ${p.tagName} 裁掉` }
|
||||
}
|
||||
return { ok: true, ask: el.getAttribute('data-inquiry-ask') }
|
||||
})
|
||||
/** 等着某个条件成立(最多 ms 毫秒) */
|
||||
async function until(fn, ms = 3000) {
|
||||
const end = Date.now() + ms
|
||||
@@ -330,10 +394,22 @@ await section('入口:悬停、键盘聚焦一句出现「质询」,Tab 到
|
||||
await expand(page)
|
||||
const units = await page.locator('[data-inquiry-ask]').evaluateAll((els) => els.map((el) => el.getAttribute('data-inquiry-ask')))
|
||||
check('每句结论后各一个「质询」,标题不挂', JSON.stringify(units) === JSON.stringify(['u1', 'u2', 'u3', 'u4', 'u5', 'u6', 'u7']), JSON.stringify(units))
|
||||
check('平时都看不见、不占地方', !(await seen(askBtn(page, 'u2'))) && (await askBtn(page, 'u2').boundingBox())?.width < 1)
|
||||
await page.locator('[data-ev-unit="u2"] [data-seg="s9"]').hover()
|
||||
const idle = await askBtn(page, 'u2').evaluate((el) => ({ w: el.getBoundingClientRect().width, o: Number(getComputedStyle(el).opacity) }))
|
||||
check('平时是句末淡淡的一个小图标(不抢眼,宽度固定)', !(await seen(askBtn(page, 'u2'))) && idle.o < 0.75 && idle.w > 8 && idle.w < 24,
|
||||
JSON.stringify(idle))
|
||||
const before = await layout(page)
|
||||
// 悬停第一句(9 月销售额……),它的「质询」显出来;同一段里后面那一句(退款金额 21,500元……)不能被挤开
|
||||
await page.locator('[data-ev-unit="u1"] [data-seg="s4"]').hover()
|
||||
await page.waitForTimeout(100)
|
||||
check('悬停一句:这一句的「质询」出现,别的句子的不出现', await seen(askBtn(page, 'u2')) && !(await seen(askBtn(page, 'u1'))))
|
||||
const mid = await layout(page)
|
||||
check('悬停前后正文一个字都不挪(「质询」显出来只换颜色,不撑开)', moved(before, mid).length === 0, moved(before, mid).slice(0, 3).join(';'))
|
||||
// 悬停第一句时看到的后一句的数字,把鼠标移过去:第一句的「质询」收起,数字还在原处,鼠标底下就是它
|
||||
const s9 = mid.get('s9')
|
||||
await page.mouse.move(s9.x + 4, s9.y + s9.h / 2)
|
||||
await page.waitForTimeout(100)
|
||||
check('移到悬停时看到的后一句的数字上:鼠标底下就是它', await page.evaluate(([x, y]) => document.elementFromPoint(x, y)?.closest('[data-seg]')?.getAttribute('data-seg'),
|
||||
[s9.x + 4, s9.y + s9.h / 2]) === 's9')
|
||||
check('悬停一句:这一句的「质询」显出来,别的句子的不显', await seen(askBtn(page, 'u2')) && !(await seen(askBtn(page, 'u1'))))
|
||||
check('读屏听得到问的是哪一句', (await askBtn(page, 'u2').getAttribute('aria-label')) === '质询这句:退款金额 21,500元,退款率 1.89%。')
|
||||
await page.mouse.move(5, 5)
|
||||
await page.waitForTimeout(100)
|
||||
@@ -702,11 +778,25 @@ await section('运行详情「证据」页签:封存了的探索运行有入
|
||||
await ctx.close()
|
||||
}
|
||||
{
|
||||
const { ctx, page, hits, errors } = await open('inquiry-run-formal')
|
||||
// 审批时问过的随运行封存:审批处理掉以后,在运行详情的报告下面只读列出(查过的 SQL、行数、快照都在)
|
||||
const { ctx, page, hits, errors } = await open('inquiry-run-formal', { setup: (s) => {
|
||||
s.lists[RUNS.sealed] = fx.items.map((x) => ({ ...x, approval_id: fx.formal_approval_id }))
|
||||
s.approvals[RUNS.sealed] = [approvalRow(fx.formal_approval_id, RUNS.sealed, fx.reports.sealed.doc_artifact)]
|
||||
} })
|
||||
await page.locator('[data-evidence-report="write"] [data-ev-unit], [data-evidence-report="write"] [data-seg="s4"]').first().waitFor()
|
||||
await page.waitForTimeout(300)
|
||||
check('正式运行封存后:报告上没有「质询」,也没有问答串和提问入口',
|
||||
await page.locator('[data-inquiry-ask], [data-inquiry], [data-inquiry-ask-report]').count() === 0)
|
||||
await page.locator('[data-inquiry="write"] [data-inquiry-entry="31"]').waitFor()
|
||||
await page.waitForTimeout(200)
|
||||
check('正式运行封存后:报告上没有「质询」,也没有提问入口、提问框',
|
||||
await page.locator('[data-inquiry-ask], [data-inquiry-ask-report], [data-inquiry-composer], [data-inquiry-intro]').count() === 0)
|
||||
const groups = await groupsOf(page)
|
||||
check('正式运行封存后:审批时问过的照列,按句子挂在报告下面(审的就是封存的这一稿)',
|
||||
JSON.stringify(groups.map((g) => [g.unit, g.entries])) === JSON.stringify([['u2', ['31']], ['u3', ['34']], ['u4', ['36']]]),
|
||||
JSON.stringify(groups.map((g) => [g.unit, g.entries])))
|
||||
const e2 = entry(page, 34)
|
||||
await e2.locator('[data-inquiry-queries-toggle]').click()
|
||||
const q2 = e2.locator('[data-inquiry-query="Q2"]')
|
||||
check('正式运行封存后:查过的 SQL、行数照样看得到', (await text(q2.locator('[data-inquiry-sql]'))).startsWith('SELECT member_id')
|
||||
&& (await text(q2.locator('[data-inquiry-rows]'))) === '50 行')
|
||||
await page.evaluate(() => {
|
||||
const el = [...document.querySelectorAll('[data-evidence-report="write"] [data-block]')].find((b) => b.textContent.includes('枫岭区'))
|
||||
const range = document.createRange()
|
||||
@@ -716,10 +806,442 @@ await section('运行详情「证据」页签:封存了的探索运行有入
|
||||
})
|
||||
await page.waitForTimeout(200)
|
||||
check('正式运行封存后:划选也不浮出「质询」', await page.locator('[data-inquiry-selection]').count() === 0)
|
||||
check('正式运行封存后:不取问答列表', hits.lists.length === 0, JSON.stringify(hits.lists))
|
||||
check('正式运行封存后:按运行和报告取问答列表,没有发出提问', hits.lists.length === 1 && hits.lists[0].run === RUNS.sealed && hits.asks.length === 0,
|
||||
JSON.stringify(hits.lists))
|
||||
check('正式运行:页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
}
|
||||
{
|
||||
// 没有任何问答的正式运行:什么都不画
|
||||
const { ctx, page, errors } = await open('inquiry-run-formal')
|
||||
await page.locator('[data-evidence-report="write"] [data-seg="s4"]').first().waitFor()
|
||||
await page.waitForTimeout(300)
|
||||
check('正式运行封存后、没有问答:不画问答串', await page.locator('[data-inquiry], [data-inquiry-ask]').count() === 0)
|
||||
check('没有问答的正式运行:页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
}
|
||||
})
|
||||
|
||||
await section('有表格的报告:悬停句子、表格的格子都不挪动排版;表格的格子不挂「质询」', async () => {
|
||||
// 报告文档换成带表格的那份(run/__tests__/evidence-doc.json):表里带出处的格子也是结论句
|
||||
const ev = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-doc.json`, 'utf8'))
|
||||
const { ctx, page, errors } = await open('inquiry-approval', { setup: (s) => {
|
||||
s.docs[fx.reports.approve.doc_artifact] = { ...ev.doc, node_id: 'write' }
|
||||
} })
|
||||
await expand(page)
|
||||
const table = page.locator('[data-approval-evidence-doc] table').first()
|
||||
await table.waitFor()
|
||||
check('表格的格子不挂「质询」', await table.locator('[data-inquiry-ask]').count() === 0
|
||||
&& await table.locator('[data-ev-unit]').count() === 0)
|
||||
check('表格外的结论句照常挂', await page.locator('[data-approval-evidence-doc] [data-inquiry-ask]').count() >= 5)
|
||||
const before = await layout(page)
|
||||
const cell = table.locator('td [data-seg]').first()
|
||||
await cell.hover()
|
||||
await page.waitForTimeout(100)
|
||||
const mid = await layout(page)
|
||||
check('悬停表格里有出处的格子:列宽、正文都不变', moved(before, mid).length === 0, moved(before, mid).slice(0, 3).join(';'))
|
||||
const units = await page.locator('[data-approval-evidence-doc] [data-ev-unit]').evaluateAll((els) => els.map((el) => el.getAttribute('data-ev-unit')))
|
||||
const shifted = []
|
||||
for (const u of units) {
|
||||
await hoverUnit(page, u)
|
||||
await page.waitForTimeout(60)
|
||||
const now = await layout(page)
|
||||
// 悬停时页面为了把那一句放到中间滚动过:比相对位置,不比绝对位置
|
||||
const base = before.get(u)
|
||||
const dy = now.get(u).y - base.y
|
||||
const shift = [...before].filter(([k, v]) => { const w = now.get(k); return !w || Math.abs(w.x - v.x) > 0.5 || Math.abs(w.y - dy - v.y) > 0.5 || Math.abs(w.w - v.w) > 0.5 })
|
||||
if (shift.length) shifted.push(`${u}:${shift.slice(0, 2).map(([k]) => k).join('、')}`)
|
||||
}
|
||||
check('逐句悬停:正文、表格一处都不挪', shifted.length === 0, shifted.slice(0, 3).join(';'))
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('Esc、「取消」关掉提问框:焦点回到那一句的「质询」,并滚回视野', async () => {
|
||||
for (const [width, height] of [[390, 844], [1100, 700]]) {
|
||||
const { ctx, page, errors } = await open('inquiry-approval', { width, height })
|
||||
await expand(page)
|
||||
await page.locator('[data-seg="s4"]').focus()
|
||||
await page.keyboard.press('Tab')
|
||||
await page.keyboard.press('Enter')
|
||||
await composer(page).waitFor()
|
||||
await page.waitForTimeout(200)
|
||||
await page.keyboard.press('Escape')
|
||||
check(`${width}px:Esc 后焦点回到 u1 的「质询」`, await until(async () => (await active(page))?.ask === 'u1'), JSON.stringify(await active(page)))
|
||||
await page.waitForTimeout(150)
|
||||
const shown = await focusShown(page)
|
||||
check(`${width}px:……它在视野里(页面、报告框都滚到它),键盘用户看得见焦点`, shown.ok, JSON.stringify(shown))
|
||||
await clickAsk(page, 'u4')
|
||||
await composer(page).waitFor()
|
||||
await page.waitForTimeout(200)
|
||||
await composer(page).locator('button', { hasText: '取消' }).click()
|
||||
check(`${width}px:点「取消」后焦点回到 u4 的「质询」`, await until(async () => (await active(page))?.ask === 'u4'), JSON.stringify(await active(page)))
|
||||
await page.waitForTimeout(150)
|
||||
check(`${width}px:……同样在视野里`, (await focusShown(page)).ok, JSON.stringify(await focusShown(page)))
|
||||
check(`${width}px:页面没有报错`, errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
}
|
||||
})
|
||||
|
||||
await section('同一张审批卡同时挂在两处(画布的审批浮层 + 右栏):提问框只在点的那一处,焦点不跳走', async () => {
|
||||
const { ctx, page, hits, state, errors } = await open('inquiry-approval-twice', { width: 1400, height: 900 })
|
||||
const pop = page.locator('[data-fixture-pop]')
|
||||
const panel = page.locator('[data-fixture-panel]')
|
||||
await pop.locator('[data-inquiry-entry="31"]').waitFor()
|
||||
await panel.locator('[data-inquiry-entry="31"]').waitFor()
|
||||
const where = () => page.evaluate(() => {
|
||||
const a = document.activeElement
|
||||
return { pop: !!a?.closest('[data-fixture-pop]'), panel: !!a?.closest('[data-fixture-panel]'), input: !!a?.hasAttribute('data-inquiry-input') }
|
||||
})
|
||||
await pop.locator('[data-inquiry-ask-report]').click()
|
||||
await pop.locator('[data-inquiry-composer]').waitFor()
|
||||
await page.waitForTimeout(200)
|
||||
check('在浮层里点「就整份报告提问」:只有浮层里出提问框', await page.locator('[data-inquiry-composer]').count() === 1
|
||||
&& await pop.locator('[data-inquiry-composer]').count() === 1)
|
||||
check('焦点在浮层的输入框里,没有跳到右栏', JSON.stringify(await where()) === JSON.stringify({ pop: true, panel: false, input: true }),
|
||||
JSON.stringify(await where()))
|
||||
check('右栏同一组写明提问框在另一处打开', (await text(panel.locator('[data-inquiry-group="report"] [data-inquiry-draft-elsewhere]'))).startsWith('提问框已在另一处打开'))
|
||||
await page.keyboard.type('浮层里写的问题')
|
||||
check('敲的字进浮层的输入框,右栏没有第二个输入框', (await pop.locator('[data-inquiry-input]').inputValue()) === '浮层里写的问题'
|
||||
&& await panel.locator('[data-inquiry-input]').count() === 0)
|
||||
// 句子上的「质询」:两处都展开逐段证据,在浮层里点
|
||||
await pop.locator('[data-approval-evidence]').click()
|
||||
await pop.locator('[data-approval-evidence-doc] [data-block]').first().waitFor()
|
||||
await panel.locator('[data-approval-evidence]').click()
|
||||
await panel.locator('[data-approval-evidence-doc] [data-block]').first().waitFor()
|
||||
await clickAsk(page, 'u3', pop)
|
||||
await page.waitForTimeout(200)
|
||||
check('在浮层里点一句的「质询」:提问框换到那一句,仍只在浮层里,问题留着', await page.locator('[data-inquiry-composer]').count() === 1
|
||||
&& (await pop.locator('[data-inquiry-composer]').getAttribute('data-inquiry-composer')) === 'unit'
|
||||
&& (await pop.locator('[data-inquiry-input]').inputValue()) === '浮层里写的问题')
|
||||
check('……焦点仍在浮层里', JSON.stringify(await where()) === JSON.stringify({ pop: true, panel: false, input: true }), JSON.stringify(await where()))
|
||||
await page.keyboard.press('Escape')
|
||||
check('Esc:焦点回到浮层里那一句的「质询」', await until(async () => (await where()).pop && (await active(page))?.ask === 'u3'),
|
||||
JSON.stringify(await where()))
|
||||
// 浮层关掉:右栏接过提问框,但不抢焦点
|
||||
await clickAsk(page, 'u3', pop)
|
||||
await pop.locator('[data-inquiry-input]').fill('关浮层之前写的问题')
|
||||
await page.locator('[data-fixture-toggle]').click()
|
||||
await panel.locator('[data-inquiry-composer]').waitFor()
|
||||
await page.waitForTimeout(200)
|
||||
check('浮层关了:右栏接过提问框,问题还在', await page.locator('[data-inquiry-composer]').count() === 1
|
||||
&& (await panel.locator('[data-inquiry-input]').inputValue()) === '关浮层之前写的问题')
|
||||
check('……接过来时不抢焦点(焦点还在刚点的按钮上)', await page.evaluate(() => document.activeElement?.hasAttribute('data-fixture-toggle')))
|
||||
// 再打开浮层:提问框留在右栏,浮层里可以「在这里继续」
|
||||
await page.locator('[data-fixture-toggle]').click()
|
||||
await pop.locator('[data-inquiry-draft-elsewhere]').waitFor()
|
||||
check('再打开浮层:提问框仍在右栏,浮层写明在另一处打开', await panel.locator('[data-inquiry-composer]').count() === 1
|
||||
&& await pop.locator('[data-inquiry-composer]').count() === 0)
|
||||
await pop.locator('[data-inquiry-draft-elsewhere] button').click()
|
||||
await pop.locator('[data-inquiry-composer]').waitFor()
|
||||
await page.waitForTimeout(150)
|
||||
check('点「在这里继续」:提问框挪到浮层,焦点跟过去,问题还在', await panel.locator('[data-inquiry-composer]').count() === 0
|
||||
&& (await pop.locator('[data-inquiry-input]').inputValue()) === '关浮层之前写的问题'
|
||||
&& JSON.stringify(await where()) === JSON.stringify({ pop: true, panel: false, input: true }))
|
||||
// 在浮层里提交:回答期间、回答回来时焦点都只在浮层里交接
|
||||
let release
|
||||
state.gate = new Promise((r) => { release = r })
|
||||
await pop.locator('[data-inquiry-submit]').click()
|
||||
await pop.locator('[data-inquiry-pending]').waitFor()
|
||||
await page.waitForTimeout(150)
|
||||
check('回答期间:两处都显示正在回答,焦点接到浮层里那一条', await page.locator('[data-inquiry-pending]').count() === 2
|
||||
&& await page.evaluate(() => !!document.activeElement?.closest('[data-fixture-pop] [data-inquiry-pending]')))
|
||||
check('回答期间:两处的批准都不可用', await page.locator('[data-approval]').getByRole('button', { name: '批准', exact: true }).evaluateAll((els) => els.length === 2 && els.every((el) => el.disabled)))
|
||||
release()
|
||||
await pop.locator('[data-inquiry-entry="40"]').waitFor()
|
||||
await panel.locator('[data-inquiry-entry="40"]').waitFor()
|
||||
await page.waitForTimeout(200)
|
||||
check('回答回来:两处都挂上,焦点接到浮层里的回答', await page.evaluate(() => !!document.activeElement?.closest('[data-fixture-pop] [data-inquiry-entry="40"]')))
|
||||
const heard = await page.evaluate(() => [...document.querySelectorAll('[data-inquiry-live]')].map((el) => [!!el.closest('[data-fixture-pop]'), el.textContent]))
|
||||
check('「已收到回答」只在问的那一处播报', heard.filter(([, t]) => t).length === 1 && heard.find(([, t]) => t)?.[0] === true, JSON.stringify(heard))
|
||||
check('只发出一次提问', hits.asks.length === 1 && hits.asks[0].body.approval_id === fx.approval_id)
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('同一份报告先后两张审批:换了审批,上一张的提问框不跟过来,提问带新的那张', async () => {
|
||||
for (const syn of ['inquiry-approval-steps', 'inquiry-approval-steps-same']) {
|
||||
const { ctx, page, hits, errors } = await open(syn, { setup: (s) => {
|
||||
s.approvals[RUNS.approve] = [approvalRow(fx.approval_id, RUNS.approve, fx.reports.approve.doc_artifact),
|
||||
approvalRow('ap-inq-3', RUNS.approve, fx.reports.approve.doc_artifact)]
|
||||
} })
|
||||
await entry(page, 31).waitFor()
|
||||
await page.locator('[data-inquiry-ask-report]').click()
|
||||
await page.locator('[data-inquiry-input]').fill('上一张审批上没发出去的问题')
|
||||
await page.locator('[data-fixture-next]').click()
|
||||
await page.locator('[data-approval="ap-inq-3"]').waitFor()
|
||||
await entry(page, 31).waitFor()
|
||||
await page.waitForTimeout(200)
|
||||
check(`${syn}:换到下一张审批,上一张的提问框不跟过来`, await composer(page).count() === 0)
|
||||
check(`${syn}:也没抢焦点`, !(await active(page))?.input)
|
||||
check(`${syn}:上一级审批问过的(同一份报告)照样挂在句子下面`, JSON.stringify((await groupsOf(page)).map((g) => [g.unit, g.entries]))
|
||||
=== JSON.stringify([['u2', ['31']], ['u3', ['34']], ['u4', ['36']]]), JSON.stringify((await groupsOf(page)).map((g) => [g.unit, g.entries])))
|
||||
await page.locator('[data-inquiry-ask-report]').click()
|
||||
await composer(page).waitFor()
|
||||
check(`${syn}:在新的审批上点提问:从头写`, (await page.locator('[data-inquiry-input]').inputValue()) === '')
|
||||
await page.locator('[data-inquiry-input]').fill('总监的问题')
|
||||
await page.locator('[data-inquiry-submit]').click()
|
||||
await entry(page, 40).waitFor()
|
||||
check(`${syn}:请求体带的是新的那张审批`, hits.asks.length === 1 && hits.asks[0].body.approval_id === 'ap-inq-3',
|
||||
JSON.stringify(hits.asks.map((a) => a.body)))
|
||||
check(`${syn}:回答挂在句子下面(问的就是这一稿)`, await group(page, 'report').locator('[data-inquiry-entry="40"]').count() === 1)
|
||||
check(`${syn}:页面没有报错`, errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
}
|
||||
})
|
||||
|
||||
await section('之前几轮审批里问的:报告改写过的不按句子编号挂,单独一组', async () => {
|
||||
const prev = { ...clone(fx.items[0]), seq: 12, approval_id: 'ap-inq-0', question: '上一轮:退款率 3.5% 是怎么算的?', at: '2026-10-05T05:40:00Z' }
|
||||
const level = { ...clone(fx.items[1]), seq: 20, approval_id: 'ap-inq-9', question: '主管那一级问的:新客怎么界定?', at: '2026-10-05T05:50:00Z' }
|
||||
const lost = { ...clone(fx.items[1]), seq: 22, approval_id: 'ap-gone', question: '审批列表里找不到这张审批', at: '2026-10-05T05:55:00Z' }
|
||||
const { ctx, page, hits, errors } = await open('inquiry-approval', { setup: (s) => {
|
||||
s.lists[RUNS.approve] = [prev, level, lost, ...fx.items]
|
||||
s.approvals[RUNS.approve] = [approvalRow(fx.approval_id, RUNS.approve, fx.reports.approve.doc_artifact),
|
||||
approvalRow('ap-inq-9', RUNS.approve, fx.reports.approve.doc_artifact), approvalRow('ap-inq-0', RUNS.approve, '0'.repeat(64))]
|
||||
} })
|
||||
await entry(page, 12).waitFor()
|
||||
await entry(page, 36).waitFor()
|
||||
const groups = await groupsOf(page)
|
||||
// 组按各自最早的那一条排:主管那一级问新客的那条最早
|
||||
check('同一稿报告上问的照常按句子挂(另一级审批审的是同一份文档的也挂上)',
|
||||
JSON.stringify(groups.slice(0, 3).map((g) => [g.unit, g.entries])) === JSON.stringify([['u3', ['20', '34']], ['u2', ['31']], ['u4', ['36']]]),
|
||||
JSON.stringify(groups.map((g) => [g.unit, g.entries])))
|
||||
const other = groups.at(-1)
|
||||
check('报告改写前问的、对不上是哪一稿的:单独一组,排在最后', other?.unit === 'other' && JSON.stringify(other.entries) === JSON.stringify(['12', '22']),
|
||||
JSON.stringify(other))
|
||||
check('那一组写明是报告其他版本上的质询,不拿当前报告同编号的句子当抬头', other?.about.startsWith('报告其他版本上的质询')
|
||||
&& !other.about.includes('退款金额'), other?.about)
|
||||
check('次数照算全部', (await text(page.locator('[data-inquiry-count]'))) === '· 6 次')
|
||||
check('按运行取一次审批列表(全部状态)', hits.approvals.length === 1 && hits.approvals[0].run === RUNS.approve && hits.approvals[0].status === 'all',
|
||||
JSON.stringify(hits.approvals))
|
||||
// 那一组里的回答引用了报告已有的出处:不在当前这一稿里找
|
||||
const e = entry(page, 12)
|
||||
await e.locator('[data-inquiry-answer-doc]').waitFor()
|
||||
check('报告其他版本上的回答:引用报告出处的数,读屏说是提问时那一版里的', ((await e.locator('[data-inquiry-cite="report"]').getAttribute('aria-label')) ?? '')
|
||||
.includes('提问时那一版报告中的出处'))
|
||||
await e.locator('[data-inquiry-cite="report"]').click()
|
||||
await page.waitForTimeout(200)
|
||||
check('……点了不去当前报告里打开证据,照文档记下的出处说一句', await page.locator('[data-approval-evidence-doc]').count() === 0
|
||||
&& (await text(e.locator('[data-inquiry-elsewhere]'))).includes('提问时那一版报告中的出处'))
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
{
|
||||
// 审批列表取不到:别的审批里问的一律单独一组(宁可不挂,不挂错)
|
||||
const { ctx, page, errors } = await open('inquiry-approval', { setup: (s) => { s.lists[RUNS.approve] = [prev, level, ...fx.items] } })
|
||||
await entry(page, 12).waitFor()
|
||||
const g = await groupsOf(page)
|
||||
check('审批列表取不到:别的审批里问的都单独一组', JSON.stringify(g.at(-1)) !== undefined && g.at(-1).unit === 'other'
|
||||
&& JSON.stringify(g.at(-1).entries) === JSON.stringify(['12', '20']), JSON.stringify(g.map((x) => [x.unit, x.entries])))
|
||||
check('审批列表取不到:页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
}
|
||||
{
|
||||
// 运行详情:显示的是封存的那一稿。封存之后问的、审批时审的就是这一稿的挂句子下面,更早那一稿上问的单独一组
|
||||
const early = { ...clone(fx.items[0]), seq: 14, approval_id: 'ap-early', question: '改写前:退款率是不是算错了?', at: '2026-10-05T05:10:00Z' }
|
||||
const last = { ...clone(fx.items[1]), seq: 30, approval_id: 'ap-last', question: '最后一轮审批:新客口径?', at: '2026-10-05T06:10:00Z' }
|
||||
const { ctx, page, errors } = await open('inquiry-run-explore', { setup: (s) => {
|
||||
s.lists[RUNS.explore] = [early, last, ...fx.explore_items]
|
||||
s.approvals[RUNS.explore] = [approvalRow('ap-early', RUNS.explore, '1'.repeat(64)),
|
||||
approvalRow('ap-last', RUNS.explore, fx.reports.explore.doc_artifact)]
|
||||
} })
|
||||
await page.locator('[data-inquiry="write"] [data-inquiry-entry="14"]').waitFor()
|
||||
const g = await groupsOf(page)
|
||||
check('运行详情:封存之后问的、最后一轮审批时问的挂句子下面,改写前那一稿上问的单独一组',
|
||||
JSON.stringify(g.map((x) => [x.unit, x.entries])) === JSON.stringify([['u3', ['30']], ['u2', ['58']], ['other', ['14']]]),
|
||||
JSON.stringify(g.map((x) => [x.unit, x.entries])))
|
||||
check('运行详情:页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
}
|
||||
})
|
||||
|
||||
await section('回答期间另开了提问框,正在回答的那一条没问成:新写的不被覆盖', async () => {
|
||||
const { ctx, page, state, errors } = await open('inquiry-approval')
|
||||
await expand(page)
|
||||
let release
|
||||
state.gate = new Promise((r) => { release = r })
|
||||
state.reply = { status: 500, json: { detail: '核查模型调用超时,请稍后重试' } }
|
||||
await page.locator('[data-seg="s4"]').focus()
|
||||
await page.keyboard.press('Tab')
|
||||
await page.keyboard.press('Enter')
|
||||
await page.locator('[data-inquiry-input]').fill('第一个问题:销售额含不含线上?')
|
||||
await page.locator('[data-inquiry-submit]').click()
|
||||
await page.locator('[data-inquiry-pending]').waitFor()
|
||||
await page.waitForTimeout(3300)
|
||||
const status = await page.locator('[data-inquiry-pending] [role="status"]').evaluateAll((els) => els.map((el) => el.textContent.trim()))
|
||||
check('等了几秒:播报区只有不变的那句,「已等待 N 秒」在播报区外、读屏不读', JSON.stringify(status) === JSON.stringify(['正在查询与回答,通常需要几十秒'])
|
||||
&& (await page.locator('[data-inquiry-waited]').getAttribute('aria-hidden')) === 'true'
|
||||
&& (await text(page.locator('[data-inquiry-waited]'))).startsWith('· 已等待'), JSON.stringify(status))
|
||||
await page.locator('[data-inquiry-ask-report]').click()
|
||||
await page.locator('[data-inquiry-input]').fill('第二个问题:数据截止到哪天?')
|
||||
release()
|
||||
await page.locator('[data-inquiry-failed-ask]').waitFor()
|
||||
await page.waitForTimeout(150)
|
||||
check('提问框原样留着:还是整份报告、还是新写的问题', await composer(page).count() === 1
|
||||
&& (await composer(page).getAttribute('data-inquiry-composer')) === 'report'
|
||||
&& (await page.locator('[data-inquiry-input]').inputValue()) === '第二个问题:数据截止到哪天?')
|
||||
const failedAsk = group(page, 'u1').locator('[data-inquiry-failed-ask]')
|
||||
check('没问成的那一条挂在它那一句下面:问题和服务端的说明都在', (await text(failedAsk.locator('[data-inquiry-question]'))) === '第一个问题:销售额含不含线上?'
|
||||
&& (await text(failedAsk.locator('[data-inquiry-error]'))) === '没有问成:核查模型调用超时,请稍后重试', await text(failedAsk))
|
||||
check('……原因用 role=alert 报出', (await failedAsk.locator('[data-inquiry-error]').getAttribute('role')) === 'alert')
|
||||
check('提问框开着时不给「放回提问框」(免得盖掉新写的)', await failedAsk.locator('[data-inquiry-reask]').count() === 0)
|
||||
await composer(page).locator('button', { hasText: '取消' }).click()
|
||||
await failedAsk.locator('[data-inquiry-reask]').click()
|
||||
await composer(page).waitFor()
|
||||
check('放回提问框:就那一句、原来的问题,没问成的那一条拿掉', (await composer(page).getAttribute('data-inquiry-composer')) === 'unit'
|
||||
&& (await page.locator('[data-inquiry-input]').inputValue()) === '第一个问题:销售额含不含线上?'
|
||||
&& await group(page, 'u1').locator('[data-inquiry-composer]').count() === 1 && await page.locator('[data-inquiry-failed-ask]').count() === 0)
|
||||
check('批准、驳回照常可用', !(await approve(page).isDisabled()))
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('回答回来时读屏播报,连着两次都报', async () => {
|
||||
const { ctx, page, state, errors } = await open('inquiry-approval')
|
||||
await entry(page, 31).waitFor()
|
||||
await page.evaluate(() => {
|
||||
const live = document.querySelector('[data-inquiry="write"] [data-inquiry-live]')
|
||||
window.__heard = []
|
||||
new MutationObserver(() => { const t = live.textContent; if (t) window.__heard.push(t) })
|
||||
.observe(live, { childList: true, characterData: true, subtree: true })
|
||||
})
|
||||
let seq = 100
|
||||
state.reply = (body) => ({ status: 200, json: answerFor(body, { inquiry: { ...answerFor(body).inquiry, seq: seq++ } }) })
|
||||
for (const n of [100, 101]) {
|
||||
await page.locator('[data-inquiry-ask-report]').click()
|
||||
await page.locator('[data-inquiry-input]').fill('数据截止到哪一天?')
|
||||
await page.locator('[data-inquiry-submit]').click()
|
||||
await entry(page, n).waitFor()
|
||||
await page.waitForTimeout(200)
|
||||
}
|
||||
const heard = await page.evaluate(() => window.__heard)
|
||||
check('同一个问题问两遍,两次回答各报一遍「已收到回答」', heard.length === 2 && heard.every((t) => t === '已收到回答:数据截止到哪一天?'),
|
||||
JSON.stringify(heard))
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('花费估不出不写 $0;查询列表收着也写截断;核对不了、猜测来源的字照报告的样子画', async () => {
|
||||
const items = clone(fx.items)
|
||||
items[0].usage = { input_tokens: 5210, output_tokens: 640, cost_usd: 0, calls: 3, unpriced_calls: 3 }
|
||||
items[1].usage = { input_tokens: 5210, output_tokens: 640, cost_usd: 0.002, calls: 3, unpriced_calls: 1 }
|
||||
const docId = items[0].answer.doc_artifact
|
||||
const doc = clone(fx.docs[docId])
|
||||
// 回答里再加一句:一个核对不了的表名、一个按数值猜测来源的数
|
||||
doc.blocks.push({ id: 'b9', type: 'paragraph', units: [{ id: 'u9', kind: 'claim', segments: [
|
||||
{ id: 's90', kind: 'structural', text: '\n\n', state: 'neutral' },
|
||||
{ id: 's91', kind: 'text', text: '退款明细在 ', state: 'neutral' },
|
||||
{ id: 's92', kind: 'entity', text: 'refund_items', state: 'none', issue: 'unverified_entity' },
|
||||
{ id: 's93', kind: 'text', text: ' 表中,约占 ', state: 'neutral' },
|
||||
{ id: 's94', kind: 'number', text: '96%', state: 'candidate' },
|
||||
{ id: 's95', kind: 'text', text: '。', state: 'neutral' },
|
||||
] }] })
|
||||
const { ctx, page, errors } = await open('inquiry-approval', { setup: (s) => { s.lists[RUNS.approve] = items; s.docs[docId] = doc } })
|
||||
const e1 = entry(page, 31)
|
||||
await e1.locator('[data-inquiry-answer-doc]').waitFor()
|
||||
const c1 = e1.locator('[data-inquiry-cost]')
|
||||
check('模型都不在价格目录中:写「金额无法估算」,不写 $0', (await text(c1)) === '· 金额无法估算'
|
||||
&& ((await c1.getAttribute('title')) ?? '').startsWith('3 次调用的模型不在价格目录中'), await text(e1.locator('[data-inquiry-judged]')))
|
||||
check('部分调用估不出金额:写「≥」', (await text(entry(page, 34).locator('[data-inquiry-cost]'))) === '· 花费 ≥ $0.002',
|
||||
await text(entry(page, 34).locator('[data-inquiry-judged]')))
|
||||
const e2 = entry(page, 34)
|
||||
check('查询列表收着时也写明有几次结果被截断', (await e2.locator('[data-inquiry-queries-toggle]').getAttribute('aria-expanded')) === 'false'
|
||||
&& (await text(e2.locator('[data-inquiry-queries-truncated]'))) === '· 其中 1 次结果已截断'
|
||||
&& await e1.locator('[data-inquiry-queries-truncated]').count() === 0)
|
||||
const marks = await e1.locator('.ev-mark').evaluateAll((els) => els.map((el) => [el.getAttribute('data-ev-state'), el.getAttribute('data-ev-line'),
|
||||
getComputedStyle(el).textDecorationStyle, el.textContent]))
|
||||
check('核对不了的名称、猜测的来源:点状线,读屏听得到是什么状态', JSON.stringify(marks.map((m) => m.slice(0, 3))) === JSON.stringify([
|
||||
['unverified', 'dotted', 'dotted'], ['candidate', 'dotted', 'dotted']]) && marks[0][3].includes('(') && marks[1][3].includes('('),
|
||||
JSON.stringify(marks))
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('没有逐段证据的报告:只说可以就整份报告提问;列表取回来之前不说「还没有问答」', async () => {
|
||||
let release
|
||||
const { ctx, page, errors } = await open('inquiry-approval-nodoc', { waitUntil: 'load', setup: (s) => {
|
||||
s.lists[RUNS.approve] = []
|
||||
s.listGate = new Promise((r) => { release = r })
|
||||
} })
|
||||
await page.locator('[data-inquiry="write"]').waitFor()
|
||||
await page.waitForTimeout(300)
|
||||
check('列表还在取:不说「还没有问答、可以这样问」', await page.locator('[data-inquiry-intro]').count() === 0)
|
||||
release()
|
||||
await page.locator('[data-inquiry-intro]').waitFor()
|
||||
check('没有逐段证据:没有「展开逐段证据」,提示只说就整份报告提问', await page.locator('[data-approval-evidence]').count() === 0
|
||||
&& (await text(page.locator('[data-inquiry-intro]'))) === '这份报告没有逐段证据,可以就整份报告提问')
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('设置里关了审批质询:不给入口,写明已关闭,之前的问答照列', async () => {
|
||||
const { ctx, page, hits, errors } = await open('inquiry-approval', { setup: (s) => { s.settings = { verify: { inquiry: { enabled: false } } } } })
|
||||
await entry(page, 31).waitFor()
|
||||
await page.waitForTimeout(200)
|
||||
check('关了:没有「就整份报告提问」,写明已在设置中关闭', await page.locator('[data-inquiry-ask-report]').count() === 0
|
||||
&& (await text(page.locator('[data-inquiry-disabled]'))) === '审批质询已在设置中关闭')
|
||||
check('关了:之前的问答照列', await page.locator('[data-inquiry-entry]').count() === 3)
|
||||
await expand(page)
|
||||
check('关了:报告上没有「质询」', await page.locator('[data-inquiry-ask]').count() === 0)
|
||||
await select(page, 'u4', '枫岭区销售额最高')
|
||||
await page.waitForTimeout(200)
|
||||
check('关了:划选也不浮出「质询」', await page.locator('[data-inquiry-selection]').count() === 0)
|
||||
check('关了:没有发出提问', hits.asks.length === 0)
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('遮罩:接口给了原值、只在快照里记着遮罩列时,表格、悬停说明、导出的 CSV 都不出原值', async () => {
|
||||
const raw = clone(fx.snapshots[MEMBERS])
|
||||
raw.rows = raw.rows.map((r, i) => [r[0], `139-0000-${1000 + i}`, r[2], r[3]])
|
||||
delete raw.masked
|
||||
raw.mask_columns = ['phone']
|
||||
const { ctx, page, errors } = await open('inquiry-approval', { setup: (s) => { s.snaps[MEMBERS] = { id: MEMBERS, content: raw } } })
|
||||
const e2 = entry(page, 34)
|
||||
await e2.locator('[data-inquiry-answer-doc]').waitFor()
|
||||
await e2.locator('[data-inquiry-cite="Q2"]').click()
|
||||
const q2 = e2.locator('[data-inquiry-query="Q2"]')
|
||||
await q2.locator('[data-inquiry-snapshot="ok"]').waitFor()
|
||||
const leak = await q2.evaluate((el) => [el.innerText, ...[...el.querySelectorAll('[title]')].map((x) => x.getAttribute('title'))]
|
||||
.filter((t) => t && t.includes('139-0000')).length)
|
||||
check('表格里、悬停说明里都没有原值', leak === 0 && await q2.locator('td[data-masked]').count() === 50, `${leak} 处`)
|
||||
const [download] = await Promise.all([page.waitForEvent('download'), q2.getByRole('button', { name: 'CSV' }).click()])
|
||||
const csv = readFileSync(await download.path(), 'utf8')
|
||||
check('导出的 CSV 也没有原值,写「已遮罩」', !csv.includes('139-0000') && csv.includes('已遮罩'), csv.split('\n').slice(0, 2).join(' | '))
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('写进运行记录的问答:画布正显示这次运行、没在接流时补接一次,时间线收到那一条', async () => {
|
||||
const urls = []
|
||||
const { ctx, page, state, errors } = await open('inquiry-approval', { ws: (ws) => {
|
||||
urls.push(ws.url())
|
||||
ws.send(JSON.stringify({ seq: 40, type: 'approval.inquiry', node_id: null, ts: 1759646000, data: {
|
||||
question: '这份报告的数据截止到哪一天?', quote: null, report: 'write', unit: null, approval_id: fx.approval_id,
|
||||
answer_text: '截止到 9 月 30 日。', answer_doc: null, ok: true, limit: null, error: null, inquiry_artifacts: [], queries: [] } }))
|
||||
ws.send(JSON.stringify({ seq: 0, type: 'stream.end', node_id: null, ts: 0, data: { status: 'interrupted' } }))
|
||||
} })
|
||||
await entry(page, 31).waitFor()
|
||||
// 画布正显示这次运行:停在审批上,流已经收尾(streaming 为假),手上最大的序号是 39
|
||||
await page.evaluate(([run]) => window.__studio.setState({ run: { id: run, status: 'interrupted', run_class: 'exploratory' },
|
||||
streaming: false, unsubscribe: null, events: [{ seq: 39, type: 'human.requested', node_id: 'review', ts: 1759645000, data: {} }] }), [RUNS.approve])
|
||||
await page.locator('[data-inquiry-ask-report]').click()
|
||||
await page.locator('[data-inquiry-input]').fill('这份报告的数据截止到哪一天?')
|
||||
await page.locator('[data-inquiry-submit]').click()
|
||||
await entry(page, 40).waitFor()
|
||||
check('补接事件流:从手上最大的序号之后接', await until(async () => urls.length === 1) && urls[0].includes('after=39'), urls.join('、'))
|
||||
check('那一条质询事件进了画布的事件(时间线据此出「审批人质询」)', await until(() => page.evaluate(() =>
|
||||
window.__studio.getState().events.some((e) => e.type === 'approval.inquiry' && e.seq === 40))))
|
||||
// 没写进运行记录的不补接
|
||||
state.reply = (body) => ({ status: 200, json: answerFor(body, { inquiry: { ...answerFor(body).inquiry, seq: null }, recorded: false,
|
||||
note: '运行已不在等待审批' }) })
|
||||
await page.locator('[data-inquiry-ask-report]').click()
|
||||
await page.locator('[data-inquiry-input]').fill('再问一句')
|
||||
await page.locator('[data-inquiry-submit]').click()
|
||||
await page.locator('[data-inquiry-recorded="false"]').waitFor()
|
||||
await page.waitForTimeout(300)
|
||||
check('没写进运行记录的不补接', urls.length === 1, urls.join('、'))
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
/** 页面和某个容器都没有横向滚动(表格、SQL 框自己的滚动条不算) */
|
||||
@@ -783,11 +1305,14 @@ await section('窄屏与深浅色', async () => {
|
||||
await composer(page).waitFor()
|
||||
const o = await overflow(page, '[data-approval]')
|
||||
check(`390px(${theme}):展开逐段证据、查询结果、提问框也没有横向溢出`, o.page <= 0 && o.box <= 1, JSON.stringify(o))
|
||||
// 「质询」量的是悬停这一句时显出来的样子(平时是淡淡的提示,悬停、焦点在这一句上时才是要读清的那个按钮)
|
||||
await hoverUnit(page, 'u1')
|
||||
await page.waitForTimeout(100)
|
||||
const ratios = {
|
||||
unrecorded: await ratioOf(page, '[data-inquiry-unrecorded]'),
|
||||
limit: await ratioOf(page, '[data-inquiry-limit]'),
|
||||
failed: await ratioOf(page, '[data-inquiry-failed]'),
|
||||
ask: await ratioOf(page, '[data-inquiry-ask="u1"] > span'),
|
||||
ask: await ratioOf(page, '[data-inquiry-ask="u1"]'),
|
||||
judged: await ratioOf(page, '[data-inquiry-judged]'),
|
||||
meta: await ratioOf(page, '[data-inquiry-meta]'),
|
||||
}
|
||||
@@ -805,6 +1330,19 @@ await section('窄屏与深浅色', async () => {
|
||||
check(`390px(${theme}):页面没有报错`, errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
}
|
||||
for (const theme of ['dark', 'light']) {
|
||||
// 回答期间批准 / 驳回下面那句原因:审批卡铺着一层提醒色的底,提醒色的字在浅色主题下不够深
|
||||
const { ctx, page, state, errors } = await open('inquiry-approval', { theme })
|
||||
state.gate = new Promise(() => {})
|
||||
await page.locator('[data-inquiry-ask-report]').click()
|
||||
await page.locator('[data-inquiry-input]').fill('回答期间的说明看得清吗')
|
||||
await page.locator('[data-inquiry-submit]').click()
|
||||
await page.locator('[data-approval-inquiry-busy]').waitFor()
|
||||
const r = await ratioOf(page, '[data-approval-inquiry-busy]')
|
||||
check(`${theme}:回答期间批准 / 驳回不可用的原因,对比度 ≥ 4.5`, r >= 4.5, r.toFixed(2))
|
||||
check(`${theme}:页面没有报错`, errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
}
|
||||
{
|
||||
const { ctx, page, errors } = await open('inquiry-approval-dense', { width: 1100, height: 900 })
|
||||
await page.locator('[data-inquiry-entry="34"] [data-inquiry-answer-doc]').waitFor()
|
||||
|
||||
Reference in New Issue
Block a user