TDD 进阶:换个角度看会议室预约

会议室冲突检测是预约系统的核心能力。前两篇文章分别介绍了"冲突均标红"和"后选标红"两种方案------两者都在追问"谁该被标红"。本文提出第三种思路:不再追问"谁冲突",而是先问"哪间会议室可用"

一、问题重述:从"冲突标记"到"可选空间"

前两篇文章TDD 实战:会议室冲突检测的红绿重构循环TDD 新范式:测试用例定规范,AI 打工讨论过两种冲突标红方案:

方案 核心逻辑 适用场景
冲突均标红 两两重叠即标红,冲突对等 严格约束,不允许任何重叠
后选标红 后选入的会议才标红,先选的保留正常 用户体验优先,新操作才触发提示

这两种方案回答的是同一个问题:"当会议时间重叠时,如何标记冲突?"

但现实中还有另一个同等重要的问题:

"当一个会议要选择会议室时,哪些会议室是可用的?"

这不是冲突标记的变体,而是一个逆向视角的问题。冲突标记关注"已发生的状态",而可用性判断关注"未来的选择空间"。两者的数据结构、算法逻辑、交互形态截然不同。

本文介绍的第三种方案------控制可选------正是由此诞生。

代码案例可点击查看预约会议室


二、"控制可选"方案详解

2.1 设计理念

"控制可选"的核心思想是:不再维护会议之间的冲突关系,而是基于会议室已有的"时间占用表"(scschedules)来判断一个会议能否选入某间会议室。

这与前两种方案的关键区别在于:

复制代码
冲突均标红 / 后选标红:
  会议 → 冲突检测 → isConflict 标记

控制可选:
  会议 + 会议室 → 查询 scschedules → 返回可选/不可选

2.2 数据结构

"控制可选"方案引入了两种新的数据概念:

Room(会议室)--- 包含 scschedules(日程表):

typescript 复制代码
interface Room {
  id: string
  name: string
  scschedules: Scschedule[]  // 该会议室已占用的时间段列表
}

interface Scschedule {
  start: string   // "08:00" 或 "2021-01-01 08:00:00"
  end: string     // "10:00" 或 "2021-01-01 10:00:00"
  meetingId: string  // 关联的会议 ID
}

数据来源示例(room.json):

json 复制代码
{
  "2021-01-01": [
    { "id": "A", "name": "会议室 A", "scschedules": [
        { "start": "08:00", "end": "10:00", "meetingId": "10" }
    ]},
    { "id": "B", "name": "会议室 B", "scschedules": [] },
    { "id": "C", "name": "会议室 C", "scschedules": [
        { "start": "14:00", "end": "16:00", "meetingId": "9" }
    ]}
  ]
}

关键设计约束: 初始 scschedules 中可能已有预约(如 ID:10 和 ID:9),严禁在任何操作中将它们清除。

2.3 核心算法

时间冲突判断

方案使用字典序字符串比较来判断两个时间段是否重叠。由于日期时间格式为 "YYYY-MM-DD HH:mm:ss",字符串的字典序等同于时间顺序:

typescript 复制代码
canSelectRoom(meeting: Meeting, roomId: string): boolean {
  const schedules = this.getRoomSchedules(meeting.date, roomId)
  return !schedules.some(s => {
    const sStart = this.normalizeTime(s.start, meeting.date)
    const sEnd = this.normalizeTime(s.end, meeting.date)
    return sStart < meeting.end && meeting.start < sEnd
  })
}

重叠条件sStart < meeting.end && meeting.start < sEnd ------ 两个时间段有交集,当且仅当一个的开始在另一个的结束之前。

日程追加与移除

typescript 复制代码
private addSchedule(meeting: Meeting, roomId: string): void {
  const room = this.findRoom(meeting.date, roomId)
  if (!room) return
  const exists = room.scschedules.some(s => s.meetingId === String(meeting.id))
  if (!exists) {
    room.scschedules.push({  // 追加(append),绝不覆盖
      start: meeting.start, end: meeting.end,
      meetingId: String(meeting.id),
    })
  }
}

private removeSchedule(meeting: Meeting, roomId: string): void {
  const room = this.findRoom(meeting.date, roomId)
  if (!room) return
  const index = room.scschedules.findIndex(s => s.meetingId === String(meeting.id))
  if (index !== -1) room.scschedules.splice(index, 1)
  // 仅移除本管理器添加的条目,初始数据(ID:9, ID:10)绝不会被触碰
}

2.4 数据安全机制

"控制可选"方案面临一个独特挑战:初始数据必须被完整保留,但运行时又需要修改 scschedules。

解决方案是"双副本 + 深拷贝"方案:

scss 复制代码
room.json (原始数据)
  → setRoomsData → deepClone
      ├── pristineRooms (只读洁净副本,用于恢复)
      └── roomsData (运行时工作副本,可修改)

clear():
  roomsData = deepCloneRooms(pristineRooms)  // 重置为初始状态
  // 绝不执行 scschedules = [] 的粗暴重置

这样做有三个好处:

  1. 原始 room.json 导入数据不会被任何操作污染。
  2. clear() 恢复的是包含初始预约的完整状态。
  3. 多次 resetMeetings 之间数据独立,没有副作用。

2.5 可视化呈现

与传统方案的"表格+冲突标红"不同,"控制可选"方案需要呈现每个会议室的可选状态和日程占用。项目中通过两层次的可视化实现:

层次一:会议---会议室交叉表

会议 时间 会议室可选信息 分配
Meeting 1 08:00-09:00 A: ✕ 不可选(初始08:00~10:00) / B: ✓ 可选 / C: ✓ 可选 [Select]
Meeting 2 08:30-10:00 A: ✕ 不可选(初始08:00~10:00) / B: ✓ 可选 / C: ✓ 可选 [Select]

层次二:日程时间轴

会议室日程时间轴以甘特图形式可视化展示每个会议室的时间占用情况:

makefile 复制代码
08:00      09:00      10:00    ...    14:00      15:00      16:00
  ├──────────┤                              │          │          │
  │ ████████ │                              │          │          │  会议室 A
  │ 初始预约 08:00-10:00                    │          │          │
  ├──────────┤                              │          │          │
  │ (空闲) │                              │          │          │  会议室 B
  ├──────────┤                              ├──────────┤          │
  │          │                              │ ████████ │          │  会议室 C
  │          │                              │ 初始预约 14:00-16:00│

三、三种方案详解

方案一:冲突均标红(AllConflict)

架构特点:

  • 采用 meetingsMap: Record<date, Record<roomId, Meeting[]>> 二维存储。
  • 每次状态变更后,使用 O(n²) 两两比较检测所有冲突对。
  • 冲突标记直接设置在 Meeting.isConflict 属性上。
  • 移除/移动后完全重算当前会议室的全部冲突状态。
typescript 复制代码
removeMeetingFromRoom(meeting, roomId) {
  // 1. 从列表中移除该会议
  // 2. findAllConflicts() --- O(n²) 扫描所有剩余会议对
  // 3. 更新所有会议的 isConflict
}

addMeetingToRoom(meeting, roomId) {
  // 1. push 添加到列表尾部
  // 2. findAllConflicts() --- 同样的 O(n²) 扫描
  // 3. 更新所有会议的 isConflict
}

优势:

  • 逻辑最直观:两两重叠即为冲突,无歧义。
  • 实现简单:纯嵌套循环,无分支。
  • 稳定可靠:每次变更都完整重算,无状态残留。

局限性:

  • O(n²) 复杂度在大规模时性能下降。但对会议室场景通常 < 20 个会议,可忽略。
  • 全量重算,当仅新增一个会议时做了不必要的重复工作。
  • 只能回答"谁冲突",无法回答"哪间会议室可用"。

方案二:后选标红(LastConflict)

架构特点:

  • 同样使用 meetingsMap 二维存储。
  • 关键技巧 :使用 unshift 向数组头部插入新会议,而非 push
    • 数组头部 = 后选入的会议。
    • Array.find() 从左到右搜索,天然找到"最后选入"的会议。
  • 冲突分组算法:先按时间排序,再按重叠关系分组。
  • 每组内如果全部标红,将"第一顺位"的会议翻绿。
typescript 复制代码
addMeetingToRoom(meeting, roomId) {
  // 1. 检查与现有会议是否有冲突
  const hasConflict = roomMeetings.some(m => this.hasConflict(m, meeting))
  meeting.isConflict = hasConflict
  // 2. unshift 插入到头部------新会议后来居上
  roomMeetings.unshift(meeting)
}

resolveConflicts(roomMeetings) {
  // 1. 排序
  // 2. 按时间重叠分组
  // 3. 每组全红则找第一顺位翻绿
}

优势:

  • 用户体验更好:先选的会议不会因后选而突然变红。
  • 算法思路独特:用 unshift + find 组合天然实现"后选者标红"。
  • 移除逻辑简洁:分组翻绿方案不超过 10 行核心逻辑。

局限性:

  • 依赖添加顺序:同样的会议集,不同顺序导致不同结果。
  • 逻辑理解成本高:需要理解"数组位置等价于添加顺序"的不变量。
  • 同样无法回答"哪间会议室可用"。

方案三:控制可选(Options)

架构特点:

  • 放弃 meetingsMap,采用 roomsData: Record<date, Room[]> 存储。
  • 完全移除 isConflict 标记体系。
  • 核心能力从"冲突检测"变为"可用性查询"。
  • 引入双副本深拷贝机制确保数据安全。
  • 新增 canSelectRoom(meeting, roomId): boolean 公共方法。
typescript 复制代码
class OptionsManager {
  private pristineRooms: Record<string, Room[]>  // 只读备份
  private roomsData: Record<string, Room[]>      // 工作副本

  setRoomsData(rooms)  // 初始化:深拷贝两份副本
  clear()              // roomsData = deepClone(pristineRooms)
  getRoomSchedules()   // 查询指定会议室的 scschedules
  canSelectRoom()      // 判断会议室是否可选
  handleRoomChange()   // 追加/移除 scschedules 条目
}

优势:

  • 视角转换:从"冲突标记"到"可选空间",回答不同问题。
  • 数据安全:双副本 + 深拷贝,初始数据永不丢失。
  • 支持外部数据:可加载已存在的预约数据(如从其他系统同步的日程)。
  • 可组合性好:canSelectRoom 可被 UI 层灵活使用。

局限性:

  • 不兼容 IMeetingManager 接口的 isConflict 体系。
  • 不维护 meetingsMap,需要外部自行维护会议列表。
  • 复杂度从"冲突算法"转移到"数据一致性保障"。

四、三种方案的横向对比

4.1 综合对比表

维度 冲突均标红 后选标红 控制可选
核心问题 谁冲突了? 谁该为冲突负责? 哪间会议室可用?
数据模型 meetingsMap meetingsMap + unshift roomsData + scschedules
状态维护 isConflict 布尔标记 isConflict + 分组逻辑 无 isConflict,直接查询 scschedules
算法复杂度 O(n²) 全量重算 O(n²) 分组重算 O(m) 查表(m = 该会议室日程条数)

4.2 适用场景

  • 冲突均标红 :适合强约束场景,如手术室排期、实验室设备预约,任何时间重叠都不允许。
  • 后选标红 :适合用户体验优先场景,如办公会议室预约,先约的保持不变,后约的得到提示。
  • 控制可选 :适合已有固定日程 + 开放预约场景,如酒店房间预订系统、共享办公空间管理。

五、测试用例定义的"问题视角"

前两种方案共用同一套 IMeetingManager 接口和相似的测试框架,而"控制可选"方案的测试用例完全不同------它测试的不是 isConflict 的值,而是 canSelectRoom 的返回值和 scschedules 的内容。

typescript 复制代码
// 冲突均标红 / 后选标红的测试模式
expect(meetings[0].isConflict).toBe(true)
expect(meetings[1].isConflict).toBe(false)

// 控制可选的测试模式
expect(manager.canSelectRoom(meetings[1], 'A')).toBe(false)
expect(manager.canSelectRoom(meetings[1], 'B')).toBe(true)
expect(manager.getRoomSchedules('2021-01-01', 'A')).toHaveLength(2)

这揭示了一个更深层的洞察:测试用例不仅是"验证代码正确性"的工具,更是"定义问题视角"的方式。

  • 测试 isConflict → 你在解决"冲突标记"问题。
  • 测试 canSelectRoom → 你在解决"会议室选择"问题。
  • 测试 getRoomSchedules → 你在解决"日程管理"问题。

六、总结

三种方案代表了会议室预约系统中三个不同的思维层次:

层次 方案 思维方式
冲突检测 冲突均标红 两两比较,无差别标记
冲突归因 后选标红 按顺序判定责任,先到者优先
选择空间 控制可选 查询占用表,判断可用性
  • 用"冲突均标红"做硬约束检查
  • 用"后选标红"做用户体验优化
  • 用"控制可选"做会议室推荐和可用性展示
相关推荐
Amy_yang1 小时前
uni-app 安卓端纯前端预览 DOCX 的实现思路
前端·vue.js
x_y_1 小时前
分享一个自己总结的前端开发skill~ requirement-to-delivery
前端·ai编程
梨子同志1 小时前
CSS Grid
前端·css
子兮曰1 小时前
SuperSplat 深度解析:7.6K Stars 的浏览器端 3D 高斯泼溅编辑器 — 在 Web 上编辑现实
前端·javascript·webgl
小徐_23331 小时前
Wot UI v1 升级 v2?这份迁移指南帮你少踩坑!
前端·微信小程序·uni-app
xiangxiongfly9151 小时前
Vue3 动态加载静态资源
前端·javascript·vue.js
子兮曰1 小时前
whisper.cpp 深度解析:从边缘设备到实时语音识别
前端·c++·后端
子兮曰1 小时前
Ruflo 深度解析:49K Stars 的 AI Agent 编排平台 — 给 Claude Code 装上分布式神经系统
前端·后端·ai编程
小皮咖1 小时前
发给那个让你加班的同事
前端