From a358fbd244e12867e76ce9152abfd97de03a9789 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 12:19:13 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E8=B4=A8=E8=AF=A2=E6=94=B6?= =?UTF-8?q?=E5=B0=BE=EF=BC=9A=E5=B7=A5=E4=BB=B6=E9=A1=B5=E7=AD=BE=E8=AE=A4?= =?UTF-8?q?=E5=BE=97=E8=B4=A8=E8=AF=A2=E5=9B=9E=E7=AD=94=EF=BC=9B=E5=87=A0?= =?UTF-8?q?=E5=A4=84=E9=97=AE=E7=AD=94=E4=B8=B2=E8=A6=81=E5=AF=B9=E7=9A=84?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E4=B8=8D=E5=90=8C=E3=80=81=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E5=8F=96=E4=B8=8D=E5=88=B0=E6=97=B6=E5=90=84?= =?UTF-8?q?=E8=87=AA=E7=85=A7=E6=A0=B7=E5=88=97=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 工件名:质询的回答文档以 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 深浅色下不溢出、 看得清 --- frontend/src/dev/fixtures/inquiry.json | 55 ++++++++++- frontend/src/dev/fixtures/inquiry.tsx | 53 +++++++++- frontend/src/lib/terms.ts | 13 +++ frontend/src/pages/runs/model.ts | 5 + frontend/src/run/Inquiry.tsx | 12 ++- scripts/check-inquiry.mjs | 131 ++++++++++++++++++++++++- 6 files changed, 260 insertions(+), 9 deletions(-) diff --git a/frontend/src/dev/fixtures/inquiry.json b/frontend/src/dev/fixtures/inquiry.json index dcf1c971..d7d2a0d8 100644 --- a/frontend/src/dev/fixtures/inquiry.json +++ b/frontend/src/dev/fixtures/inquiry.json @@ -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" } diff --git a/frontend/src/dev/fixtures/inquiry.tsx b/frontend/src/dev/fixtures/inquiry.tsx index 88e69b3d..608dd590 100644 --- a/frontend/src/dev/fixtures/inquiry.tsx +++ b/frontend/src/dev/fixtures/inquiry.tsx @@ -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 ( +
+
+
+ undefined} showWorkflow={false} /> +
+
+
+ (id ? LABELS[id] : undefined)} refreshKey="0" /> +
+
+ ) +} + +/** 运行详情的「工件」页签:工件列表由检查脚本按 inquiry.json 的 run_artifacts 答 */ +function RunArtifacts({ run }: { run: Run }) { + const list = useRunArtifacts(run.id, '0') + const trace = useMemo(() => emptyTrace(), []) + return ( +
+ (id ? LABELS[id] : undefined)} /> +
+ ) +} + export const cases: Record JSX.Element> = { 'inquiry-approval': () => ( @@ -138,6 +175,18 @@ export const cases: Record JSX.Element> = { ), 'inquiry-approval-steps': () => , 'inquiry-approval-steps-same': () => , + 'inquiry-approval-two': () => ( + + ), 'inquiry-run-explore': () => , 'inquiry-run-formal': () => , + 'inquiry-run-waiting': () => ( + + ), + 'inquiry-run-artifacts': () => , } diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 76b9a95e..f9e6ff1b 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -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: '审批质询的回答:逐段的片段和每个数字的出处', } /** diff --git a/frontend/src/pages/runs/model.ts b/frontend/src/pages/runs/model.ts index 0c8ed4c7..2380b144 100644 --- a/frontend/src/pages/runs/model.ts +++ b/frontend/src/pages/runs/model.ts @@ -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 = { 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 | 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 '工件' } } diff --git a/frontend/src/run/Inquiry.tsx b/frontend/src/run/Inquiry.tsx index 90f4ff41..58cba9c6 100644 --- a/frontend/src/run/Inquiry.tsx +++ b/frontend/src/run/Inquiry.tsx @@ -308,7 +308,17 @@ export const useInquiry = create((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]: { diff --git a/scripts/check-inquiry.mjs b/scripts/check-inquiry.mjs index 94f3ba91..b4c017ca 100644 --- a/scripts/check-inquiry.mjs +++ b/scripts/check-inquiry.mjs @@ -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()