mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:01:58 +00:00
合入期 5 验收修复 M3:可验证报告核对正文显示的文字与引用的值一致——文字取值逐字比对,数字按服务端的显示规则(千分位、小数位、百分数、万 / 亿换算、单位)核对,认不出的格式判无法核对而不是通过;紧挨数字的不可见字符、缩进挤出屏幕的列表一并判不通过
This commit is contained in:
+740
-108
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,12 @@
|
||||
快照随文件附带,数字核对得过)和选出了号码的那份会员消费(只给哈希)
|
||||
- nan:运行开始事件和门店流水快照里有 json.dumps 写出的裸 NaN(快照里那一列报告没有引用)
|
||||
- stray:报告自查记下的文档是另一次运行写的
|
||||
- formats:经营月报,每种显示格式各引用一处:口径卡的千分位加货币单位、换算成万 / 亿、比率显示成百分数、负数、不分组、
|
||||
小数位与 |.N / |int、pct;流水快照里的文字取值(地区)、DECIMAL 落成的数字文本、负数、换算成万、超过 2^53 的订单号、
|
||||
ISO 日期时间(文档记下 2 版显示规则)。check-bundle 在它上面只改显示文字做伪造,tests/test_bundle_shown.py 在 node 里
|
||||
逐个格式核一遍
|
||||
- formats-unit:同经营月报,销售额的单位带尾随空白(「元 」:单位输入框不去空白,指标节点照存),正文显示「74,613.65元 」,
|
||||
原样导出照样全部通过
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -62,6 +68,34 @@ WEEKLY = ("## 门店周报\n\n本周销售额 [[m:gmv]],*按发生周统计*
|
||||
"**会员消费** [[v:Q2.r0.spend]],另有 42 笔退款待入账。[[see:Q1]]\n\n- 云岭店流水最高\n- 枫桥店流水最低\n\n"
|
||||
"[[table:Q1 cols=store,amount rows=0-7]]")
|
||||
MONTHLY = "## 门店月报\n\n本月销售额 [[m:gmv]],青石店流水 [[v:Q1.r0.amount]]。[[see:Q1]]"
|
||||
|
||||
#: 经营月报的口径卡:每个指标一种显示格式(千分位加货币单位、不分组、比率显示成百分数、负数、小数位、不写小数位)
|
||||
FORMATS_CARD = {"kind": "metric_set", "caliber": "经营月报口径", "caliber_version": "v1", "metrics": [
|
||||
{"id": "gmv", "name": "销售额", "value": 74613.65, "unit": "元", "decimals": 2, "format": "thousands"},
|
||||
{"id": "annual", "name": "年度销售额", "value": 1234567890.5, "unit": "元", "decimals": 2, "format": "thousands"},
|
||||
{"id": "refund", "name": "退款", "value": -3120.5, "unit": "元", "decimals": 2, "format": "thousands"},
|
||||
{"id": "conv_rate", "name": "转化率", "value": 0.0235, "unit": "", "decimals": 4, "format": "percent_of_ratio"},
|
||||
{"id": "growth", "name": "环比", "value": -0.0815, "unit": "", "decimals": 3, "format": "percent_of_ratio"},
|
||||
{"id": "orders", "name": "订单数", "value": 12840, "unit": "单", "decimals": 0, "format": "plain"},
|
||||
{"id": "avg", "name": "客单价", "value": 5.8113, "unit": "元", "decimals": 2, "format": "thousands"},
|
||||
{"id": "share", "name": "线上占比", "value": 0.346, "unit": "", "decimals": None, "format": "thousands"},
|
||||
{"id": "export", "name": "出口额", "value": 15230.4, "unit": "美元", "decimals": 1, "format": "thousands"},
|
||||
]}
|
||||
#: 同上,销售额的单位带尾随空白:服务端照原样接在数后面
|
||||
FORMATS_UNIT_CARD = {**FORMATS_CARD, "metrics": [{**FORMATS_CARD["metrics"][0], "unit": "元 "},
|
||||
*FORMATS_CARD["metrics"][1:]]}
|
||||
#: 经营流水:地区是文字,金额一格是 DECIMAL 落成的数字文本、一格是负数,订单号超过 2^53,付款时间是 ISO 日期时间
|
||||
LEDGER = {"columns": ["region", "gmv", "order_no", "paid_at"],
|
||||
"column_types": {"region": "text", "gmv": "number", "order_no": "number", "paid_at": "datetime"},
|
||||
"rows": [["华南", "45678.50", 202609071234567890, "2026-09-07T08:21:39"],
|
||||
["华东", -1200.75, 202609071234567891, "2026-09-08T19:02:11"]],
|
||||
"source": "shop", "sql": "SELECT region, gmv, order_no, paid_at FROM ledger ORDER BY region DESC",
|
||||
"truncated": False}
|
||||
FORMATS = ("## 经营月报\n\n本月销售额 [[m:gmv]](约 [[m:gmv|万]]),年度累计 [[m:annual|亿]],退款 [[m:refund]]。"
|
||||
"转化率 [[m:conv_rate]],环比 [[m:growth]],订单 [[m:orders]]。客单价 [[m:avg]],精确些是 [[m:avg|.3]],"
|
||||
"取整是 [[m:avg|int]];线上占比 [[m:share|pct]],出口额 [[m:export]]。\n\n"
|
||||
"[[v:Q1.r0.region]]流水 [[v:Q1.r0.gmv]](约 [[v:Q1.r0.gmv|万]]),[[v:Q1.r1.region]]冲减 [[v:Q1.r1.gmv]]。"
|
||||
"最大一单的订单号 [[v:Q1.r0.order_no]],付款时间 [[v:Q1.r0.paid_at]]。[[see:Q1]]")
|
||||
#: 会员周报:Q3 是会员按地区汇总(没碰遮罩列),Q2 是选出了号码的会员消费
|
||||
MEMBERS = ("## 会员周报\n\n华东会员消费 [[v:Q3.r0.spend]],华南 [[v:Q3.r1.spend]]。[[see:Q3]]\n\n"
|
||||
"会员林一消费 [[v:Q2.r0.spend]]。")
|
||||
@@ -94,18 +128,22 @@ def masked_card(crm_id: str) -> dict[str, Any]:
|
||||
|
||||
class Sample:
|
||||
"""一次虚构的已封存运行:工件(原字节)、证据目录、报告文档、事件。masked=True 时口径卡带着取自遮罩列的输入;
|
||||
regions=True 时会员库另有一次按地区汇总的查询(目录里排在 Q3)。"""
|
||||
regions=True 时会员库另有一次按地区汇总的查询(目录里排在 Q3)。card、table 换掉口径卡和门店流水的快照(经营月报);
|
||||
inputs 是运行输入(报告里写 [[i:字段]] 引用)。"""
|
||||
|
||||
def __init__(self, *, nan: bool = False, masked: bool = False, regions: bool = False) -> None:
|
||||
def __init__(self, *, nan: bool = False, masked: bool = False, regions: bool = False,
|
||||
card: dict[str, Any] | None = None, table: dict[str, Any] | None = None,
|
||||
inputs: dict[str, Any] | None = None) -> None:
|
||||
self.files: dict[str, bytes] = {}
|
||||
self.shop_id = self.put(shop(nan))
|
||||
snapshot = table or shop(nan)
|
||||
self.shop_id = self.put(snapshot)
|
||||
self.crm_id = self.put(CRM)
|
||||
self.card = masked_card(self.crm_id) if masked else CARD
|
||||
self.card = card or (masked_card(self.crm_id) if masked else CARD)
|
||||
self.card_id = self.put(self.card)
|
||||
self.entries = {
|
||||
"fetch": {"kind": "query", "node_id": "fetch", "exec": 1, "artifact": self.shop_id,
|
||||
"tool": "db_query__shop", "source": "shop", "columns": ["store", "amount", "ratio"],
|
||||
"rows": len(STORES), "truncated": False},
|
||||
"tool": "db_query__shop", "source": "shop", "columns": snapshot["columns"],
|
||||
"rows": len(snapshot["rows"]), "truncated": False},
|
||||
"members": {"kind": "query", "node_id": "members", "exec": 1, "artifact": self.crm_id,
|
||||
"tool": "db_query__crm", "source": "crm", "columns": CRM["columns"], "rows": len(CRM["rows"]),
|
||||
"truncated": False},
|
||||
@@ -116,7 +154,7 @@ class Sample:
|
||||
"columns": REGIONS["columns"], "rows": len(REGIONS["rows"]), "truncated": False}
|
||||
self.entries["card"] = {"kind": "metric_set", "node_id": "card", "artifact": self.card_id}
|
||||
self.ledger = list(self.entries.values())
|
||||
self.catalog = build_catalog(nodes={"card": self.card}, ledger=self.ledger)
|
||||
self.catalog = build_catalog(nodes={"card": self.card}, ledger=self.ledger, inputs=inputs)
|
||||
|
||||
def put(self, obj: Any) -> str:
|
||||
artifact, raw = stored(obj)
|
||||
@@ -189,62 +227,79 @@ class Sample:
|
||||
return rows, bound
|
||||
|
||||
|
||||
def render(sample: Sample, events: list[tuple[int, str, str | None, Any]], bound: int, *, run: dict[str, Any] | None = None,
|
||||
legacy: bool = False, drop: tuple[str, ...] = (), source_masks: dict[str, list[str]] | None = None) -> str:
|
||||
"""真实封存哈希的页面。drop:导出时服务端已没有文件的工件;source_masks:数据源现在设的遮罩。"""
|
||||
def build(sample: Sample, events: list[tuple[int, str, str | None, Any]], bound: int, *,
|
||||
run: dict[str, Any] | None = None, legacy: bool = False, drop: tuple[str, ...] = (),
|
||||
source_masks: dict[str, list[str]] | None = None) -> dict[str, Any]:
|
||||
"""真实封存哈希的页面数据(bundle-data)。drop:导出时服务端已没有文件的工件;source_masks:数据源现在设的遮罩。"""
|
||||
sealed = [e for e in events if e[0] <= bound]
|
||||
files = {k: v for k, v in sample.files.items() if k not in drop}
|
||||
bundle = build_bundle(run or RUN, events, manifest_seq=bound, manifest_hash=manifest_hash(sealed), read=files.get,
|
||||
exported_at=EXPORTED_AT, legacy=legacy, graph=GRAPH, source_masks=source_masks or {})
|
||||
return render_html(bundle)
|
||||
return build_bundle(run or RUN, events, manifest_seq=bound, manifest_hash=manifest_hash(sealed), read=files.get,
|
||||
exported_at=EXPORTED_AT, legacy=legacy, graph=GRAPH, source_masks=source_masks or {})
|
||||
|
||||
|
||||
def pages() -> dict[str, str]:
|
||||
"""{页面名: HTML}"""
|
||||
out: dict[str, str] = {}
|
||||
def formats_bundle(*, card: dict[str, Any] = FORMATS_CARD, table: dict[str, Any] = LEDGER, text: str = FORMATS,
|
||||
inputs: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""经营月报(formats)的页面数据:每种显示格式各引用一处。tests/test_bundle_shown.py 换掉口径卡、流水、正文或者
|
||||
加上运行输入,组装别的写法。"""
|
||||
f = Sample(card=card, table=table, inputs=inputs)
|
||||
events, bound = f.events({"write": f.doc(text, "write")})
|
||||
return build(f, events, bound)
|
||||
|
||||
|
||||
def bundles() -> dict[str, dict[str, Any]]:
|
||||
"""{页面名: 页面数据}"""
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
s = Sample()
|
||||
weekly = s.doc(WEEKLY, "write")
|
||||
events, bound = s.events({"write": weekly})
|
||||
out["base"] = render(s, events, bound)
|
||||
out["missing-doc"] = render(s, events, bound, drop=(weekly,))
|
||||
out["missing-card"] = render(s, events, bound, drop=(s.card_id,))
|
||||
out["base"] = build(s, events, bound)
|
||||
out["missing-doc"] = build(s, events, bound, drop=(weekly,))
|
||||
out["missing-card"] = build(s, events, bound, drop=(s.card_id,))
|
||||
# 早期运行:运行结束事件落库之前封存,范围到它前一条为止(runner.verify_manifest 的老口径)
|
||||
finished = next(seq for seq, etype, _, _ in events if etype == "run.finished")
|
||||
out["legacy"] = render(s, events, finished - 1, legacy=True)
|
||||
out["legacy"] = build(s, events, finished - 1, legacy=True)
|
||||
|
||||
monthly = s.doc(MONTHLY, "monthly")
|
||||
events, bound = s.events({"write": weekly, "monthly": monthly})
|
||||
out["two-reports"] = render(s, events, bound)
|
||||
out["two-reports"] = build(s, events, bound)
|
||||
|
||||
events, bound = s.events({"write": weekly}, ending="failed")
|
||||
out["failed"] = render(s, events, bound, run={**RUN, "status": "failed"})
|
||||
out["failed"] = build(s, events, bound, run={**RUN, "status": "failed"})
|
||||
|
||||
# 早期运行先失败、经继续运行跑到成功:封存范围同样按结束事件推算,到成功结束的前一条为止
|
||||
events, _ = s.events({"write": weekly}, ending="continued")
|
||||
finished = next(seq for seq, etype, _, _ in events if etype == "run.finished")
|
||||
out["continued"] = render(s, events, finished - 1, legacy=True)
|
||||
out["continued"] = build(s, events, finished - 1, legacy=True)
|
||||
|
||||
events, bound = s.events({"write": weekly}, tiers=("formal", "degraded"))
|
||||
out["degraded"] = render(s, events, bound)
|
||||
out["degraded"] = build(s, events, bound)
|
||||
|
||||
stray = s.doc(WEEKLY, "write", run_id="another-run-0000")
|
||||
events, bound = s.events({"write": stray})
|
||||
out["stray"] = render(s, events, bound)
|
||||
out["stray"] = build(s, events, bound)
|
||||
|
||||
n = Sample(nan=True)
|
||||
events, bound = n.events({"write": n.doc(WEEKLY, "write")}, nan=True)
|
||||
out["nan"] = render(n, events, bound)
|
||||
out["nan"] = build(n, events, bound)
|
||||
|
||||
m = Sample(masked=True)
|
||||
events, bound = m.events({"write": m.doc(WEEKLY, "write")})
|
||||
out["masked-card"] = render(m, events, bound)
|
||||
out["masked-card"] = build(m, events, bound)
|
||||
|
||||
r = Sample(regions=True)
|
||||
events, bound = r.events({"write": r.doc(MEMBERS, "write")})
|
||||
out["masked-source"] = render(r, events, bound, source_masks={"crm": ["phone"]})
|
||||
out["masked-source"] = build(r, events, bound, source_masks={"crm": ["phone"]})
|
||||
|
||||
out["formats"] = formats_bundle()
|
||||
out["formats-unit"] = formats_bundle(card=FORMATS_UNIT_CARD)
|
||||
return out
|
||||
|
||||
|
||||
def pages() -> dict[str, str]:
|
||||
"""{页面名: HTML}"""
|
||||
return {name: render_html(bundle) for name, bundle in bundles().items()}
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) != 2:
|
||||
print("用法:python -m tests.bundle_samples <目录>", file=sys.stderr)
|
||||
|
||||
@@ -464,7 +464,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", "legacy", "failed", "continued", "degraded", "missing-doc",
|
||||
"missing-card", "nan", "stray", "masked-card", "masked-source"}
|
||||
"missing-card", "nan", "stray", "masked-card", "masked-source", "formats", "formats-unit"}
|
||||
for name, bundle in got.items():
|
||||
assert browser_manifest(bundle["events"]) == bundle["meta"]["manifest_hash"], name
|
||||
|
||||
@@ -513,3 +513,6 @@ def test_the_sample_pages_for_the_browser_check_are_sealed_for_real():
|
||||
nan = got["nan"]
|
||||
assert "NaN" in next(e["canonical"] for e in nan["events"] if e["type"] == "run.started")
|
||||
assert b"NaN" in base64.b64decode(next(a["b64"] for a in nan["artifacts"] if a.get("node_id") == "fetch"))
|
||||
# 口径卡单位带尾随空白:服务端把「元 」原样接在数后面,正文里的销售额带着那个空白
|
||||
shown = {s.get("ref"): s["text"] for s in segments(got["formats-unit"])}
|
||||
assert shown["m:gmv"] == "74,613.65元 " and shown["m:gmv|万"] == "7.46万元 "
|
||||
|
||||
@@ -0,0 +1,782 @@
|
||||
"""可验证报告核对正文显示的字(bundle/verifier.js 的显示规则)。
|
||||
|
||||
以前页面上 ⑤ 只核「引用的值 = 口径卡的值或快照的格子」,不核正文里显示的字:只改显示文字(cite.value 不动)、重算
|
||||
全部哈希的伪造页照样全部通过。现在浏览器按服务端的显示规则(engine/evidence.py 的 render_number / render_cell)把那个
|
||||
值渲染一遍,和正文显示的字逐字比对(只去掉首尾空白)。这里用 node 直接载入 verifier.js(没有页面时它交出核对用的纯函数):
|
||||
|
||||
- 移植的渲染和服务端逐条对得上:千分位、不分组、小数位(含负数位)、比率显示成百分数、万 / 亿换算、pct、int、.N、
|
||||
货币等单位后缀、负数、0、超过 2^53 的整数(按 JSON 原文,不经 double)、DECIMAL 落成的数字文本、文字压成一行、竖线、
|
||||
日期时间按文档记下的显示规则版本;服务端渲染不出来的,浏览器也不给出一个字。本页没有移植的写法(对象、列表、非 ASCII
|
||||
数字)一律渲染不出来,判无法核对,不会给出一串不一样的字;
|
||||
- 整份核对(verify):compose_doc 真实组装的报告原样通过;只改一个数字或取值的显示文字、重算全部哈希的伪造页,
|
||||
①–④ 照样对得上,⑤ 里那一项不通过,原因点名「正文显示「X」,引用的值是「Y」」;认不出的换算判无法核对,不判通过;
|
||||
- 口径卡单位前后带空白(「元 」,服务端照原样接):两边都只去首尾空白,原样导出照样通过;运行输入照旧无法核对,显示
|
||||
文字拿引用的值核对,只改显示文字的不通过;出处面板里数字文本照原文写;
|
||||
- 正文的排法:核对通过的字要原样显示——伪造的链接藏不起它(整句按原文显示),正文里不显示的判不通过;紧挨着补数字、
|
||||
负号(长得像负号的横线也算,隔着不占位置的字也算),紧挨着显示成什么说不准的字(控制字符、组合符号这类),同一句里
|
||||
有改变文字方向的字(方向控制字符、希伯来文和阿拉伯文这类从右往左书写的文字),判无法核对;区间、日期这类真报告里的
|
||||
写法照常通过;列表缩进有上限、表格只排有格的列,伪造的缩进、列号挤不走核对过的数。
|
||||
|
||||
数据全是虚构的。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import app.bundle
|
||||
from app.core.artifact_store import canonical_json
|
||||
from app.engine.evidence import RenderError, _Snapshots, render_cell, render_number
|
||||
|
||||
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),按 stdin 交来的任务算。
|
||||
#: noBigInt:载入时把 BigInt 遮掉,当作较早的浏览器
|
||||
HARNESS = r"""
|
||||
const fs = require('fs')
|
||||
const { createHash } = require('crypto')
|
||||
const input = JSON.parse(fs.readFileSync(0, 'utf8'))
|
||||
const mod = { exports: {} }
|
||||
new Function('module', 'BigInt', fs.readFileSync(process.argv[1], 'utf8'))(mod, input.noBigInt ? undefined : BigInt)
|
||||
const v = mod.exports
|
||||
|
||||
function render(c) {
|
||||
try {
|
||||
const value = v.parseJson(c.json)
|
||||
const token = v.tokenAt(v.numberTokens(c.json), [])
|
||||
if (c.cell) return { shown: v.renderCell(value, token, c.conv, c.kind, c.display) }
|
||||
const decimals = v.parseJson(c.decimals)
|
||||
return { shown: v.renderNumber(value, token, { unit: c.unit, decimals,
|
||||
decimalsToken: v.tokenAt(v.numberTokens(c.decimals), []), fmt: c.format, conv: c.conv }) }
|
||||
} catch (e) {
|
||||
return { shown: null, why: String(e && e.message), unshown: e instanceof v.Unshown }
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (input.task === 'render') return input.cases.map(render)
|
||||
// 列表各项显示的缩进级数、表格排出来的列号(核对和显示共用的那两个函数)
|
||||
if (input.task === 'layout') {
|
||||
return input.blocks.map((b) => (b.type === 'list' ? v.listItems(b.units).map((i) => i.depth)
|
||||
: v.tableCells(b.units).cols))
|
||||
}
|
||||
const digest = async (bytes) => createHash('sha256').update(bytes).digest('hex')
|
||||
const out = await v.verify(input.data, digest, input.seal)
|
||||
const results = (items) => items.map((i) => i.result)
|
||||
return {
|
||||
events: results(out.events), manifest: results(out.manifest), artifacts: results(out.artifacts),
|
||||
chain: results(out.chain), meta: results(out.meta),
|
||||
// shownOk:显示文字核对过、对上了;truthText:出处面板「快照里的值」写的字(数字文本照原文);onPage:正文里这处
|
||||
// 可点的数字上读的人看到的字(没有显示成可点的数字是 null)
|
||||
numbers: out.numbers.map((n) => ({ key: n.key, ref: n.seg.ref ?? null, text: String(n.seg.text ?? ''),
|
||||
result: n.result, reason: n.reason || '', expected: n.expected ?? null, shownOk: !!n.shownOk,
|
||||
truthText: n.truthText ?? null, onPage: n.onPage ?? null })),
|
||||
}
|
||||
}
|
||||
main().then((r) => process.stdout.write(JSON.stringify(r)), (e) => {
|
||||
console.error((e && e.stack) || String(e))
|
||||
process.exit(1)
|
||||
})
|
||||
"""
|
||||
|
||||
|
||||
def node(task: str, **payload: Any) -> Any:
|
||||
done = subprocess.run([NODE, "-e", HARNESS, str(VERIFIER)], input=json.dumps({"task": task, **payload}),
|
||||
capture_output=True, text=True, timeout=300)
|
||||
assert done.returncode == 0, done.stderr[-2000:]
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 渲染规则逐条对照服务端
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
#: 口径卡、运行输入里的值(render_number)。浮点、整数(含超过 2^53 的、1000 位上下的)、0 和 -0.0、文字、布尔、空值、
|
||||
#: 非有限数(服务端渲染不出来)
|
||||
NUMBERS: list[Any] = [
|
||||
0, 7, -7, 12840, 8381.25, 74613.65, -74613.65, 1234567890.5, 0.0235, -0.0815, 0.346, 5.8113, 2.675, -2.675,
|
||||
0.1 + 0.2, 1e16, 1.5e-7, 0.004, -0.0, 999.995, 0.005, 1e21, 123456789012345678901234567890, 2**53 + 1,
|
||||
202609071234567890, -202609071234567891, 1e300, 10**999, 10**1000, 49196.64, 30278.22,
|
||||
float("nan"), float("inf"), True, False, None, "华南", " 华南 \n 大区\u3000", "a|b", "2026", "",
|
||||
]
|
||||
FORMATS = [None, "thousands", "plain", "percent_of_ratio", "bogus"]
|
||||
DECIMALS = ["null", "0", "1", "2", "4", "-2", "6", "100", "101", "-101", "true", "2.0", "\"2\""]
|
||||
CONVS = [None, "", "万", "亿", "pct", "int", ".0", ".3", ".6", ".7", "千", "PCT"]
|
||||
#: 单位照口径卡上写的原样接在后面:前后带空白(含全角空格)的也照接,服务端不去空白
|
||||
UNITS = ["", "元", "美元", "%", "%", "人次", "元 ", "元\u3000", " 元"]
|
||||
|
||||
|
||||
def py_number(value: Any, unit: str, decimals: Any, fmt: Any, conv: Any) -> str | None:
|
||||
try:
|
||||
return render_number(value, unit=unit, decimals=decimals, fmt=fmt, conv=conv)
|
||||
except RenderError:
|
||||
return None
|
||||
|
||||
|
||||
def py_cell(raw: Any, conv: Any, kind: Any, display: int) -> str | None:
|
||||
try:
|
||||
return render_cell(_Snapshots(display=display).shown(raw), conv, kind=kind)
|
||||
except RenderError:
|
||||
return None
|
||||
|
||||
|
||||
def number_cases() -> list[dict[str, Any]]:
|
||||
cases = []
|
||||
for value in NUMBERS:
|
||||
text = json.dumps(value, ensure_ascii=False)
|
||||
for fmt in FORMATS:
|
||||
for decimals in DECIMALS:
|
||||
for conv in CONVS:
|
||||
for unit in (UNITS if fmt in (None, "thousands") and decimals in ("null", "2") else ("元", "%")):
|
||||
cases.append({"json": text, "decimals": decimals, "format": fmt, "conv": conv, "unit": unit,
|
||||
"py": py_number(json.loads(text), unit, json.loads(decimals), fmt, conv)})
|
||||
# 随机:各种量级、小数位的浮点和整数,按口径卡常见的几种格式
|
||||
rng = random.Random(20261006)
|
||||
for _ in range(4000):
|
||||
scale = 10 ** rng.randint(-8, 14)
|
||||
value: Any = rng.choice([round(rng.uniform(-1, 1) * scale, rng.randint(0, 10)), rng.randint(-10**18, 10**18),
|
||||
rng.uniform(-1, 1) * scale, rng.randint(-999, 999) / 1000])
|
||||
fmt = rng.choice(FORMATS[:4])
|
||||
decimals = rng.choice(["null", "0", "1", "2", "3", "4", "-1", "-3", "8"])
|
||||
conv = rng.choice([None, None, "万", "亿", "pct", "int", ".1", ".2", ".5"])
|
||||
unit = rng.choice(["", "元", "单"])
|
||||
text = json.dumps(value)
|
||||
cases.append({"json": text, "decimals": decimals, "format": fmt, "conv": conv, "unit": unit,
|
||||
"py": py_number(json.loads(text), unit, json.loads(decimals), fmt, conv)})
|
||||
return cases
|
||||
|
||||
|
||||
#: 查询快照里的一格(render_cell):数、DECIMAL 落成的数字文本(带空白、指数、前导零的编号、正号)、文字(多个空白、
|
||||
#: 全角空格、竖线、换行)、ISO 日期时间(各种写法,2 版显示规则把 T 换成空格)、布尔、空值、超长的整数文本
|
||||
CELLS: list[Any] = [
|
||||
1280.5, 732, -1200.75, 0, -0.0, 202609071234567890, 2**63, 0.1 + 0.2, 1e-11, 1e22,
|
||||
"45678.50", "960.00", "-12.5", " 12 ", "1E+5", "1E-7", "1E+999", "1E+1000", "0.000000000049", "0012", "+5",
|
||||
"1e5", "-0", "1.", ".5", "1" * 4301, "9" * 4300,
|
||||
"华南", " 多 空格\n换行 ", "\u3000全角空格\u3000", "a|b|c", "\x1c分隔\x1f", "\ufeff零宽\ufeff",
|
||||
"2026-09-07T08:21:39", "2026-09-07T08:21:39.123+08:00", " 2026-09-07T08:21Z ", "2026-09-07T8:21",
|
||||
"2026-09-07 08:21:39", "2026-09-07", True, False, None,
|
||||
]
|
||||
CELL_KINDS = [None, "number", "text", "datetime"]
|
||||
CELL_CONVS = [None, "万", "亿", "pct", "int", ".2", "千"]
|
||||
|
||||
|
||||
def cell_cases() -> list[dict[str, Any]]:
|
||||
cases = []
|
||||
for raw in CELLS:
|
||||
text = json.dumps(raw, ensure_ascii=False)
|
||||
for kind in CELL_KINDS:
|
||||
for conv in CELL_CONVS:
|
||||
for display in (1, 2):
|
||||
cases.append({"cell": True, "json": text, "kind": kind, "conv": conv, "display": display,
|
||||
"py": py_cell(json.loads(text), conv, kind, display)})
|
||||
return cases
|
||||
|
||||
|
||||
def test_the_ported_rules_render_exactly_like_the_server():
|
||||
"""服务端渲染得出来的,浏览器逐字一样;服务端渲染不出来的(引用就算解析不了,不会出现在报告里),浏览器也不给出字。"""
|
||||
cases = number_cases() + cell_cases()
|
||||
got = node("render", cases=[{k: v for k, v in c.items() if k != "py"} for c in cases])
|
||||
wrong = [(c, g) for c, g in zip(cases, got) if g["shown"] != c["py"]]
|
||||
assert not wrong, [(c["json"][:40], c.get("format"), c.get("decimals"), c.get("unit"), c.get("conv"),
|
||||
c.get("kind"), c.get("display"), c["py"], g) for c, g in wrong[:12]]
|
||||
# 服务端渲染不出来的,浏览器给的是「核对不了」(Unshown),不是脚本自己出错
|
||||
assert all(g["unshown"] for c, g in zip(cases, got) if c["py"] is None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", [{"a": 1, "b": [1, 2.0]}, [1, "x"], "1٢٣", "0.٥"])
|
||||
@pytest.mark.parametrize("kind", [None, "number"])
|
||||
def test_shapes_the_page_does_not_port_cannot_be_checked(raw, kind):
|
||||
"""对象、列表(服务端按 Python 的 json.dumps 写)、非 ASCII 数字的数字文本(Python 按数渲染):本页没有移植,
|
||||
渲染不出来,判无法核对;不会给出一串和服务端不一样的字。"""
|
||||
text = json.dumps(raw, ensure_ascii=False)
|
||||
[got] = node("render", cases=[{"cell": True, "json": text, "kind": kind, "conv": None, "display": 2}])
|
||||
assert got["shown"] is None and got["unshown"], got
|
||||
|
||||
|
||||
def test_integers_beyond_double_precision_render_from_the_json_text():
|
||||
"""订单号这类超过 2^53 的整数:JSON.parse 读成 double 会丢位,渲染按 JSON 原文取精确值。"""
|
||||
order = 202609071234567891
|
||||
[number, cell] = node("render", cases=[
|
||||
{"json": str(order), "decimals": "null", "format": None, "conv": None, "unit": "号"},
|
||||
{"cell": True, "json": str(order), "kind": "number", "conv": None, "display": 2}])
|
||||
assert number["shown"] == "202,609,071,234,567,891号" and cell["shown"] == "202,609,071,234,567,891"
|
||||
|
||||
|
||||
def test_floats_round_half_up_on_their_shortest_decimal_form():
|
||||
"""浮点按最短的十进制写法(Python 的 repr、JS 的 String 同一个)四舍五入:2.675 留两位是 2.68,不是 double
|
||||
里的 2.67499…;-8.15 留一位是 -8.2(远离 0)。"""
|
||||
got = node("render", cases=[
|
||||
{"json": "2.675", "decimals": "2", "format": None, "conv": None, "unit": ""},
|
||||
{"json": "-0.0815", "decimals": "3", "format": "percent_of_ratio", "conv": None, "unit": ""},
|
||||
{"json": "999.995", "decimals": "2", "format": None, "conv": None, "unit": "元"}])
|
||||
assert [g["shown"] for g in got] == ["2.68", "-8.2%", "1,000.00元"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 整份核对:原样导出全部通过,只改显示文字的伪造页点名不通过
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def sha(data: bytes | str) -> str:
|
||||
return hashlib.sha256(data.encode() if isinstance(data, str) else data).hexdigest()
|
||||
|
||||
|
||||
def seal_of(bundle: dict[str, Any]) -> str:
|
||||
lines = [f"{e['seq']}|{e['type']}|{e['node_id'] or ''}|{e['data_sha256']}"
|
||||
for e in sorted(bundle["events"], key=lambda e: e["seq"])]
|
||||
return sha("\n".join(lines))
|
||||
|
||||
|
||||
def doc_of(bundle: dict[str, Any], node_id: str = "write") -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
doc_id = next(r["doc_artifact"] for r in bundle["reports"] if r["node_id"] == node_id)
|
||||
art = next(a for a in bundle["artifacts"] if a["id"] == doc_id)
|
||||
return art, json.loads(base64.b64decode(art["b64"]))
|
||||
|
||||
|
||||
def segment(doc: dict[str, Any], ref: str) -> dict[str, Any]:
|
||||
return next(s for b in doc["blocks"] for u in b["units"] for s in u["segments"] if s.get("ref") == ref)
|
||||
|
||||
|
||||
def reseal(bundle: dict[str, Any], art: dict[str, Any], doc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""改过的文档重新落成工件:文档 id、引用它的事件(规范字符串和哈希)、封存清单哈希全部重算。和服务端写工件一样
|
||||
用规范 JSON:① 到 ④ 都对得上,伪造只可能在 ⑤ 露馅。伪造者还能写进不成对的代理项(JSON 里的 \\ud835 转义),
|
||||
规范 JSON 编不成 UTF-8:这时照 JSON 的转义写,页面只看字节的哈希,照样对得上。"""
|
||||
try:
|
||||
raw = canonical_json(doc).encode()
|
||||
except UnicodeEncodeError:
|
||||
raw = json.dumps(doc, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode()
|
||||
old, new = art["id"], sha(raw)
|
||||
art["id"], art["b64"] = new, base64.b64encode(raw).decode()
|
||||
for report in bundle["reports"]:
|
||||
if report["doc_artifact"] == old:
|
||||
report["doc_artifact"] = new
|
||||
for event in bundle["events"]:
|
||||
if isinstance(event.get("canonical"), str) and old in event["canonical"]:
|
||||
event["canonical"] = event["canonical"].replace(old, new)
|
||||
event["data_sha256"] = sha(event["canonical"])
|
||||
bundle["meta"]["manifest_hash"] = seal_of(bundle)
|
||||
return bundle
|
||||
|
||||
|
||||
def forged(ref: str, text: str, bundle: dict[str, Any] | None = None, **cite: Any) -> dict[str, Any]:
|
||||
"""经营月报样本(或给的 bundle)里一个片段的显示文字改成 text(cite.value 不动,cite.rendered 跟着改,cite 另改
|
||||
cite 里给的几项),重算全部哈希。"""
|
||||
from tests.bundle_samples import formats_bundle
|
||||
|
||||
bundle = bundle or formats_bundle()
|
||||
art, doc = doc_of(bundle)
|
||||
seg = segment(doc, ref)
|
||||
seg["text"] = text
|
||||
seg["cite"].update(rendered=text, **cite)
|
||||
return reseal(bundle, art, doc)
|
||||
|
||||
|
||||
def rewritten(bundle: dict[str, Any], change: Any) -> dict[str, Any]:
|
||||
"""change(文档) 就地改报告文档,再重算全部哈希(同 reseal)。"""
|
||||
art, doc = doc_of(bundle)
|
||||
change(doc)
|
||||
return reseal(bundle, art, doc)
|
||||
|
||||
|
||||
def by_id(doc: dict[str, Any], seg_id: str) -> dict[str, Any]:
|
||||
return next(s for b in doc["blocks"] for u in b["units"] for s in u["segments"] if s.get("id") == seg_id)
|
||||
|
||||
|
||||
def verdicts(bundle: dict[str, Any]) -> dict[str, Any]:
|
||||
return node("verify", data=bundle, seal=bundle["meta"]["manifest_hash"])
|
||||
|
||||
|
||||
def item(got: dict[str, Any], ref: str) -> dict[str, Any]:
|
||||
return next(n for n in got["numbers"] if n["ref"] == ref)
|
||||
|
||||
|
||||
def test_every_format_in_the_monthly_sample_passes_as_exported():
|
||||
"""经营月报样本(compose_doc 真实组装:货币单位、万 / 亿、比率、负数、不分组、小数位、pct、DECIMAL 文本、
|
||||
超过 2^53 的订单号、日期时间、文字取值)原样导出:每个数字和取值都通过,显示文字核对过(expected 等于正文)。"""
|
||||
from tests.bundle_samples import formats_bundle
|
||||
|
||||
got = verdicts(formats_bundle())
|
||||
assert all(r == "pass" for key in ("events", "manifest", "artifacts", "chain", "meta") for r in got[key]), got
|
||||
numbers = got["numbers"]
|
||||
assert len(numbers) == 19
|
||||
assert all(n["result"] == "pass" and n["expected"] == n["text"] for n in numbers), \
|
||||
[n for n in numbers if n["result"] != "pass" or n["expected"] != n["text"]]
|
||||
shown = {n["ref"]: n["text"] for n in numbers}
|
||||
assert shown == {
|
||||
"m:gmv": "74,613.65元", "m:gmv|万": "7.46万元", "m:annual|亿": "12.35亿元", "m:refund": "-3,120.50元",
|
||||
"m:conv_rate": "2.35%", "m:growth": "-8.2%", "m:orders": "12840单", "m:avg": "5.81元", "m:avg|.3": "5.811元",
|
||||
"m:avg|int": "6元", "m:share|pct": "34.6%", "m:export": "15,230.4美元",
|
||||
"v:Q1.r0.region": "华南", "v:Q1.r0.gmv": "45,678.5", "v:Q1.r0.gmv|万": "4.57万", "v:Q1.r1.region": "华东",
|
||||
"v:Q1.r1.gmv": "-1,200.75", "v:Q1.r0.order_no": "202,609,071,234,567,890",
|
||||
"v:Q1.r0.paid_at": "2026-09-07 08:21:39",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ref, text, cited, expected", [
|
||||
("v:Q1.r0.region", "华东", "华南", None), # 文字取值:换了地区
|
||||
("m:gmv", "84,613.65元", "74613.65", "74,613.65元"), # 千分位 + 货币单位
|
||||
("m:gmv|万", "8.46万元", "74613.65", "7.46万元"), # 口径卡引用换算成万
|
||||
("m:annual|亿", "13.35亿元", "1234567890.5", "12.35亿元"), # 换算成亿
|
||||
("m:refund", "3,120.50元", "-3120.5", "-3,120.50元"), # 负数去掉负号
|
||||
("m:conv_rate", "3.35%", "0.0235", "2.35%"), # 比率显示成百分数
|
||||
("m:growth", "8.2%", "-0.0815", "-8.2%"), # 负的百分数
|
||||
("m:share|pct", "43.6%", "0.346", "34.6%"), # pct 换算
|
||||
("m:orders", "13840单", "12840", "12840单"), # 不分组
|
||||
("m:avg", "5.80元", "5.8113", "5.81元"), # 口径卡的小数位
|
||||
("m:avg|.3", "5.812元", "5.8113", "5.811元"), # |.N
|
||||
("m:avg|int", "5元", "5.8113", "6元"), # |int
|
||||
("m:export", "15,230.4元", "15230.4", "15,230.4美元"), # 换了货币单位
|
||||
("v:Q1.r0.gmv", "54,678.5", "45678.5", "45,678.5"), # DECIMAL 落成的数字文本
|
||||
("v:Q1.r1.gmv", "1,200.75", "-1200.75", "-1,200.75"), # 负数单元格
|
||||
("v:Q1.r0.gmv|万", "5.57万", "45678.5", "4.57万"), # 单元格换算成万
|
||||
("v:Q1.r0.order_no", "202,609,071,234,567,891", "202609071234567890",
|
||||
"202,609,071,234,567,890"), # 超过 2^53 的整数
|
||||
("v:Q1.r0.paid_at", "2026-09-08 08:21:39", "2026-09-07T08:21:39", "2026-09-07 08:21:39"), # 日期时间
|
||||
])
|
||||
def test_changing_only_the_shown_text_fails_and_names_both(ref, text, cited, expected):
|
||||
"""只改显示文字、引用值不动、重算全部哈希:①–④ 照样通过,⑤ 里只有这一项不通过,原因点名正文显示的字和引用的值
|
||||
(数字另写按显示规则应显示的字)。"""
|
||||
got = verdicts(forged(ref, text))
|
||||
assert all(r == "pass" for key in ("events", "manifest", "artifacts", "chain") for r in got[key]), got
|
||||
hit = next(n for n in got["numbers"] if n["ref"] == ref)
|
||||
assert hit["result"] == "fail", hit
|
||||
assert hit["reason"].startswith(f"正文显示「{text}」,引用的值是「{cited}」"), hit["reason"]
|
||||
if expected is None:
|
||||
assert "应显示为" not in hit["reason"], hit["reason"]
|
||||
else:
|
||||
assert hit["reason"].endswith(f",按显示规则应显示为「{expected}」"), hit["reason"]
|
||||
assert all(n["result"] == "pass" for n in got["numbers"] if n["ref"] != ref)
|
||||
|
||||
|
||||
def test_surrounding_blanks_in_the_shown_text_are_tolerated():
|
||||
"""只去掉首尾空白:「 华南 」仍算和「华南」一致;中间多一个字就不是。"""
|
||||
assert next(n for n in verdicts(forged("v:Q1.r0.region", " 华南\u3000"))["numbers"]
|
||||
if n["ref"] == "v:Q1.r0.region")["result"] == "pass"
|
||||
assert next(n for n in verdicts(forged("v:Q1.r0.region", "华南区"))["numbers"]
|
||||
if n["ref"] == "v:Q1.r0.region")["result"] == "fail"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ref, cite, text", [
|
||||
("m:gmv", {"conv": "千"}, "74.61千元"), # 系统没有的换算
|
||||
("v:Q1.r0.gmv", {"conv": "千"}, "45.68千"),
|
||||
("m:gmv", {"conv": 4}, "7.46万元"), # 换算不是文字
|
||||
])
|
||||
def test_a_format_the_page_does_not_know_cannot_pass(ref, cite, text):
|
||||
"""不认识的写法:渲染不出来,判无法核对显示文字,不判通过。"""
|
||||
hit = next(n for n in verdicts(forged(ref, text, **cite))["numbers"] if n["ref"] == ref)
|
||||
assert hit["result"] == "na" and hit["reason"].startswith("无法核对显示文字:"), hit
|
||||
|
||||
|
||||
def test_an_unknown_cell_display_version_cannot_pass():
|
||||
"""文档记下的单元格显示规则版本认不出(服务端复核记 bad_schema):单元格的显示文字判无法核对;口径卡的照常核对。"""
|
||||
from tests.bundle_samples import formats_bundle
|
||||
|
||||
bundle = formats_bundle()
|
||||
art, doc = doc_of(bundle)
|
||||
doc["cell_display"] = 3
|
||||
got = verdicts(reseal(bundle, art, doc))
|
||||
cells = [n for n in got["numbers"] if n["ref"].startswith("v:")]
|
||||
assert cells and all(n["result"] == "na" and n["reason"].startswith("无法核对显示文字:") for n in cells)
|
||||
assert all(n["result"] == "pass" for n in got["numbers"] if n["ref"].startswith("m:"))
|
||||
|
||||
|
||||
def test_a_browser_without_bigint_cannot_check_numbers_but_still_checks_the_rest():
|
||||
"""较早的浏览器没有 BigInt:数字的显示文字判无法核对(写明原因),文字取值照常核对,别的核对项不受影响,脚本不出错。"""
|
||||
from tests.bundle_samples import formats_bundle
|
||||
|
||||
bundle = formats_bundle()
|
||||
got = node("verify", data=bundle, seal=bundle["meta"]["manifest_hash"], noBigInt=True)
|
||||
assert all(r == "pass" for key in ("events", "manifest", "artifacts", "chain", "meta") for r in got[key])
|
||||
texts = {"v:Q1.r0.region", "v:Q1.r1.region", "v:Q1.r0.paid_at"}
|
||||
assert all(n["result"] == "pass" for n in got["numbers"] if n["ref"] in texts)
|
||||
# 原因写给读报告的人:说清该怎么办,不提 BigInt、正则这类实现细节
|
||||
assert all(n["result"] == "na" and n["reason"] == "无法核对显示文字:这个浏览器版本较旧,请用新版浏览器打开本文件"
|
||||
for n in got["numbers"] if n["ref"] not in texts)
|
||||
|
||||
|
||||
def test_every_sample_page_still_passes_its_display_check():
|
||||
"""check-bundle 的样本页(tests/bundle_samples.py)原样导出:每个核对得到出处的数字和取值,显示文字都核对通过;
|
||||
没有一项因为显示文字不通过或核对不了。"""
|
||||
from tests.bundle_samples import bundles
|
||||
|
||||
for name, bundle in bundles().items():
|
||||
got = verdicts(bundle)
|
||||
assert not [n for n in got["numbers"] if n["reason"].startswith(("正文显示", "无法核对显示文字"))], name
|
||||
assert all(n["expected"] == n["text"] for n in got["numbers"] if n["result"] == "pass"), name
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 单位前后带空白:服务端照原样接在数后面,只去首尾空白时两边一样去
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def card_with_unit(unit: str) -> dict[str, Any]:
|
||||
"""经营月报的口径卡,销售额的单位换成 unit。"""
|
||||
from tests.bundle_samples import FORMATS_CARD
|
||||
|
||||
card = copy.deepcopy(FORMATS_CARD)
|
||||
card["metrics"][0]["unit"] = unit
|
||||
return card
|
||||
|
||||
|
||||
@pytest.mark.parametrize("unit", ["元 ", "元\u3000", " 元", "元 \u3000"])
|
||||
def test_a_unit_with_surrounding_blanks_passes_as_exported(unit):
|
||||
"""单位输入框不去空白,指标节点照存:服务端把「元 」原样接在数后面,正文显示「74,613.65元 」。只去掉首尾空白时
|
||||
两边一样去:原样导出的销售额和换算成万的那处都通过,不会被说成文件被改过。"""
|
||||
from tests.bundle_samples import formats_bundle
|
||||
|
||||
got = verdicts(formats_bundle(card=card_with_unit(unit)))
|
||||
for ref in ("m:gmv", "m:gmv|万"):
|
||||
hit = item(got, ref)
|
||||
assert hit["result"] == "pass" and hit["shownOk"] and hit["expected"] == hit["text"], hit
|
||||
assert all(n["result"] == "pass" for n in got["numbers"]), [n for n in got["numbers"] if n["result"] != "pass"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("unit", ["元 ", "元\u3000"])
|
||||
@pytest.mark.parametrize("ref, digits", [("m:gmv", "84,613.65"), ("m:gmv|万", "8.46万")])
|
||||
def test_a_unit_with_trailing_blanks_still_catches_a_changed_digit(unit, ref, digits):
|
||||
"""单位带尾随空白时,改了一位数字照样不通过,原因点名改过的字。"""
|
||||
from tests.bundle_samples import formats_bundle
|
||||
|
||||
text = f"{digits}{unit}"
|
||||
hit = item(verdicts(forged(ref, text, formats_bundle(card=card_with_unit(unit)))), ref)
|
||||
assert hit["result"] == "fail" and hit["reason"].startswith(f"正文显示「{text}」,引用的值是「74613.65」"), hit
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 运行输入:输入本身不随文件附带,显示文字照样拿引用的值核对
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
INPUTS = {"week_start": "2026-09-01", "target": 50000, "region": "华南", "rate": 0.125, "reviewed": True}
|
||||
INPUT_TEXT = ("## 周报\n\n统计周期自 [[i:week_start]] 起,目标 [[i:target]](约 [[i:target|万]]),重点地区 [[i:region]],"
|
||||
"达成率 [[i:rate|pct]],已复核:[[i:reviewed]]。")
|
||||
|
||||
|
||||
def test_input_citations_check_their_shown_text():
|
||||
"""运行输入不随文件附带,引用的值核对不了(照旧无法核对,原因不变);正文显示的字照样拿引用的值按服务端的写法
|
||||
(不分组、不接单位,可带换算)重新渲染比对:原样导出时对得上,出处面板注明一致。"""
|
||||
from tests.bundle_samples import formats_bundle
|
||||
|
||||
got = verdicts(formats_bundle(text=INPUT_TEXT, inputs=INPUTS))
|
||||
inputs = [n for n in got["numbers"] if n["ref"].startswith("i:")]
|
||||
assert {n["ref"]: n["text"] for n in inputs} == {
|
||||
"i:week_start": "2026-09-01", "i:target": "50000", "i:target|万": "5万", "i:region": "华南",
|
||||
"i:rate|pct": "12.5%", "i:reviewed": "是"}
|
||||
assert all(n["result"] == "na" and n["reason"] == "引用的是运行输入,运行输入未随文件附带" and n["shownOk"]
|
||||
for n in inputs), inputs
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ref, text, cited, expected", [
|
||||
("i:week_start", "2026-09-08", "2026-09-01", None), # 改了统计周期(文字取值)
|
||||
("i:region", "华东", "华南", None),
|
||||
("i:target", "60000", "50000", None),
|
||||
("i:target|万", "6万", "50000", "5万"),
|
||||
("i:rate|pct", "21.5%", "0.125", "12.5%"),
|
||||
])
|
||||
def test_changing_only_the_shown_text_of_an_input_fails(ref, text, cited, expected):
|
||||
"""只改运行输入的显示文字(引用的值不动)、重算全部哈希:不通过,原因点名正文显示的字和引用的值。"""
|
||||
from tests.bundle_samples import formats_bundle
|
||||
|
||||
hit = item(verdicts(forged(ref, text, formats_bundle(text=INPUT_TEXT, inputs=INPUTS))), ref)
|
||||
assert hit["result"] == "fail" and hit["reason"].startswith(f"正文显示「{text}」,引用的值是「{cited}」"), hit
|
||||
if expected:
|
||||
assert hit["reason"].endswith(f",按显示规则应显示为「{expected}」"), hit
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 出处面板:落成数字文本的值照原文写
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_numeric_text_cells_show_their_own_digits_in_the_panel():
|
||||
"""DECIMAL(20,0) 落成数字文本的订单号:按数比对时读成 double 会丢位,出处面板「快照里的值」照快照的原文写,
|
||||
和「引用的值」一位不差;45678.50 这类也照原文写。"""
|
||||
from tests.bundle_samples import LEDGER, formats_bundle
|
||||
|
||||
table = copy.deepcopy(LEDGER)
|
||||
table["rows"][0][2] = "202609071234567891"
|
||||
got = verdicts(formats_bundle(table=table))
|
||||
order = item(got, "v:Q1.r0.order_no")
|
||||
assert order["result"] == "pass" and order["text"] == "202,609,071,234,567,891", order
|
||||
assert order["truthText"] == "202609071234567891", order
|
||||
assert item(got, "v:Q1.r0.gmv")["truthText"] == "45678.50"
|
||||
# 不是数字文本的格子不另写原文:照旧按值写
|
||||
assert item(got, "v:Q1.r1.gmv")["truthText"] is None and item(got, "v:Q1.r0.region")["truthText"] is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 正文的排法:核对通过的字要原样显示,旁边的字不能把它改读成别的数
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_every_checked_value_is_shown_on_the_page_as_checked():
|
||||
"""原样导出:每个数字和取值都显示成可点的数字,读的人看到的字就是核对过的字。"""
|
||||
from tests.bundle_samples import formats_bundle
|
||||
|
||||
got = verdicts(formats_bundle())
|
||||
assert all(n["onPage"] == n["text"] for n in got["numbers"]), [n for n in got["numbers"] if n["onPage"] != n["text"]]
|
||||
|
||||
|
||||
def test_inline_markup_cannot_hide_a_checked_value():
|
||||
"""伪造的链接把核对通过的数字、取值塞进链接地址(链接只显示文字、不显示地址),链接文字写成别的数:这一句改按
|
||||
未排版的原文显示,核对过的字一个不少地露出来,读的人看得到两个数。"""
|
||||
from tests.bundle_samples import formats_bundle
|
||||
|
||||
def change(doc: dict[str, Any]) -> None:
|
||||
by_id(doc, "s3")["text"] += "[84,613.65元](x " # 本月销售额 [84,613.65元](x 74,613.65元)(约 …
|
||||
by_id(doc, "s5")["text"] = ")" + by_id(doc, "s5")["text"]
|
||||
by_id(doc, "s36")["text"] += "[华南](x " # ),[华南](x 华东)冲减 …
|
||||
by_id(doc, "s38")["text"] = ")" + by_id(doc, "s38")["text"]
|
||||
|
||||
got = verdicts(rewritten(formats_bundle(), change))
|
||||
for ref, text in (("m:gmv", "74,613.65元"), ("v:Q1.r1.region", "华东")):
|
||||
hit = item(got, ref)
|
||||
assert hit["result"] == "pass" and hit["onPage"] == text, hit
|
||||
|
||||
|
||||
GLUED = "无法核对显示文字:紧挨着的正文里还有数字或负号,读起来和核对过的不是同一个值"
|
||||
BLURRED = "无法核对显示文字:紧挨着的正文里有看不见或在不同设备上显示不同的字,读起来可能和核对过的不是同一个值"
|
||||
TURNED = "无法核对显示文字:这一句里有从右往左书写的文字或改变文字方向的控制字符,显示出来的顺序可能和核对过的不一样"
|
||||
#: 经营月报第一段里的数字(第二段是 Q1 的取值)
|
||||
FIRST = {"m:gmv", "m:gmv|万", "m:annual|亿", "m:refund", "m:conv_rate", "m:growth", "m:orders", "m:avg", "m:avg|.3",
|
||||
"m:avg|int", "m:share|pct", "m:export"}
|
||||
|
||||
|
||||
def _glue_before(seg_id: str, extra: str, after: str = ""):
|
||||
def change(doc: dict[str, Any]) -> None:
|
||||
by_id(doc, seg_id)["text"] += extra
|
||||
if after:
|
||||
nxt = by_id(doc, f"s{int(seg_id[1:]) + 2}")
|
||||
nxt["text"] = after + nxt["text"]
|
||||
return change
|
||||
|
||||
|
||||
def _glue_after(seg_id: str, extra: str):
|
||||
def change(doc: dict[str, Any]) -> None:
|
||||
by_id(doc, seg_id)["text"] = extra + by_id(doc, seg_id)["text"]
|
||||
return change
|
||||
|
||||
|
||||
def _split_pair(high: str, low: str):
|
||||
"""销售额前面插两个文字片段:前一个以代理对的前半结尾,后一个是后半。页面上两半挨着,拼成一个字显示。"""
|
||||
def change(doc: dict[str, Any]) -> None:
|
||||
unit = next(u for b in doc["blocks"] for u in b["units"] if any(s.get("id") == "s4" for s in u["segments"]))
|
||||
at = next(i for i, s in enumerate(unit["segments"]) if s.get("id") == "s4")
|
||||
by_id(doc, "s3")["text"] += high
|
||||
unit["segments"].insert(at, {"kind": "text", "id": "s3b", "text": low, "state": "neutral"})
|
||||
return change
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ref, change", [
|
||||
("m:gmv", _glue_before("s3", "1")), # 前面多一位:174,613.65元
|
||||
("m:gmv", _glue_before("s3", "1" + chr(0x200B))), # 隔着看不见的零宽空格
|
||||
("m:gmv", _glue_before("s3", "1**", after="**")), # 隔着排版时藏起来的粗体标记
|
||||
("m:gmv", _glue_before("s3", chr(0x1D7F7))), # 数学字母区的数字 1(占两个 UTF-16 单元)
|
||||
# 隔着显示时不占位置的字(Chrome 里量过,宽 0 到 1px 不到):控制字符 DEL、未分配的默认不显示字、韩文填充符、
|
||||
# 对象替换符、发丝空格、蒙古文里一个宽 0 的字、叠在数字上的组合符号、变体选择符
|
||||
("m:gmv", _glue_before("s3", "1" + chr(0x7F))),
|
||||
("m:gmv", _glue_before("s3", "1" + chr(0xFFF0))),
|
||||
("m:gmv", _glue_before("s3", "1" + chr(0x115F))),
|
||||
("m:gmv", _glue_before("s3", "1" + chr(0xFFFC))),
|
||||
("m:gmv", _glue_before("s3", "1" + chr(0x200A))),
|
||||
("m:gmv", _glue_before("s3", "1" + chr(0x1843))),
|
||||
("m:gmv", _glue_before("s3", "1" + chr(0x0301))),
|
||||
("m:gmv", _glue_before("s3", "1" + chr(0xFE0F))),
|
||||
("m:gmv", _glue_before("s3", "1" + chr(0x201A))), # 和半角逗号长得一样的低位单引号(U+201A)
|
||||
("m:gmv", _glue_before("s3", chr(0xD800) + "1")), # 不成对的代理项不和后面的 1 拼成一个字
|
||||
("m:gmv", _split_pair(chr(0xD835), chr(0xDFF7))), # 代理对分在两个文字片段里,页面上照样拼成数字 1
|
||||
("v:Q1.r0.gmv", _glue_after("s34", "0")), # 后面多一位:45,678.50
|
||||
("v:Q1.r0.gmv", _glue_after("s34", ".5")), # 后面接小数:45,678.5.5
|
||||
("v:Q1.r0.gmv", _glue_after("s34", chr(0x2024) + "5")), # 和句点长得一样的单点前导符
|
||||
("m:conv_rate", _glue_before("s12", "-")), # 前面加负号:-2.35%
|
||||
("m:conv_rate", _glue_before("s12", chr(0xFF0D))), # 全角负号
|
||||
# 长得像负号的横线:连字符、不断行连字符、数字宽的横线、短破折号、长破折号、修饰字母负号、粗减号、小号长破折号、
|
||||
# 数学减号、上标负号,和显示出来同样是一道横线的汉字「一」、假名长音、制表符横线、罗马盎司符号(码点在两个
|
||||
# UTF-16 单元里)
|
||||
*[("m:conv_rate", _glue_before("s12", chr(cp))) for cp in (
|
||||
0x2010, 0x2011, 0x2012, 0x2013, 0x2014, 0x02D7, 0x2796, 0xFE58, 0x2212, 0x207B, 0x4E00, 0x30FC, 0x2500,
|
||||
0x10191)],
|
||||
("m:refund", _glue_before("s9", "-")), # 负数前面再加负号:--3,120.50元
|
||||
("v:Q1.r0.paid_at", _glue_after("s45", "5")), # 取值以数字结尾:2026-09-07 08:21:395
|
||||
])
|
||||
def test_digits_glued_to_a_checked_value_cannot_pass(ref, change):
|
||||
"""伪造者在相邻的文字片段里补一位数、一个负号(隔着不占位置、显示成什么说不准的字,藏起来的粗体标记也算紧挨着;
|
||||
长得像负号的横线也算负号):显示出来的数不止核对过的这几位,这一项判无法核对、写明原因;别的照常通过。"""
|
||||
from tests.bundle_samples import formats_bundle
|
||||
|
||||
got = verdicts(rewritten(formats_bundle(), change))
|
||||
hit = item(got, ref)
|
||||
assert hit["result"] == "na" and hit["reason"] == GLUED and hit["shownOk"], hit
|
||||
assert all(n["result"] == "pass" for n in got["numbers"] if n["ref"] != ref)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ref, change", [
|
||||
("m:gmv", _glue_before("s3", chr(0x0336))), # 叠在空格上的长横线:页面上像一个负号
|
||||
("m:gmv", _glue_before("s3", chr(0x82))), # 控制字符,Chrome 里画成一道短横
|
||||
("m:gmv", _glue_before("s3", chr(0x7F))), # 控制字符 DEL,什么都不显示
|
||||
("m:gmv", _glue_before("s3", chr(0xAD))), # 可选连字符:在它那里换行时显示成连字符
|
||||
("m:gmv", _glue_before("s3", chr(0xE000))), # 私用区的字:各家字体画成什么都有
|
||||
("v:Q1.r0.gmv", _glue_after("s34", chr(0x0378))), # 未分配的码点紧跟在数字后面
|
||||
("v:Q1.r0.gmv", _glue_after("s34", "." + chr(0x7F))), # 隔着句点紧跟一个说不准的字
|
||||
])
|
||||
def test_characters_that_may_show_as_anything_next_to_a_value_cannot_pass(ref, change):
|
||||
"""紧挨着数字的是显示成什么说不准的字(控制字符、组合符号、可选连字符、私用区和未分配的字):各家浏览器、字体
|
||||
可能把它显示成数字、横线或者什么都不显示,判无法核对,原因说清是这类字;别的照常通过。"""
|
||||
from tests.bundle_samples import formats_bundle
|
||||
|
||||
got = verdicts(rewritten(formats_bundle(), change))
|
||||
hit = item(got, ref)
|
||||
assert hit["result"] == "na" and hit["reason"] == BLURRED and hit["shownOk"], hit
|
||||
assert all(n["result"] == "pass" for n in got["numbers"] if n["ref"] != ref)
|
||||
|
||||
|
||||
def test_uncertain_characters_where_they_cannot_change_the_value_still_pass():
|
||||
"""说不准的字挨着的不是数字的头尾(销售额以「元」结尾,后面跟一个控制字符),表情后面的变体选择符,数字前面的波浪线,
|
||||
都改不了读到的数:照常通过。"""
|
||||
from tests.bundle_samples import formats_bundle
|
||||
|
||||
def change(doc: dict[str, Any]) -> None:
|
||||
by_id(doc, "s5")["text"] = chr(0x7F) + chr(0x0336) + by_id(doc, "s5")["text"]
|
||||
by_id(doc, "s12")["text"] += chr(0x2705) + chr(0xFE0F) # 转化率紧前面:对勾表情加上表情样式的变体选择符
|
||||
by_id(doc, "s14")["text"] += chr(0x301C) # 环比紧前面:波浪线不是横线,不算负号
|
||||
|
||||
got = verdicts(rewritten(formats_bundle(), change))
|
||||
assert all(n["result"] == "pass" and n["onPage"] == n["text"] for n in got["numbers"]), got["numbers"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change, scope", [
|
||||
(lambda doc: by_id(doc, "s5").update(text=chr(0x202E) + by_id(doc, "s5")["text"]), FIRST), # 从右往左覆盖
|
||||
# 希伯来字母:逻辑上在销售额后面的「5」显示在销售额前面(页面上读作 574,613.65元)
|
||||
(lambda doc: (by_id(doc, "s3").update(text=by_id(doc, "s3")["text"] + chr(0x05D0)),
|
||||
by_id(doc, "s5").update(text="5" + by_id(doc, "s5")["text"])), FIRST),
|
||||
# 希伯来字母后面的「5」显示在 45,678.50 紧后面(读作 45,678.505);阿拉伯字母同样
|
||||
(lambda doc: by_id(doc, "s34").update(text=chr(0x05D0) + "5" + by_id(doc, "s34")["text"]), None),
|
||||
(lambda doc: by_id(doc, "s34").update(text=chr(0x0628) + "5" + by_id(doc, "s34")["text"]), None),
|
||||
# 腓尼基字母(从右往左书写,码点在两个 UTF-16 单元里)
|
||||
(lambda doc: by_id(doc, "s34").update(text=chr(0x10900) + "5" + by_id(doc, "s34")["text"]), None),
|
||||
])
|
||||
def test_text_that_changes_direction_next_to_checked_values_cannot_pass(change, scope):
|
||||
"""改变文字方向的控制字符、从右往左书写的文字(希伯来文、阿拉伯文这类)能把后面的一位数挪到数字的另一边显示,
|
||||
按字的先后看不出紧挨着:同一段里出现在别的片段里,这一段的数字、取值判无法核对;别的段照常通过。scope 是受影响的
|
||||
那一段(None:经营月报第二段,Q1 的取值)。"""
|
||||
from tests.bundle_samples import formats_bundle
|
||||
|
||||
got = verdicts(rewritten(formats_bundle(), change))
|
||||
for n in got["numbers"]:
|
||||
inside = n["ref"] in scope if scope is not None else n["ref"] not in FIRST
|
||||
if inside:
|
||||
assert n["result"] == "na" and n["reason"] == TURNED, n
|
||||
else:
|
||||
assert n["result"] == "pass", n
|
||||
|
||||
|
||||
def test_ranges_dates_and_signs_written_by_the_system_still_pass():
|
||||
"""真报告里常见的写法不算紧挨着:两个数之间的连字符、破折号(区间)、日期和数字之间隔着字或空白、负数自己的负号。"""
|
||||
from tests.bundle_samples import formats_bundle
|
||||
|
||||
text = ("## 区间\n\n销售额区间 [[m:gmv]]-[[m:export]],流水区间 [[v:Q1.r0.gmv]]-[[v:Q1.r1.gmv]]。"
|
||||
"截至2026年9月[[m:orders]],2026-09-07 当天客单价 [[m:avg]],环比 [[m:growth]],"
|
||||
"退款区间 [[m:refund]]" + chr(0x2014) + "[[m:annual|亿]]。")
|
||||
got = verdicts(formats_bundle(text=text))
|
||||
assert len(got["numbers"]) == 9
|
||||
assert all(n["result"] == "pass" and n["onPage"] == n["text"] for n in got["numbers"]), got["numbers"]
|
||||
|
||||
|
||||
NOT_SHOWN = "正文里没有显示这一处:导出时不会出现这种情况,文件可能被改过"
|
||||
|
||||
|
||||
def test_a_checked_value_in_a_block_the_page_does_not_show_fails():
|
||||
"""分隔线不显示句子:把一段改成分隔线,这一段里核对通过的数字、取值读的人都看不到(伪造的字可以写在别处),
|
||||
服务端导出时不会出现,判不通过;别的段照常通过。"""
|
||||
from tests.bundle_samples import formats_bundle
|
||||
|
||||
def change(doc: dict[str, Any]) -> None:
|
||||
doc["blocks"][2]["type"] = "hr"
|
||||
|
||||
got = verdicts(rewritten(formats_bundle(), change))
|
||||
second = {"v:Q1.r0.region", "v:Q1.r0.gmv", "v:Q1.r0.gmv|万", "v:Q1.r1.region", "v:Q1.r1.gmv",
|
||||
"v:Q1.r0.order_no", "v:Q1.r0.paid_at"}
|
||||
for n in got["numbers"]:
|
||||
if n["ref"] in second:
|
||||
assert n["result"] == "fail" and n["reason"] == NOT_SHOWN and n["onPage"] is None, n
|
||||
else:
|
||||
assert n["result"] == "pass" and n["onPage"] == n["text"], n
|
||||
|
||||
|
||||
def test_a_table_cell_outside_the_rows_and_columns_fails():
|
||||
"""表格只显示表头和各行各列上的格:把一格的位置改成第 1.5 列,读的人看不到它,判不通过。原样导出时表格里每一格都
|
||||
显示成可点的数字。"""
|
||||
from tests.bundle_samples import FORMATS, formats_bundle
|
||||
|
||||
bundle = formats_bundle(text=FORMATS + "\n\n[[table:Q1 cols=region,gmv rows=0-1]]")
|
||||
got = verdicts(bundle)
|
||||
assert all(n["result"] == "pass" and n["onPage"] == n["text"] for n in got["numbers"])
|
||||
moved: list[str] = []
|
||||
|
||||
def change(doc: dict[str, Any]) -> None:
|
||||
table = next(b for b in doc["blocks"] if b["type"] == "table")
|
||||
unit = next(u for u in table["units"] if u.get("loc") == {"row": 1, "col": 1})
|
||||
unit["loc"] = {"row": 1, "col": 1.5}
|
||||
moved.extend(f"write|{s['id']}" for s in unit["segments"] if s["kind"] in ("number", "value"))
|
||||
|
||||
got = verdicts(rewritten(bundle, change))
|
||||
assert len(moved) == 1
|
||||
for n in got["numbers"]:
|
||||
if n["key"] in moved:
|
||||
assert n["result"] == "fail" and n["reason"] == NOT_SHOWN, n
|
||||
else:
|
||||
assert n["result"] == "pass", n
|
||||
|
||||
|
||||
def _list_unit(depth: Any) -> dict[str, Any]:
|
||||
return {"segments": [{"kind": "structural", "text": "- "}, {"kind": "text", "text": "一项"}], "depth": depth}
|
||||
|
||||
|
||||
def test_list_indentation_is_capped_on_screen():
|
||||
"""列表照级数缩进(每级 14px),最多 8 级:服务端按原文的缩进算级数、没有上限,伪造的级数写成十万或负数,那一项
|
||||
也留在屏幕上(读的人看得到核对过的数,窄屏不横向溢出)。核对和显示用的是同一份级数(listItems)。"""
|
||||
depths = [0, 1, 3, 8, 9, 100000, -1, -100000, 2.7, "x", None, "4"]
|
||||
got = node("layout", blocks=[{"type": "list", "units": [_list_unit(d) for d in depths]}])
|
||||
assert got == [[0, 1, 3, 8, 8, 8, 0, 0, 2, 0, 0, 4]]
|
||||
|
||||
|
||||
def test_table_columns_without_cells_are_not_laid_out():
|
||||
"""表格只排有格的列:服务端每行从第 0 列起一格不落,列号连着;伪造的列号写成十万,那一格紧接着前一列显示,不会
|
||||
被几万个空列挤到表格右边看不见的地方。列号不是非负整数的格照旧不排(判不通过,见上一条)。"""
|
||||
units = [{"loc": {"row": r, "col": c}, "segments": []}
|
||||
for r, c in [(-1, 0), (-1, 1), (0, 0), (0, 1), (0, 100000), (1, 1.5), (1, -1), (1, "x"), (1, "2")]]
|
||||
assert node("layout", blocks=[{"type": "table", "units": units}]) == [[0, 1, 2, 100000]]
|
||||
|
||||
|
||||
def test_a_huge_column_number_neither_hides_nor_breaks_the_check():
|
||||
"""列号写成 2^32:以前按列号建空列,数组长度超出上限,整段核对出错;现在那一格紧接着显示、照常核对通过。"""
|
||||
from tests.bundle_samples import FORMATS, formats_bundle
|
||||
|
||||
def change(doc: dict[str, Any]) -> None:
|
||||
table = next(b for b in doc["blocks"] if b["type"] == "table")
|
||||
unit = next(u for u in table["units"] if u.get("loc") == {"row": 1, "col": 1})
|
||||
unit["loc"] = {"row": 1, "col": 2 ** 32}
|
||||
|
||||
got = verdicts(rewritten(formats_bundle(text=FORMATS + "\n\n[[table:Q1 cols=region,gmv rows=0-1]]"), change))
|
||||
assert all(n["result"] == "pass" and n["onPage"] == n["text"] for n in got["numbers"]), got["numbers"]
|
||||
|
||||
|
||||
def test_reasons_for_readers_do_not_name_implementation_details():
|
||||
"""核对页是发给业务读者的文件:「无法核对显示文字」的原因说清是什么情况、该怎么办,不提 BigInt、正则、ASCII。"""
|
||||
source = VERIFIER.read_text(encoding="utf-8")
|
||||
start = source.index("const T = {")
|
||||
block = source[start:source.index("\n }\n", start)]
|
||||
for word in ("BigInt", "Unicode", "ASCII", "正则", "重新渲染时出错"):
|
||||
assert word not in block, word
|
||||
+352
-26
@@ -8,15 +8,22 @@
|
||||
// - 导出:从服务端找一份已封存、封存核对一致、有报告文档的运行(GET /api/runs 找成功结束的,再看 /verify 和证据接口),
|
||||
// 下载 bundle.html:状态码、类型、附件名(RFC 5987)、页面本身零外链。找不到可导出的运行时照实报失败并说明原因;
|
||||
// - 断网打开:拦掉一切网络请求,从本地文件打开:各项核对没有不通过、总览通过、没有发出任何请求,数字点开看得到出处;
|
||||
// 数字与取值的显示文字都核对得了(没有「无法核对显示文字」:移植的显示规则漏了真实写法时这里先报);
|
||||
// - 去掉 crypto.subtle:页面加载前删掉它,照常全部通过,页脚写明用的是本页内置的实现;
|
||||
// - 篡改:① 一条带规范字符串的事件内容、② 一份嵌入工件的一个字节、③ 元信息里的封存哈希,各自对应的核对项不通过、
|
||||
// 总览显示不通过;另做一次「哈希全部重算」的伪造:改报告里一个数字的引用值,文档、事件、清单、页面顶部的封存哈希
|
||||
// 都重算一遍,①–④ 都对得上,只有 ⑤ 拿口径卡、快照对出来不通过,报告标题旁的徽标跟着不通过;
|
||||
// 总览显示不通过;另做两种「哈希全部重算」的伪造:改报告里一个数字的引用值,或者引用值不动、只改它的显示文字(数字改
|
||||
// 一位;有引用快照文字的取值时也改一个),文档、事件、清单、页面顶部的封存哈希都重算一遍,①–④ 都对得上,只有 ⑤ 拿
|
||||
// 口径卡、快照对出来(显示文字按显示规则重新渲染后逐字比对)不通过,原因点名正文显示的字和引用的值,报告标题旁的徽标
|
||||
// 跟着不通过;
|
||||
// - 深浅色、窄屏:390 宽不横向溢出,点开的数字面板在窄屏上从底部抽出,深色下底是深的、字是浅的;
|
||||
// - 样本页:tests/bundle_samples.py 用虚构数据真实生成的几份页面(遮罩快照、两份报告、早期运行、失败结束、先失败后
|
||||
// 经继续运行跑到成功的早期运行、两条出具
|
||||
// 判定、服务端缺文件、裸 NaN、别的运行的文档、口径卡有输入取自遮罩列、设了遮罩的数据源上按地区汇总的查询),逐份
|
||||
// 断言各项结果和原因文案;在上面篡改 ④
|
||||
// 判定、服务端缺文件、裸 NaN、别的运行的文档、口径卡有输入取自遮罩列、设了遮罩的数据源上按地区汇总的查询、每种显示
|
||||
// 格式各引用一处的经营月报、单位带尾随空白的经营月报),逐份断言各项结果和原因文案;经营月报上只改文字取值、数字、
|
||||
// 换算成万的口径卡引用的显示文字并重算全部哈希,各自变红并点名;核对过的字不动、改它旁边的(链接地址藏起数字、前面
|
||||
// 补一位数、隔着显示时不占位置的控制字符补一位数、长得像负号的连字符、叠在空格上的横线、从右往左书写的字母把一位数
|
||||
// 挪到数字前面、整段改成分隔线、文字片段冒用数字的 id),读的人看到的数要就是核对过的那个;列表缩进写成十万级或负数、
|
||||
// 表格的列号写成十万,核对通过的数字照样留在屏幕上(宽屏、窄屏都不横向溢出);在上面篡改 ④
|
||||
// 引用链(换文档、拿掉报告、多一份没人引用的工件)、⑥ 运行信息(运行结果、类别、出具档位)、拿掉被引用的口径卡、把
|
||||
// 报告文档标成遮罩、把遮罩卡标成服务端的文件与 id 不一致或解析不出来(服务端对被改过、解析不了的遮罩卡就这么标)、
|
||||
// 遮罩源上随文件附带的汇总快照改一个字节、它的数字改引用值并重算全部哈希、
|
||||
@@ -141,6 +148,11 @@ const stateOf = (page) => page.evaluate(() => ({
|
||||
acc[b.dataset.result] = (acc[b.dataset.result] ?? 0) + 1
|
||||
return acc
|
||||
}, {}),
|
||||
// 正文里标成不通过的数字:读屏念的那一句
|
||||
failedButtons: [...document.querySelectorAll('button.num[data-result="fail"]')].map((b) => b.getAttribute('aria-label') ?? ''),
|
||||
// 正文里每个可点的数字:哪一处、结果、读的人看到的字
|
||||
buttons: [...document.querySelectorAll('button.num')].map((b) => ({ key: b.dataset.num, result: b.dataset.result,
|
||||
text: b.textContent })),
|
||||
sha: document.querySelector('[data-sha]')?.dataset.sha ?? null,
|
||||
seal: document.getElementById('seal-hash')?.textContent ?? '',
|
||||
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
@@ -155,22 +167,35 @@ const hasItem = (s, check, result, text) => s.items.some((i) => i.check === chec
|
||||
&& (!text || i.text.includes(text)))
|
||||
|
||||
/**
|
||||
* 「哈希全部重算」的伪造:改报告里一个能核对的数字的引用值,再把文档、引用它的事件、清单哈希、页面顶部的封存哈希
|
||||
* 全部重算。返回改了哪一处的说明,找不到可改的数字返回 null
|
||||
* 「哈希全部重算」的伪造:报告里挑一个能核对的数字或取值(引用口径卡或随文件附带的快照,pick(片段) 为真的第一个),
|
||||
* edit(片段) 改它,再把文档、引用它的事件、清单哈希、页面顶部的封存哈希全部重算。返回改了哪一处的说明(what、所在报告
|
||||
* node、改过的片段 seg),找不到可改的返回 null
|
||||
*/
|
||||
function forge(data) {
|
||||
function rewrite(data, pick, edit) {
|
||||
const embedded = new Set(data.artifacts.filter((a) => typeof a.b64 === 'string').map((a) => a.id))
|
||||
return rewriteDoc(data, (doc) => {
|
||||
let hit = null
|
||||
for (const block of doc.blocks ?? []) for (const unit of block.units ?? []) for (const seg of unit.segments ?? []) {
|
||||
const c = seg.cite
|
||||
if (!hit && c?.status === 'resolved' && ['metric', 'cell'].includes(c.kind)
|
||||
&& embedded.has(doc.catalog?.[c.alias]?.artifact) && pick(seg)) hit = seg
|
||||
}
|
||||
if (!hit) return null
|
||||
edit(hit)
|
||||
return { what: `片段 ${hit.id}(${hit.cite.kind})`, seg: hit }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* change(文档) 就地改报告文档(返回 { what, seg? } 说明改了哪里,找不到可改的返回 null),再把文档、引用它的事件、
|
||||
* 清单哈希全部重算(页面顶部的封存哈希由 tamper 的 seal 换)。返回说明,另带所在报告 node
|
||||
*/
|
||||
function rewriteDoc(data, change) {
|
||||
const docArt = data.artifacts.find((a) => a.kind === 'report_doc' && typeof a.b64 === 'string')
|
||||
if (!docArt) return null
|
||||
const doc = JSON.parse(Buffer.from(docArt.b64, 'base64').toString('utf8'))
|
||||
const embedded = new Set(data.artifacts.filter((a) => typeof a.b64 === 'string').map((a) => a.id))
|
||||
let hit = null
|
||||
for (const block of doc.blocks ?? []) for (const unit of block.units ?? []) for (const seg of unit.segments ?? []) {
|
||||
const c = seg.cite
|
||||
if (!hit && c?.status === 'resolved' && ['metric', 'cell'].includes(c.kind) && typeof c.value === 'number'
|
||||
&& embedded.has(doc.catalog?.[c.alias]?.artifact)) hit = seg
|
||||
}
|
||||
if (!hit) return null
|
||||
hit.cite.value += 1
|
||||
const done = change(doc)
|
||||
if (!done) return null
|
||||
const bytes = Buffer.from(JSON.stringify(doc), 'utf8')
|
||||
const oldId = docArt.id
|
||||
const newId = sha(bytes)
|
||||
@@ -186,7 +211,48 @@ function forge(data) {
|
||||
const lines = data.events.map((e, i) => [e, i]).sort((x, y) => x[0].seq - y[0].seq || x[1] - y[1])
|
||||
.map(([e]) => `${e.seq}|${e.type}|${e.node_id ?? ''}|${e.data_sha256}`)
|
||||
data.meta.manifest_hash = sha(lines.join('\n'))
|
||||
return { what: `片段 ${hit.id}(${hit.cite.kind})`, node: data.reports.find((r) => r.doc_artifact === newId)?.node_id }
|
||||
return { ...done, node: data.reports.find((r) => r.doc_artifact === newId)?.node_id }
|
||||
}
|
||||
|
||||
/** 经营月报样本里按 id 找片段(改它两边的文字做排版上的伪造) */
|
||||
const segById = (doc, id) => doc.blocks.flatMap((b) => b.units).flatMap((u) => u.segments).find((s) => s.id === id)
|
||||
|
||||
/** 改一个数字的引用值(显示文字不动),重算全部哈希:⑤ 拿口径卡、快照对出来 */
|
||||
const forge = (data) => rewrite(data, (seg) => typeof seg.cite.value === 'number', (seg) => { seg.cite.value += 1 })
|
||||
|
||||
/**
|
||||
* 只改显示文字的伪造(验收里的 T4、T5):引用值不动,正文显示的字换成 change(原来的字),cite.rendered 跟着改,重算全部
|
||||
* 哈希。pick 挑哪一个片段(默认第一个能核对的)
|
||||
*/
|
||||
const forgeShown = (change, pick = () => true) => (data) => rewrite(data, pick, (seg) => {
|
||||
seg.text = change(seg.text)
|
||||
seg.cite.rendered = seg.text
|
||||
})
|
||||
|
||||
/** 显示文字里第一个数字加一(9 变 0):74,613.65元 → 84,613.65元 */
|
||||
const bumpDigit = (text) => text.replace(/\d/, (d) => String((Number(d) + 1) % 10))
|
||||
|
||||
/** 引用快照里文字的取值(华南、门店名这类) */
|
||||
const isTextValue = (seg) => seg.kind === 'value' && typeof seg.cite.value === 'string' && seg.cite.value.trim() !== ''
|
||||
|
||||
/**
|
||||
* 只改显示文字的伪造该有的样子:⑤ 里只有那一项不通过,原因点名正文显示的字和引用的值,正文里那个数字标成不通过、
|
||||
* 读屏读作「不通过」;①–④ 照样通过(伪造重算了全部哈希)。quiet:读的是真数据,原因只断言、不打印
|
||||
*/
|
||||
const namesShown = ({ quiet = false } = {}) => (s, forged) => {
|
||||
// 条目多时不通过的列在外面一份、收起的全部里又一份:只数外面的
|
||||
const failed = s.items.filter((i) => i.check === 'numbers' && i.result === 'fail' && !i.folded)
|
||||
const named = `正文显示「${forged.seg.text}」,引用的值是「${forged.seg.cite.value}」`
|
||||
return [
|
||||
...turnsRed('numbers', ['events', 'manifest', 'artifacts', 'chain'])(s),
|
||||
['⑤ 里只有这一项不通过', failed.length === 1, `${failed.length} 项`],
|
||||
['原因点名正文显示的字和引用的值', failed.length === 1 && failed[0].text.includes(named),
|
||||
quiet ? '' : failed[0]?.text ?? '没有不通过的项'],
|
||||
['正文里那个数字标成不通过,读屏读作「不通过」', s.failedButtons.length === 1
|
||||
&& s.failedButtons[0].includes(`${forged.seg.text},不通过,查看出处`), quiet ? '' : s.failedButtons.join(' | ')],
|
||||
['那份报告标题旁的徽标跟着不通过', s.reports.find((r) => r.node === forged.node)?.result === 'fail',
|
||||
JSON.stringify(s.reports.map((r) => [r.node, r.result]))],
|
||||
]
|
||||
}
|
||||
|
||||
/** 改一份嵌入工件的一个字节(优先改数字),返回说明 */
|
||||
@@ -311,6 +377,12 @@ await section('断网打开', async () => {
|
||||
check('核对项「artifacts」没有不通过', noFail(s.checks.artifacts), s.checks.artifacts)
|
||||
check('数字与取值没有不通过的', noFail(s.checks.numbers) || s.checks.numbers === 'na',
|
||||
`${s.checks.numbers}:${JSON.stringify(s.numberStates)}`)
|
||||
// 显示文字在真数据上也要核得了:移植的显示规则漏了某种真实写法(或者真报告的排版被判成紧挨着)时,数字会大批变成
|
||||
// 「无法核对显示文字」,上面那条「没有不通过」看不出来。Chrome 有 BigInt,正常导出的都该核得了。读的是真数据:
|
||||
// 只打印条数
|
||||
const unshown = new Set(s.items.filter((i) => i.check === 'numbers' && i.result === 'na'
|
||||
&& i.text.includes('无法核对显示文字')).map((i) => i.text)).size
|
||||
check('数字与取值的显示文字都核对得了(没有「无法核对显示文字」)', unshown === 0, `${unshown} 项`)
|
||||
// 早期运行的运行结果、升级前的运行类别核对不了(无法核对),只要求没有对不上的
|
||||
check('运行信息没有与已核对的事件对不上的', s.checks.meta !== 'fail', `${s.checks.meta}:${JSON.stringify(s.meta)}`)
|
||||
check('没有发出任何网络请求', requests.length === 0, requests.slice(0, 3).join('、'))
|
||||
@@ -332,7 +404,7 @@ await section('断网打开', async () => {
|
||||
const text = await page.locator('#detail').innerText()
|
||||
const cited = await target.getAttribute('data-kind') === 'number'
|
||||
check('点开数字看得到出处和核对结果', /出处/.test(text) && /核对结果/.test(text)
|
||||
&& (!cited || /显示格式由服务端渲染/.test(text)) && await page.locator('#detail').getAttribute('data-open') === key)
|
||||
&& (!cited || /与按显示规则渲染的结果一致/.test(text)) && await page.locator('#detail').getAttribute('data-open') === key)
|
||||
await page.keyboard.press('Escape')
|
||||
check('Esc 关掉出处面板', await page.locator('#detail').isHidden())
|
||||
} else {
|
||||
@@ -359,8 +431,9 @@ await section('去掉 crypto.subtle', async () => {
|
||||
// ---------------------------------------------------------------- 篡改
|
||||
|
||||
/**
|
||||
* 在一份页面上篡改后断网打开。mutate(data) 改数据、返回说明(找不到可改的返回 null);expect(state) 返回
|
||||
* [[检查名, 条件, 说明]…]。seal:同时把页面顶部的封存哈希换成改过的数据里的那一份(重算全部哈希的伪造会这么做)
|
||||
* 在一份页面上篡改后断网打开。mutate(data) 改数据、返回说明(找不到可改的返回 null);expect(state, 说明, page) 返回
|
||||
* [[检查名, 条件, 说明]…](要点开面板的可以是异步的)。seal:同时把页面顶部的封存哈希换成改过的数据里的那一份
|
||||
* (重算全部哈希的伪造会这么做)
|
||||
*/
|
||||
async function tamper(source, label, mutate, expect, { seal = false } = {}) {
|
||||
const data = structuredClone(dataOf(source))
|
||||
@@ -374,7 +447,9 @@ async function tamper(source, label, mutate, expect, { seal = false } = {}) {
|
||||
const opened = await openFile(writePage(`tampered-${label.replace(/[^\w一-鿿]+/g, '_')}.html`, page))
|
||||
try {
|
||||
const s = await stateOf(opened.page)
|
||||
for (const [name, cond, detail] of expect(s, what)) check(`篡改${label}:${name}`, cond, detail ?? `${typeof what === 'string' ? what : what.what};${describe(s)}`)
|
||||
for (const [name, cond, detail] of await expect(s, what, opened.page)) {
|
||||
check(`篡改${label}:${name}`, cond, detail ?? `${typeof what === 'string' ? what : what.what};${describe(s)}`)
|
||||
}
|
||||
} finally {
|
||||
await opened.ctx.close()
|
||||
}
|
||||
@@ -408,6 +483,18 @@ await section('篡改', async () => {
|
||||
['那份报告标题旁的徽标跟着不通过', s.reports.find((r) => r.node === forged.node)?.result === 'fail',
|
||||
JSON.stringify(s.reports.map((r) => [r.node, r.result]))],
|
||||
], { seal: true })
|
||||
// 只改显示文字(验收里的 T4):引用值不动,一个数字的显示文字改一位,重算全部哈希。⑤ 按显示规则重新渲染对出来,点名
|
||||
// 正文显示的字和引用的值。读的是真数据:原因只断言、不打印
|
||||
await tamper(html, '一个数字的显示文字并重算全部哈希',
|
||||
forgeShown(bumpDigit, (seg) => typeof seg.cite.value === 'number' && /\d/.test(seg.text ?? '')),
|
||||
namesShown({ quiet: true }), { seal: true })
|
||||
// 文字取值(验收里的 T5):导出的报告里有引用快照文字的取值才做;样本页 formats 上一定做
|
||||
if (rewrite(structuredClone(tamperable), isTextValue, () => {})) {
|
||||
await tamper(html, '一个文字取值的显示并重算全部哈希', forgeShown((t) => `${t}(改)`, isTextValue),
|
||||
namesShown({ quiet: true }), { seal: true })
|
||||
} else {
|
||||
note('导出的报告里没有引用快照文字的取值,文字取值的篡改在样本页 formats 上做')
|
||||
}
|
||||
// ③ 改元信息里的封存哈希
|
||||
await tamper(html, '元信息里的封存哈希', (data) => {
|
||||
const h = data.meta.manifest_hash
|
||||
@@ -493,7 +580,7 @@ await section('样本页:各种形状', async () => {
|
||||
check('生成样本页(tests/bundle_samples.py)', false, String(e?.message ?? e))
|
||||
return
|
||||
}
|
||||
check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 12, Object.keys(samples).join('、'))
|
||||
check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 14, Object.keys(samples).join('、'))
|
||||
|
||||
await sample('base', async (s, page) => {
|
||||
check('base:总览通过,有无法核对的项时写「可核对的项全部通过」', s.overall === 'pass' && s.text === '可核对的项全部通过',
|
||||
@@ -517,7 +604,7 @@ await section('样本页:各种形状', async () => {
|
||||
check('base:斜体渲染成斜体', (await page.locator('.doc em').allTextContents()).includes('按发生周统计'))
|
||||
const cell = page.locator('td.n button.num')
|
||||
check('base:表格里数字列右对齐', await cell.count() === 8)
|
||||
// 「显示格式由服务端渲染」只附在带出处的数字上:模型自己写的数、表格里的文字取值都是原文
|
||||
// 「与按显示规则渲染的结果一致」只附在显示文字核对过的数字和取值上:模型自己写的数没人重新渲染过
|
||||
const detailOf = async (locator) => {
|
||||
await locator.click()
|
||||
await page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 })
|
||||
@@ -528,9 +615,9 @@ await section('样本页:各种形状', async () => {
|
||||
const cellNumber = page.locator('td.n button.num[data-kind="number"]').first()
|
||||
const value = page.locator('button.num[data-kind="value"]').first()
|
||||
const bare = page.locator('button.num[data-kind="number"][data-result="na"]').filter({ hasText: /^42$/ })
|
||||
check('base:带出处的数字注明显示格式由服务端渲染', /显示格式由服务端渲染/.test(await detailOf(cellNumber)))
|
||||
check('base:文字取值、没有出处的数字不注明显示格式', !/显示格式由服务端渲染/.test(await detailOf(value))
|
||||
&& !/显示格式由服务端渲染/.test(await detailOf(bare)))
|
||||
check('base:带出处的数字注明显示文字与按显示规则渲染的结果一致', /与按显示规则渲染的结果一致/.test(await detailOf(cellNumber)))
|
||||
check('base:表格里的文字取值同样核对显示文字', /与按显示规则渲染的结果一致/.test(await detailOf(value)))
|
||||
check('base:没有出处的数字不注明', !/按显示规则/.test(await detailOf(bare)))
|
||||
check('base:读屏把文字取值读作「取值」、数字读作「数字」', (await value.getAttribute('aria-label')).startsWith('取值 ')
|
||||
&& (await cellNumber.getAttribute('aria-label')).startsWith('数字 '))
|
||||
})
|
||||
@@ -657,6 +744,53 @@ await section('样本页:各种形状', async () => {
|
||||
check('stray:文档没通过核对时数字不予核对', s.checks.numbers === 'na' && hasItem(s, 'numbers', 'na', '所在的报告文档没有通过核对')
|
||||
&& s.reports[0]?.warn.includes('其中的数字不予核对'), s.checks.numbers)
|
||||
})
|
||||
// 经营月报:每种显示格式各引用一处(货币单位、万 / 亿、比率、负数、不分组、小数位、pct、DECIMAL 文本、超过 2^53 的
|
||||
// 订单号、日期时间、文字取值),原样导出全部通过:每个数字和取值的显示文字都按显示规则重新渲染、逐字对上
|
||||
await sample('formats', async (s, page) => {
|
||||
check('formats:总览「全部通过」,没有一项无法核对', s.overall === 'pass' && s.text === '全部通过'
|
||||
&& !s.items.some((i) => i.result !== 'pass'), `${s.overall}:${s.text}`)
|
||||
check('formats:⑤ 数字 16 个、取值 3 个全部通过', s.checks.numbers === 'pass'
|
||||
&& s.sums.numbers.includes('数字 16 个 · 取值 3 个') && s.numberStates.pass === 19, s.sums.numbers)
|
||||
const wan = page.locator('button.num').filter({ hasText: /^7\.46万元$/ })
|
||||
let detail = '找不到「7.46万元」'
|
||||
if (await wan.count()) {
|
||||
await wan.first().click()
|
||||
detail = await appears(page.locator('#detail')) ? await page.locator('#detail').innerText() : '点开后没有出处面板'
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
check('formats:点开换算成万的数字,引用的值、口径卡里的值照原值写,注明与按显示规则渲染的结果一致',
|
||||
detail.includes('74613.65') && detail.includes('与按显示规则渲染的结果一致') && !detail.includes('按显示规则应为'),
|
||||
detail.replace(/\s+/g, ' ').slice(0, 160))
|
||||
const order = page.locator('button.num').filter({ hasText: '202,609,071,234,567,890' })
|
||||
detail = '找不到订单号'
|
||||
let rowCell = ''
|
||||
if (await order.count()) {
|
||||
await order.first().click()
|
||||
detail = await appears(page.locator('#detail')) ? await page.locator('#detail').innerText() : '点开后没有出处面板'
|
||||
rowCell = await page.locator('#detail td[data-cell="order_no"]').textContent().catch(() => '') ?? ''
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
// 引用的值、快照里的值、快照第 1 行那一格都按 JSON 原文写:读成 double 的 202609071234567900 一处都不能有
|
||||
check('formats:超过 2^53 的订单号核对通过,出处面板里的值(连同快照那一行)一位不差',
|
||||
detail.includes('202609071234567890') && !detail.includes('202609071234567900')
|
||||
&& rowCell === '202609071234567890' && detail.includes('与按显示规则渲染的结果一致'),
|
||||
`${detail.replace(/\s+/g, ' ').slice(0, 160)} | 那一行的格:${rowCell}`)
|
||||
})
|
||||
// 口径卡单位带尾随空白(「元 」):服务端照原样接在数后面,正文是「74,613.65元 」,两边都只去首尾空白,原样导出照样
|
||||
// 全部通过,不会被说成文件被改过
|
||||
await sample('formats-unit', async (s, page) => {
|
||||
check('formats-unit:口径卡单位带尾随空白时原样导出「全部通过」', s.overall === 'pass' && s.text === '全部通过'
|
||||
&& s.numberStates.pass === 19, `${s.overall}:${s.text};${JSON.stringify(s.numberStates)}`)
|
||||
const gmv = page.locator('button.num').filter({ hasText: /^74,613\.65元\s*$/ })
|
||||
let detail = '找不到销售额'
|
||||
if (await gmv.count()) {
|
||||
await gmv.first().click()
|
||||
detail = await appears(page.locator('#detail')) ? await page.locator('#detail').innerText() : '点开后没有出处面板'
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
check('formats-unit:点开销售额,注明与按显示规则渲染的结果一致,不另列「按显示规则应为」',
|
||||
detail.includes('与按显示规则渲染的结果一致') && !detail.includes('按显示规则应为'), detail.replace(/\s+/g, ' ').slice(0, 160))
|
||||
})
|
||||
|
||||
// 不执行脚本(附件预览、快速查看):封存哈希、运行 ID、工作流名照样看得到,状态写「尚未核对」,不是一直「正在核对」
|
||||
{
|
||||
@@ -804,6 +938,198 @@ await section('样本页:篡改', async () => {
|
||||
...turnsRed('numbers', ['events', 'manifest', 'artifacts', 'chain', 'meta'])(s),
|
||||
['报告徽标不通过', s.reports[0]?.result === 'fail' && s.reports[0].parts.includes('不通过 1'), JSON.stringify(s.reports[0])],
|
||||
], { seal: true })
|
||||
// 只改显示文字、引用值不动、重算全部哈希(验收里的 T4、T5):经营月报上改文字取值、千分位加货币单位的数字、换算成万的
|
||||
// 口径卡引用,各自只有那一项不通过。原因点名正文显示的字和引用的值,数字另写按显示规则应显示的字
|
||||
const shownForgeries = [
|
||||
['文字取值的显示', 'v:Q1.r0.region', '华东', null],
|
||||
['数字的显示', 'm:gmv', '84,613.65元', '74,613.65元'],
|
||||
['换算成万的口径卡引用的显示', 'm:gmv|万', '8.46万元', '7.46万元'],
|
||||
]
|
||||
for (const [label, ref, text, expected] of shownForgeries) {
|
||||
await tamper(samples.formats, `${label}并重算全部哈希`, forgeShown(() => text, (seg) => seg.ref === ref), (s, forged) => {
|
||||
const reason = s.items.find((i) => i.check === 'numbers' && i.result === 'fail')?.text ?? ''
|
||||
return [
|
||||
...namesShown()(s, forged),
|
||||
expected ? ['原因另写按显示规则应显示的字', reason.includes(`按显示规则应显示为「${expected}」`), reason]
|
||||
: ['文字取值的原因不另写应显示的字', !reason.includes('按显示规则应显示为'), reason],
|
||||
]
|
||||
}, { seal: true })
|
||||
}
|
||||
// 点开改过的那个数字:出处面板并排列出按显示规则应为的字和正文显示的字
|
||||
{
|
||||
const data = structuredClone(dataOf(samples.formats))
|
||||
const forged = forgeShown(() => '8.46万元', (seg) => seg.ref === 'm:gmv|万')(data)
|
||||
const opened = await openFile(writePage('tampered-shown-detail.html', withSeal(withData(samples.formats, data),
|
||||
data.meta.manifest_hash)))
|
||||
try {
|
||||
await opened.page.locator(`button.num[data-num="${forged.node}|${forged.seg.id}"]`).click()
|
||||
const detail = await appears(opened.page.locator('#detail')) ? await opened.page.locator('#detail').innerText() : ''
|
||||
check('篡改换算成万的口径卡引用的显示:出处面板写明不通过,列出按显示规则应为「7.46万元」、正文显示「8.46万元」',
|
||||
detail.includes('不通过') && /按显示规则应为\s*7\.46万元/.test(detail) && /正文显示\s*8\.46万元/.test(detail),
|
||||
detail.replace(/\s+/g, ' ').slice(0, 200))
|
||||
} finally {
|
||||
await opened.ctx.close()
|
||||
}
|
||||
}
|
||||
// 核对过的字不动、改它旁边的(经营月报,重算全部哈希):读的人看到的数要就是核对过的那个
|
||||
const buttonOf = (s, forged) => s.buttons.find((b) => b.key === `${forged.node}|${forged.seg.id}`)
|
||||
// 伪造的链接把销售额塞进链接地址(链接只显示文字),链接文字写成 84,613.65元:这一句改按原文显示,核对过的字露出来
|
||||
await tamper(samples.formats, '用链接地址藏起核对通过的数字', (data) => rewriteDoc(data, (doc) => {
|
||||
segById(doc, 's3').text += '[84,613.65元](x '
|
||||
segById(doc, 's5').text = `)${segById(doc, 's5').text}`
|
||||
return { what: '销售额塞进链接地址', seg: segById(doc, 's4') }
|
||||
}), async (s, forged, page) => {
|
||||
const btn = buttonOf(s, forged)
|
||||
const sentence = await page.locator('.doc p').first().innerText()
|
||||
return [
|
||||
['销售额照样显示成可点的数字,字一个不少', btn?.text === '74,613.65元' && btn.result === 'pass', JSON.stringify(btn)],
|
||||
['这一句按原文显示,伪造的链接写法露在外面', sentence.includes('[84,613.65元](x 74,613.65元)'), sentence.slice(0, 80)],
|
||||
]
|
||||
}, { seal: true })
|
||||
// 前面补一位数:读起来是 174,613.65元,绿色下划线只在 74,613.65元 下面
|
||||
await tamper(samples.formats, '核对通过的数字前面补一位数', (data) => rewriteDoc(data, (doc) => {
|
||||
segById(doc, 's3').text += '1'
|
||||
return { what: '本月销售额 174,613.65元', seg: segById(doc, 's4') }
|
||||
}), (s, forged) => {
|
||||
const btn = buttonOf(s, forged)
|
||||
const item = s.items.find((i) => i.check === 'numbers' && i.result === 'na' && i.text.includes('紧挨着的正文里还有数字或负号'))
|
||||
return [
|
||||
['销售额判无法核对,不再标通过', btn?.result === 'na', JSON.stringify(btn)],
|
||||
['⑤ 写明紧挨着的正文里还有数字或负号', !!item, item?.text ?? '没有这一项'],
|
||||
['总览不再是「全部通过」', s.overall === 'pass' && s.text === '可核对的项全部通过', `${s.overall}:${s.text}`],
|
||||
]
|
||||
}, { seal: true })
|
||||
// 一段改成分隔线:分隔线不显示句子,这一段里核对通过的数字读的人看不到,导出时不会出现
|
||||
await tamper(samples.formats, '把一段改成分隔线', (data) => rewriteDoc(data, (doc) => {
|
||||
doc.blocks[2].type = 'hr'
|
||||
return { what: '第二段改成分隔线' }
|
||||
}), (s) => [
|
||||
...turnsRed('numbers', ['events', 'manifest', 'artifacts', 'chain'])(s),
|
||||
['原因写明正文里没有显示这一处', hasItem(s, 'numbers', 'fail', '正文里没有显示这一处:导出时不会出现这种情况')],
|
||||
], { seal: true })
|
||||
// 文字片段冒用销售额的 id:页面按片段本身找核对结果,冒用的文字显示不成可点的数字、借不到「通过」
|
||||
await tamper(samples.formats, '文字片段冒用核对通过的数字的 id', (data) => rewriteDoc(data, (doc) => {
|
||||
const gmv = segById(doc, 's4')
|
||||
doc.blocks[2].units.at(-1).segments.push({ kind: 'text', id: gmv.id, text: '84,613.65元', state: 'neutral' })
|
||||
return { what: '第二段末尾加一段冒用 id 的文字', seg: gmv }
|
||||
}), (s, forged) => {
|
||||
const same = s.buttons.filter((b) => b.key === `${forged.node}|${forged.seg.id}`)
|
||||
return [
|
||||
['冒用 id 的文字不显示成可点的数字', same.length === 1 && same[0].text === '74,613.65元'
|
||||
&& !s.buttons.some((b) => b.text.includes('84,613.65')), JSON.stringify(same)],
|
||||
]
|
||||
}, { seal: true })
|
||||
// 紧挨着的字(经营月报,重算全部哈希):隔着显示时不占位置的控制字符补一位数、长得像负号的连字符、叠在空格上的横线、
|
||||
// 从右往左书写的字母把一位数挪到数字前面——读起来都不是核对过的那个数,判无法核对并写明原因。字一律按码点写,脚本
|
||||
// 里不出现看不见、改变方向的字
|
||||
const cp = (...points) => String.fromCodePoint(...points)
|
||||
const naBecause = (why) => (s, forged) => {
|
||||
const btn = buttonOf(s, forged)
|
||||
const item = s.items.find((i) => i.check === 'numbers' && i.result === 'na' && i.text.includes(why))
|
||||
return [
|
||||
['那个数字判无法核对,不再标通过', btn?.result === 'na', JSON.stringify(btn)],
|
||||
[`⑤ 写明「${why}」`, !!item, item?.text ?? '没有这一项'],
|
||||
['总览不再是「全部通过」', s.overall === 'pass' && s.text === '可核对的项全部通过', `${s.overall}:${s.text}`],
|
||||
]
|
||||
}
|
||||
const GLUED = '紧挨着的正文里还有数字或负号'
|
||||
await tamper(samples.formats, '隔着显示时不占位置的控制字符补一位数', (data) => rewriteDoc(data, (doc) => {
|
||||
segById(doc, 's3').text += `1${cp(0x7f)}`
|
||||
return { what: '销售额前面补「1」和一个 DEL', seg: segById(doc, 's4') }
|
||||
}), naBecause(GLUED), { seal: true })
|
||||
await tamper(samples.formats, '前面加一个长得像负号的连字符', (data) => rewriteDoc(data, (doc) => {
|
||||
segById(doc, 's12').text += cp(0x2010)
|
||||
return { what: '转化率前面加 U+2010', seg: segById(doc, 's13') }
|
||||
}), naBecause(GLUED), { seal: true })
|
||||
await tamper(samples.formats, '叠在空格上的横线', (data) => rewriteDoc(data, (doc) => {
|
||||
segById(doc, 's3').text += cp(0x336)
|
||||
return { what: '销售额前面的空格上叠一道长横线(U+0336)', seg: segById(doc, 's4') }
|
||||
}), naBecause('紧挨着的正文里有看不见或在不同设备上显示不同的字'), { seal: true })
|
||||
// 希伯来字母:逻辑上在销售额后面的「5」显示在销售额紧前面(读作 574,613.65元)。先量一下页面上确实这样显示,
|
||||
// 说明这种伪造能把数读错
|
||||
await tamper(samples.formats, '从右往左书写的字母把一位数挪到数字前面', (data) => rewriteDoc(data, (doc) => {
|
||||
segById(doc, 's3').text += cp(0x5d0)
|
||||
segById(doc, 's5').text = `5${segById(doc, 's5').text}`
|
||||
return { what: '销售额前面加一个希伯来字母、后面加「5」', seg: segById(doc, 's4') }
|
||||
}), async (s, forged, page) => {
|
||||
const gap = await page.evaluate((key) => {
|
||||
const btn = document.querySelector(`button.num[data-num="${key}"]`)
|
||||
const text = btn?.nextSibling?.firstChild
|
||||
if (!btn || !text || text.nodeType !== 3 || !text.data.startsWith('5')) return null
|
||||
const range = document.createRange()
|
||||
range.setStart(text, 0)
|
||||
range.setEnd(text, 1)
|
||||
return Math.round(btn.getBoundingClientRect().left - range.getBoundingClientRect().right)
|
||||
}, `${forged.node}|${forged.seg.id}`)
|
||||
return [
|
||||
['页面上「5」显示在销售额紧前面', gap !== null && Math.abs(gap) <= 2, `间距 ${gap}px`],
|
||||
...naBecause('这一句里有从右往左书写的文字')(s, forged),
|
||||
]
|
||||
}, { seal: true })
|
||||
// 列表缩进写得极大或写成负数(服务端按原文的缩进算级数、没有上限):最多缩进 8 级,列表里核对通过的数字都留在屏幕上、
|
||||
// 照常通过,宽屏、窄屏都不横向溢出
|
||||
for (const [label, depths] of [['列表缩进写成十万级', [100000, 3]], ['列表缩进写成负数', [-100000, -3]]]) {
|
||||
const data = structuredClone(dataOf(samples.formats))
|
||||
rewriteDoc(data, (doc) => {
|
||||
const block = doc.blocks[2]
|
||||
block.type = 'list'
|
||||
block.units.forEach((unit, i) => {
|
||||
unit.depth = depths[i]
|
||||
if (i > 0) unit.segments.unshift({ kind: 'structural', id: `li${i}`, text: '\n- ' })
|
||||
})
|
||||
return { what: label }
|
||||
})
|
||||
for (const width of [1280, 390]) {
|
||||
const opened = await openFile(writePage(`tampered-depth-${depths[0]}-${width}.html`,
|
||||
withSeal(withData(samples.formats, data), data.meta.manifest_hash)), { width })
|
||||
try {
|
||||
const s = await stateOf(opened.page)
|
||||
const boxes = await opened.page.evaluate(() => [...document.querySelectorAll('.doc li button.num')].map((b) => {
|
||||
const r = b.getBoundingClientRect()
|
||||
return [Math.round(r.left), Math.round(r.right)]
|
||||
}))
|
||||
check(`篡改${label}(${width} 宽):列表里的数字都在屏幕上、照常通过,页面不横向溢出`,
|
||||
boxes.length === 7 && boxes.every(([l, r]) => l >= 0 && r <= width) && s.overflow <= 0
|
||||
&& s.overall === 'pass' && s.text === '全部通过',
|
||||
`${JSON.stringify(boxes)};溢出 ${s.overflow}px;${s.overall}:${s.text}`)
|
||||
} finally {
|
||||
await opened.ctx.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
// 表格里一格的列号写成十万:只排有格的列,那一格紧接着前一列显示,不会被几万个空列挤到表格右边,也不建几十万个空格
|
||||
{
|
||||
const data = structuredClone(dataOf(samples.base))
|
||||
const done = rewriteDoc(data, (doc) => {
|
||||
const table = doc.blocks.find((b) => b.type === 'table')
|
||||
const unit = table?.units.find((u) => u.loc?.row === 0 && u.loc?.col === 1)
|
||||
const seg = unit?.segments.find((x) => x.kind === 'number' || x.kind === 'value')
|
||||
if (!seg) return null
|
||||
unit.loc = { row: 0, col: 100000 }
|
||||
return { what: '表格第一行第二格的列号改成 100000', seg }
|
||||
})
|
||||
if (!done) {
|
||||
check('篡改表格的列号:样本页 base 里找不到表格第一行第二格的数字', false)
|
||||
} else {
|
||||
const opened = await openFile(writePage('tampered-table-col.html', withSeal(withData(samples.base, data),
|
||||
data.meta.manifest_hash)))
|
||||
try {
|
||||
const got = await opened.page.evaluate((key) => {
|
||||
const btn = document.querySelector(`button.num[data-num="${key}"]`)
|
||||
const wrap = btn?.closest('.table-wrap')
|
||||
if (!btn || !wrap) return null
|
||||
const b = btn.getBoundingClientRect()
|
||||
const w = wrap.getBoundingClientRect()
|
||||
return { result: btn.dataset.result, visible: b.left >= w.left && b.right <= w.left + wrap.clientWidth,
|
||||
cols: wrap.querySelectorAll('thead th').length, cells: wrap.querySelectorAll('td').length }
|
||||
}, `${done.node}|${done.seg.id}`)
|
||||
check('篡改表格的列号:那一格紧接着前一列显示、照常通过,表格只有 3 列',
|
||||
got?.result === 'pass' && got.visible && got.cols === 3 && got.cells === 24, JSON.stringify(got))
|
||||
} finally {
|
||||
await opened.ctx.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 记录页的导出按钮
|
||||
|
||||
Reference in New Issue
Block a user