mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 01:41:57 +00:00
9344cb9232
复核发现「紧挨着」只认一部分字:隔着 DEL、U+FFF0 这类显示时不占位置的字补一位数,或者前面加一个 U+2010 这类和负号 长得一样的横线,读的人看到的数已经变了,页面仍「全部通过」;伪造的列表缩进写成十万级,核对通过的数字被挤出屏幕仍判 通过。 - 紧挨着的字按码点分几类(kindOf):数字;负号(横线类标点全算、波浪形的除外,减号类符号,Unicode 易混字符表里映射到 「-」「ー」的字,在 Chrome 常用字体里逐个画出来是一道横线的字);分隔(半角逗号、句点和易混字符表里长得一样的窄标点, 全角的不算);不占位置的格式字符、默认不显示的字、变体选择符照旧跳过;控制字符(制表、换行等空白除外)、未分配和私用 区的字、孤立的代理项、组合附加符号、可选连字符、发丝空格、对象替换符和在 Chrome 里量出来几乎不占宽度的几个字算「说不 准」:隔着它们照样往外找数字、负号,找不到的也判无法核对、原因另写。只跳过不标记的话,叠在空格上的组合横线、Chrome 画成短横的 C1 控制字符照样能在数字前面显示出一个「负号」 - 同一句里有从右往左书写的文字(希伯来文、阿拉伯文这类)和方向控制字符一样判无法核对:Chrome 里实测,一个希伯来字母 就能把逻辑上在数字后面的「5」显示到数字紧前面(读作 574,613.65元),按字的先后看不出来 - 代理对只在真成对时拼成一个字:不成对的前半不再把后面的「1」吞进去;分在两个文字片段里的一对页面上照样显示成数学字母 区的数字,也拼起来看 - 列表缩进最多显示 8 级,负数按 0 级:十万级的缩进把数字挤到 140 万像素以外,负的连滚动都滚不到;表格只排有格的列:列号 写成十万时不再建几十万个空格、把那一格挤到右边,写成 2^32 时整段核对也不再出错。核对和显示共用 listItems / tableCells - 真数据(两份数据目录里 39 份报告文档、1591 处数字和取值、45 个列表、70 张表格)里新旧规则都一处没有触发 - 测试:test_bundle_shown.py 补 DEL、U+FFF0、U+2010、U+2013 等紧挨着的写法,说不准的字、从右往左的文字、缩进和列号的 用例,逐项去掉实现都会变红;check-bundle 在经营月报上补四种紧挨着的伪造(从右往左那条先量出「5」确实显示在数字紧前 面),缩进写成十万级和负数(宽屏、窄屏)、表格列号写成十万的伪造
783 lines
46 KiB
Python
783 lines
46 KiB
Python
"""可验证报告核对正文显示的字(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
|