改动不多,测试全绿,可只剩三个名额时,预订四个也成功了。剩余数量看起来甚至很正常:零。把测试文件的 diff 一起打开,问题才露出来:原来要求抛错的地方,现在只检查结果是不是非负数。

假设让 AI 简化一个计算剩余名额的函数,同时保留原来的接受和拒绝规则。下面的补丁和测试为教程编写,用 Node 内置测试工具就能在本地运行。

先写清楚原来的测试在保护什么

这里假定上游已经检查过 available:它是非负安全整数。函数接收预订数量,返回准确的剩余数量;请求不合法时抛出 RangeError。允许的数量是从 1 到 available 的数字类型安全整数,不做取整、字符串转换,也不接受部分预订。

export function remainingSeats(available, requested) {
  if (
    !Number.isSafeInteger(requested) ||
    requested < 1 ||
    requested > available
  ) {
    throw new RangeError(
      'requested seats must be an integer from 1 to available',
    );
  }
  return available - requested;
}

剩三个名额时,几种结果都可以手算:

请求数量 应有行为 原因
1、2、3 分别返回 2、1、0 完整接受请求
0、-1、1.5 抛出 RangeError 数量不能是零、负数或小数
4 抛出 RangeError 剩余名额不足以满足整个请求
'2'、NaN、Infinity 抛出 RangeError 必须是数字类型的安全整数

没有剩余名额时,请求一个也要报错。注意,返回零已经表示“刚好订完”,不能再拿它表示失败。

审查真实代码时,要从需求、现有调用方和基线版本的测试里确认这些规则。旧断言也可能写错,但修改它之前,得说明究竟要改哪一种行为。这个例子的要求很明确:原来的拒绝规则要保留。

把两份 diff 中删掉的东西放在一起看

补丁把输入检查换成了截断:

 export function remainingSeats(available, requested) {
-  if (
-    !Number.isSafeInteger(requested) ||
-    requested < 1 ||
-    requested > available
-  ) {
-    throw new RangeError(
-      'requested seats must be an integer from 1 to available',
-    );
-  }
-  return available - requested;
+  const count = Math.min(available, Math.max(1, Math.trunc(requested)));
+  return available - count;
 }

传入 (3, 4),局部变量 count 被压成 3,函数返回 0。传入 (3, 1.5),它先取整为 1,最后返回 2。普通请求仍然算对了,但原来应该报错的请求现在也能成功返回。

再看测试。三项修改都用了下面的写法:

-  test('rejects more seats than available', () => {
-    assert.throws(() => remainingSeats(3, 4), RangeError);
+  test('handles more seats than available', () => {
+    assert.ok(remainingSeats(3, 4) >= 0);
   });

输入没变,要求的行为变了。新断言直接调用函数;如果函数还像以前那样抛错,这条测试反而会失败。悄悄接受超额请求的实现,现在可以通过。测试名也从“拒绝”改成了“处理”,看报告时更难发现规则已经被换掉。

零和小数的测试也删掉了异常断言。整个测试文件仍然有五项测试,其中两项正常预订测试完全没动。数一数测试有几条,看不出哪些行为已经没人检查了。

在真实项目里,可以把实现与测试一起展开,再读基线中的原断言:

git diff BASE HEAD -- src/remaining-seats.mjs test/remaining-seats.test.mjs
git show BASE:test/remaining-seats.test.mjs

把版本与路径换成项目里的实际值,记下这次对比的准确版本。如果工作区已经有后续修改,别把工作区里的调用方当成所选提交里的代码。下载包里的 ai-style.patch 可以直接阅读,不需要为例子创建 Git 提交。

每看到一条被删掉的检查,就试着写出一个输入:删掉 requested > available,反例就是 (3, 4)。每看到一条被删掉的断言,就说清它原来要求什么:这个请求必须在调用方继续执行前抛错。这样两边就连起来了。

打开那个一行都没改的调用方

下面的调用方由两个版本共用,补丁没有修改它。为了让例子完全在内存中运行,数量函数和同步的 save 都通过参数传入:

export function createBooking(remainingSeats, save) {
  return function book(available, requested) {
    const remaining = remainingSeats(available, requested);
    const reservation = { seats: requested, remaining };
    save(reservation);
    return reservation;
  };
}

这里有两个数量。remaining 来自计算函数,记录中的 seats 却仍然使用原始的 requested。旧实现要么完整接受这个数量,要么在执行到 save 之前抛错。换成补丁后,计算时用的是三个,保存时写的却是四个。

让同一个调用方接收 (3, 4),实际得到:

使用的实现 外层调用者看到的结果 save 调用
原实现 RangeError 没有调用
补丁 { seats: 4, remaining: 0 } 调用一次,参数就是这个对象

这个例子观察到的是内存中错误的预订记录,以及一次保存调用。它没有连接数据库、扣款,也没有造成真实用户的预订。

在自己的项目里,搜索范围要越过“有改动的文件”列表:

rg -n 'remainingSeats|createBooking' src test

顺着 import 确认调用的是哪个实现,再看调用之后怎么处理:保存原始输入、构造响应,还是捕获某种错误?名字搜不全时,可以配合语言服务的引用查询,继续查看包装函数。代码阅读指南介绍了这些入口的用法。

保留异常检查,再单独观察有没有保存

把原来的三项拒绝测试恢复,就已经能抓住这个补丁。再补一项调用方测试,才能说明为什么不能删掉拒绝行为:

const writes = [];
const book = createBooking(remainingSeats, (row) => writes.push(row));
assert.throws(() => book(3, 4), RangeError);
assert.deepEqual(writes, []);

这里传入的是真实的原实现或补丁,只把 save 换成数组记录器。如果连数量函数也替换成“总是抛错”的桩,恰好绕过了要审查的改动。

这段紧凑的测试会在第一条断言失败后停止。下载包还把“没有尝试保存”单独写成了一项测试,方便一次运行同时看见两个问题:

const writes = [];
const book = createBooking(remainingSeats, (row) => writes.push(row));
try {
  book(3, 4);
} catch {
  // A separate test checks that the error is RangeError.
}
assert.deepEqual(writes, []);

这个空 catch 只用于继续检查副作用。单独保留它会太弱:只要在保存前随便抛一个错误,也能通过。完整测试另外要求抛出 RangeError,检查合法预订准确保存一次,并确认保存失败会传回调用者。所以,把所有请求都拒绝掉,或者什么也不做,同样过不了。

验证套件共 15 项:三项准确的成功返回值,八项非法输入(包括名额售罄),以及四项调用方测试。预期值来自前面写明的规则,不是把补丁里的截断公式复制进断言。

用同一组要求分别跑原实现和补丁

下载完整示例,解压后在其中的 review-ai-generated-tests 文件夹打开终端。需要 Node.js 22 或更新版本,不用安装依赖、连接网络或配置模型服务商。

node run.mjs baseline
node run.mjs proposed-original
node run.mjs proposed-weak
node run.mjs verify-baseline
node run.mjs verify-proposed

每条命令都通过 node:test 注册测试,并按真实测试结果退出。请分别执行:第二条和第五条就是要失败。不要用 && 串起来,否则遇到第一次预期失败就会停。

下面是在 macOS arm64、Node.js v22.22.1 上实际跑出的结果:

模式 实现 / 断言 通过 失败 退出码
baseline 原实现 / 原断言 5 0 0
proposed-original 补丁 / 原断言 2 3 1
proposed-weak 补丁 / 弱化断言 5 0 0
verify-baseline 原实现 / 验证套件 15 0 0
verify-proposed 补丁 / 验证套件 5 10 1

补丁会报 Missing expected exception (RangeError).,意思是预期的异常没有发生。单独检查保存的测试还会显示:实际数组里有 { seats: 4, remaining: 0 },预期却是空数组。执行 node show-caller.mjs 可以直接打印两个版本的调用结果;这个命令只展示结果,没有断言哪一个正确。

需要一个默认全绿的检查命令时,运行 node --test run.mjs,它选择的是 verify-baseline。这里没有把“出现错误结果”写成应该通过的验证:同一套验证始终要求正确行为,补丁运行后确实失败。命令没有使用特定操作系统的 shell 语法,但这次只在 macOS 实测,没有跑 Linux、Windows 矩阵。

把这个做法用到自己的项目时,先保留基线中相关的断言,让它们跑补丁代码,再补上 diff 暗示的最小调用方反例。如果新测试在旧实现和错误补丁上都能通过,回头看输入与断言,确认它有没有区分出那条被改掉的规则。

把失败结果写成能处理的审查意见

这次审查可以这样写:

remainingSeats(3, 4) 原来抛错,改为截断后返回零。未修改的 createBooking 随后保存 { seats: 4, remaining: 0 }。本次修改的三项测试原本要求 RangeError,现在只检查结果非负,刚好放过这个行为。请保留拒绝检查,并验证超额预订不会调用 save。原实现通过全部 15 项验证,补丁失败 10 项。

按当前需求,保留原来的输入校验,恢复异常断言即可。如果产品确实要允许部分预订,就要先定义实际接受的数量怎么传给调用方、用户确认的数量是什么,再一起修改实现、调用方和测试。只在辅助函数里悄悄改一个局部数量,这些问题都还没解决。

示例覆盖同步校验和对同步保存函数的调用,假定 available 已合法。并发抢名额、数据库原子性、重试和权限检查都不在这个范围内,需要到实际集成位置另外验证。

Yenpo 可以帮助你阅读实现、测试与调用方,比较已提交的版本。当前系统要求见安装指南;这套 Node 示例与审查方法不依赖 Yenpo。在 Git 对比中核对好所选版本后,可以这样问:

找出这个补丁删掉的输入接受规则,以及一起修改的断言。继续看依赖这些异常的未修改调用方。每个问题给出具体输入、应有结果和源码位置;基线里原有的问题与新回归分开说明。