From e96b0180d9f4e72cf7a16f08b6c36092de165c41 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 12:46:36 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=AF=E9=AA=8C=E8=AF=81=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=E3=80=81=E6=8A=A5=E5=91=8A=E5=AF=BC=E5=87=BA=E5=86=99=E3=80=8C?= =?UTF-8?q?=E8=A2=AB=E9=A9=B3=E5=9B=9E=E3=80=8D=EF=BC=9A=E5=85=83=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E5=B8=A6=20end=5Freason=EF=BC=8C=E6=A0=B8=E5=AF=B9?= =?UTF-8?q?=E4=BB=8D=E6=8C=89=E8=BF=90=E8=A1=8C=E5=8F=96=E6=B6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:被驳回的运行导出后,可验证报告的运行结果写「已取消」,报告导出的运行状态也写「已取消」,和系统里「被驳回」两种 说法。封存里被驳回和手动停止是同一种结束事件(运行取消),分开它们的原因在那条事件的内容里,而运行取消的内容不随文件 附带(里面有驳回人和备注)——光看封存分不出来。 做了什么: - 两种导出的元信息都带 end_reason(bundle/build、report_export/build、对应的接口)。 - verifier.js:运行结果按运行记录写「被驳回」,核对的仍是「最后一条结束类事件是运行取消」,旁边写「已核对运行取消,被驳回 取自运行记录」,原因里写明这一半要去系统里对照;手动停止、老运行照旧「已取消」;文件里写被驳回、事件却是运行结束的照旧 不通过。runtime.js 的运行状态照 end_reason 写「被驳回」。 - 样本页加一份 rejected(check-bundle 样本数 17 → 18),断言写被驳回、写明取自运行记录、不提重新封存。 测试:test_bundle_rejected 用 node 载入 verifier.js 核运行信息(被驳回、手动停止、老运行、伪造);test_bundle_build 核 元信息和样本页;test_bundle_api 核两种导出接口都带 end_reason。把实现退回去回跑,被驳回、伪造两条和元信息、样本页、 接口的断言都失败(手动停止、老运行两条守的是原样,照旧通过);改完通过。 --- backend/app/api/bundle.py | 3 +- backend/app/api/evidence.py | 2 +- backend/app/bundle/build.py | 6 +- backend/app/bundle/verifier.js | 25 +++++++-- backend/app/report_export/build.py | 4 +- backend/app/report_export/runtime.js | 5 +- backend/tests/bundle_samples.py | 10 ++++ backend/tests/test_bundle_api.py | 18 ++++++ backend/tests/test_bundle_build.py | 11 +++- backend/tests/test_bundle_rejected.py | 79 +++++++++++++++++++++++++++ scripts/check-bundle.mjs | 13 ++++- 11 files changed, 159 insertions(+), 17 deletions(-) create mode 100644 backend/tests/test_bundle_rejected.py diff --git a/backend/app/api/bundle.py b/backend/app/api/bundle.py index 00e973b0..f0256724 100644 --- a/backend/app/api/bundle.py +++ b/backend/app/api/bundle.py @@ -53,7 +53,8 @@ async def export_bundle(run_id: str) -> Response: raise CodedHTTPException(404, "运行记录不存在", RUN_NOT_FOUND) info = {"id": run.id, "workflow_id": run.workflow_id, "workflow_name": run.workflow_name, "version": run.version, "version_hash": run.version_hash, "run_class": run.run_class, - "status": run.status, "started_at": run.started_at, "finished_at": run.finished_at} + "status": run.status, "end_reason": run.end_reason, "started_at": run.started_at, + "finished_at": run.finished_at} # 封存之后追加的事件(按需裁判、审批质询):_sealed 只留了封存范围内的,这几条另取,单列在页面上 later = [tuple(r) for r in await session.execute( select(RunEvent.seq, RunEvent.type, RunEvent.node_id, RunEvent.data) diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py index 75a77730..9ce10855 100644 --- a/backend/app/api/evidence.py +++ b/backend/app/api/evidence.py @@ -2851,7 +2851,7 @@ async def export_report_html(run_id: str, report: str | None = None) -> Response if run is None: # 两次读之间被删掉了 raise CodedHTTPException(404, "运行记录不存在", RUN_NOT_FOUND) info = {"id": run.id, "workflow_id": run.workflow_id, "workflow_name": run.workflow_name, - "run_class": run.run_class, "status": run.status, + "run_class": run.run_class, "status": run.status, "end_reason": run.end_reason, "started_at": run.started_at, "finished_at": run.finished_at} data = build_report(info, report_node=chosen.node_id, doc_artifact=chosen.doc_artifact, doc_hash_ok=chosen.hash_ok, doc=doc, seal=sealed.seal, chains=chains, refs=refs, diff --git a/backend/app/bundle/build.py b/backend/app/bundle/build.py index 806ecff7..a7ca0679 100644 --- a/backend/app/bundle/build.py +++ b/backend/app/bundle/build.py @@ -1237,8 +1237,8 @@ def build_bundle( ) -> dict[str, Any]: """一次已封存运行 → 页面里的数据(bundle-data)。 - - run:运行记录的几样(id、workflow_id、workflow_name、version、version_hash、run_class、status、 - started_at、finished_at) + - run:运行记录的几样(id、workflow_id、workflow_name、version、version_hash、run_class、status、end_reason、 + started_at、finished_at)。end_reason 分开同是 cancelled 的被驳回和手动停止(05-已知遗留 (a)),不在封存范围内 - events:这次运行的全部事件 (seq, type, node_id, data),按数据库里 seq 的顺序。和封存核对 (runner.verify_manifest)读的是同一个顺序:seq 相同的几条,浏览器按这里的先后排,清单才算得一样 - manifest_seq / manifest_hash:封存范围(老运行是按结束事件推算的那个,legacy 为真)和封存哈希 @@ -1306,7 +1306,7 @@ def build_bundle( "meta": { "run_id": run.get("id"), "workflow_id": run.get("workflow_id"), "workflow_name": display_name(run), "version": run.get("version"), "version_hash": run.get("version_hash"), - "run_class": run.get("run_class"), "status": run.get("status"), + "run_class": run.get("run_class"), "status": run.get("status"), "end_reason": run.get("end_reason"), "started_at": _iso(run.get("started_at")), "finished_at": _iso(run.get("finished_at")), "manifest_seq": manifest_seq, "manifest_hash": manifest_hash, "legacy": bool(legacy), "tier": _tier(sealed), "exported_at": exported_at, diff --git a/backend/app/bundle/verifier.js b/backend/app/bundle/verifier.js index 0d253e43..f8cebed3 100644 --- a/backend/app/bundle/verifier.js +++ b/backend/app/bundle/verifier.js @@ -49,7 +49,9 @@ const KIND_LABEL = { report_doc: '报告文档', metric_set: '口径卡', query: '查询快照' } const TIER_LABEL = { formal: '完整出具', degraded: '降档出具', withheld: '不予出具' } const CLASS_LABEL = { formal: '正式运行', exploratory: '探索运行' } - const STATUS_LABEL = { succeeded: '已完成', failed: '已失败', cancelled: '已取消' } + // rejected:被审批人驳回结束(运行记录 cancelled + end_reason=rejected)。封存里它和手动停止是同一种结束事件(运行取消), + // 分开二者的原因在那条事件的内容里,内容不随文件附带:见 checkMeta + const STATUS_LABEL = { succeeded: '已完成', failed: '已失败', cancelled: '已取消', rejected: '被驳回' } /** 结束类事件 → 它说明的运行结果(runner:成功、失败、取消各发一条,落库之后才封存) */ const TERMINAL = { 'run.finished': 'succeeded', 'run.failed': 'failed', 'run.cancelled': 'cancelled' } const EVENT_LABEL = { @@ -84,6 +86,7 @@ range: (seq, count) => `第 1–${seq} 条事件(范围内 ${count} 条)`, rangeLegacy: '(早期运行,范围按结束事件推算)', markPass: '已核对', + markPassRejected: '已核对运行取消,被驳回取自运行记录', markFail: (claim) => `文件里写的是「${claim}」,见核对明细 ⑥`, markNa: '无法核对', noReports: '这次运行没有报告文档,下方只核对运行记录。', @@ -208,6 +211,8 @@ inputFrom: (query, row, column) => (query ? `查询 ${query}` : '') + (Number.isInteger(row) ? ` · 第 ${row + 1} 行` : '') + (column ? ` · ${column}` : ''), inputNotAttached: '快照未随文件附带(见核对明细 ③),输入的值按口径卡的记录展示', statusOk: (seq, what) => `与第 ${seq} 条事件(${what})一致`, + statusOkRejected: (seq, what) => `与第 ${seq} 条事件(${what})一致。被驳回和手动停止在封存里是同一种结束事件,` + + '「被驳回」取自运行记录(这条事件的内容不随文件附带),需要在系统中对照', statusMismatch: (claim, seq, what) => `文件里写的是「${claim}」,与封存范围内最后一条结束类事件(第 ${seq} 条,${what})不符`, statusLegacy: '早期运行在结束事件落库之前封存,结束事件不在封存范围内,运行结果无法核对', statusNoEvent: '封存范围内没有运行结束、失败或取消的事件,运行结果无法核对', @@ -1509,7 +1514,8 @@ */ function checkMeta(meta, out) { const items = [] - const add = (field, shown, claim, result, reason) => items.push({ field, shown, claim, result, reason }) + // mark:显示值旁边那一句的特例(默认按结果写已核对 / 不通过 / 无法核对) + const add = (field, shown, claim, result, reason, mark) => items.push({ field, shown, claim, result, reason, mark }) const order = out.order const sealOk = out.manifest[0].result === PASS const label = (map, value) => map[value] || (value ? String(value) : '—') @@ -1519,7 +1525,10 @@ // 它就是范围内的最后一条;早期运行在 run.finished 落库之前封存,成功结束的那条在范围外,失败、取消的那条在范围的最后。 // 先失败、经继续运行接着跑的,范围里那条运行失败之后运行又继续了,它不是运行的结束:拿它去对,元信息改成「已失败」 // 也会被标成已核对。真正截止的那条不在范围里时,只有早期运行成功结束说得通(无法核对),别的说法都没有事件印证 - const statusClaim = label(STATUS_LABEL, meta.status) + // 被驳回(cancelled + end_reason=rejected)照运行记录写「被驳回」,不写成手动停止的「已取消」;能核对的只有结束事件 + // 是运行取消这一半,另一半写明取自运行记录 + const rejected = meta.status === 'cancelled' && meta.end_reason === 'rejected' + const statusClaim = rejected ? STATUS_LABEL.rejected : label(STATUS_LABEL, meta.status) const lastSeq = order.length ? order[order.length - 1].seq : null const final = [...order].reverse().find((ev) => TERMINAL[ev.type]) // 同一序号的几条(老数据里有)都算最后:结束事件和别的事件撞了序号,不能因此说它之后运行又继续了 @@ -1527,8 +1536,12 @@ if (!sealOk) add('status', statusClaim, statusClaim, NA, T.metaNeedsManifest) else if (end) { const ok = TERMINAL[end.type] === meta.status - add('status', label(STATUS_LABEL, TERMINAL[end.type]), statusClaim, ok ? PASS : FAIL, - ok ? T.statusOk(end.seq, what(end)) : T.statusMismatch(statusClaim, end.seq, what(end))) + if (ok && rejected) { + add('status', statusClaim, statusClaim, PASS, T.statusOkRejected(end.seq, what(end)), T.markPassRejected) + } else { + add('status', label(STATUS_LABEL, TERMINAL[end.type]), statusClaim, ok ? PASS : FAIL, + ok ? T.statusOk(end.seq, what(end)) : T.statusMismatch(statusClaim, end.seq, what(end))) + } } else if (meta.legacy && meta.status === 'succeeded') add('status', statusClaim, statusClaim, NA, T.statusLegacy) // 显示的值写「—」:范围里没有说明运行怎样结束的事件,不能把文件里写的当成核对出来的 else if (final) add('status', '—', statusClaim, FAIL, T.statusContinued(statusClaim, final.seq, what(final))) @@ -1899,7 +1912,7 @@ const checked = (field, fallback) => { const item = out.meta.find((m) => m.field === field) if (!item) return fallback - const note = item.result === PASS ? T.markPass : item.result === FAIL ? T.markFail(item.claim) : T.markNa + const note = item.mark ?? (item.result === PASS ? T.markPass : item.result === FAIL ? T.markFail(item.claim) : T.markNa) return h('span', { 'data-meta': field, 'data-result': item.result }, item.shown, h('span', { class: item.result === FAIL ? 'bad' : 'muted' }, `(${note})`)) } diff --git a/backend/app/report_export/build.py b/backend/app/report_export/build.py index df2ccb88..1832abba 100644 --- a/backend/app/report_export/build.py +++ b/backend/app/report_export/build.py @@ -320,7 +320,8 @@ def build_report( ) -> dict[str, Any]: """页面里的数据(report-data)。 - - run:运行记录的几样(id、workflow_id、workflow_name、run_class、status、started_at、finished_at) + - run:运行记录的几样(id、workflow_id、workflow_name、run_class、status、end_reason、started_at、finished_at); + end_reason 分开同是 cancelled 的被驳回和手动停止(05-已知遗留 (a)) - report_node / doc_artifact / doc_hash_ok:选中的报告节点、它的文档工件、取回时哈希核对的结果 - doc:报告文档(正文的服务端渲染另走 render_body,这里不给:数据块只装点开数字要用的东西) - seal:{_sealed 的 seal 字段里的几样}(sealed、ok、manifest_hash、manifest_seq) @@ -334,6 +335,7 @@ def build_report( "meta": { "run_id": run.get("id"), "workflow_id": run.get("workflow_id"), "workflow_name": display_name(run), "run_class": run.get("run_class"), "status": run.get("status"), + "end_reason": run.get("end_reason"), "started_at": _iso(run.get("started_at")), "finished_at": _iso(run.get("finished_at")), "report": {"node_id": report_node, "label": (nodes.get(report_node) or {}).get("label") or report_node}, "doc_artifact": doc_artifact, "doc_hash_ok": doc_hash_ok, diff --git a/backend/app/report_export/runtime.js b/backend/app/report_export/runtime.js index a66b748c..e0f7eeef 100644 --- a/backend/app/report_export/runtime.js +++ b/backend/app/report_export/runtime.js @@ -25,7 +25,7 @@ metaRun: '运行 ID', metaClass: '运行类别', metaStatus: '运行状态', metaReport: '报告节点', metaStarted: '开始', metaFinished: '结束', metaExported: '导出时间', classLabel: { formal: '正式运行', exploratory: '探索运行' }, - statusLabel: { succeeded: '已完成', failed: '已失败', cancelled: '已取消', running: '进行中', waiting: '等待审批' }, + statusLabel: { succeeded: '已完成', failed: '已失败', cancelled: '已取消', rejected: '被驳回', running: '进行中', waiting: '等待审批' }, docHashBad: '这份报告文档与哈希不一致(疑似被修改),正文仅供参考,其中的口径和出处未经核对。', sealNone: '这次运行导出时尚未封存:下列出处来自导出那一刻的系统记录,未经封存核对。', sealOk: (hash) => `运行已封存,清单哈希 ${hash}。要向系统外的人证明文件未被修改,请导出「可验证报告」(记录页 · 凭证详情),拿封存哈希去系统对照。`, @@ -147,7 +147,8 @@ $('meta').replaceChildren(...[...[ [T.metaRun, h('code', {}, String(meta.run_id || ''))], [T.metaClass, label(T.classLabel, meta.run_class)], - [T.metaStatus, label(T.statusLabel, meta.status)], + // 被驳回(cancelled + end_reason=rejected)照运行记录写「被驳回」,不写成手动停止的「已取消」 + [T.metaStatus, label(T.statusLabel, meta.status === 'cancelled' && meta.end_reason === 'rejected' ? 'rejected' : meta.status)], [T.metaReport, String(meta.report?.label || meta.report?.node_id || NONE)], [T.metaStarted, when(meta.started_at)], [T.metaFinished, when(meta.finished_at)], diff --git a/backend/tests/bundle_samples.py b/backend/tests/bundle_samples.py index 2ee5abc1..7aada838 100644 --- a/backend/tests/bundle_samples.py +++ b/backend/tests/bundle_samples.py @@ -16,6 +16,8 @@ 引用链(④)按「已核对的口径卡的输入」认它;点开数字看得到指标、原式、代入式、输入和复算 - legacy:早期运行,封存范围按结束事件推算(运行结束事件在范围外,运行结果无法核对) - failed:失败结束的运行(没有出具判定),封存说明多一句「继续运行会重新封存」 +- rejected:被审批人驳回结束的运行(运行记录 cancelled + end_reason=rejected,最后一条是运行取消):运行结果写「被驳回」, + 写明能核对的只是「运行取消」这一半、被驳回取自运行记录;不提重新封存 - continued:早期运行先失败、经继续运行跑到成功:范围里有那条运行失败,之后运行又继续了,成功结束的那条在范围外 - degraded:两条出具判定,先完整出具、后降档出具,认最后一条 - missing-doc / missing-card:导出时服务端已没有报告文档 / 口径卡的文件 @@ -278,6 +280,11 @@ class Sample: add("issuance", "out", {"tier": tier, "mode": "citations"}) add("node.finished", "out", {"duration_ms": 2, "attempt": 1, "preview": {"周报": "(报告正文)"}}) add("run.finished", None, {"output": {"周报": "(报告正文)"}}) + elif ending == "rejected": + # 审批人在成果之前驳回(engine/runner:run.cancelled 带 reason=rejected、在哪一步、谁、备注;内容不随文件附带) + add("node.failed", "out", {"error": "人工驳回:数字对不上"}) + add("run.cancelled", None, {"reason": "rejected", "node_id": "out", "label": "成果", "kind": "approve", + "decision": "reject", "actor": "值班分析员", "note": "数字对不上"}) else: add("node.failed", "out", {"error": "出口复核没有通过"}) add("run.failed", None, {"error": "出口复核没有通过"}) @@ -326,6 +333,9 @@ def bundles() -> dict[str, dict[str, Any]]: events, bound = s.events({"write": weekly}, ending="failed") out["failed"] = build(s, events, bound, run={**RUN, "status": "failed"}) + events, bound = s.events({"write": weekly}, ending="rejected") + out["rejected"] = build(s, events, bound, run={**RUN, "status": "cancelled", "end_reason": "rejected"}) + # 早期运行先失败、经继续运行跑到成功:封存范围同样按结束事件推算,到成功结束的前一条为止 events, _ = s.events({"write": weekly}, ending="continued") finished = next(seq for seq, etype, _, _ in events if etype == "run.finished") diff --git a/backend/tests/test_bundle_api.py b/backend/tests/test_bundle_api.py index 949071cc..dcad9948 100644 --- a/backend/tests/test_bundle_api.py +++ b/backend/tests/test_bundle_api.py @@ -353,6 +353,24 @@ async def test_a_failed_run_is_exportable(client, monkeypatch): assert data_of(resp.text)["meta"]["status"] == "failed" +async def test_a_rejected_run_carries_its_end_reason(client, monkeypatch): + """被审批人驳回结束的运行(cancelled + end_reason=rejected,05-已知遗留 (a)):可验证报告和报告导出的元信息都带上 + end_reason,页面照它写「被驳回」,不写成手动停止的「已取消」。""" + row = await sealed_run(monkeypatch) + async with SessionLocal() as session: + run = await session.get(Run, row.id) + run.status, run.end_reason = "cancelled", "rejected" + await session.commit() + bundle = await client.get(f"/api/runs/{row.id}/bundle.html") + assert bundle.status_code == 200, bundle.text + meta = data_of(bundle.text)["meta"] + assert (meta["status"], meta["end_reason"]) == ("cancelled", "rejected") + report = await client.get(f"/api/runs/{row.id}/report.html") + assert report.status_code == 200, report.text + m = re.search(r'', report.text, re.S) + assert m and json.loads(m.group(1))["meta"]["end_reason"] == "rejected" + + async def test_a_broken_seal_is_refused(client, monkeypatch): row = await sealed_run(monkeypatch) async with SessionLocal() as session: diff --git a/backend/tests/test_bundle_build.py b/backend/tests/test_bundle_build.py index 7dd58ded..4172ade7 100644 --- a/backend/tests/test_bundle_build.py +++ b/backend/tests/test_bundle_build.py @@ -340,6 +340,11 @@ def test_meta_names_the_seal_and_the_run(): assert meta["manifest_seq"] == 15 and meta["manifest_hash"] == "ab" * 32 and meta["legacy"] is False assert meta["started_at"] == RUN["started_at"] and meta["finished_at"] == RUN["finished_at"] assert meta["exported_at"] == "2026-10-05T08:00:00+00:00" + # 同是 cancelled 的被驳回和手动停止靠 end_reason 分开;没有的(成功、失败、老运行)是 None + assert meta["end_reason"] is None + rejected = build_bundle({**RUN, "status": "cancelled", "end_reason": "rejected"}, sample_events(), manifest_seq=15, + manifest_hash="ab" * 32, graph=GRAPH, read=STORE.get, exported_at="x") + assert (rejected["meta"]["status"], rejected["meta"]["end_reason"]) == ("cancelled", "rejected") # 出具档位取封存范围内最后一条出具判定事件,带上序号,浏览器拿已核对的事件对照 assert meta["tier"] == {"value": "formal", "seq": 13} assert bundle["nodes"]["write"] == {"label": "写周报", "type": "report"} @@ -463,7 +468,7 @@ def test_the_sample_pages_for_the_browser_check_are_sealed_for_real(): rendered = pages() got = {name: json.loads(data_block(page)) for name, page in rendered.items()} - assert set(got) == {"base", "two-reports", "metric-only", "legacy", "failed", "continued", "degraded", + assert set(got) == {"base", "two-reports", "metric-only", "legacy", "failed", "rejected", "continued", "degraded", "missing-doc", "missing-card", "nan", "stray", "masked-card", "masked-old-doc", "masked-source", "formats", "formats-unit", "prose"} for name, bundle in got.items(): @@ -504,6 +509,10 @@ def test_the_sample_pages_for_the_browser_check_are_sealed_for_real(): assert [s["text"] for s in segments(only)] and not [s for s in segments(only) if s.get("cite", {}).get("kind") == "cell"] failed = got["failed"] assert failed["meta"]["status"] == "failed" and failed["meta"]["tier"] is None + rejected = got["rejected"] + assert (rejected["meta"]["status"], rejected["meta"]["end_reason"]) == ("cancelled", "rejected") + assert max(rejected["events"], key=lambda e: e["seq"])["type"] == "run.cancelled" + assert re.search(r'id="seal-failed"[^>]*\shidden', rendered["rejected"]), "被驳回不是失败:不提重新封存" assert max(failed["events"], key=lambda e: e["seq"])["type"] == "run.failed" # 先失败、经继续运行跑到成功的早期运行:范围里有那条运行失败,之后运行又继续了,最后一条不是结束事件 continued = got["continued"] diff --git a/backend/tests/test_bundle_rejected.py b/backend/tests/test_bundle_rejected.py new file mode 100644 index 00000000..6a3e41d1 --- /dev/null +++ b/backend/tests/test_bundle_rejected.py @@ -0,0 +1,79 @@ +"""可验证报告里被驳回的运行写「被驳回」,不写成手动停止的「已取消」(05-已知遗留 (a)、总方案 X9)。 + +人的驳回让运行结束在 cancelled + end_reason=rejected,手动停止也是 cancelled(end_reason=stopped)。封存里二者是同一种 +结束事件(运行取消),分开它们的原因在那条事件的内容里,内容不随文件附带。所以: + +- 运行结果照运行记录(元信息的 end_reason)写「被驳回」,核对的仍是「最后一条结束类事件是运行取消」这一半,旁边写明 + 被驳回取自运行记录,不把它说成已核对出来的; +- 手动停止、老运行(没有 end_reason)照旧「已取消」; +- 文件里写着被驳回、最后一条事件却是运行结束的,照旧不通过,点名文件里写的「被驳回」。 + +用 node 直接载入 verifier.js(同 test_bundle_shown.py),只看运行信息(⑥)这一块。数据全是虚构的。 +""" +from __future__ import annotations + +import copy +import json +import shutil +import subprocess +from pathlib import Path +from typing import Any + +import pytest + +import app.bundle +from tests.bundle_samples import RUN, Sample, WEEKLY, build, bundles + +NODE = shutil.which("node") +VERIFIER = Path(app.bundle.__file__).parent / "verifier.js" + +pytestmark = pytest.mark.skipif(NODE is None, reason="没有 node,无法执行页面上的核对脚本") + +#: 载入 verifier.js(node 里没有 document,它把核对用的纯函数交给 module.exports),交回运行信息每一项 +HARNESS = r""" +const fs = require('fs') +const { createHash } = require('crypto') +const input = JSON.parse(fs.readFileSync(0, 'utf8')) +const mod = { exports: {} } +new Function('module', fs.readFileSync(process.argv[1], 'utf8'))(mod) +const digest = async (bytes) => createHash('sha256').update(bytes).digest('hex') +mod.exports.verify(input.data, digest, input.data.meta.manifest_hash).then((out) => { + process.stdout.write(JSON.stringify(out.meta.map((m) => ({ field: m.field, shown: m.shown, claim: m.claim, + result: m.result, reason: m.reason || '', mark: m.mark ?? null })))) +}, (e) => { console.error((e && e.stack) || String(e)); process.exit(1) }) +""" + + +def status_of(bundle: dict[str, Any]) -> dict[str, Any]: + done = subprocess.run([NODE, "-e", HARNESS, str(VERIFIER)], input=json.dumps({"data": bundle}), + capture_output=True, text=True, timeout=120) + assert done.returncode == 0, done.stderr[-2000:] + return next(m for m in json.loads(done.stdout) if m["field"] == "status") + + +def ended(end_reason: str | None) -> dict[str, Any]: + """同一串事件(最后一条是运行取消),运行记录分别写被驳回、手动停止、没写(老运行)。""" + s = Sample() + events, bound = s.events({"write": s.doc(WEEKLY, "write")}, ending="rejected") + return build(s, events, bound, run={**RUN, "status": "cancelled", "end_reason": end_reason}) + + +def test_a_rejected_run_reads_rejected_and_says_which_half_is_checked(): + got = status_of(bundles()["rejected"]) + assert got["result"] == "pass" and got["shown"] == "被驳回" and got["claim"] == "被驳回", got + assert "运行取消" in got["reason"] and "取自运行记录" in got["reason"], got["reason"] + # 旁边那一句不说成全核对过了 + assert got["mark"] == "已核对运行取消,被驳回取自运行记录", got + + +@pytest.mark.parametrize("end_reason", ["stopped", None]) +def test_a_stopped_or_old_cancelled_run_still_reads_cancelled(end_reason): + got = status_of(ended(end_reason)) + assert (got["result"], got["shown"], got["claim"], got["mark"]) == ("pass", "已取消", "已取消", None), got + + +def test_a_file_claiming_rejected_over_a_finished_run_fails_and_names_the_claim(): + forged = copy.deepcopy(bundles()["base"]) + forged["meta"].update(status="cancelled", end_reason="rejected") + got = status_of(forged) + assert got["result"] == "fail" and got["shown"] == "已完成" and "被驳回" in got["reason"], got diff --git a/scripts/check-bundle.mjs b/scripts/check-bundle.mjs index 3b5fb378..6ac7b7b4 100644 --- a/scripts/check-bundle.mjs +++ b/scripts/check-bundle.mjs @@ -17,7 +17,7 @@ // 口径卡、快照对出来(显示文字按显示规则重新渲染后逐字比对)不通过,原因点名正文显示的字和引用的值,报告标题旁的徽标 // 跟着不通过; // - 深浅色、窄屏:390 宽不横向溢出,点开的数字面板在窄屏上从底部抽出,深色下底是深的、字是浅的; -// - 样本页:tests/bundle_samples.py 用虚构数据真实生成的几份页面(遮罩快照、两份报告、早期运行、失败结束、先失败后 +// - 样本页:tests/bundle_samples.py 用虚构数据真实生成的几份页面(遮罩快照、两份报告、早期运行、失败结束、被驳回结束(写「被驳回」、写明取自运行记录)、先失败后 // 经继续运行跑到成功的早期运行、两条出具 // 判定、服务端缺文件、裸 NaN、别的运行的文档、口径卡有输入取自遮罩列(卡的节点完成事件只给哈希)、同一张卡配上证据 // 目录里写着号码的老文档(文档只给哈希、页面写明原因)、设了遮罩的数据源上按地区汇总的查询、每种显示 @@ -611,7 +611,7 @@ await section('样本页:各种形状', async () => { check('生成样本页(tests/bundle_samples.py)', false, String(e?.message ?? e)) return } - check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 17, Object.keys(samples).join('、')) + check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 18, Object.keys(samples).join('、')) await sample('base', async (s, page) => { check('base:有无法核对的项,总览是中性的「可核对的项全部通过,另有 N 项无法核对」,不是绿色的通过', neutral(s), @@ -734,6 +734,15 @@ await section('样本页:各种形状', async () => { await sample('base', async (_, page) => { check('成功结束的运行不提重新封存', await page.locator('#seal-failed').isHidden()) }) + // 被审批人驳回结束(cancelled + end_reason=rejected):写「被驳回」,不写成手动停止的「已取消」;封存里只核对得到 + // 「运行取消」这一半,旁边写明被驳回取自运行记录。不是失败,不提重新封存 + await sample('rejected', async (s, page) => { + check('rejected:运行结果写「被驳回」,与运行取消事件对上', s.meta.status?.result === 'pass' + && s.meta.status.text.includes('被驳回') && !s.meta.status.text.includes('已取消'), JSON.stringify(s.meta.status)) + check('rejected:写明被驳回取自运行记录', s.meta.status?.text.includes('取自运行记录') + && hasItem(s, 'meta', 'pass', '取自运行记录'), JSON.stringify(s.meta.status)) + check('rejected:不提重新封存', await page.locator('#seal-failed').isHidden()) + }) // 早期运行先失败、经继续运行跑到成功:范围里那条运行失败之后运行又继续了,它不是运行的结束;成功结束的那条在范围外 await sample('continued', async (s) => { check('continued:总览中性,运行结果无法核对(成功结束的那条不在封存范围内),不拿中途那条失败去对',