可验证报告、报告导出写「被驳回」:元信息带 end_reason,核对仍按运行取消

为什么:被驳回的运行导出后,可验证报告的运行结果写「已取消」,报告导出的运行状态也写「已取消」,和系统里「被驳回」两种
说法。封存里被驳回和手动停止是同一种结束事件(运行取消),分开它们的原因在那条事件的内容里,而运行取消的内容不随文件
附带(里面有驳回人和备注)——光看封存分不出来。

做了什么:
- 两种导出的元信息都带 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。把实现退回去回跑,被驳回、伪造两条和元信息、样本页、
接口的断言都失败(手动停止、老运行两条守的是原样,照旧通过);改完通过。
This commit is contained in:
ThearchyHelios
2026-10-16 12:46:36 +08:00
parent b6cb5816db
commit e96b0180d9
11 changed files with 159 additions and 17 deletions
+2 -1
View File
@@ -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)
+1 -1
View File
@@ -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,
+3 -3
View File
@@ -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,
+19 -6
View File
@@ -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})`))
}
+3 -1
View File
@@ -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,
+3 -2
View File
@@ -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)],
+10
View File
@@ -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")
+18
View File
@@ -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'<script type="application/json" id="report-data">(.*?)</script>', 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:
+10 -1
View File
@@ -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"]
+79
View File
@@ -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
+11 -2
View File
@@ -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:总览中性,运行结果无法核对(成功结束的那条不在封存范围内),不拿中途那条失败去对',