在 AI 辅助编程日益普及的今天,一个关键问题浮现:在 AI 生成代码的流程中,有什么比"写代码"本身更重要? 本文通过"后选标红"会议室冲突检测功能的实现过程,展示测试用例如何成为 AI 代码与人类代码的共同"锚点"。
一、写在前面:AI 写代码的时代,我们还需要什么?
过去一年,AI 编码能力突飞猛进,各家大模型可以在几秒内生成数百行可运行的代码。然而,一个经常被忽视的事实是:AI 生成的代码"看起来对"和"实际对"之间,存在巨大鸿沟。
这个鸿沟怎么填补?答案不是"写更多代码",而是写更好的测试。
当测试用例足够完整时,谁来写实现代码已经不重要了。测试用例才是真正的"需求规范"。
二、业务需求:"后选标红"是什么?
在会议室预约系统中,当多个会议时间冲突时,有两种标红策略:
- 冲突均标红:所有冲突的会议都被标记(TDD实战-会议室冲突检测的红绿重构循环)
- 后选标红:只有后选入的会议才被标记,先选的保持正常
为什么会有"后选标红"? 因为用户体验更友好:用户先安排好的会议不应因为后面添加的会议而突然变红,只有新操作才触发冲突提示。
核心规则
新会议与已有会议冲突 → 只标红新会议。移除会议后,剩余会议重新评估,保证每个冲突组至少有一个正常。
选定的具体场景:三个会议交叉重叠
| 会议 | 开始时间 | 结束时间 |
|---|---|---|
| Meeting 1 | 08:00 | 09:00 |
| Meeting 2 | 08:30 | 09:30 |
| Meeting 3 | 09:00 | 10:00 |
时间关系:1-2 重叠,2-3 重叠,1-3 不重叠。
Meeting 1 ████████░░░░░░░░░░░░
Meeting 2 ░░░░████████░░░░░░░░
Meeting 3 ░░░░░░░░░████████████
08:00 09:00 10:00
场景一:按 1→2→3 顺序添加
| 用例 | 操作 | 预期 |
|---|---|---|
| 1A 2A 3A => 2x 3x | 全部放 A | 1 正常,2、3 冲突 |
| -> 1 null => 2x | 移出 1 | 2 冲突,1、3 正常 |
| -> 2 null => | 移出 2 | 全部正常 |
| -> 3 null => 2x | 移出 3 | 2 冲突,1、3 正常 |
| -> 1 B => 2x | 1 移到 B | 2 冲突,1、3 正常 |
| -> 2 B => | 2 移到 B | 全部正常 |
| -> 3 B => 2x | 3 移到 B | 2 冲突,1、3 正常 |
场景二:按 2→1→3 顺序添加
| 用例 | 操作 | 预期 |
|---|---|---|
| 2A 1A 3A => 1x 3x | 全部放 A | 2 正常,1、3 冲突 |
| -> 1 null => 3x | 移出 1 | 1、2 正常,3 冲突 |
| -> 2 null => | 移出 2 | 全部正常 |
| -> 3 null => 1x | 移出 3 | 1 冲突,2、3 正常 |
| -> 1 B => 3x | 1 移到 B | 1、2 正常,3 冲突 |
| -> 2 B => | 2 移到 B | 全部正常 |
| -> 3 B => 1x | 3 移到 B | 1 冲突,2、3 正常 |
三、补齐测试用例
在写任何实现代码之前——无论是我们自己写,还是让 AI 生成——先把测试用例变成可执行的代码。
具体如何补齐测试用例,请参考上一篇文章 TDD实战-会议室冲突检测的红绿重构循环。
四、AI 实现:让大模型生成代码
现在,将同一套测试用例作为"需求规范"输入给 AI。以下是 AI 生成的会议室移除时的核心逻辑:
export default class LastConflictManager {
removeMeetingFromRoom(meeting, roomId) {
const roomMeetings = this.getRoomMeetings(meeting.date, roomId)
const index = roomMeetings.findIndex(m => m.id === meeting.id)
if (index === -1) return
roomMeetings.splice(index, 1)
roomMeetings.forEach(m => { m.isConflict = false })
// Stage 1: 左侧反向扫描
for (let i = index - 1; i >= 0; i--) {
for (let j = i - 1; j >= 0; j--) {
if (this.hasConflict(roomMeetings[i], roomMeetings[j]))
roomMeetings[i].isConflict = true
}
}
// Stage 2: 右侧扫描(区分有序/无序)
const isTimeOrdered = (() => {
for (let k = index + 1; k < roomMeetings.length; k++)
if (dayjs(roomMeetings[k-1].start).isAfter(dayjs(roomMeetings[k].start)))
return false
return true
})()
if (isTimeOrdered) {
for (let i = index; i < roomMeetings.length; i++)
for (let j = i + 1; j < roomMeetings.length; j++)
if (this.hasConflict(roomMeetings[i], roomMeetings[j]))
roomMeetings[i].isConflict = true
} else {
for (let i = roomMeetings.length - 1; i >= index; i--)
for (let j = i - 1; j >= index; j--)
if (this.hasConflict(roomMeetings[i], roomMeetings[j]))
roomMeetings[i].isConflict = true
}
// Stage 3: 跨侧冲突,标记右侧
for (let i = 0; i < index; i++)
for (let j = index; j < roomMeetings.length; j++)
if (this.hasConflict(roomMeetings[i], roomMeetings[j]))
roomMeetings[j].isConflict = true
}
}
removeMeetingFromRoom 方法的实现非常复杂,包含三个扫描阶段:
- 左侧反向扫描:从断点向前遍历,冲突时标记当前会议(即更靠近断点的)。
- 右侧分叉:先判断剩余会议是否时间有序,然后选择正向或反向扫描。
- 跨侧冲突:左侧与右侧比较时,标记右侧会议。
逻辑复杂,难以直接判断是否正确。测试用例会替我们验证。
五、人类实现:用"冲突分组"让逻辑更清晰
人类手写的实现如下:
class LastConflictManager {
// 存储策略:新会议插入数组头部
addMeetingToRoom(meeting, roomId) {
const { date } = meeting
const roomMeetings = this.getRoomMeetings(date, roomId)
const hasConflictFlag = roomMeetings.some(m => this.hasConflict(m, meeting))
meeting.isConflict = hasConflictFlag
roomMeetings.unshift(meeting)
}
// 分组 + 翻绿策略
removeMeetingFromRoom(meeting, roomId) {
const { date } = meeting
const roomMeetings = this.getRoomMeetings(date, roomId)
const index = roomMeetings.findIndex(m => m.id === meeting.id)
if (index !== -1) roomMeetings.splice(index, 1)
this.resolveConflicts(roomMeetings)
}
private resolveConflicts(roomMeetings: Meeting[]): void {
const groups = this.groupMeetings(roomMeetings)
groups.forEach(group => {
if (group.meetings.every(m => m.isConflict)) {
const firstMeeting = roomMeetings.find(m =>
group.meetings.map(g => g.id).includes(m.id)
)
if (firstMeeting) firstMeeting.isConflict = false
}
})
}
sortMeetings(meetings) {
return meetings.map(m => ({ ...m })).sort((a, b) => {
if (a.start === b.start)
return dayjs(a.end).isBefore(dayjs(b.end)) ? -1 : 1
return dayjs(a.start).isBefore(dayjs(b.start)) ? -1 : 1
})
}
groupMeetings(meetings) {
const sorted = this.sortMeetings(meetings)
const result = []
let currentGroup = null
sorted.forEach(event => {
if (!currentGroup || dayjs(event.start).isSameOrAfter(currentGroup.end)) {
currentGroup = {
meetings: [event],
end: event.end,
}
result.push(currentGroup)
} else if (dayjs(event.start).isBefore(currentGroup.end)) {
currentGroup.meetings.push(event)
if (dayjs(event.end).isAfter(currentGroup.end)) {
currentGroup.end = event.end
}
}
})
return result
}
}
其中,sortMeetings 和 groupMeetings 方法来自于之前的文章 如何优雅展示日历中的重叠日程?三步搞定复杂布局。
人类实现的思路与 AI 完全不同:
- 存储策略:用
unshift(插入到数组头部),而非push(追加到尾部)。find从数组开头查找,找到的就是后添加的会议。 - 移除策略:不搞三段扫描,而是用"冲突分组"。先把会议按时间重叠程度归组,每组内部如果全部标红,就把第一个恢复为正常。
人类之所以能想得更简洁,是因为抓住了一个关键不变量:unshift 使得"添加顺序"与"数组位置"绑定,find 天然帮我们识别了"谁后选"。
尽管两者采用了完全不同的数据结构和算法,它们都通过了完全相同的 72 个测试用例。
这意味着什么?如果你只看代码质量,人类写的更优。但站在业务正确性的角度,两者的产出是等价的——因为测试用例定义了"正确行为"。
六、核心结论:测试才是真正的"需求规范"
1. 测试用例 = 可执行的规格文档
72 个测试用例精确地定义了这个功能在每一个操作下的预期行为。它们比任何需求文档都更精确、更完整、更可信。
2. AI 生成代码的质量 = 测试用例的质量
反过来也成立:
测试用例写得多好,AI 生成的代码就有多好。
AI 缺乏对业务上下文的理解,只能根据输入的规则描述输出代码。如果测试用例覆盖了所有边缘情况,AI 即使生成了复杂的实现,它也至少是正确的。反之,如果测试用例有缺口,对应的错误就会悄悄溜进生产环境。
3. 测试先行比谁写代码更重要
回顾整个流程:
需求分析 → 编写测试(红)
↓
AI/人类编写代码(绿)
↓
重构优化
最关键的步骤不是"写代码",而是"写测试"。 无论是 AI 还是人类,只要测试用例覆盖得足够完整,实现代码的正确性就有保障。
七、结语
回到开头的问题:在 AI 编码时代,我们还需要什么?
我们需要更好的测试。 AI 编码降低了"写代码"的门槛,但"定义正确行为"的门槛并没有降低。72 个测试用例就像 72 个探照灯,照到哪里,代码就在哪里是安全的。
这个项目最终形成了有趣的对比:
- AI 实现:逻辑复杂,但正确。
- 人类实现:逻辑简洁,也正确。
两者代码风格差异很大,但都通过同一套测试,回答同一个问题:会议室冲突检测该怎么工作?
它们给出了不同的答案,却得到了相同的分数。因为评分标准——测试用例——是事先定好的。
在 AI 编码时代,写测试不再是"为了验证代码",而是"为了定义问题"。如果你能清晰地定义问题,那么答案——无论是 AI 给的还是自己写的——都不再重要。因为正确的测试自然会把代码引向正确的方向。
附录:完整测试用例
两个会议
| 会议 | 开始时间 | 结束时间 |
|---|---|---|
| Meeting 1 | 08:00 | 09:00 |
| Meeting 2 | 08:30 | 10:00 |
时间关系:Meeting 1 和 Meeting 2 互相重叠。
| 用例名称 | 操作步骤 | 预期结果 |
|---|---|---|
1 A 2 A => 2 x | Meeting 1 分配到 A,Meeting 2 分配到 A | Meeting 1 正常,Meeting 2 冲突 |
1 A 2 A -> 1 null => | 上述基础上,Meeting 1 移出 | 两个会议都正常 |
1 A 2 A -> 1 B => | 上述基础上,Meeting 1 移到 B | 两个会议都正常 |
1 A 2 A -> 2 null => | 上述基础上,Meeting 2 移出 | 两个会议都正常 |
1 A 2 A -> 2 B => | 上述基础上,Meeting 2 移到 B | 两个会议都正常 |
三个会议 - 全重叠
| 会议 | 开始时间 | 结束时间 |
|---|---|---|
| Meeting 1 | 08:00 | 09:30 |
| Meeting 2 | 08:30 | 09:30 |
| Meeting 3 | 09:00 | 10:00 |
时间关系:三个会议两两之间都互相重叠。
| 用例名称 | 操作步骤 | 预期结果 |
|---|---|---|
1 A 2 A 3 A => 2 x 3 x | 初始状态 | Meeting 1 正常,Meeting 2 冲突,Meeting 3 冲突 |
1 A 2 A 3 A -> 1 null => 2 x 3 x | Meeting 1 移出 | Meeting 1 正常,Meeting 2 冲突,Meeting 3 冲突 |
1 A 2 A 3 A -> 2 null => 3 x | Meeting 2 移出 | Meeting 1 正常,Meeting 2 正常,Meeting 3 冲突 |
1 A 2 A 3 A -> 3 null => 2 x | Meeting 3 移出 | Meeting 1 正常,Meeting 2 冲突,Meeting 3 正常 |
1 A 2 A 3 A -> 1 B => 2 x | Meeting 1 移到 B | Meeting 1 正常,Meeting 2 冲突,Meeting 3 冲突 |
1 A 2 A 3 A -> 2 B => 3 x | Meeting 2 移到 B | Meeting 1 正常,Meeting 2 正常,Meeting 3 冲突 |
1 A 2 A 3 A -> 3 B => 2 x | Meeting 3 移到 B | Meeting 1 正常,Meeting 2 冲突,Meeting 3 正常 |
三个会议 - 交叉重叠
| 会议 | 开始时间 | 结束时间 |
|---|---|---|
| Meeting 1 | 08:00 | 09:00 |
| Meeting 2 | 08:30 | 09:30 |
| Meeting 3 | 09:00 | 10:00 |
时间关系:Meeting 1 与 Meeting 2 重叠,Meeting 2 与 Meeting 3 重叠,Meeting 1 与 Meeting 3 不重叠(首尾相接)。
场景一:1 A 2 A 3 A(按 1、2、3 顺序分配到会议室 A)
| 用例名称 | 操作步骤 | 预期结果 |
|---|---|---|
1 A 2 A 3 A => 2 x 3 x | 初始状态 | Meeting 1 正常,Meeting 2 冲突,Meeting 3 冲突 |
1 A 2 A 3 A -> 1 null => 2 x | Meeting 1 移出 | Meeting 1 正常,Meeting 2 冲突,Meeting 3 正常 |
1 A 2 A 3 A -> 2 null => | Meeting 2 移出 | 三个会议都正常 |
1 A 2 A 3 A -> 3 null => 2 x | Meeting 3 移出 | Meeting 1 正常,Meeting 2 冲突,Meeting 3 正常 |
1 A 2 A 3 A -> 1 B => 2 x | Meeting 1 移到 B | Meeting 1 正常,Meeting 2 冲突,Meeting 3 正常 |
1 A 2 A 3 A -> 2 B => | Meeting 2 移到 B | 三个会议都正常 |
1 A 2 A 3 A -> 3 B => 2 x | Meeting 3 移到 B | Meeting 1 正常,Meeting 2 冲突,Meeting 3 正常 |
场景二:2 A 1 A 3 A(按 2、1、3 顺序分配到会议室 A)
| 用例名称 | 操作步骤 | 预期结果 |
|---|---|---|
2 A 1 A 3 A => 1 x 3 x | 初始状态 | Meeting 1 冲突,Meeting 2 正常,Meeting 3 冲突 |
2 A 1 A 3 A -> 1 null => 1 x | Meeting 1 移出 | Meeting 1 正常,Meeting 2 正常,Meeting 3 冲突 |
2 A 1 A 3 A -> 2 null => | Meeting 2 移出 | 三个会议都正常 |
2 A 1 A 3 A -> 3 null => 1 x | Meeting 3 移出 | Meeting 1 冲突,Meeting 2 正常,Meeting 3 正常 |
2 A 1 A 3 A -> 1 B => 2 x | Meeting 1 移到 B | Meeting 1 正常,Meeting 2 正常,Meeting 3 冲突 |
2 A 1 A 3 A -> 2 B => | Meeting 2 移到 B | 三个会议都正常 |
2 A 1 A 3 A -> 3 B => 1 x | Meeting 3 移到 B | Meeting 1 冲突,Meeting 2 正常,Meeting 3 正常 |
四个会议 - 链式重叠
| 会议 | 开始时间 | 结束时间 |
|---|---|---|
| Meeting 1 | 08:00 | 09:00 |
| Meeting 2 | 08:30 | 09:30 |
| Meeting 3 | 09:00 | 10:00 |
| Meeting 4 | 09:30 | 10:30 |
时间关系:链式重叠,1-2 重叠,2-3 重叠,3-4 重叠,1-3、1-4、2-4 不重叠。
场景一:1 A 2 A 3 A 4 A(按时间顺序分配到会议室 A)
| 用例名称 | 操作步骤 | 预期结果 |
|---|---|---|
1 A 2 A 3 A 4 A => 2 x 3 x 4 x | 初始状态 | Meeting 1 正常,Meeting 2、3、4 冲突 |
1 A 2 A 3 A 4 A -> 1 null => 2 x 3 x | Meeting 1 移出 | Meeting 1 正常,Meeting 2、3 冲突,Meeting 4 正常 |
1 A 2 A 3 A 4 A -> 2 null => 3 x | Meeting 2 移出 | Meeting 1、2、4 正常,Meeting 3 冲突 |
1 A 2 A 3 A 4 A -> 3 null => 2 x | Meeting 3 移出 | Meeting 1、3、4 正常,Meeting 2 冲突 |
1 A 2 A 3 A 4 A -> 4 null => 2 x 3 x | Meeting 4 移出 | Meeting 1、4 正常,Meeting 2、3 冲突 |
1 A 2 A 3 A 4 A -> 1 B => 2 x 3 x | Meeting 1 移到 B | Meeting 1 正常,Meeting 2、3 冲突,Meeting 4 正常 |
1 A 2 A 3 A 4 A -> 2 B => 3 x | Meeting 2 移到 B | Meeting 1、2、4 正常,Meeting 3 冲突 |
1 A 2 A 3 A 4 A -> 3 B => 2 x | Meeting 3 移到 B | Meeting 1、3、4 正常,Meeting 2 冲突 |
1 A 2 A 3 A 4 A -> 4 B => 2 x 3 x | Meeting 4 移到 B | Meeting 1、4 正常,Meeting 2、3 冲突 |
场景二:2 A 1 A 4 A 3 A(按 2、1、4、3 顺序分配到会议室 A)
| 用例名称 | 操作步骤 | 预期结果 |
|---|---|---|
2 A 1 A 4 A 3 A => 1 x 3 x | 初始状态 | Meeting 1、3 冲突,Meeting 2、4 正常 |
2 A 1 A 4 A 3 A -> 1 null => 3 x | Meeting 1 移出 | Meeting 1、2、4 正常,Meeting 3 冲突 |
2 A 1 A 4 A 3 A -> 2 null => 3 x | Meeting 2 移出 | Meeting 1、2、4 正常,Meeting 3 冲突 |
2 A 1 A 4 A 3 A -> 3 null => 1 x | Meeting 3 移出 | Meeting 2、3、4 正常,Meeting 1 冲突 |
2 A 1 A 4 A 3 A -> 4 null => 1 x 3 x | Meeting 4 移出 | Meeting 1、3 冲突,Meeting 2、4 正常 |
2 A 1 A 4 A 3 A -> 1 B => 3 x | Meeting 1 移到 B | Meeting 1、2、4 正常,Meeting 3 冲突 |
2 A 1 A 4 A 3 A -> 2 B => 3 x | Meeting 2 移到 B | Meeting 1、2、4 正常,Meeting 3 冲突 |
2 A 1 A 4 A 3 A -> 3 B => 1 x | Meeting 3 移到 B | Meeting 2、3、4 正常,Meeting 1 冲突 |
2 A 1 A 4 A 3 A -> 4 B => 1 x 3 x | Meeting 4 移到 B | Meeting 1、3 冲突,Meeting 2、4 正常 |