M3 可验证报告核对正文显示的字:按服务端的显示规则把引用的值重新渲染、逐字比对

验收发现:页面只核「引用的值 = 口径卡的值或快照的格子」,不核正文显示的字。只改显示文字(74,613.65元 改成
84,613.65元、华南改成华东)、引用值不动、重算全部哈希的伪造页照样「全部通过」,读者看到的数和核过的数不是同一个。

- verifier.js 移植 engine/evidence.py 的 render_number / render_cell:千分位、不分组、小数位(含负数位)、比率显示成
  百分数、万 / 亿换算、pct、int、.N、单位后缀、负数、0,文字压成一行(按 Python 的空白),竖线换成 ¦,ISO 日期时间按
  文档记下的单元格显示规则版本。数照 Python 的 Decimal 算:系数用 BigInt,ROUND_HALF_UP,浮点取最短写法,整数取 JSON
  原文(新写的 numberTokens 读出每个数的原文,超过 2^53 的订单号一位不差)
- ⑤ 在引用的值对上之后再核显示文字(只去掉首尾空白):对不上判不通过,原因点名「正文显示「X」,引用的值是「Y」」,
  数字另写按显示规则应显示的字;渲染不出来的(不认识的换算、显示规则版本、对象和列表、非 ASCII 数字、缺 BigInt 的
  浏览器)判「无法核对显示文字」,不判通过。出处面板改成列出按显示规则应为的字,一致时注明,页脚说明同步
- 在 node 里载入时(没有 document)交出核对用的纯函数,tests/test_bundle_shown.py 拿服务端的渲染逐条对照约八万个
  组合,并在 node 里整份核对:经营月报样本每种格式原样通过,只改显示文字的伪造逐个变红并点名
- 样本页加经营月报(formats);check-bundle 在真导出上只改一个数字(有文字取值时也改一个)的显示文字,在经营月报上
  改文字取值、数字、换算成万的口径卡引用,各自变红并点名,原样导出照旧全绿
This commit is contained in:
ThearchyHelios
2026-10-07 00:17:22 +08:00
parent 58a6759fe2
commit 6ff9d7155b
5 changed files with 973 additions and 68 deletions
+398 -23
View File
@@ -8,8 +8,10 @@
* ③ 每份嵌入的工件:sha256(原字节) 等于工件 id;
* ④ 引用链:报告文档的 id 是这个报告节点最后一次自查事件(已核对)记下的那份,文档记的运行、节点对得上;
* 口径卡和快照的 id 出现在已核对的事件内容里,或者已核对的报告文档的证据目录里;
* ⑤ 报告里的每个数字和取值:引用的值等于口径卡里那个指标的值,或者快照里那一格的值。判等照搬服务端
* engine/expressions.py 的 cell_value / same_value;数字怎么显示是服务端渲染好的,这里不重新排版;
* ⑤ 报告里的每个数字和取值:引用的值等于口径卡里那个指标的值,或者快照里那一格的值(判等照搬服务端
* engine/expressions.py 的 cell_value / same_value);正文显示的字等于按服务端的显示规则(engine/evidence.py 的
* render_number / render_cell:千分位、小数位、单位、万 / 亿换算、百分数,文字压成一行)把那个值渲染出来的字,
* 只去掉首尾空白。本页渲染不出来的写法判无法核对,不判通过;
* ⑥ 运行信息:运行结果、运行类别、出具档位拿封存范围内已核对的事件对照。工作流名、版本、起止时间、节点名称
* 绑不进封存,页面上写明它们取自运行记录。
* 每项给出通过 / 不通过 / 无法核对和原因。服务端导出时不会出现的状态(被引用的工件不在清单里、报告文档标着遮罩)
@@ -148,6 +150,26 @@
numMetricDiffers: '引用的值与口径卡里的值不一致',
numNoCell: (why) => `快照里找不到这一格:${why}`,
numCellDiffers: '引用的值与快照里那一格的值不一致',
/** cited 已经加好引号(空值写「空值(NULL)」);expected 和引用的值写法一样时不写(文字取值) */
numShownDiffers: (shown, cited, expected) => `正文显示「${shown}」,引用的值是${cited}`
+ (expected == null ? '' : `,按显示规则应显示为「${expected}」`),
numShownUnknown: (why) => `无法核对显示文字:${why}`,
shownFormat: (fmt) => `显示格式「${fmt}」无法识别`,
shownConv: (conv) => `换算「${conv}」无法识别`,
shownTextConv: (conv) => `文字不能换算为「${conv}」`,
shownRatioScale: (conv) => `比率不能换算为「${conv}」`,
shownPctUnit: '单位已是百分号,不能再换算为百分数',
shownDecimals: (d) => `小数位「${d}」无法识别`,
shownZero: '按显示规则会显示为 0',
shownTooLong: '数值位数超出可显示的范围',
shownNotFinite: '不是有限的数值',
shownInexact: '数值超出浏览器能精确表示的范围,文件里也找不到它的原文',
shownUnit: '口径卡记下的单位不是文字',
shownShape: '值是对象或列表,本页无法按系统的显示规则重新渲染',
shownDigits: '数字文本中含非 ASCII 数字,本页无法按系统的显示规则重新渲染',
shownDisplay: (v) => `报告文档记下的单元格显示规则版本「${v}」无法识别`,
shownOld: '这个浏览器不支持按系统的显示规则重新渲染(缺少 BigInt 或 Unicode 正则)',
shownBroken: '重新渲染时出错',
numAria: (kind, text, result) => `${kind === 'value' ? '取值' : '数字'} ${text},${result},查看出处`,
statusOk: (seq, what) => `与第 ${seq} 条事件(${what})一致`,
statusMismatch: (claim, seq, what) => `文件里写的是「${claim}」,与封存范围内最后一条结束类事件(第 ${seq} 条,${what})不符`,
@@ -168,14 +190,16 @@
tierAbsent: (seq) => `第 ${seq} 条事件(出具判定)的内容未随文件附带:导出时不会出现这种情况,文件可能被改过`,
metaNeedsManifest: '封存清单没有通过核对,事件的类型和序号不可信,无法对照',
dResult: '核对结果', dSource: '出处', dNode: '来源节点', dCaliber: '口径', dCited: '引用的值', dCard: '口径卡里的值',
dCell: '快照里的值', dShown: '正文显示', dShownNote: '显示格式由服务端渲染', dArtifact: '所在工件', dReport: '所在报告',
dCell: '快照里的值', dShown: '正文显示', dShownSame: '与按显示规则渲染的结果一致', dExpected: '按显示规则应为',
dArtifact: '所在工件', dReport: '所在报告',
dSql: 'SQL', dRow: (row) => `快照第 ${row + 1} 行`,
nullValue: '空值(NULL)',
appendedNote: '这些事件追加在封存之后,不在封存范围内,不参与清单核对,也不能当作证据。',
appendedTag: '封存之后追加,不在封存范围内',
shaSubtle: 'SHA-256 由浏览器内置实现计算(crypto.subtle)',
shaJs: 'SHA-256 由本页内置的实现计算(浏览器未提供 crypto.subtle),结果与浏览器内置实现一致',
renderNote: '数字的显示格式(千分位、单位、小数位)由服务端渲染;本页核对的是引用的值,不重新排版。',
renderNote: '正文里带出处的数字和取值,本页按系统的显示规则(千分位、小数位、单位、万 / 亿换算、百分数,文字压成一行)'
+ '把口径卡或快照里的值重新渲染一遍,与显示的文字逐字比对。',
schema: (s) => `文件格式 ${s}`,
}
@@ -348,7 +372,7 @@
return textOf(m).replace(/\s+/g, ' ').trim() === textOf(t).replace(/\s+/g, ' ').trim()
}
/** locate_cell:(那一格的原值, 列名)。列号换成列名;行是对象时按键取 */
/** locate_cell:(那一格的原值, 列名, 这一格在行里的下标或键)。列号换成列名;行是对象时按键取 */
function locateCell(table, row, column) {
if (!isObj(table) || !Array.isArray(table.rows)) throw new Error('快照里没有表格数据')
const rows = table.rows
@@ -367,12 +391,12 @@
}
if (isObj(record)) {
if (!Object.prototype.hasOwnProperty.call(record, name)) throw new Error(`没有列「${name}」`)
return [record[name], name]
return [record[name], name, name]
}
const index = columns.indexOf(name)
if (index < 0) throw new Error(`没有列「${name}」`)
if (!Array.isArray(record) || index >= record.length) throw new Error(`第 ${row} 行中没有「${name}」对应的单元格`)
return [record[index], name]
return [record[index], name, index]
}
function columnKind(table, column) {
@@ -380,6 +404,276 @@
return typeof kind === 'string' ? kind : null
}
// ------------------------------------------------------------------ 显示规则:照搬 engine/evidence.py
//
// 正文里带出处的数字和取值,字是服务端按引用的值渲染的(render_number / render_cell)。这里按同样的规则把口径卡、快照里
// 的值再渲染一遍,和正文显示的字逐字比对:只改显示文字、重算全部哈希的伪造(74,613.65元 改成 84,613.65元、华南改成
// 华东)在这里露馅。数照 Python 的 Decimal 算:系数用 BigInt 存,位数照原样记,四舍五入是 ROUND_HALF_UP(一半远离 0);
// 浮点取 repr 的那串字(和 JS 的 String 同一个最短写法),整数取 JSON 原文(超过 2^53 的,JSON.parse 读成 double 会丢位)。
// 服务端渲染不出来的(引用就算解析不了,正常导出不会出现)和本页没有移植的写法一律抛 Unshown:核对结果是「无法核对」,
// 不是通过
/** 显示文字核对不了:不认识的格式、本页没有移植的写法 */
class Unshown extends Error {}
const FORMATS = ['plain', 'thousands', 'percent_of_ratio']
const MISSING = '—'
/** 换算成万、亿:小数点左移几位 */
const CONV_SCALE = { '万': 4, '亿': 8 }
const CONV_PLACES = /^\.([0-6])$/
const MAX_PLACES = 100
const MAX_DIGITS = 1000
/** Python 的 int() 最多认这么多位:更长的整数文本 cell_value 照文本 */
const INT_DIGITS = 4300
/** Python 的空白(str.isspace(),re 的 \s 同一套):比 JS 的 \s 多 \x1c–\x1f、\x85,少 U+FEFF */
const PY_SPACE = '\\t\\n\\v\\f\\r\\x1c-\\x1f \\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000'
const PY_RUNS = new RegExp(`[${PY_SPACE}]+`, 'g')
const PY_EDGES = new RegExp(`^[${PY_SPACE}]+|[${PY_SPACE}]+$`, 'g')
// 较早的浏览器没有 BigInt、不认 \p{…}:只是显示文字核对不了(判无法核对),整段脚本不能因此出错,别的照常核对
const BIG = typeof BigInt === 'function' ? BigInt : null
const unicode = (source) => {
try {
return new RegExp(source, 'u')
} catch (e) {
return null
}
}
// Python 的 \d 认所有 Unicode 十进制数字:_NUMERIC_TEXT、_ISO_DATETIME 照这个认
const PY_NUMERIC = unicode(`^[${PY_SPACE}]*-?(?:0|[1-9]\\p{Nd}*)(?:\\.\\p{Nd}+)?(?:E[+-]?\\p{Nd}+)?[${PY_SPACE}]*$`)
const ISO_DATETIME = unicode('^(\\p{Nd}{4}-\\p{Nd}{2}-\\p{Nd}{2})T(\\p{Nd}{2}:\\p{Nd}{2}(?::\\p{Nd}{2}(?:\\.\\p{Nd}+)?)?'
+ '(?:Z|[+-]\\p{Nd}{2}:?\\p{Nd}{2})?)$')
const pyStrip = (text) => text.replace(PY_EDGES, '')
/** _clean:文本值压成一行 */
const pyClean = (text) => pyStrip(text.replace(PY_RUNS, ' '))
/** 十进制数 { neg, coef, exp }:(−1)^neg × coef × 10^exp,coef 是非负的 BigInt。text 是 JSON 的数、数字文本或 String(数) */
function decimalOf(text) {
if (!BIG) throw new Unshown(T.shownOld)
const m = /^(-?)(\d+)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/.exec(text)
if (!m) throw new Unshown(T.shownBroken)
const frac = m[3] || ''
return { neg: m[1] === '-', coef: BIG(m[2] + frac), exp: Number(m[4] || 0) - frac.length }
}
const isZero = (d) => d.coef === BIG(0)
const width = (coef) => coef.toString().length
/** Decimal.adjusted():最高位的指数 */
const adjusted = (d) => (isZero(d) ? d.exp : width(d.coef) - 1 + d.exp)
/** Decimal.quantize:舍入到 10^exp 这一位,ROUND_HALF_UP(一半远离 0) */
function quantize(d, exp) {
if (d.exp >= exp) return { neg: d.neg, coef: d.coef * BIG(10) ** BIG(d.exp - exp), exp }
const shift = exp - d.exp
// 要舍掉的位数比系数还多:不到一半,舍成 0(不必真去算 10 的几万次方)
if (shift > width(d.coef)) return { neg: d.neg, coef: BIG(0), exp }
const unit = BIG(10) ** BIG(shift)
const up = (d.coef % unit) * BIG(2) >= unit ? BIG(1) : BIG(0)
return { neg: d.neg, coef: d.coef / unit + up, exp }
}
/** Decimal.normalize:去掉尾零,0 写成 0E0 */
function normalize(d) {
if (isZero(d)) return { neg: d.neg, coef: d.coef, exp: 0 }
let { coef, exp } = d
while (coef % BIG(10) === BIG(0)) {
coef /= BIG(10)
exp++
}
return { neg: d.neg, coef, exp }
}
/** format(Decimal, ',f'):定点写法,group 时整数部分千分位。0 不带负号(服务端 copy_abs),正指数的 0 写成「0」 */
function fixed(d, group) {
const exp = isZero(d) && d.exp > 0 ? 0 : d.exp
let digits = d.coef.toString()
let int = digits
let frac = ''
if (exp > 0) int = digits + '0'.repeat(exp)
else if (exp < 0) {
digits = digits.padStart(1 - exp, '0')
int = digits.slice(0, digits.length + exp)
frac = digits.slice(digits.length + exp)
}
if (group) {
let out = ''
for (let end = int.length; end > 0; end -= 3) out = int.slice(Math.max(0, end - 3), end) + (out ? `,${out}` : '')
int = out
}
return (d.neg && !isZero(d) ? '-' : '') + int + (frac ? `.${frac}` : '')
}
/** _digits:按小数位四舍五入后写出来。不是 0 的值写出来成了 0,服务端不写(引用算解析不了) */
function digitsOf(d, places, group, strip) {
const q = quantize(d, places == null ? -10 : -places)
if (isZero(q) && !isZero(d)) throw new Unshown(T.shownZero)
return fixed(places == null || strip ? normalize(q) : q, group)
}
const shifted = (d, k) => ({ neg: d.neg, coef: d.coef, exp: d.exp + k })
/** _render_numeric:万 / 亿留两位、去尾零,单位接在后面;pct、比率乘 100 加 %(不接单位);int、.N 改小数位 */
function renderNumeric(d, unit, decimals, fmt, conv) {
if (adjusted(d) >= MAX_DIGITS) throw new Unshown(T.shownTooLong)
const ratio = fmt === 'percent_of_ratio'
const group = fmt !== 'plain'
if (typeof conv === 'string' && Object.prototype.hasOwnProperty.call(CONV_SCALE, conv)) {
if (ratio) throw new Unshown(T.shownRatioScale(conv))
return digitsOf(shifted(d, -CONV_SCALE[conv]), 2, true, true) + conv + unit
}
if (conv === 'pct') {
if (unit.includes('%') || unit.includes('%')) throw new Unshown(T.shownPctUnit)
return `${digitsOf(shifted(d, 2), decimals != null ? Math.max(decimals - 2, 0) : 1, group, false)}%`
}
let places
if (conv === 'int') places = 0
else if (typeof conv === 'string' && CONV_PLACES.test(conv)) places = Number(conv.slice(1))
else if (conv) throw new Unshown(T.shownConv(conv))
else places = decimals == null ? null : ratio ? Math.max(decimals - 2, 0) : decimals
if (ratio) return `${digitsOf(shifted(d, 2), places, group, false)}%`
return digitsOf(d, places, group, false) + unit
}
/** _decimal:JSON 里的数 → 十进制数。token 是它在 JSON 里的原文:整数按原文取精确值,浮点按 repr 的那串字 */
function pyDecimal(value, token) {
if (typeof token === 'string' && /^-?\d+$/.test(token)) return decimalOf(token)
if (!Number.isFinite(value)) throw new Unshown(T.shownNotFinite)
// 没有原文(找不到它在文件里的位置):浮点和 2^53 以内的整数,String 就是 Python 的写法;更大的整数不知道原来的位
if (typeof token !== 'string' && Number.isInteger(value) && !Number.isSafeInteger(value)) {
throw new Unshown(T.shownInexact)
}
return decimalOf(String(value))
}
/**
* render_number:口径卡里的指标按卡上的格式渲染(单元格也走这里,格式是千分位)。token 是值在 JSON 里的原文(数才有),
* decimalsToken 是小数位的原文:写成 2.0 的在 Python 里是浮点,服务端不认
*/
function renderNumber(value, token, { unit = '', decimals = null, decimalsToken = null, fmt = null, conv = null } = {}) {
const format = fmt || 'thousands'
if (!FORMATS.includes(format)) throw new Unshown(T.shownFormat(format))
if (value == null) return MISSING
if (typeof value !== 'number') {
if (conv) throw new Unshown(T.shownTextConv(conv))
if (typeof value === 'boolean') return value ? '是' : '否'
if (typeof value === 'string') return pyClean(value)
throw new Unshown(T.shownShape)
}
if (decimals != null && (typeof decimals !== 'number' || !Number.isInteger(decimals) || Math.abs(decimals) > MAX_PLACES
|| (typeof decimalsToken === 'string' && !/^-?\d+$/.test(decimalsToken)))) {
throw new Unshown(T.shownDecimals(decimalsToken ?? decimals))
}
return renderNumeric(pyDecimal(value, token), unit, decimals, format, conv)
}
/**
* render_cell(shown(raw)):快照里的一格。kind 是查询时记下的列类型,display 是报告文档记下的单元格显示规则版本
* (2 版起 ISO 日期时间的 T 换成空格)。DECIMAL 落成的数字文本按数渲染、不经 float;文字压成一行,竖线换成 ¦
*/
function renderCell(raw, token, conv, kind, display) {
if (display !== 1 && display !== 2) throw new Unshown(T.shownDisplay(display))
if (!PY_NUMERIC || !ISO_DATETIME) throw new Unshown(T.shownOld)
let value = raw
if (display >= 2 && typeof raw === 'string') {
const m = ISO_DATETIME.exec(pyStrip(raw))
if (m) value = `${m[1]} ${m[2]}`
}
if (typeof value === 'string' && kind !== 'text' && PY_NUMERIC.test(value)) {
const text = pyStrip(value)
// Python 把全角、阿拉伯文这些数字也当数(int('1٢٣') 是 123),本页只照 ASCII 算
if (/[^\x00-\x7f]/.test(text)) throw new Unshown(T.shownDigits)
if (!/^-?\d+$/.test(text) || text.replace('-', '').length <= INT_DIGITS) {
return renderNumeric(decimalOf(text), '', null, 'thousands', conv)
}
}
// 对象、列表:服务端按 Python 的 json.dumps 写(键的先后、浮点的写法),本页没有移植
if (isObj(value) || Array.isArray(value)) throw new Unshown(T.shownShape)
return renderNumber(value, token, { conv }).replace(/\|/g, '¦')
}
/**
* JSON 文本里每个数的原文。交回和 JSON.parse 同形状的一棵树:对象是 Map(键重复时后面的算,同 JSON.parse),数组是
* 数组,数是它的原文,别的值是 null。Python 写出的裸 NaN、Infinity、-Infinity 也认(同 parseJson)。读不了抛错
*/
function numberTokens(text) {
const NUMBER = /-?(?:Infinity|(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)|NaN/y
const WORD = /true|false|null/y
let at = 0
const fail = () => {
throw new Error('JSON 格式有误')
}
const skip = () => {
while (at < text.length && ' \t\n\r'.includes(text[at])) at++
}
const string = () => {
const from = at++
while (at < text.length && text[at] !== '"') at += text[at] === '\\' ? 2 : 1
if (at >= text.length) fail()
at++
return text.slice(from, at)
}
const value = () => {
skip()
const c = text[at]
if (c === '{' || c === '[') {
const object = c === '{'
const close = object ? '}' : ']'
const out = object ? new Map() : []
at++
skip()
if (text[at] === close) {
at++
return out
}
for (;;) {
if (object) {
skip()
if (text[at] !== '"') fail()
const key = JSON.parse(string())
skip()
if (text[at++] !== ':') fail()
out.set(key, value())
} else {
out.push(value())
}
skip()
const next = text[at++]
if (next === close) return out
if (next !== ',') fail()
}
}
if (c === '"') {
string()
return null
}
for (const re of [NUMBER, WORD]) {
re.lastIndex = at
const m = re.exec(text)
if (m) {
at = re.lastIndex
return re === NUMBER ? m[0] : null
}
}
return fail()
}
const tree = value()
skip()
if (at !== text.length) fail()
return tree
}
/** numberTokens 的树里一处的原文:path 是一串键和下标。不是数、找不到都是 null */
function tokenAt(tree, path) {
let node = tree
for (const key of path) {
if (node instanceof Map) node = node.has(key) ? node.get(key) : null
else if (Array.isArray(node) && Number.isInteger(key)) node = key >= 0 && key < node.length ? node[key] : null
else return null
}
return typeof node === 'string' ? node : null
}
// ------------------------------------------------------------------ 核对
function push(map, key, value) {
@@ -399,16 +693,30 @@
return found
}
/** 文档里的每个片段和它在文档 JSON 里的位置(引用的值按原文取,见 numberTokens) */
function* segmentsOf(doc) {
for (const block of Array.isArray(doc.blocks) ? doc.blocks : []) {
for (const unit of isObj(block) && Array.isArray(block.units) ? block.units : []) {
for (const seg of isObj(unit) && Array.isArray(unit.segments) ? unit.segments : []) {
if (isObj(seg)) yield seg
const list = (holder, key) => (isObj(holder) && Array.isArray(holder[key]) ? holder[key] : [])
for (const [b, block] of list(doc, 'blocks').entries()) {
for (const [u, unit] of list(block, 'units').entries()) {
for (const [s, seg] of list(unit, 'segments').entries()) {
if (isObj(seg)) yield [seg, ['blocks', b, 'units', u, 'segments', s]]
}
}
}
}
/** 一份嵌入工件里每个数的原文(numberTokens):用到时才算,只算一次;算不出来是 null */
function tokensOf(item) {
if (item.tokens === undefined) {
try {
item.tokens = numberTokens(decoder.decode(fromBase64(item.a.b64)))
} catch (e) {
item.tokens = null
}
}
return item.tokens
}
/** shownSeal:页面顶部直接写出的封存哈希(服务端渲染进模板,不执行脚本也看得到的那一份) */
async function verify(data, digest, shownSeal) {
const out = {
@@ -581,9 +889,11 @@
const doc = trusted.get(report) || (art && art.parsed && isObj(art.loose) ? art.loose : null)
out.docs.push({ report, art, link, doc, trusted: trusted.has(report) })
if (!doc) continue
for (const seg of segmentsOf(doc)) {
for (const [seg, path] of segmentsOf(doc)) {
if (seg.kind !== 'number' && seg.kind !== 'value') continue
const check = trusted.has(report) ? checkNumber(seg, doc, arts, chained)
// 引用的值在文档 JSON 里的原文:超过 2^53 的整数照原文写进原因和出处面板
const citedToken = () => tokenAt(tokensOf(art), [...path, 'cite', 'value'])
const check = trusted.has(report) ? checkNumber(seg, doc, arts, chained, citedToken)
: { result: NA, reason: T.numDocUntrusted, source: describe(seg, doc) }
out.numbers.push({ ...check, seg, report, key: `${report.node_id}|${seg.id}` })
}
@@ -608,7 +918,11 @@
return T.srcOther
}
function checkNumber(seg, doc, arts, chained) {
/**
* citedToken() 交回引用的值在文档 JSON 里的原文(数才有)。引用的值对上了,再核正文显示的字(checkShown):两样都对上
* 才算通过
*/
function checkNumber(seg, doc, arts, chained, citedToken = () => null) {
const cite = isObj(seg.cite) ? seg.cite : null
const source = describe(seg, doc)
if (!cite) return { result: NA, reason: T.numUncited, source }
@@ -631,26 +945,76 @@
return { ...info, result: NA, reason: art.a.masked ? art.reason : art.a.missing ? T.artifactMissing : T.numArtifactUnparsable }
}
if (!chained.has(entry.artifact)) return { ...info, result: NA, reason: T.numNotChained }
info.citedToken = citedToken()
if (cite.kind === 'metric') {
const metricId = own(cite.locator, 'metric') ?? own(entry.locator, 'metric')
const metrics = Array.isArray(art.json.metrics) ? art.json.metrics : []
const metric = metrics.find((m) => isObj(m) && m.id === metricId)
const at = metrics.findIndex((m) => isObj(m) && m.id === metricId)
const metric = metrics[at]
info.card = art.json
if (!metric) return { ...info, result: FAIL, reason: T.numNoMetric }
info.truth = metric.value
return sameValue(cite.value, metric.value) ? { ...info, result: PASS } : { ...info, result: FAIL, reason: T.numMetricDiffers }
info.truthToken = tokenAt(tokensOf(art), ['metrics', at, 'value'])
if (!sameValue(cite.value, metric.value)) return { ...info, result: FAIL, reason: T.numMetricDiffers }
// 口径卡上记着这个指标的单位、小数位、显示格式,报告里另写的换算记在引用上
return checkShown(info, seg, () => renderNumber(metric.value, info.truthToken, {
unit: unitOf(metric.unit), decimals: metric.decimals,
decimalsToken: tokenAt(tokensOf(art), ['metrics', at, 'decimals']), fmt: metric.format, conv: convOf(cite) }))
}
const loc = isObj(cite.locator) ? cite.locator : {}
info.snapshot = art.json
let cell
try {
const [raw, column] = locateCell(art.json, loc.row, loc.column)
const [raw, column, key] = locateCell(art.json, loc.row, loc.column)
info.row = loc.row
info.column = column
info.truth = cellValue(raw, columnKind(art.json, column))
info.truthToken = tokenAt(tokensOf(art), ['rows', loc.row, key])
cell = { raw, column }
} catch (e) {
return { ...info, result: FAIL, reason: T.numNoCell(e.message) }
}
return sameValue(cite.value, info.truth) ? { ...info, result: PASS } : { ...info, result: FAIL, reason: T.numCellDiffers }
if (!sameValue(cite.value, info.truth)) return { ...info, result: FAIL, reason: T.numCellDiffers }
const display = Object.prototype.hasOwnProperty.call(doc, 'cell_display') ? doc.cell_display : 1
return checkShown(info, seg, () => renderCell(cell.raw, info.truthToken, convOf(cite),
columnKind(art.json, cell.column), display))
}
/** 引用上记的换算(|万、|pct 这类),没写是 null */
const convOf = (cite) => (own(cite, 'conv') === undefined ? null : cite.conv)
/** 口径卡的单位:服务端是 str(unit or ""),不是文字的本页不认 */
function unitOf(unit) {
if (!unit) return ''
if (typeof unit !== 'string') throw new Unshown(T.shownUnit)
return unit
}
/** 数的写法:超过 2^53 的整数照 JSON 原文(String 会丢位),别的同 String */
const numberText = (value, token) => (typeof token === 'string' && /^-?\d+$/.test(token) && !Number.isSafeInteger(value)
? token : String(value))
/** 出处面板里的值:数同 numberText,别的同 valueText */
const exactText = (value, token) => (typeof value === 'number' ? numberText(value, token) : valueText(value))
/**
* 正文显示的字和按显示规则渲染出来的字逐字比对,只去掉首尾空白。渲染不出来的判无法核对:不认识的格式不能当成核对过了。
* 对不上的点名两样:正文显示的字、引用的值(加上按显示规则应显示的字,和引用的值写法一样时不写)
*/
function checkShown(info, seg, render) {
let expected
try {
expected = render()
} catch (e) {
return { ...info, result: NA, reason: T.numShownUnknown(e instanceof Unshown ? e.message : T.shownBroken) }
}
info.expected = expected
const shown = String(seg.text ?? '')
if (pyStrip(shown) === expected) return { ...info, result: PASS }
const value = info.cite.value
const cited = typeof value === 'number' ? numberText(value, info.citedToken) : value == null ? null : textOf(value)
return { ...info, result: FAIL, reason: T.numShownDiffers(shown, cited == null ? T.nullValue : `「${cited}」`,
expected === cited ? null : expected) }
}
/**
@@ -966,16 +1330,19 @@
function detailOf(number) {
const cite = isObj(number.seg.cite) ? number.seg.cite : null
const card = number.card
// 只有带出处的数字是服务端按引用的值渲染的;模型自己写的数、表格里的文字取值都是原文
const rendered = number.seg.kind === 'number' && cite && cite.status === 'resolved'
// 显示文字核对过的(引用的值对上了,按显示规则渲染得出来):一致时注明,对不上时另列应显示的字。没有出处的数、
// 核对不了的引用不写:那些字没人重新渲染过
const checked = typeof number.expected === 'string'
const same = checked && number.result === PASS
const rows = [
[T.dResult, [badge(number.result), number.reason ? ` ${number.reason}` : '']],
[T.dSource, number.source],
number.node ? [T.dNode, nodeLabel(number.node)] : null,
card ? [T.dCaliber, [card.caliber || '—', card.caliber_version ? ` ${card.caliber_version}` : ''].join('')] : null,
cite && cite.status === 'resolved' && 'value' in cite ? [T.dCited, valueText(cite.value)] : null,
'truth' in number ? [number.kind === 'metric' ? T.dCard : T.dCell, valueText(number.truth)] : null,
[T.dShown, [String(number.seg.text ?? ''), rendered ? h('span', { class: 'muted' }, `(${T.dShownNote})`) : null]],
cite && cite.status === 'resolved' && 'value' in cite ? [T.dCited, exactText(cite.value, number.citedToken)] : null,
'truth' in number ? [number.kind === 'metric' ? T.dCard : T.dCell, exactText(number.truth, number.truthToken)] : null,
checked && !same ? [T.dExpected, number.expected] : null,
[T.dShown, [String(number.seg.text ?? ''), same ? h('span', { class: 'muted' }, `(${T.dShownSame})`) : null]],
number.artifact ? [T.dArtifact, [h('code', {}, short(number.artifact)), artifactState(number.artifact)]] : null,
[T.dReport, nodeLabel(number.report.node_id)],
]
@@ -1231,6 +1598,14 @@
document.documentElement.setAttribute('data-verified', '')
}
// 在 node 里载入(tests/test_bundle_shown.py 拿服务端的渲染逐条对照、整份核对):没有页面,交出核对用的纯函数就停
if (typeof document === 'undefined') {
if (typeof module === 'object' && module) {
module.exports = { verify, renderNumber, renderCell, numberTokens, tokenAt, parseJson, Unshown }
}
return
}
// 脚本在 head 里:一开始执行就标上 data-js,样式表据此把「尚未核对」换成「正在核对」;文档解析完再核对
document.documentElement.setAttribute('data-js', '')
const start = () => main().catch((e) => {
+72 -27
View File
@@ -23,6 +23,10 @@
快照随文件附带,数字核对得过)和选出了号码的那份会员消费(只给哈希)
- nan:运行开始事件和门店流水快照里有 json.dumps 写出的裸 NaN(快照里那一列报告没有引用)
- stray:报告自查记下的文档是另一次运行写的
- formats:经营月报,每种显示格式各引用一处:口径卡的千分位加货币单位、换算成万 / 亿、比率显示成百分数、负数、不分组、
小数位与 |.N / |int、pct;流水快照里的文字取值(地区)、DECIMAL 落成的数字文本、负数、换算成万、超过 2^53 的订单号、
ISO 日期时间(文档记下 2 版显示规则)。check-bundle 在它上面只改显示文字做伪造,tests/test_bundle_shown.py 在 node 里
逐个格式核一遍
"""
from __future__ import annotations
@@ -62,6 +66,31 @@ 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"},
]}
#: 经营流水:地区是文字,金额一格是 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 +123,20 @@ def masked_card(crm_id: str) -> dict[str, Any]:
class Sample:
"""一次虚构的已封存运行:工件(原字节)、证据目录、报告文档、事件。masked=True 时口径卡带着取自遮罩列的输入;
regions=True 时会员库另有一次按地区汇总的查询(目录里排在 Q3)。"""
regions=True 时会员库另有一次按地区汇总的查询(目录里排在 Q3)。card、table 换掉口径卡和门店流水的快照(经营月报)。"""
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) -> 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},
@@ -189,62 +220,76 @@ 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() -> dict[str, Any]:
"""经营月报(formats)的页面数据:每种显示格式各引用一处。"""
f = Sample(card=FORMATS_CARD, table=LEDGER)
events, bound = f.events({"write": f.doc(FORMATS, "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()
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)
+1 -1
View File
@@ -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"}
for name, bundle in got.items():
assert browser_manifest(bundle["events"]) == bundle["meta"]["manifest_hash"], name
+367
View File
@@ -0,0 +1,367 @@
"""可验证报告核对正文显示的字(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 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 交来的任务算
HARNESS = r"""
const fs = require('fs')
const { createHash } = require('crypto')
const mod = { exports: {} }
new Function('module', fs.readFileSync(process.argv[1], 'utf8'))(mod)
const v = mod.exports
const input = JSON.parse(fs.readFileSync(0, 'utf8'))
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)
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),
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 })),
}
}
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 = ["", "元", "美元", "%", "%", "人次"]
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:① 到 ④ 都对得上,伪造只可能在 ⑤ 露馅。"""
raw = canonical_json(doc).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, **cite: Any) -> dict[str, Any]:
"""经营月报样本里一个片段的显示文字改成 text(cite.value 不动,cite.rendered 跟着改,cite 另改 cite 里给的几项),
重算全部哈希。"""
from tests.bundle_samples import formats_bundle
bundle = 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 verdicts(bundle: dict[str, Any]) -> dict[str, Any]:
return node("verify", data=bundle, seal=bundle["meta"]["manifest_hash"])
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_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
+135 -17
View File
@@ -10,13 +10,16 @@
// - 断网打开:拦掉一切网络请求,从本地文件打开:各项核对没有不通过、总览通过、没有发出任何请求,数字点开看得到出处;
// - 去掉 crypto.subtle:页面加载前删掉它,照常全部通过,页脚写明用的是本页内置的实现;
// - 篡改:① 一条带规范字符串的事件内容、② 一份嵌入工件的一个字节、③ 元信息里的封存哈希,各自对应的核对项不通过、
// 总览显示不通过;另做一次「哈希全部重算」的伪造:改报告里一个数字的引用值,文档、事件、清单、页面顶部的封存哈希
// 都重算一遍,①–④ 都对得上,只有 ⑤ 拿口径卡、快照对出来不通过,报告标题旁的徽标跟着不通过;
// 总览显示不通过;另做两种「哈希全部重算」的伪造:改报告里一个数字的引用值,或者引用值不动、只改它的显示文字(数字改
// 一位;有引用快照文字的取值时也改一个),文档、事件、清单、页面顶部的封存哈希都重算一遍,①–④ 都对得上,只有 ⑤ 拿
// 口径卡、快照对出来(显示文字按显示规则重新渲染后逐字比对)不通过,原因点名正文显示的字和引用的值,报告标题旁的徽标
// 跟着不通过;
// - 深浅色、窄屏:390 宽不横向溢出,点开的数字面板在窄屏上从底部抽出,深色下底是深的、字是浅的;
// - 样本页:tests/bundle_samples.py 用虚构数据真实生成的几份页面(遮罩快照、两份报告、早期运行、失败结束、先失败后
// 经继续运行跑到成功的早期运行、两条出具
// 判定、服务端缺文件、裸 NaN、别的运行的文档、口径卡有输入取自遮罩列、设了遮罩的数据源上按地区汇总的查询),逐份
// 断言各项结果和原因文案;在上面篡改 ④
// 判定、服务端缺文件、裸 NaN、别的运行的文档、口径卡有输入取自遮罩列、设了遮罩的数据源上按地区汇总的查询、每种显示
// 格式各引用一处的经营月报),逐份断言各项结果和原因文案;经营月报上只改文字取值、数字、换算成万的口径卡引用的显示
// 文字并重算全部哈希,各自变红并点名;在上面篡改 ④
// 引用链(换文档、拿掉报告、多一份没人引用的工件)、⑥ 运行信息(运行结果、类别、出具档位)、拿掉被引用的口径卡、把
// 报告文档标成遮罩、把遮罩卡标成服务端的文件与 id 不一致或解析不出来(服务端对被改过、解析不了的遮罩卡就这么标)、
// 遮罩源上随文件附带的汇总快照改一个字节、它的数字改引用值并重算全部哈希、
@@ -141,6 +144,8 @@ 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') ?? ''),
sha: document.querySelector('[data-sha]')?.dataset.sha ?? null,
seal: document.getElementById('seal-hash')?.textContent ?? '',
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
@@ -155,10 +160,11 @@ const hasItem = (s, check, result, text) => s.items.some((i) => i.check === chec
&& (!text || i.text.includes(text)))
/**
* 「哈希全部重算」的伪造:改报告里一个能核对的数字的引用值,再把文档、引用它的事件、清单哈希、页面顶部的封存哈希
* 全部重算。返回改了哪一处的说明,找不到可改的数字返回 null
* 「哈希全部重算」的伪造:报告里挑一个能核对的数字或取值(引用口径卡或随文件附带的快照,pick(片段) 为真的第一个),
* edit(片段) 改它,再把文档、引用它的事件、清单哈希、页面顶部的封存哈希全部重算。返回改了哪一处的说明(what、所在报告
* node、片段 seg、改之前的显示文字 before),找不到可改的返回 null
*/
function forge(data) {
function rewrite(data, pick, edit) {
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'))
@@ -166,11 +172,12 @@ function forge(data) {
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 && c?.status === 'resolved' && ['metric', 'cell'].includes(c.kind)
&& embedded.has(doc.catalog?.[c.alias]?.artifact) && pick(seg)) hit = seg
}
if (!hit) return null
hit.cite.value += 1
const before = hit.text
edit(hit)
const bytes = Buffer.from(JSON.stringify(doc), 'utf8')
const oldId = docArt.id
const newId = sha(bytes)
@@ -186,7 +193,46 @@ 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 { what: `片段 ${hit.id}(${hit.cite.kind})`, node: data.reports.find((r) => r.doc_artifact === newId)?.node_id,
seg: hit, before }
}
/** 改一个数字的引用值(显示文字不动),重算全部哈希:⑤ 拿口径卡、快照对出来 */
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]))],
]
}
/** 改一份嵌入工件的一个字节(优先改数字),返回说明 */
@@ -332,7 +378,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 {
@@ -408,6 +454,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 +551,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 === 13, Object.keys(samples).join('、'))
await sample('base', async (s, page) => {
check('base:总览通过,有无法核对的项时写「可核对的项全部通过」', s.overall === 'pass' && s.text === '可核对的项全部通过',
@@ -517,7 +575,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 +586,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 +715,33 @@ 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 = '找不到订单号'
if (await order.count()) {
await order.first().click()
detail = await appears(page.locator('#detail')) ? await page.locator('#detail').innerText() : '点开后没有出处面板'
await page.keyboard.press('Escape')
}
check('formats:超过 2^53 的订单号核对通过,出处面板里的值一位不差', detail.includes('202609071234567890')
&& detail.includes('与按显示规则渲染的结果一致'), detail.replace(/\s+/g, ' ').slice(0, 160))
})
// 不执行脚本(附件预览、快速查看):封存哈希、运行 ID、工作流名照样看得到,状态写「尚未核对」,不是一直「正在核对」
{
@@ -804,6 +889,39 @@ 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()
}
}
})
// ---------------------------------------------------------------- 记录页的导出按钮