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

- 工件名:质询的回答文档以 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
+54 -1
View File
@@ -1,5 +1,5 @@
{
"//": "审批质询预览用例与 check-inquiry 共用的夹具(frontend/src/dev/fixtures/inquiry.tsx)。报告文档和回答文档由后端 evidence.build_catalog + compose_doc 真实组装(虚构的超市、片区、金额),查询快照照质询工件接口的样子给:遮罩列的值写「已遮罩」,另带 masked。生成脚本不在仓库里:改了文档的形状,照这个思路按后端的输出重新生成",
"//": "审批质询预览用例与 check-inquiry 共用的夹具(frontend/src/dev/fixtures/inquiry.tsx)。报告文档和回答文档由后端 evidence.build_catalog + compose_doc 真实组装(虚构的超市、片区、金额),查询快照照质询工件接口的样子给:遮罩列的值写「已遮罩」,另带 masked。生成脚本不在仓库里:改了文档的形状,照这个思路按后端的输出重新生成。run_artifacts 是探索运行的工件列表(照 GET /api/runs/{id}/artifacts 的样子手写,质询回答的 meta 同 engine/inquiry.py 写的 {report, unit, question: 问题的前 40 字}),给运行详情「工件」页签的用例",
"runs": {
"approve": "run-inq-approve",
"formal": "run-inq-formal",
@@ -4278,5 +4278,58 @@
"masked": []
}
},
"run_artifacts": [
{
"id": "8cb677489bbaafcafd3b0f0df1939c1ca57cccaa3c065e14c4efe2991a3dd9a9",
"kind": "report_doc",
"node_id": "write",
"size": 18234,
"meta": {},
"created_at": "2026-10-05T06:30:00"
},
{
"id": "02ca8aeabca59d785d982b321d22ff889cb2da5a037cd4a080407e9044356434",
"kind": "query_snapshot",
"node_id": null,
"size": 1460,
"meta": {},
"created_at": "2026-10-05T06:44:30"
},
{
"id": "911780ed990217a2a2e7e3424a9408d23b53a524ba5d1812ec677b7d6b5b80aa",
"kind": "inquiry_answer",
"node_id": null,
"size": 2140,
"meta": {
"report": "write",
"unit": "u2",
"question": "退款率怎么算的?"
},
"created_at": "2026-10-05T06:45:00"
},
{
"id": "4ed3d62d2af13bbea40195283f3caf63462ca76bb5d4f7ddf1216d36dd5e0463",
"kind": "inquiry_answer",
"node_id": null,
"size": 2510,
"meta": {
"report": "write",
"unit": null,
"question": "枫岭区的销售额是否包含线上订单?如果包含,线上订单按下单门店还是按收货地址归到片"
},
"created_at": "2026-10-05T06:52:00"
},
{
"id": "1c66d590eac155dec35e2215e20c6e7497e96d0b2d73894eff1dea3fb641bb51",
"kind": "inquiry_answer",
"node_id": null,
"size": 1980,
"meta": {
"report": "write",
"unit": "u2"
},
"created_at": "2026-10-05T06:58:00"
}
],
"card_artifact": "757e03414694c3b3480236718d32082ba449e7712d9ce99f4aee39ffdc5c3f4e"
}
+51 -2
View File
@@ -1,7 +1,9 @@
import { useState, type JSX } from 'react'
import { useMemo, useState, type JSX } from 'react'
import { createPortal } from 'react-dom'
import { ArtifactsPane, useRunArtifacts } from '../../pages/runs/ArtifactsPane'
import { EvidencePane } from '../../pages/runs/EvidencePane'
import { ApprovalCard } from '../../run/RunPanel'
import { emptyTrace } from '../../run/trace'
import type { Approval, ApprovalReport, Run } from '../../types'
import fx from './inquiry.json'
@@ -20,12 +22,17 @@ import fx from './inquiry.json'
* React 树里排在前面)和右栏;「收起浮层」按钮开关浮层
* - ?syn=inquiry-approval-steps:同一份报告先后两张审批(主管审批 → 总监审批),「处理掉这张」换下一张;卡按审批 id
* 重新挂(同右栏)。?syn=inquiry-approval-steps-same:同一个卡片实例换审批(同画布的审批浮层,不按 id 重新挂)
* - ?syn=inquiry-approval-two:一张卡审两份报告(写月报、写周报),两份报告的问答串各自挂着、共用这次运行的审批列表
*
* 运行详情的「证据」页签(pages/runs/EvidencePane,是否可问由它按运行状态判断):
* - ?syn=inquiry-run-explore:封存了的探索运行,报告上给入口,问答追加在封存之后
* - ?syn=inquiry-run-formal:封存了的正式运行,不给入口,审批时问过的照列
* - ?syn=inquiry-run-waiting:停在审批上的运行,同运行详情直接打开「证据」页签:时间线里的待审批卡和证据页签同时挂着
* 同一份报告的问答串(卡上这张审批里问的直接认,证据页签哪张审批都要对一下)
* - ?syn=inquiry-run-artifacts:运行详情的「工件」页签(pages/runs/ArtifactsPane),工件列表里有质询的回答
*
* 列表、提问、质询工件、报告文档、证据图、审批列表、设置都由检查脚本用 page.route 伪造;直接打开时问答串照实说无法读取。
* 列表、提问、质询工件、报告文档、证据图、审批列表、工件列表、设置都由检查脚本用 page.route 伪造;直接打开时问答串照实说
* 无法读取。
*/
const RUNS = fx.runs
@@ -115,6 +122,36 @@ function RunEvidence({ run, name }: { run: Run; name: string }) {
)
}
/**
* 停在审批上的运行,同运行详情直接打开「证据」页签:时间线(一直挂着)里的待审批卡在上,证据页签在下,两处挂着同一份
* 报告的问答串。卡上的问答串认得这张审批,证据页签不认任何审批(cardId 为空),两处要为不同的几张审批取审批列表
*/
function Waiting({ approval, run }: { approval: Approval; run: Run }) {
return (
<div className="flex min-h-screen min-w-0 flex-col" data-fixture="inquiry-run-waiting">
<div className="mx-auto w-full max-w-3xl p-4" data-fixture-card="">
<div className="overflow-hidden rounded-lg border" style={{ borderColor: 'var(--warn)' }}>
<ApprovalCard approval={approval} onResolved={() => undefined} showWorkflow={false} />
</div>
</div>
<div className="h-[80vh] min-w-0 border-t" data-fixture-evidence="">
<EvidencePane run={run} output={null} labelOf={(id) => (id ? LABELS[id] : undefined)} refreshKey="0" />
</div>
</div>
)
}
/** 运行详情的「工件」页签:工件列表由检查脚本按 inquiry.json 的 run_artifacts 答 */
function RunArtifacts({ run }: { run: Run }) {
const list = useRunArtifacts(run.id, '0')
const trace = useMemo(() => emptyTrace(), [])
return (
<div className="h-screen min-w-0" data-fixture="inquiry-run-artifacts">
<ArtifactsPane list={list} graph={null} trace={trace} labelOf={(id) => (id ? LABELS[id] : undefined)} />
</div>
)
}
export const cases: Record<string, () => JSX.Element> = {
'inquiry-approval': () => (
<Card name="inquiry-approval" approval={approvalOf(fx.approval_id, RUNS.approve, 'exploratory', reports.approve)} />
@@ -138,6 +175,18 @@ export const cases: Record<string, () => JSX.Element> = {
),
'inquiry-approval-steps': () => <Steps name="inquiry-approval-steps" keyed />,
'inquiry-approval-steps-same': () => <Steps name="inquiry-approval-steps-same" keyed={false} />,
'inquiry-approval-two': () => (
<Card name="inquiry-approval-two" approval={{
...approvalOf(fx.approval_id, RUNS.approve, 'exploratory', reports.approve),
payload: { kind: 'human_node', message: '请审核 9 月月报和第 39 周周报。',
reports: [reports.approve, { ...reports.approve, node_id: 'write2', label: '写周报' }] },
}} />
),
'inquiry-run-explore': () => <RunEvidence name="inquiry-run-explore" run={runOf(RUNS.explore, 'exploratory')} />,
'inquiry-run-formal': () => <RunEvidence name="inquiry-run-formal" run={runOf(RUNS.sealed, 'formal')} />,
'inquiry-run-waiting': () => (
<Waiting approval={approvalOf(fx.approval_id, RUNS.approve, 'exploratory', reports.approve)}
run={{ ...runOf(RUNS.approve, 'exploratory'), status: 'interrupted', manifest_hash: null, manifest_seq: null, finished_at: null }} />
),
'inquiry-run-artifacts': () => <RunArtifacts run={runOf(RUNS.explore, 'exploratory')} />,
}
+13
View File
@@ -3500,6 +3500,19 @@ export const INQUIRY_TEXT = {
snapshotLoading: '正在读取查询结果…',
snapshotFailed: '无法读取这次查询的结果',
masked: (columns: string) => `${columns} 在数据源中设置了遮罩,不显示原值`,
// ---- 运行详情「工件」页签:回答文档按工件种类 inquiry_answer 记在运行名下(pages/runs/model.ts 的工件名和说明) ----
artifactKind: '质询回答',
/**
* 服务端在工件的 meta 里只记问题的前 40 字(engine/inquiry.py 的 QUESTION_BRIEF):满 40 字的多半截断过,末尾补省略号,
* 免得半句话读成整个问题
*/
artifactAbout: (question: string) => {
const chars = [...question.trim()]
return `质询「${chars.join('')}${chars.length >= 40 ? '…' : ''}」的回答`
},
/** 没记问题摘要的(这一版之前存的):照报告文档的说法写装的是什么 */
artifactAboutNone: '审批质询的回答:逐段的片段和每个数字的出处',
}
/**
+5
View File
@@ -10,6 +10,7 @@ import {
RUN_STATUS_ORDER, STATUS, resolveStatus, serverStatusOf, type StatusCode,
} from '../../lib/status'
import { formatSpan, parseServerTime } from '../../lib/format'
import { INQUIRY_TEXT } from '../../lib/terms'
import { hasPendingApproval } from '../../store/catalog'
// -------------------------------------------------------------------------
@@ -334,6 +335,7 @@ export const ARTIFACT_KIND_LABEL: Record<string, string> = {
schema_snapshot: '表结构快照',
dual_check: '双盲复算对照',
challenge: '反方审稿记录',
inquiry_answer: INQUIRY_TEXT.artifactKind,
}
export const artifactKindLabel = (kind: string): string => ARTIFACT_KIND_LABEL[kind] ?? kind
@@ -356,6 +358,9 @@ export function artifactDescription(kind: string, meta?: Record<string, any> | n
? `双盲复算:${meta.metrics} 个指标两边的 SQL、取值与差异说明` : '双盲复算:两边的 SQL、取值与差异说明'
// 反方审稿的完整记录(工件行的 meta 里有 {report: 审的报告节点})
case 'challenge': return '反方审稿:每句结论的判断、理由和反方执行的查询'
// 审批质询的回答文档(工件行的 meta 里有 {report, unit, question: 问题的前 40 字}):不挂在节点上,靠问题认出是哪一条
case 'inquiry_answer': return typeof meta?.question === 'string' && meta.question.trim()
? INQUIRY_TEXT.artifactAbout(meta.question) : INQUIRY_TEXT.artifactAboutNone
default: return '工件'
}
}
+11 -1
View File
@@ -308,7 +308,17 @@ export const useInquiry = create<InquiryState>((set, get) => ({
},
loadApprovalDocs: async (runId, ids) => {
if (get().approvalDocs[runId]?.status === 'loading') return
const inflight = get().approvalDocs[runId]
if (inflight?.status === 'loading') {
// 别处已经在取(同一张卡审两份报告;运行详情的待审批卡和证据页签同时挂着,两处要对的审批不同):不再另取,但这一处
// 要对的也记成取过——列表按运行取、不按审批,在路上的那一份本来就带着全部审批。只是提前返回的话,这一处的 unknown
// 在请求回来之后不变、effect 不再执行,settled 一直为假:头上写着「· N 次」,别的审批里问的那几条一直不显示
const asked = [...new Set([...inflight.asked, ...ids])]
if (asked.length > inflight.asked.length) {
set((s) => ({ approvalDocs: { ...s.approvalDocs, [runId]: { ...s.approvalDocs[runId]!, asked } } }))
}
return
}
set((s) => {
const cur = s.approvalDocs[runId]
return { approvalDocs: { ...s.approvalDocs, [runId]: {
+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()