我给 cny-uppercase 写了一份标准答案:不参考它的实现,凭规则从零写一遍,再拿两份逐个值对。七十二点四万个值,零分歧。

然后又抓到一个 bug:1234,56 被读成了 123456。大写金额放大了整整百倍,工具没有报错,没有警告,没有提示,输出还是一段格式完美的中文。

这篇要讲的不是工具烂——恰恰相反,它在七十二点四万个值上全部正确。要讲的是那个 bug 藏在哪:藏在一个差分测试结构上就看不到的地方。

补记(写完这篇之后的事):那个逗号问题已经修掉了。工具现在对 1234,56 这个形状返回 null 而不是猜,单金额模式给出专门的提示文案,批量模式标 。修法见文末第 8 节。下面各节记录的仍是写这篇时的状态——工具当时的行为没有变好,是它变了。


1. 先写一份「标准答案」

rmbUppercase 的思路是把整数部分切成四字符一组,逐组走(src/tools/textTools.ts):

const RMB_DIGITS = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖'];
const RMB_SECTIONS = ['', '拾', '佰', '仟'];
const RMB_GROUP_UNITS = ['', '万', '亿', '万亿'];
// 单位锚在「组数」而不是「位数」上——这是它能一路处理到 16 位的原因
const unit = RMB_GROUP_UNITS[groups.length - 1 - idx];

锚在组数而不是位数,是个干净的选择。1 位、3 位、8 位、12 位、16 位的输入都靠同一行代码拿到对的单位:16 位数的最左一组单位是 万亿,3 位数的最左一组单位是 ''。写这段的人没有去数位数,也不需要。

组内的小数位处理同样是分工明确的:

if (d === 0) { if (section) zeroIn = true; continue; }

开头的零不进 zeroIn——那个桥接由另一个判断负责(padded[0] === '0')。两处各管一段,所以我没在组边界上找到任何漏写的「零」。

我不想靠手算验证这个。我另外写了一份参考实现,换一套机器:工具切字符串,参考实现把金额整体解析成一个 BigInt 的分单位,再用反复 div/mod 10⁴ 从算术上导出四位数分组。同一个约定,不同的机器——这样「两份一致」才是证据,不是循环论证。

第一次跑差分,139,711 个分歧。

分歧是我的参考实现错了。 两个独立的错:

一是精度。我把 BigInt * 100n 塞回 Number 去取角分:7214858025209141 × 100 是 7.2×10¹⁷,而 Number.MAX_SAFE_INTEGER 只有 9×10¹⁵。十四位以上的整数从这里开始掉精度,我的参考实现给 16 位的 7214858025209141 算出了「…玖仟壹佰肆拾元捌角捌分」——那个「捌角捌分」是纯噪声。工具说的是「…玖仟壹佰肆拾壹元整」,工具是对的。

二是风格。我的第一版参考实现按「先切 8 位(亿)、再切 4 位」组织,13 位的 5140645332260 于是被写成 伍万壹仟肆佰零陆亿…,而工具写 伍万亿壹仟肆佰零陆亿…。两个字符串是同一个数,但只有后者是规范写法。我一开始把这个判成工具的 bug——因为 亿 是 10⁸,万 × 亿 确实是 10¹²,但中文大写金额按 4-4-4-4 分组,超过 10⁸ 的那一组自己就该叫「万亿」,不是「万×亿」。

顺带一个小坑:这个 Node 版本里 +5n 直接抛 TypeError: Cannot convert a BigInt value to a number,我踩了两次。

第三版参考实现,七十二点四万个值,零分歧。


2. 七十二点四万个值,零分歧

七十二点四万不是暴力枚举,是四个电池:

  • 0 到 20 万全穷举;外加 0 到 2 万、步长 11 的整数配 00–99 的全部角分组合;
  • 15 万个 1–16 位随机整数(LCG,种子 987654),六成带小数;
  • 10⁰ 到 10¹⁵ 每个量级的 10^p10^p+110^p+0110^p+109·10^p+910·10^p2·10^p+200——组边界最容易漏的就是这一层;
  • 十七个选定的四位块 a·10¹² + b·10⁸ + c·10⁴ + d 全组合,覆盖四层「万亿」深度下所有组间的零桥接。

另外 7,344 条结构不变式,零违反:不得出现 零零 有且仅有一个;整数结尾必须是 元整;不得有 零整整整;不得有 亿万亿亿万万 这类重复组单位;字符集只能落在 零壹贰叁肆伍陆柒捌玖拾佰仟万亿元角分整 这十六个字里。

边界也都守得住。前导零:0000零元整,不是裸 ——靠的是 intRaw.replace(/^0+(?=\d)/, '') 那行前瞻。拒绝:-11.2341..51e5123(全角数字)、+1abc、空串,以及 17 位的 10000000000000000,全部返回 null。上限:9999999999999999玖仟玖佰玖拾玖万亿玖仟玖佰玖拾玖亿玖仟玖佰玖拾玖万玖仟玖佰玖拾玖元整,38 个汉字。

这个工具写得好。 组边界、零桥接、角分衔接、前导零、上限拒绝,我扔了七十二点四万个值没找到一处算法错误。下面几段讲的不是它写错的地方,是它没被差分测试看见的地方。


3. 逗号的百年老坑

const t = input.replace(/[¥¥,,\s]/g, '');

这一行把 ASCII 逗号全角逗号,无条件当地位分隔符剥掉了。

在大陆欧洲的写法里,, 是小数点,. 才是千位分隔符:1234,56 的意思是 1 234.56,读作「一千二百三十四点五六」。

工具给出的结果:

1234,56   -> 壹拾贰万叁仟肆佰伍拾陆元整     若按小数逗号,本应是 壹仟贰佰叁拾肆元伍角陆分
100,50    -> 壹万零伍拾元整                本应是 壹佰元零伍角
5,20      -> 伍佰贰拾元整                  本应是 伍元贰角
1234.56   -> 壹仟贰佰叁拾肆元伍角陆分      这个是对的

金额放大 100 倍,工具没有报错,没有警告,没有提示,输出是一段格式完美、可以直接抄上支票的中文大写金额。

对 cny-uppercase 这种工具来说,这是最坏的一种失败形状。它存在的唯一理由,就是把金额写成可以盖章的字。一个静默的 100 倍误差,比一个 NaN 危险得多——NaN 会被看见,这段大写金额会被盖上章。

但先说清楚它可辩护的一面。 这是一个人民币工具,, 当地位分隔符是本地写法,Excel 里复制出来的金额也长这样。工具做了一个选择,而且做了一致:1 234¥1,234.56100¥¥ 100 全按同一套规则处理。严格说,缺陷不在于它选了「逗号 = 千位」,而在于它在输入本来可以合理读作另一种东西的时候,静默地做了那个选择。

单金额和批量两个入口都继承这一点,因为 runBatch 走的是同一个函数。

而且差分测试看不见它。 我的参考实现是照着工具的代码写的,写的时候我已经知道它把逗号当地位分隔符,于是我也写了同一条正则的等价物。两份实现犯了同一个错——同一个错,被差分测试盖章认证为「正确」。

这是这次审计最有用的一条:差分测试比较的是两份实现对同一个决定的执行,它没有能力审计那个决定本身。 一个一致地错的语义,在差分测试眼里和正确完全无法区分。七十万次全过,是「两份实现内部一致」的证据,不是「语义对」的证据。


4. 「正确」是一个约定,不是一个数

我最初的手写期望表里,101000壹拾万零壹仟元整,工具给的是 壹拾万壹仟元整,我标成 FAIL。

工具是对的。规则里有一条可选项:当万位或元位是 0,或者数字中间连续有几个 0、万位元位也都是 0,但千位、角位不是 0 时,中文大写金额里可以只写一个「零」,也可以不写。教科书上最常见的两个例子:¥1,680.32 可以写成「壹仟陆佰捌拾元零叁角贰分」,也可以写成「壹仟陆佰捌拾元叁角贰分」;¥107,000.53 可以是「壹拾万柒仟元零伍角叁分」,也可以是「壹拾万零柒仟元伍角叁分」。

实测:

101000           -> 壹拾万壹仟元整
1001000          -> 壹佰万壹仟元整
10001000         -> 壹仟万壹仟元整
100000001        -> 壹亿零壹元整
100000000000001  -> 壹佰万亿零壹元整
100000000001000  -> 壹佰万亿零壹仟元整

实现一致地选「不写」。在可选条款下这不是 bug——但它意味着「大写金额正确」不是一个布尔量,而是一个约定集合。教科书给的那二十个样例用例不是测试套件,是约定样本。

真正模糊的是一档我不打算硬下结论的地方。整数部分为 0 时,工具把「零元」整个丢掉:

0.1  -> 壹角
0.5  -> 伍角
0.05 -> 伍分
0.99 -> 玖角玖分

「到分为止,分后面不写整」这条工具守得对(0.01壹分,没有「整」)。但可选条款的措辞是「…但角位不是 0 时」,0.50 落在里面,0.05 的角位是 0,严格读法下可能是「零元零伍分」。我试着去核对条文原文,没拿到可靠的出处,所以上面这句是判断不是断言——规则对「不足一元的金额要不要保留零元前缀」这个问题,我没找到明确说法,实践中两种写法都在用。

工具选了一个,选了就一直选。 这跟逗号那个问题正好相反:逗号那边是选了一个然后闭嘴,这一边至少是选了一个然后稳定。


5. 批量模式的沉默

runBatch('100\n10.05\nabc\n2000000.05\n\n0.5', ...)
-> "100 → 壹佰元整\n10.05 → 壹拾元零伍分\nabc → ✗\n2000000.05 → 贰佰万元零伍分\n0.5 → 伍角"

六行进,五行出。空行被静默丢掉,abc 标了个 ,没说是格式不对、太长、还是别的原因。

跟逗号的 100 倍比起来这是小事。但形状一样:这个工具的工作就是让金额可读,而它在这个模式下选择沉默。对照是 17 位的 10000000000000000,它返回 null,报错文案是「请输入有效金额:纯数字、最多两位小数、小于 10^16。」——这句话是准确的、诚实的。

16 位也是个真实的天花板:10¹⁶ 是十万万亿,再往上中文大写就需要「京」或「兆」——这套字符体系自己到头了。工具在 16 位停手并且明说,比悄悄溢出强得多。


6. 一个由产品约束长出来的解析器

src/tools/finance.ts 里这个工具没有预填样例,注释写得很明白:

// No prefilled sample on purpose: the live transform would render the
// Chinese uppercase result into the output box on first paint, which
// the English-view i18n scan reads as a leak. The tool's output is
// Chinese by definition — it should only appear once the visitor
// enters an amount (same exemption shape as converters/weight's
// 市斤/两, which live in labels rather than control values).
placeholder: 'e.g. 1234567.89',

解析器的输入面是被一个渲染约束决定的:不是语法定了它什么,是页面首屏要变成什么样定了它。1234567.89 住进了 placeholder,而不是住进 textarea

这类约束在工具代码里通常留不下痕迹,这条注释把它留下来了。看源码的人因此能知道:输入框为什么是空的——不是漏填,是填了会触发英文视图的中文泄漏扫描。


7. 对照能证明什么,不能证明什么

差分测试拦得住两份实现的分歧,拦不住两份实现对错同一个错。

这次审计里可复用的几件事:

  • 参考实现必须换机器。 切字符串对反复 div/mod,是这次能抓出我自己两个错的唯一原因。照抄一份「更干净的版本」不会产生任何新信息——那是在给同一份假设盖第二次章。
  • 差分测试的结论要说成它真的能证明的东西。 「七十万个值零分歧」只能证明两份实现内部一致。想证明语义对,需要一份跟代码无关的真源:规则原文、规范条文,或者一个已知正确答案的独立来源。
  • 一致性是危险的默认值。 工具在可选项条款下一律选「不写零」,在逗号上一律当千位分隔符。两个选择都一致,一个无害,一个放大 100 倍。一致本身不构成安全性——需要的是对歧义输入有反应,而不是对歧义输入没反应。
  • 手写期望表要现算,不要手数。 我这次的期望表把 10¹¹ 写成了「壹佰亿」——亿是 10⁸,10¹¹ 是壹仟亿。上一篇数值审计里我犯过同一类错,把继承下来的 1/80 边界重推到 1/40 才发现;这次是自己在数位数上手算错。位数这类事,让程序算。
  • 别把「通过了大量测试」当成「对」。 七十万次全过,然后一个静默百倍误差。覆盖是二维的:值域宽度 × 语义正确性。

8. 后来的修法

修法不是把逗号改按小数点处理,也不是把逗号一律拒掉——那会挡掉 1,234,在本地它确实是千位分隔。解析拆成两步,先判「不是数」,再判「两种读法」:

export type RmbProblem = 'format' | 'comma';

const s = input.replace(/[¥¥\s]/g, '');   // 货币符号和空格是装饰,剥掉
const t = s.replace(/[,,]/g, '');           // 再剥逗号,用它过格式校验
if (!/^\d{1,16}(\.\d{1,2})?$/.test(t)) return { problem: 'format' };
// 整串没有点,且逗号只带一到两位 → 两种读法,都不猜
if (!s.includes('.') && /[,,]\d{1,2}$/.test(s)) return { problem: 'comma' };

歧义优先于格式:abc,56 返回 format(本来就不是数),1234,56 返回 comma(是个数,但读法定不了)。

触发规则是保守的。1,2341,234.56 照转——逗号后面是三位,或者整串任意位置有个点,都只有一种读法。只有「整串没有点,且逗号带一到两位」才判歧义。1,23,456 照转成 123456,反正那是印度分组法的写法。

rmbUppercase 的签名没动,还是 string | null,因为批量模式走的就是这个契约,runBatchnull——批量模式一直是「不猜」的,只是没把原因说出来。新增的 rmbUppercaseProblem 单独给拒绝原因,只有单金额模式用它:comma 配一条专门的文案,写明两种读法各得出什么、相差 100 倍、工具没有替用户选;format 还是原来那句「请输入有效金额」。

顺手改了另一处同形状的问题:color-converter 的对比度预览写死两位小数,#006ffb 在那里印 4.50:1,而旁边 4.5:1 那一行判定是 。换成和独立检查器同一个自适应位数循环后印 4.4999:1。病和这个一样——工具把一个数字印得比它敢判定的更精确——只是代价不是 100 倍金额,而是一个自相矛盾的对号。

第 3 节那句结论因此要补一条:差分测试看不见语义决定,代码评审通常也看不见,除非读代码的人先知道逗号有两种读法。这次是知道之后才改的,不是任何测试抓出来的。

相关工具:cny-uppercaseregex-testercsv-json-converter。全部本地运算,数据不出浏览器。