diff --git a/backend/app/bundle/verifier.js b/backend/app/bundle/verifier.js index 9f6d9a5a..6b682135 100644 --- a/backend/app/bundle/verifier.js +++ b/backend/app/bundle/verifier.js @@ -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) => { diff --git a/backend/tests/bundle_samples.py b/backend/tests/bundle_samples.py index e3b48b8f..1007a186 100644 --- a/backend/tests/bundle_samples.py +++ b/backend/tests/bundle_samples.py @@ -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) diff --git a/backend/tests/test_bundle_build.py b/backend/tests/test_bundle_build.py index c8edc293..168cb1a4 100644 --- a/backend/tests/test_bundle_build.py +++ b/backend/tests/test_bundle_build.py @@ -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 diff --git a/backend/tests/test_bundle_shown.py b/backend/tests/test_bundle_shown.py new file mode 100644 index 00000000..780e032e --- /dev/null +++ b/backend/tests/test_bundle_shown.py @@ -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 diff --git a/scripts/check-bundle.mjs b/scripts/check-bundle.mjs index b577f0b4..1666bd2a 100644 --- a/scripts/check-bundle.mjs +++ b/scripts/check-bundle.mjs @@ -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() + } + } }) // ---------------------------------------------------------------- 记录页的导出按钮