mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:51:59 +00:00
1048 lines
66 KiB
Python
1048 lines
66 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」」;认不出的换算判无法核对,不判通过;
|
||
- 口径卡单位前后带空白(「元 」,服务端照原样接):两边都只去首尾空白,原样导出照样通过;运行输入照旧无法核对,显示
|
||
文字拿引用的值核对,只改显示文字的不通过;出处面板里数字文本照原文写;
|
||
- 正文的排法:核对通过的字要原样显示——伪造的链接藏不起它(整句按原文显示),正文里不显示的判不通过;紧挨着补数字、
|
||
小数点、负号(只认真当负号用的半角、数学减号、全角三个,前面是字母数字的是编号的一部分;隔着不占位置的字也算)判不
|
||
通过:读者看到的是另一个数,以前只判无法核对,总览照样是绿的;后面接着量级单位(万、亿、%、k 这类,隔着空格也算)、
|
||
画着删除线或紧挨着画了删除线的字、紧挨着像负号的短横(连字符、上标负号这类)、长得像数字的字母和数字符号(l、I、O、
|
||
上标数字、圈码)、显示成什么说不准的字(控制字符、组合符号这类),同一句里有改变文字方向的字(方向控制字符、希伯来文
|
||
和阿拉伯文这类从右往左书写的文字),判无法核对;写作者自己写在引用旁边的普通文字(「统一」「——」「SKU-」「共…家」)、
|
||
区间、日期照常通过;列表缩进有上限、表格只排有格的列,伪造的缩进、列号挤不走核对过的数;
|
||
- 总览:一项无法核对都没有才是绿色的通过,有的是中性的「可核对的项全部通过,另有 N 项无法核对」(overallOf)。
|
||
|
||
数据全是虚构的。
|
||
"""
|
||
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, c.plain) }
|
||
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))
|
||
}
|
||
// 只看正文的排法:文档里每个解析成功的数字、取值当作核对通过,交回各自的结果
|
||
if (input.task === 'layoutOf') return input.docs.map((d) => v.layoutOf(d))
|
||
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),
|
||
// 工件、引用链每一项的种类、结果和原因;每份报告的文档核对上了没有
|
||
why: { artifacts: out.artifacts.map((i) => ({ kind: i.a.kind, result: i.result, reason: i.reason || '' })),
|
||
chain: out.chain.map((c) => ({ kind: c.kind, result: c.result, reason: c.reason || '' })) },
|
||
docs: out.docs.map((d) => ({ node: d.report.node_id, trusted: d.trusted })),
|
||
// 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 })),
|
||
// 总览:结果(pass 绿 / fail 红 / incomplete、partial 中性)和那一句
|
||
overall: v.overallOf(out),
|
||
}
|
||
}
|
||
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, plain: bool = False) -> str | None:
|
||
try:
|
||
# 3 版起标识列的数不分千位:服务端按 SQL 判(cell_format),引用上记 plain,页面照它渲染
|
||
return render_cell(_Snapshots(display=display).shown(raw), conv, kind=kind, plain=plain and display >= 3)
|
||
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, plain in ((1, False), (2, False), (3, False), (3, True), (2, True)):
|
||
cases.append({"cell": True, "json": text, "kind": kind, "conv": conv, "display": display,
|
||
"plain": plain, "py": py_cell(json.loads(text), conv, kind, display, plain)})
|
||
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": "华东",
|
||
# 订单号是标识列(列名以 _no 结尾):3 版显示规则起不分千位,超过 2^53 也一位不差
|
||
"v:Q1.r1.gmv": "-1,200.75", "v:Q1.r0.order_no": "202609071234567890",
|
||
"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万"), # 单元格换算成万
|
||
# 超过 2^53 的整数;标识列不分组,应显示的字和引用的值一样,原因里不另写
|
||
("v:Q1.r0.order_no", "202609071234567891", "202609071234567890", None),
|
||
("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_identifier_cells_read_without_separators_and_the_page_follows():
|
||
"""标识列(订单号这类编号,列名以 _no 结尾)的数不分千位:文档记 cell_display: 3、引用上记 plain,页面照它渲染、
|
||
原样通过;把 plain 去掉的伪造页,页面按千分位渲染,和正文显示的字对不上,不通过。"""
|
||
from tests.bundle_samples import formats_bundle
|
||
|
||
bundle = formats_bundle()
|
||
art, doc = doc_of(bundle)
|
||
seg = segment(doc, "v:Q1.r0.order_no")
|
||
assert doc["cell_display"] == 3 and seg["text"] == "202609071234567890" and seg["cite"]["plain"] is True
|
||
assert item(verdicts(bundle), "v:Q1.r0.order_no")["result"] == "pass"
|
||
seg["cite"].pop("plain")
|
||
assert item(verdicts(reseal(bundle, art, doc)), "v:Q1.r0.order_no")["result"] == "fail"
|
||
|
||
|
||
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"] = 4
|
||
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"] == "202609071234567891", 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))), # 全角负号
|
||
("m:conv_rate", _glue_before("s12", chr(0x2212))), # 数学减号
|
||
("m:conv_rate", _glue_before("s12", "-" + chr(0x200B))), # 隔着看不见的零宽空格
|
||
# 负号前面是汉字、括号:不是编号的一部分(「SKU-12」那种前面是字母数字的见下面照常通过的几条)
|
||
("m:conv_rate", lambda doc: by_id(doc, "s12").update(text="转化率-")),
|
||
("m:conv_rate", lambda doc: by_id(doc, "s12").update(text="转化率(-")),
|
||
("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_fail(ref, change):
|
||
"""伪造者不动核对过的那段字,只在相邻的文字片段里补一位数、一个负号、一段小数(隔着不占位置、显示成什么说不准的字,
|
||
藏起来的粗体标记也算紧挨着),重算全部哈希:读者看到的是另一个数。这一项判不通过、点名正文里紧挨着的字改变了读者
|
||
看到的数——以前只判无法核对,总览照样写「可核对的项全部通过」。负号只认真当负号用的三个(半角、数学减号、全角),
|
||
前面是字母数字的是编号的一部分(见下面照常通过的几条)。显示文字本身照旧核对过(shownOk),别的照常通过。服务端组装
|
||
的文档不会这样排(两个数之间补空格),原样导出照样通过(见下面几条)。"""
|
||
from tests.bundle_samples import formats_bundle
|
||
|
||
got = verdicts(rewritten(formats_bundle(), change))
|
||
hit = item(got, ref)
|
||
assert hit["result"] == "fail" 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", lambda doc: (by_id(doc, "s3").update(text=by_id(doc, "s3")["text"] + "1"),
|
||
by_id(doc, "s5").update(text=chr(0x202E) + by_id(doc, "s5")["text"]))),
|
||
("m:gmv", lambda doc: (by_id(doc, "s3").update(text=chr(0x05D0) + by_id(doc, "s3")["text"] + "1"))),
|
||
# 说不准的字同理:紧挨着的是一位数,中间隔着叠在上面的组合符号
|
||
("m:gmv", _glue_before("s3", "1" + chr(0x0336))),
|
||
])
|
||
def test_other_doubts_do_not_soften_a_glued_digit(ref, change):
|
||
from tests.bundle_samples import formats_bundle
|
||
|
||
hit = item(verdicts(rewritten(formats_bundle(), change)), ref)
|
||
assert hit["result"] == "fail" and hit["reason"] == GLUED, hit
|
||
|
||
|
||
@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"]
|
||
|
||
|
||
UNSURE = "无法核对显示文字:"
|
||
DASHED = UNSURE + "前面紧挨着一道像负号的短横,读起来可能是负数"
|
||
SCALED = UNSURE + "后面接着量级单位(万、亿、千、百、%、‰、k、M、B 这类)。系统显示这个数时不带它,读起来的数可能差了量级"
|
||
STRUCK = UNSURE + "这个数画着删除线,或者紧挨着画了删除线的文字,读的人可能以为它已作废、以旁边的数为准"
|
||
LOOKALIKE = UNSURE + "紧挨着长得像数字的字母或符号(l、I、O、上标数字、圈码这类),读起来可能多出一位"
|
||
|
||
|
||
def _only(ref: str, change: Any, result: str, reason: str) -> dict[str, Any]:
|
||
"""改过的经营月报里只有 ref 这一项是 result(原因 reason),别的照常通过;交回核对结果。"""
|
||
from tests.bundle_samples import formats_bundle
|
||
|
||
got = verdicts(rewritten(formats_bundle(), change))
|
||
hit = item(got, ref)
|
||
assert hit["result"] == result and hit["reason"] == reason and hit["shownOk"], hit
|
||
assert all(n["result"] == "pass" for n in got["numbers"] if n["ref"] != ref), \
|
||
[n for n in got["numbers"] if n["ref"] != ref and n["result"] != "pass"]
|
||
return got
|
||
|
||
|
||
@pytest.mark.parametrize("cp", [0x2010, 0x2011, 0x2012, 0x02D7, 0x2796, 0x207B, 0x208B, 0xFE63, 0x058A])
|
||
def test_a_short_dash_that_looks_like_a_minus_cannot_pass(cp):
|
||
"""连字符、不断行连字符、数字宽的横线、修饰字母负号、粗减号、上下标负号、小号连字符减号、亚美尼亚连字符:平时不当负号
|
||
用,不判不通过;紧挨着数字开头、前面又不是字母数字时读起来像负数,判无法核对、原因说清。总览不是绿色的通过。"""
|
||
got = _only("m:conv_rate", _glue_before("s12", chr(cp)), "na", DASHED)
|
||
assert got["overall"]["result"] == "partial", got["overall"]
|
||
|
||
|
||
@pytest.mark.parametrize("ref, change", [
|
||
("v:Q1.r0.gmv", _glue_after("s34", "万")), # 45,678.5万:读作四亿多
|
||
("v:Q1.r0.gmv", _glue_after("s34", " 万")), # 中间隔着空格照样读作四亿多
|
||
("v:Q1.r0.gmv", _glue_after("s34", "亿元")),
|
||
("v:Q1.r0.gmv", _glue_after("s34", "%")),
|
||
("v:Q1.r0.gmv", _glue_after("s34", "‰")),
|
||
("v:Q1.r0.gmv", _glue_after("s34", chr(0xFF05))), # 全角百分号
|
||
*[("v:Q1.r0.gmv", _glue_after("s34", c)) for c in ("k", "K", "M", "B", "千", "百", "萬", chr(0xFF2B))],
|
||
("v:Q1.r0.gmv|万", _glue_after("s36", "亿")), # 4.57万亿:核对过的字以「万」结尾,再接一个量级
|
||
("v:Q1.r0.order_no", _glue_after("s43", "万")), # 订单号后面接「万」
|
||
("m:orders", _glue_after("s18", "")), # 对照组见下:以「单」结尾的不受影响
|
||
])
|
||
def test_a_magnitude_unit_after_a_checked_value_cannot_pass(ref, change):
|
||
"""核对过的那段字不动,后面接上量级单位(万、亿、%、k 这类,隔着空格也算):读者看到的数差了量级。系统显示一个数时
|
||
单位、万 / 亿换算都在核对过的那段字里,不会另接在后面,判无法核对、原因说清;总览不是绿色的通过。"""
|
||
if ref == "m:orders":
|
||
# 以「单」结尾的数后面接什么都不改变它的量级:照常通过
|
||
from tests.bundle_samples import formats_bundle
|
||
|
||
got = verdicts(rewritten(formats_bundle(), lambda doc: by_id(doc, "s18").update(text="万。")))
|
||
assert all(n["result"] == "pass" for n in got["numbers"]), got["numbers"]
|
||
return
|
||
got = _only(ref, change, "na", SCALED)
|
||
assert got["overall"]["result"] == "partial" and "另有 1 项无法核对" in got["overall"]["text"], got["overall"]
|
||
|
||
|
||
@pytest.mark.parametrize("change", [
|
||
# 核对过的数整个画上删除线,旁边另写一个数(读的人以为它已作废)
|
||
lambda doc: (by_id(doc, "s3").update(text=by_id(doc, "s3")["text"] + "~~"),
|
||
by_id(doc, "s5").update(text="~~ 84,613.65元" + by_id(doc, "s5")["text"])),
|
||
# 删除线只画在紧挨着它的字上
|
||
_glue_before("s3", "~~旧~~"),
|
||
_glue_after("s5", "~~作废~~"),
|
||
])
|
||
def test_a_struck_checked_value_cannot_pass(change):
|
||
"""删除线画在核对过的数上,或者画在紧挨着它的字上:显示出来的字没变,读的人却可能以为它已作废、以旁边写的数为准,
|
||
判无法核对、原因说清。"""
|
||
ref = "m:gmv"
|
||
got = _only(ref, change, "na", STRUCK)
|
||
assert got["overall"]["result"] == "partial", got["overall"]
|
||
|
||
|
||
@pytest.mark.parametrize("ref, change", [
|
||
("m:gmv", _glue_before("s3", "l")), # 小写 L:读作 174,613.65元
|
||
("m:gmv", _glue_before("s3", "I")), # 大写 i
|
||
("m:gmv", _glue_before("s3", chr(0xFF29))), # 全角大写 i
|
||
("v:Q1.r0.gmv", _glue_after("s34", "O")), # 大写 o:读作 45,678.50
|
||
("m:gmv", _glue_before("s3", chr(0xB9))), # 上标 1
|
||
("m:gmv", _glue_before("s3", chr(0x2160))), # 罗马数字 1
|
||
("m:gmv", _glue_before("s3", chr(0x2460))), # 圈码 1
|
||
("v:Q1.r0.gmv", _glue_after("s34", chr(0x3007))), # 汉字〇
|
||
])
|
||
def test_a_lookalike_digit_next_to_a_checked_value_cannot_pass(ref, change):
|
||
"""紧挨着长得像数字的字母、数字符号(l、I、O、上标数字、罗马数字、圈码、汉字〇):读起来可能多出一位,判无法核对、
|
||
原因说清。"""
|
||
got = _only(ref, change, "na", LOOKALIKE)
|
||
assert got["overall"]["result"] == "partial", got["overall"]
|
||
|
||
|
||
ODD = UNSURE + "紧挨着这个数的字不在常见写法里,无法确认读者看到的数"
|
||
|
||
|
||
@pytest.mark.parametrize("ref, change, reason", [
|
||
# 长得像 l、I、1 的字放在销售额前面(复核第 2 轮的伪造写法):西里尔、希腊、拉丁的竖线字母,ASCII 竖线,汉字「丨」,
|
||
# 全角竖线、竖排的破折号、制表符的竖线
|
||
*[("m:gmv", _glue_before("s3", chr(cp)), LOOKALIKE)
|
||
for cp in (0x0406, 0x0399, 0x04C0, 0x01C0, 0x7C, 0x4E28, 0xFF5C, 0xFE31, 0x2502)],
|
||
# 长得像 O、0 的字接在流水后面:西里尔、希腊的 O,空心圆,小写 o
|
||
*[("v:Q1.r0.gmv", _glue_after("s34", chr(cp)), LOOKALIKE) for cp in (0x041E, 0x039F, 0x25CB, 0x6F)],
|
||
# 名单以外的字:数学粗体 l、谚文的 ㅣ、全角拉丁字母、日文假名、表情(带变体选择符)、零宽空格、各种窄空格和不断行空格,
|
||
# 当笔画用的汉字(丶、丿、亅),前面不是字母的句点(读起来像小数点)
|
||
*[("m:gmv", _glue_before("s3", text), ODD)
|
||
for text in (chr(0x1D425), chr(0x3163), chr(0xFF21), chr(0x3042), chr(0x2705) + chr(0xFE0F), chr(0x200B),
|
||
"1" + chr(0x2006), "1" + chr(0x2009), "1" + chr(0x2008), "1" + chr(0x202F), chr(0xA0), "\t",
|
||
chr(0x4E36), chr(0x4E3F), chr(0x4E85), ".")],
|
||
# 量级单位的易混字接在流水后面:西里尔、希腊的 M、K、B,开尔文符号,小号百分号,谚文的 ㅇ
|
||
*[("v:Q1.r0.gmv", _glue_after("s34", chr(cp)), ODD)
|
||
for cp in (0x041C, 0x039C, 0x212A, 0x041A, 0x0392, 0xFE6A, 0x3147)],
|
||
# 隔着各种空白、左括号再接量级单位:换行、制表、em / en / thin 空格、全角空格、不断行空格,「(万元)」「十万」「w」
|
||
*[("v:Q1.r0.gmv", _glue_after("s34", text), SCALED)
|
||
for text in ("\n万", "\t万", chr(0x2003) + "万", chr(0x2002) + "万", chr(0x2009) + "万", chr(0x3000) + "万",
|
||
chr(0xA0) + "万", "\n亿元", "(万元)", " (万)", "十万", "w", "W")],
|
||
# 负号前面是别的文字系统里长得像标点的字母,不是 ASCII 字母数字:照旧是负号
|
||
*[("m:conv_rate", lambda doc, t=text: by_id(doc, "s12").update(text=t), result)
|
||
for text, result in (("转化率\ua78f-", GLUED), ("转化率\u01c0-", GLUED), ("转化率\ua78c-", GLUED),
|
||
("转化率\ua78f\u2212", GLUED), ("转化率\u0410-", GLUED), ("转化率\ua78f\u2010", DASHED))],
|
||
# 科学记数法:后面接 e 和数字
|
||
("v:Q1.r0.gmv", _glue_after("s34", "e3"), GLUED),
|
||
("v:Q1.r0.gmv", _glue_after("s34", "e+3"), GLUED),
|
||
])
|
||
def test_a_character_outside_the_common_writing_next_to_a_value_is_not_green(ref, change, reason):
|
||
"""紧挨着核对过的数字头尾的那一个字改成白名单(COMMON):汉字、中文标点、半角和全角空格、ASCII 字母(l、I、O、o 照旧算
|
||
像数字的字母)、常用的半角标点和算式符号、上一轮放行的破折号写法以外的,一律判无法核对、写明原因,总览不再是绿色的
|
||
「全部通过」。复核第 2 轮用这些写法伪造(只改相邻的普通文字、重算全部哈希),以前全是绿的、提示也不显示。负号前面只认
|
||
ASCII 字母数字是编号;后面接 e 和数字的读作科学记数法,判不通过。"""
|
||
result = "fail" if reason == GLUED else "na"
|
||
got = _only(ref, change, result, reason)
|
||
assert got["overall"]["result"] == ("fail" if result == "fail" else "partial"), got["overall"]
|
||
|
||
|
||
#: 后面紧接着乘号、乘方再接数字(×10⁴、*10^4)
|
||
TIMES = UNSURE + "后面紧接着乘号或乘方和数字(×10⁴、*10^4 这类),读起来的数可能差了量级"
|
||
#: 偏旁、部首形的汉字(刂冫氵亻阝扌忄犭礻衤讠钅饣纟丬):刂 显示成两道竖线,读起来像「11」
|
||
RADICALS = "\u5202\u51ab\u6c35\u4ebb\u961d\u6274\u5fc4\u72ad\u793b\u8864\u8ba0\u9485\u9963\u7e9f\u4e2c"
|
||
|
||
|
||
@pytest.mark.parametrize("ref, change, reason", [
|
||
# 弯撇号当千分位补一位数:1’74,613.65元 读作 174,613.65元(半角撇号本来就判无法核对)
|
||
("m:gmv", _glue_before("s3", "1\u2019"), ODD),
|
||
("m:gmv", _glue_before("s3", "1\u2018"), ODD),
|
||
("v:Q1.r0.gmv", _glue_after("s34", "\u20195"), ODD),
|
||
# 偏旁形的汉字、康熙部首、部首补充、扩展 B 区里像竖线的字:前面补一个,读起来多出一两位
|
||
*[("m:gmv", _glue_before("s3", c), ODD) for c in RADICALS],
|
||
*[("m:gmv", _glue_before("s3", chr(cp)), ODD) for cp in (0x2F01, 0x2F11, 0x2E88, 0x2E89, 0x2010C, 0x200D1)],
|
||
("v:Q1.r0.gmv", _glue_after("s34", "\u51ab"), ODD),
|
||
# 乘号、乘方再接数字:45,678.5×10⁴ 读作四亿多
|
||
*[("v:Q1.r0.gmv", _glue_after("s34", t), TIMES) for t in ("\u00d710\u2074", "*10^4", "^2", "\u00d710")],
|
||
# 英文的量级写法:紧接着或隔着空白、左括号
|
||
*[("v:Q1.r0.gmv", _glue_after("s34", t), SCALED)
|
||
for t in ("T", "G", "m", "b", "bn", "Bn", "mn", "tn", " billion", " Billion", " million", " thousand",
|
||
" trillion", "(bn)")],
|
||
# en dash 紧挨在数字前面:转化率–2.35% 读作负数(前面是数字的区间见下面照常通过的一条)
|
||
("m:conv_rate", lambda doc: by_id(doc, "s12").update(text="转化率\u2013"), DASHED),
|
||
("m:conv_rate", _glue_before("s12", "\u2013"), DASHED),
|
||
])
|
||
def test_lookalikes_named_by_the_third_review_are_not_green(ref, change, reason):
|
||
"""复核第 3 轮点名的几类:弯撇号(U+2018、U+2019)紧挨着数字、偏旁形的汉字(刂、冫、氵这类,康熙部首、部首补充两个区块,
|
||
扩展 B 区里像竖线的字)、后面紧接着乘号或乘方再接数字、后面接着英文的量级写法(T、G、bn、m、b、billion、million、
|
||
thousand 这类,隔着空白也算)、en dash 紧挨在数字前面。只改相邻的普通文字、重算全部哈希,以前都是绿色的「全部通过」、
|
||
提示也不显示;现在判无法核对、写明原因,总览中性。"""
|
||
got = _only(ref, change, "na", reason)
|
||
assert got["overall"]["result"] == "partial", got["overall"]
|
||
|
||
|
||
def test_soft_breaks_currency_signs_formulas_and_ranges_still_pass():
|
||
"""常见的正常写法照常通过、总览全绿:段内软换行紧挨着数字(页面上显示成一个空格)、数字前面的「¥」「¥」、两个核对过的数
|
||
相乘(乘号后面紧跟的是另一个核对过的数)、en dash 连着的区间(前面是数)、数字后面跟着 ms、GB 这类不是量级的单位。"""
|
||
from tests.bundle_samples import formats_bundle
|
||
|
||
text = ("## 写法\n\n本月销售额\n[[m:gmv]],客单价 \uffe5[[m:avg]],取整 \u00a5[[m:avg|int]],"
|
||
"流水乘订单号 [[v:Q1.r0.gmv]]\u00d7[[v:Q1.r0.order_no]]、[[v:Q1.r0.gmv]]*[[v:Q1.r0.order_no]],"
|
||
"订单 [[m:orders]],区间 [[v:Q1.r1.gmv]]\u2013[[v:Q1.r0.gmv]],耗时 [[v:Q1.r0.gmv]] ms、"
|
||
"容量 [[v:Q1.r0.gmv]]GB。")
|
||
got = verdicts(formats_bundle(text=text))
|
||
assert len(got["numbers"]) == 12, got["numbers"]
|
||
assert all(n["result"] == "pass" and n["onPage"] == n["text"] for n in got["numbers"]), \
|
||
[n for n in got["numbers"] if n["result"] != "pass"]
|
||
assert got["overall"]["result"] == "pass", got["overall"]
|
||
|
||
|
||
def test_common_punctuation_next_to_checked_values_still_passes():
|
||
"""常见的写法照常通过:等号、半角括号和冒号、加号、斜杠、百分比前面的加号、半角逗号和句点结句、约等号和乘号、编号后面的
|
||
句点(NO.)、全角的冒号括号顿号。"""
|
||
from tests.bundle_samples import formats_bundle
|
||
|
||
text = ("## 算式\n\n客单价=[[m:avg]],销售额:[[m:gmv]],转化率(+[[m:conv_rate]]),订单 [[v:Q1.r0.order_no]], 另计。"
|
||
"流水约≈[[v:Q1.r0.gmv]]/月,退款×[[m:refund]],编号No.[[m:orders]],出口额:[[m:export]]、"
|
||
"年度([[m:annual|亿]])。合计 [[v:Q1.r1.gmv]].")
|
||
got = verdicts(formats_bundle(text=text))
|
||
assert len(got["numbers"]) == 10, got["numbers"]
|
||
assert all(n["result"] == "pass" and n["onPage"] == n["text"] for n in got["numbers"]), \
|
||
[n for n in got["numbers"] if n["result"] != "pass"]
|
||
assert got["overall"]["result"] == "pass", got["overall"]
|
||
|
||
|
||
def test_ordinary_writing_next_to_checked_values_still_passes_as_exported():
|
||
"""正文里的普通写法照常通过,原样导出总览全绿:「统一」后面紧跟数(汉字「一」不是负号)、破折号(——、—、―,小号的)
|
||
后面紧跟数、段内软换行紧挨着数、金额前面的「¥」「¥」、编号「SKU-」「A-」后面紧跟数(连字符前面是字母,是编号的一部分)、
|
||
「共…家」、全角括号、区间、「NO.」后面跟数、数后面跟「元」「单」这类单位以外的字、假名长音和制表符横线、粗体和斜体包着
|
||
的数。"""
|
||
from tests.bundle_samples import PROSE, formats_bundle
|
||
|
||
got = verdicts(formats_bundle(text=PROSE))
|
||
assert len(got["numbers"]) == 17, got["numbers"]
|
||
assert all(n["result"] == "pass" and n["onPage"] == n["text"] for n in got["numbers"]), \
|
||
[n for n in got["numbers"] if n["result"] != "pass"]
|
||
assert got["overall"] == {"result": "pass", "text": "全部通过", "pass": got["overall"]["pass"], "fail": 0, "na": 0}
|
||
|
||
|
||
def test_the_overview_is_green_only_without_anything_unverifiable():
|
||
"""总览:原样导出、一项无法核对都没有的是绿色的「全部通过」;有一项无法核对就是中性的「可核对的项全部通过,另有
|
||
N 项无法核对」(以前照样是绿的);有不通过的是红的。"""
|
||
from tests.bundle_samples import formats_bundle
|
||
|
||
assert verdicts(formats_bundle())["overall"]["result"] == "pass"
|
||
got = verdicts(rewritten(formats_bundle(), lambda doc: by_id(doc, "s5").update(
|
||
text=chr(0x202E) + by_id(doc, "s5")["text"])))
|
||
assert got["overall"]["result"] == "partial" and got["overall"]["fail"] == 0, got["overall"]
|
||
assert got["overall"]["text"] == f"可核对的项全部通过,另有 {got['overall']['na']} 项无法核对", got["overall"]
|
||
got = verdicts(rewritten(formats_bundle(), _glue_before("s3", "1")))
|
||
assert got["overall"]["result"] == "fail" and got["overall"]["text"] == "有 1 项不通过", got["overall"]
|
||
|
||
|
||
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
|