审批质询收尾:工件页签认得质询回答;几处问答串要对的审批不同、审批列表取不到时各自照样列出

- 工件名:质询的回答文档以 inquiry_answer 登记在运行名下,工件页签原先直接显示英文原值。ARTIFACT_KIND_LABEL 加「质询
  回答」,说明在 meta.question 有值时写「质询「…」的回答」(服务端只记前 40 字,满 40 字的补省略号),没有时写「审批
  质询的回答:逐段的片段和每个数字的出处」;文案放在 INQUIRY_TEXT
- 问答串的竞态:loadApprovalDocs 发现别处正在取审批列表就直接返回,没把这一处要对的审批记进 asked。请求回来后这一处的
  unknown 不变、effect 不再执行,settled 一直为假,头上写着「· N 次」,别的审批里问的那几条一直不显示(一张卡审两份
  报告;运行详情的待审批卡和证据页签同时挂着、审批列表失败时)。现在提前返回前也把这一处的 id 并进 asked:列表按运行取,
  在路上的那一份本来就带着全部审批,不另发请求
- 预览用例:inquiry-approval-two(一张卡审两份报告)、inquiry-run-waiting(待审批卡和证据页签同时挂着)、
  inquiry-run-artifacts(工件页签);夹具补探索运行的工件列表 run_artifacts
- check-inquiry:审批列表压住、再答 404,两处的条目都列出(查不到审批的单独成组),头上的次数和列出的条数一致、审批
  列表只取一次;工件页签里有「质询回答」的名字和说明、不出现英文的种类名、打开的弹窗标题也是中文;390px 深浅色下不溢出、
  看得清
This commit is contained in:
ThearchyHelios
2026-10-06 12:19:13 +08:00
parent a18b24ab6f
commit a358fbd244
6 changed files with 260 additions and 9 deletions
+126 -5
View File
@@ -1,6 +1,6 @@
// 审批质询的回归检查:报告上的入口(run/EvidenceDoc.tsx 的句末「质询」、划选原文浮出的「质询」)、问答串和提问框
// (run/Inquiry.tsx)、审批卡上的挂法(run/ApprovalReport.tsx、run/RunPanel.tsx)、运行详情「证据」页签的挂法
// (pages/runs/EvidencePane.tsx)。
// (pages/runs/EvidencePane.tsx),以及运行详情「工件」页签里质询回答的名字(pages/runs/model.ts)。
//
// 守的是这些「看着没坏、其实用不了」的地方:悬停、键盘聚焦一句都不出「质询」(或者出了 Tab 却到不了、整份报告多出一串
// Tab 位)、没有可点内容的句子键盘走不到、划选原文不浮出入口(或者带上的原文混进了徽标和「质询」本身)、提交的请求体
@@ -8,11 +8,14 @@
// 进入审批卡时问答串没有恢复、问答串写不出问的是哪一句、回答里的数字点了不去取质询工件(或者每点一次取一次、标不出
// 那一格)、引用报告已有出处的数字点了没反应、查询的 SQL / 行数 / 截断 / 遮罩不显示、触顶和没答成不说、recorded 为假
// 不醒目、409 / 422 不照服务端的中文说明显示(或者问题文字丢了)、Esc 关不掉提问框(或者连带收起了别的、焦点掉到页面上)、
// 已处理的审批和封存了的正式运行还给入口、封存了的探索运行反而没有;窄屏(390px)被撑出横向滚动、浅色主题下看不清。
// 已处理的审批和封存了的正式运行还给入口、封存了的探索运行反而没有;同一次运行的几处问答串要对的审批不同、审批列表
// 取不到时后一处一直不列别的审批里问的(头上写着次数,下面一条没有);工件页签里质询的回答只写英文的种类名;
// 窄屏(390px)被撑出横向滚动、浅色主题下看不清。
//
// 跑在预览页上:/preview.html?syn=inquiry-*(用例见 frontend/src/dev/fixtures/inquiry.tsx)。夹具在
// frontend/src/dev/fixtures/inquiry.json,报告文档和回答文档是后端 build_catalog + compose_doc 真实组装的(虚构的数据)。
// 质询的三个接口、工件、证据图、片段接口都在这里伪造;别的写请求一律拦掉并记下来,别的 GET 答 404,不碰真实服务。
// 质询的三个接口、工件、证据图、片段接口、审批列表、运行的工件列表都在这里伪造;别的写请求一律拦掉并记下来,别的 GET
// 答 404,不碰真实服务。
//
// node scripts/check-inquiry.mjs
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-inquiry.mjs
@@ -66,8 +69,9 @@ const answerFor = (body, extra = {}) => ({
/**
* 开一个预览用例。列表按运行答夹具里的问答(state.lists 可改),提问按 state.reply 答({status, json} 或按请求体算的函数;
* state.gate 是个没兑现的 Promise 时先挂着,测「回答期间」;state.listGate 同样挂着列表)。审批列表按 state.approvals
* (运行 → 审批数组)答,设置按 state.settings 答;state.docs、state.snaps 盖过夹具里的文档、快照。setup 在打开页面之前
* 改 state(进入时就要取的列表、设置得先备好);ws 接管运行的事件流。记下列表、提问、取快照、取工件、取审批列表的请求和拦下的写请求
* (运行 → 审批数组)答,没有的答 404,state.approvalsGate 同样先挂着;设置按 state.settings 答;state.docs、state.snaps
* 盖过夹具里的文档、快照;运行的工件列表答夹具里的 run_artifacts(探索运行)。setup 在打开页面之前改 state(进入时就要
* 取的列表、设置得先备好);ws 接管运行的事件流。记下列表、提问、取快照、取工件、取审批列表的请求和拦下的写请求
*/
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' })
@@ -79,6 +83,7 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark', setup, ws
reply: (body) => ({ status: 200, json: answerFor(body) }),
gate: null,
listGate: null,
approvalsGate: null,
approvals: {},
settings: {},
docs: {},
@@ -114,6 +119,7 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark', setup, ws
if (path === '/api/approvals') {
const run = url.searchParams.get('run_id')
hits.approvals.push({ run, status: url.searchParams.get('status') })
if (state.approvalsGate) await state.approvalsGate
const list = state.approvals[run]
return list ? route.fulfill({ json: list }) : route.fulfill({ status: 404, json: { detail: '检查脚本没有伪造这个接口' } })
}
@@ -141,6 +147,8 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark', setup, ws
hits.segments.push(decodeURIComponent(seg[2]))
return route.fulfill({ json: {} })
}
const arts = path.match(/^\/api\/runs\/([^/]+)\/artifacts$/)
if (arts) return route.fulfill({ json: decodeURIComponent(arts[1]) === RUNS.explore ? fx.run_artifacts : [] })
const graph = path.match(/^\/api\/runs\/([^/]+)\/evidence$/)
if (graph) {
const run = decodeURIComponent(graph[1])
@@ -822,6 +830,37 @@ await section('运行详情「证据」页签:封存了的探索运行有入
}
})
await section('运行详情「工件」页签:质询的回答有名字和说明,不出现英文的工件种类名', async () => {
const { ctx, page, errors } = await open('inquiry-run-artifacts')
await page.locator('[data-run-artifacts] [data-artifact-id]').first().waitFor()
// 每一行:种类(行首的标签)、说明(同一种类有几件时末尾带「#n」,去掉再比)、悬停
const rows = await page.locator('[data-artifact-id]').evaluateAll((els) => els.map((el) => {
const open = el.querySelector('[data-action="artifact-open"]')
const spans = open ? [...open.children] : []
return { id: el.getAttribute('data-artifact-id'), kind: spans[0]?.textContent?.trim() ?? '',
about: (spans[1]?.textContent ?? '').replace(/\s*#\d+$/, '').trim(), title: open?.getAttribute('title') ?? '' }
}))
const answers = fx.run_artifacts.filter((a) => a.kind === 'inquiry_answer').map((a) => rows.find((r) => r.id === a.id))
check('质询的回答:种类写「质询回答」', answers.length === 3 && answers.every((r) => r?.kind === '质询回答'), JSON.stringify(answers))
check('有问题摘要的:说明写「质询「问题」的回答」', answers[0]?.about === '质询「退款率怎么算的?」的回答', answers[0]?.about)
check('问题摘要满 40 字(服务端只记前 40 字):末尾补省略号',
answers[1]?.about === '质询「枫岭区的销售额是否包含线上订单?如果包含,线上订单按下单门店还是按收货地址归到片…」的回答', answers[1]?.about)
check('没有问题摘要的:也有一句说明', answers[2]?.about === '审批质询的回答:逐段的片段和每个数字的出处', answers[2]?.about)
check('悬停写「打开质询回答」', answers.every((r) => r?.title.startsWith('打开质询回答')), answers.map((r) => r?.title).join(' / '))
const pane = await text(page.locator('[data-run-artifacts]'))
check('工件页签里不出现英文的工件种类名', !/inquiry_answer|report_doc|query_snapshot/.test(pane) && rows.every((r) => !/[a-z_]{4,}/.test(r.kind)),
rows.map((r) => r.kind).join('、'))
check('不挂在节点上的写「不属于某个节点」', (await text(page.locator('[data-artifact-group=""] h3'))).startsWith('不属于某个节点'))
// 打开一件:弹窗标题也是中文的种类名
await page.locator(`[data-artifact-id="${answers[0]?.id}"] [data-action="artifact-open"]`).click()
const dialog = page.getByRole('dialog')
await dialog.waitFor()
const head = await text(dialog.locator('h2').first())
check('打开的弹窗标题写「质询回答」,不写英文', head.includes('质询回答') && !head.includes('inquiry_answer'), head)
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'))
@@ -1054,6 +1093,73 @@ await section('之前几轮审批里问的:报告改写过的不按句子编
}
})
/** 一处问答串:抬头写的次数、实际列出的条数 */
const tally = async (page, scope) => ({
head: (await page.locator(`${scope} [data-inquiry-count]`).getAttribute('data-inquiry-count').catch(() => null)) ?? '',
shown: await page.locator(`${scope} [data-inquiry-entry]`).count(),
})
await section('两处要对的审批不同、审批列表取不到:两处的问答都列出来,次数和条数一致', async () => {
// 一张卡审两份报告:写月报上有 ap-x 里问的,写周报上有 ap-y 里问的,两份报告的问答串共用这次运行的审批列表。先发的那一处
// 请求还在路上时,后一处要对的审批也得记上:不然请求回来(这里是 404)之后后一处一直等着,头上写「· 1 次」,下面一条没有
const x = { ...clone(fx.items[0]), seq: 41, approval_id: 'ap-x', question: '写月报上一轮问的:退款率怎么算?', at: '2026-10-05T05:30:00Z' }
const y = { ...clone(fx.items[1]), seq: 42, report: 'write2', approval_id: 'ap-y', question: '写周报上一轮问的:新客怎么界定?',
at: '2026-10-05T05:35:00Z' }
let release
// 审批列表压着,页面等不到网络空闲:打开到 load 就往下走
const { ctx, page, hits, errors } = await open('inquiry-approval-two', { waitUntil: 'load', setup: (s) => {
s.lists[RUNS.approve] = [x, y]
s.approvalsGate = new Promise((r) => { release = r })
} })
// 两份报告的问答串都取回来了(头上写着次数),审批列表的请求还压着
await page.locator('[data-inquiry="write"] [data-inquiry-count]').waitFor()
await page.locator('[data-inquiry="write2"] [data-inquiry-count]').waitFor()
await until(() => hits.approvals.length >= 1)
await page.waitForTimeout(200)
release()
const both = await until(async () => (await tally(page, '[data-inquiry="write"]')).shown === 1
&& (await tally(page, '[data-inquiry="write2"]')).shown === 1)
const [gw, gw2] = [await groupsOf(page, '[data-inquiry="write"]'), await groupsOf(page, '[data-inquiry="write2"]')]
check('写月报:查不到那张审批,问答单独一组', JSON.stringify(gw.map((g) => [g.unit, g.entries])) === JSON.stringify([['other', ['41']]]),
JSON.stringify(gw.map((g) => [g.unit, g.entries])))
check('写周报:请求是另一处先发的,回来之后照样列出、单独一组', both
&& JSON.stringify(gw2.map((g) => [g.unit, g.entries])) === JSON.stringify([['other', ['42']]]), JSON.stringify(gw2.map((g) => [g.unit, g.entries])))
const [tw, tw2] = [await tally(page, '[data-inquiry="write"]'), await tally(page, '[data-inquiry="write2"]')]
check('两份报告头上的次数都和列出的条数一致', tw.head === '1' && tw.shown === 1 && tw2.head === '1' && tw2.shown === 1,
JSON.stringify({ tw, tw2 }))
check('按运行只取一次审批列表(一份列表里各张审批都有)', hits.approvals.length === 1, JSON.stringify(hits.approvals))
check('页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
{
// 运行详情直接打开「证据」页签、运行停在审批上:时间线里的待审批卡只为更早那一轮(ap-inq-0)取审批列表,证据页签不认
// 任何审批,ap-inq-0 和这张卡的 ap-inq-1 都要对。卡那一处先发了请求,证据页签那一处的条目在请求回来之后也得列出来
const early = { ...clone(fx.items[0]), seq: 12, approval_id: 'ap-inq-0', question: '上一轮:退款率 3.5% 是怎么算的?', at: '2026-10-05T05:40:00Z' }
const { ctx, page, hits, errors } = await open('inquiry-run-waiting', { waitUntil: 'load', setup: (s) => {
s.lists[RUNS.approve] = [early, clone(fx.items[1])]
s.approvalsGate = new Promise((r) => { release = r })
} })
const card = '[data-fixture-card] [data-inquiry="write"]'
const pane = '[data-fixture-evidence] [data-inquiry="write"]'
await page.locator(`${card} [data-inquiry-count]`).waitFor()
await page.locator(`${pane} [data-inquiry-count]`).waitFor()
await until(() => hits.approvals.length >= 1)
await page.waitForTimeout(200)
release()
const listed = await until(async () => (await tally(page, card)).shown === 2 && (await tally(page, pane)).shown === 2)
const [gc, gp] = [await groupsOf(page, card), await groupsOf(page, pane)]
check('待审批卡:这张审批里问的挂句子下面,查不到的那一轮单独一组',
JSON.stringify(gc.map((g) => [g.unit, g.entries])) === JSON.stringify([['u3', ['34']], ['other', ['12']]]),
JSON.stringify(gc.map((g) => [g.unit, g.entries])))
check('证据页签:两条都列出来,查不到审批的单独一组(不再一直等着)', listed
&& JSON.stringify(gp.map((g) => [g.unit, g.entries])) === JSON.stringify([['other', ['12', '34']]]), JSON.stringify(gp.map((g) => [g.unit, g.entries])))
const [tc, tp] = [await tally(page, card), await tally(page, pane)]
check('两处头上的次数都和列出的条数一致', tc.head === '2' && tc.shown === 2 && tp.head === '2' && tp.shown === 2, JSON.stringify({ tc, tp }))
check('运行详情:按运行只取一次审批列表', hits.approvals.length === 1, JSON.stringify(hits.approvals))
check('运行详情:页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
}
})
await section('回答期间另开了提问框,正在回答的那一条没问成:新写的不被覆盖', async () => {
const { ctx, page, state, errors } = await open('inquiry-approval')
await expand(page)
@@ -1366,6 +1472,21 @@ await section('窄屏与深浅色', async () => {
check('390px 运行详情:页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
}
for (const theme of ['dark', 'light']) {
// 运行详情「工件」页签:质询回答的说明可能很长(问题的前 40 字),窄屏上截断、不撑宽;种类和说明深浅色都看得清
const { ctx, page, errors } = await open('inquiry-run-artifacts', { width: 390, height: 844, theme })
const long = fx.run_artifacts.find((a) => a.kind === 'inquiry_answer' && [...(a.meta.question ?? '')].length >= 40)
const row = `[data-artifact-id="${long.id}"] [data-action="artifact-open"]`
await page.locator(row).waitFor()
const o = await overflow(page, '[data-run-artifacts]')
const ratios = { kind: await ratioOf(page, `${row} > span:nth-child(1)`), about: await ratioOf(page, `${row} > span:nth-child(2)`) }
check(`390px 工件页签(${theme}):质询回答的长说明截断、不横向溢出,种类和说明对比度 ≥ 4.5`,
o.page <= 0 && o.box <= 0 && ratios.kind >= 4.5 && ratios.about >= 4.5,
JSON.stringify({ o, kind: Number(ratios.kind.toFixed(2)), about: Number(ratios.about.toFixed(2)) }))
await shot(page, `run-artifacts-narrow-${theme}`)
check(`390px 工件页签(${theme}):页面没有报错`, errors.length === 0, errors.join(';'))
await ctx.close()
}
})
await browser.close()