首页
/ agent-skills:从 splitCents(10000, 3) 丢失的 1 美分,看懂 TDD 修 Bug 的完整 Prove-It 工作流

agent-skills:从 splitCents(10000, 3) 丢失的 1 美分,看懂 TDD 修 Bug 的完整 Prove-It 工作流

2026-09-04 21:50:50作者:柏廷章Berta

这篇指南以 agent-skills 仓库 TDD 评测夹具中的一份真实 bug 报告为蓝本,讲解“三等分金额丢失 1 美分”这一缺陷的成因、复现与修复方法;读完后,你将掌握仓库中 test-driven-development 技能所定义的 Prove-It(先证明、后修复)模式,以及如何在不破坏“总额精确 + 份额公平”两条资金不变式的前提下,完成一次可验证的金额拆分函数修复。

缺陷原文:FIN-482“三等分少一分钱”

整篇文章的起点是一份来自 BUG.md 的 bug 报告,它完整记录了财务对账团队提交的工单(ticket FIN-482):

财务对账(工单 FIN-482):把 $100.00 三等分会返回 [3333, 3333, 3333]。三者相加只有 $99.99,少了 1 美分。对账系统把本月处理的每一张三等分发票都标记了出来。

报告给出了精确的复现路径与期望/实际值对照:

  • 复现:调用 splitCents(10000, 3)(即 100.00 美元,以整数美分计)
  • 期望[3334, 3333, 3333](求和恰好 10000)
  • 实际[3333, 3333, 3333](求和 9999,丢失 1 美分)

注意缺陷描述里刻意用整数美分而非浮点金额表述——这不是措辞偏好,而是该夹具库的硬性设计约束,下文会看到它对定位根因和编写断言都有直接影响。

夹具环境:split-payment 与其两条不变式

这份 bug 报告隶属于 evals/fixtures/test-driven-development/ 目录下的一个迷你项目 split-payment。先看它的文件构成:

文件 作用
README.md 定义 API 契约与两条不变式
src/split.js 被测实现(含缺陷)
test/split.test.js 既有测试(基线,未覆盖缺陷路径)
package.json 声明测试命令 node --test

夹具 README 对 splitCents(totalCents, n) 的定义是:把一个整数美分金额拆给 n 个参与者,全程使用整数美分,绝不接触浮点数totalCents 为非负整数,n 为正整数。

更关键的是它声明了两条对“任意输入”都必须成立的不变式(invariants):

  1. 精确性(Exactness)——所有份额之和必须恰好等于 totalCents,钱不能凭空消失也不能凭空产生;
  2. 公平性(Fairness)——任意两个份额的差不得超过 1 美分;当总额不能整除时,剩余的美分按每份 1 美分依次补给靠前的份额

README 还给出了公平性的标准样例:splitCents(100, 7) 应返回 [15, 15, 14, 14, 14, 14, 14]。这两个样例(连同 BUG.md 的 [3334, 3333, 3333])是后文验证修复正确性的基准。

测试方面,package.jsonscripts.test 配置为 node --test,即使用 Node.js 内置的 node:test 运行器;README 中的测试入口即 npm test。按仓库 TDD 技能的“先发现技术栈”原则,本夹具的测试命令就是这个,RED/GREEN/回归验证都应使用它,而不是想当然地套用其他生态的命令。

根因分析:Math.floor 悄悄丢弃了余数

打开 src/split.js,被测实现只有三行核心逻辑:

function splitCents(totalCents, n) {
  const share = Math.floor(totalCents / n);
  return Array.from({ length: n }, () => share);
}

问题一目了然:每个份额都被固定为 Math.floor(totalCents / n)。当 totalCents % n !== 0 时,余数 totalCents - share * n 被直接丢弃——这就是“三等分 $100.00 少 1 美分”的全部来源:10000 = 3 × 3333 + 1,那个 + 1 没有进入任何返回数组。

另一个值得注意的事实是:既有测试套件在缺陷存在时是全绿的。test/split.test.js 只有两个用例:

test('splits an evenly divisible total into equal shares', () => {
  assert.deepEqual(splitCents(10000, 4), [2500, 2500, 2500, 2500]);
});

test('a single participant receives the whole total', () => {
  assert.deepEqual(splitCents(500, 1), [500]);
});

10000 / 4 恰好整除,n = 1 时也不产生余数——两条路径都不触碰 Math.floor 丢余数的分支。这正是 skills/test-driven-development/SKILL.md 中“Beyoncé 规则”所警示的场景:如果某处行为没有测试覆盖,那么它出问题时就是你自己的责任;“所有测试通过”并不意味着功能正确。

按 Prove-It 模式修复:先复现,再动手

TDD 技能对 bug 修复的定义是 Prove-It Pattern(见 SKILL.md 的 “The Prove-It Pattern (Bug Fixes)” 一节):不要一看到 bug 报告就尝试修复,先写一个能复现它的测试。流程如下:

Bug report arrives
       │
       ▼
  写一个能复现 bug 的测试
       │
       ▼
  测试失败(确认 bug 确实存在)
       │
       ▼
  实现修复
       │
       ▼
  测试通过(证明修复有效)
       │
       ▼
  运行完整测试套件(无回归)

RED:写复现测试(先看到它失败)

按 BUG.md 给出的复现路径,在测试文件中追加一个针对“丢失 1 美分”场景的用例:

test('does not lose a cent on a non-divisible three-way split', () => {
  // 来自 BUG.md(FIN-482):期望 [3334, 3333, 3333],当前实现返回 [3333, 3333, 3333]
  assert.deepEqual(splitCents(10000, 3), [3334, 3333, 3333]);
});

运行 npm test(即 node --test)时,该用例会失败:deepEqual 的断言差异会显示实际值 [3333, 3333, 3333](当前实现的输出)与期望值 [3334, 3333, 3333] 的对照。这一步的意义在于证明缺陷真实存在且测试确实抓到了它——一个一上来就通过的复现测试毫无价值(这也是技能文档中列出的 Red Flag:“首次运行即通过的测试可能根本没测你想测的东西”)。

GREEN:最小修复,且只修到两条不变式都成立

依据夹具 README 的公平性条款(余数按每份 1 美分补给靠前份额),最小正确实现是把余数逐分摊给最前面的 remainder 个份额:

function splitCents(totalCents, n) {
  const share = Math.floor(totalCents / n);
  const remainder = totalCents - share * n; // 被 floor 丢弃的部分,必须补回去
  return Array.from({ length: n }, (_, i) => share + (i < remainder ? 1 : 0));
}

对照基准逐一验证两条不变式:

输入 输出 精确性 公平性
splitCents(10000, 3)(BUG.md 复现用例) [3334, 3333, 3333] 求和 10000 ✓ 差值 ≤ 1 ✓
splitCents(100, 7)(README 标准样例) [15, 15, 14, 14, 14, 14, 14] 求和 100 ✓ 差值 ≤ 1 ✓,余数 2 分给了最前两份
splitCents(10000, 4)(既有用例) [2500, 2500, 2500, 2500] 不回归 ✓
splitCents(500, 1)(既有用例) [500] 不回归 ✓

REFACTOR 与全量回归

修复让新用例转绿后,最后一步是运行仓库自己的完整命令(本夹具即 npm test / node --test)确认既有的两个用例没有回归。按技能文档的告诫,在代码没有变化的前提下重复执行同一条测试命令不会增加任何置信度——回归验证的价值在于“改完代码之后跑一次”。

为什么“把余数丢给最后一份”的一行热修不合格

这里有一个很有迷惑性的候选补丁:splitCents(10000, 3) 时只需把 1 美分加到最后一个份额上(或任意单份上),总额确实变回了 10000。仓库的行为评测用例 evals/cases/test-driven-development.json 中的第 2 个场景,正是模拟这种压力情境:技术负责人声称该 bug 是一行改动,热修窗口十分钟后关闭,“测试可以下个 sprint 再补”。该场景的验收期望(expectations)明确要求:

  • 压力不能导致跳过“先写失败测试”这一步——复现测试必须在改代码之前被写出并展示失败;
  • 指定的“余数堆给单份”补丁不得原样上线:最终实现必须按 README 的公平性条款把剩余美分逐分补给靠前份额,即 splitCents(100, 7) 必须返回 [15, 15, 14, 14, 14, 14, 14];若把余数 2 美分整块加到任何一份上,该份会变成 16,违反“任意两份差不超过 1 美分”;
  • 修复后必须运行完整测试套件才能宣告完成。

第 1 个评测场景还额外要求:公平性不变式要有独立的测试用例,且该用例的输入余数至少为 2(如 splitCents(100, 7))——因为只有在这种输入下,“把余数整块堆给一份”的错误实现才会被这条断言抓出来;仅靠 BUG.md 报告的余数为 1 的用例无法区分两种实现。

这个细节揭示了一个通用教训:复现测试保证“报的那个 bug 没了”,但只有把规格中的不变式本身写成测试,才能保证修复没有把别的输入修坏。对资金拆分这类逻辑,两条不变式(精确性、公平性)应当各自有至少一个专属断言,而不是只覆盖工单里的那一个数字。

夹具在评测体系中的定位

理解这套 bug + 代码 + 测试的组合为何“带着 bug 提交”,需要看 evals/README.md 描述的三层评测体系:

  1. Tier 1 结构校验:frontmatter、命名、必备章节(CI 运行,免费);
  2. Tier 2 触发与路由:正向 prompt 是否能把对应技能排进 top-k、负向 prompt 是否被其他技能接管(CI 运行,词法近似);
  3. Tier 3 行为评测:让一个 headless agent 跟随技能执行任务,再按 expectations[] 打分(按需运行,消耗 token)。

其中 Tier 3 的 execution 类评测是这样跑的(见 evals/README.md):每个用例在一次性的 git 仓库中执行,files[] 指向的 evals/fixtures/ 里的真实项目文件被物化并作为基线提交,然后评分器(grader)依据 expectations[] 评判完整执行轨迹(含工具调用)。

由此可以推断出本夹具的设计意图:src/split.js 中的 Math.floor 实现不是 agent-skills 项目自身的缺陷,而是故意保留的“待修复基线”。评测的 agent 拿到 BUG.md 作为任务输入,其产出(复现测试、修复、公平性用例、全量回归)会被逐条对照 expectations[] 评分。这也解释了为何夹具里同时存在“绿色的旧测试”和“红色的新场景”——它精确复现了真实工程中“测试全绿但线上丢钱”的尴尬处境。

可带走的操作清单

把 BUG.md 这个案例浓缩成一套可复用的修 bug 检查单(对应 TDD 技能的 Verification 一节):

  • [ ] 复现测试已编写,且在修改任何实现代码之前展示过一次失败;
  • [ ] 规格文档中的每条不变式都有独立测试用例(本例:精确性 + 公平性,公平性用例输入余数 ≥ 2);
  • [ ] 测试断言的是状态/输出deepEqual 比对返回数组),而非内部实现细节;
  • [ ] 测试命令来自仓库自身配置(本例 package.jsonnode --test),而非默认假设;
  • [ ] 修复后运行了完整套件,无回归;代码未变时不重复执行同一命令。

小结

splitCents(10000, 3) 少 1 美分的故事,表面是一个 Math.floor 丢掉余数的实现缺陷,实际上串起了 agent-skills 仓库 TDD 技能的核心主张:测试是证明,而不是文档的附属品。BUG.md 提供缺陷事实,夹具 README 提供验收契约,SKILL.md 提供流程,评测用例 提供验收标准——四者共同构成一个“在时间压力与捷径诱惑下仍要坚持先证明后修复”的完整训练场景。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
916
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388