yayacal/.zcode/plans/plan-sess_ace6e49c-6557-47c4-9e42-2b0bbfd83e00.md
xfy d8014b5154 test(shift): add safety-net tests for long-press rephase scenarios
锁定重构前行为:单次长按重排、连续两次长按、撤销。
作为 RephaseFlip 重构的回归基线。
2026-07-09 16:25:23 +08:00

6.7 KiB

目标

修复班次设置页的 6 个问题:

  1. 长按撤销误删巧合重合的独立断点
  2. 长按产生的 override 与 phaseBreak 解耦,单独点翻转留幽灵重排
  3. 连续长按产生断点链,撤销老断点不级联
  4. kindAt 每格每次重组重算(无 remember 缓存)
  5. "恢复默认"无撤销,立即落盘
  6. 角标颜色硬编码 onPrimary、固定 6 行

根因:问题 1-3 都是"长按产生的 override + phaseBreak 是两个独立数据,但语义上是原子操作"。彻底解法是重构数据模型。

总体方案

引入 RephaseFlip 原子结构,把"翻转某天 + 从次日起重排"绑定成一个不可分割的数据单元。overrides(纯单日翻转)和 phaseBreaks 两字段保留,但长按操作改为产出 RephaseFlip 而非拆成两个独立字段。

新数据模型

// 新增:原子化的"翻转并重排"操作记录
data class RephaseFlip(
    val date: LocalDate,        // 被翻转的天
    val flippedTo: ShiftKind,   // 翻转后的值
    val rephaseFrom: LocalDate  // 重排起点(= date + 1)
)

data class ShiftPattern(
    val anchorDate: LocalDate,
    val cycle: List<ShiftKind>,
    val overrides: Map<LocalDate, ShiftKind> = emptyMap(),  // 保留:纯单日翻转(点)
    val rephaseFlips: List<RephaseFlip> = emptyList(),      // 新增:翻转并重排(长按)
    val name: String = "默认"
) {
    fun kindAt(date: LocalDate): ShiftKind? {
        if (cycle.isEmpty()) return null
        // 1. 纯单日翻转优先
        overrides[date]?.let { return it }
        // 2. rephaseFlips 中被翻转的当天
        rephaseFlips.find { it.date == date }?.let { return it.flippedTo }
        // 3. 找活跃锚点:rephaseFlips 的 rephaseFrom <= 当天 的最大那个;无则基础锚点
        val (anchor, offset) = activeAnchor(date)
        ...
    }
}

关键变化:

  • phaseBreaks 字段删除,替换为 rephaseFlips
  • activeAnchor 改为从 rephaseFlipsrephaseFrom 作锚点
  • kindAt 优先级:overrides → rephaseFlip 当天 → 活跃锚点 cycle

撤销语义变干净:长按 7/10 产生 RephaseFlip(7/10, OFF, 7/11)。再次长按 7/10 → 按 date == 7/10 精确匹配整个原子记录删除,不会误删别的(解决问题 1)。单独点 7/10 翻转时,如果存在 rephaseFlip.date == 7/10,提示或阻止(解决问题 2)。

存储格式

App 未正式发布(git 历史显示个人项目,SharedPreferences 刚引入)。不做向后兼容迁移:

  • 存储格式改:KEY_BREAKSKEY_REPHASE,编码 日期:flippedTo:rephaseFrom(1/0:ISO日期)
  • 旧数据(含 KEY_BREAKS 的)在 load() 时解析 KEY_REPHASE 失败返回 null → 回退 DEFAULT_PATTERN(load 已有 try/catch)
  • 用户唯一数据是默认 2班2休,丢失无感知

测试策略(先建安全网)

Task 0 优先:在改模型前,先用端到端测试锁定当前所有行为(kindAt 的全部输出),确保重构后这些断言仍然成立。新增针对长按场景(翻转+重排、撤销、连续长按)的测试,先在旧模型上跑过(记录当前行为),再重构后验证不回归。

实施步骤(7 个 Task,TDD)

Task 0: 建立测试安全网(不改产品代码)

在改任何产品代码前,补充测试覆盖当前行为,作为重构的回归基线:

  • ShiftPatternTest:补充"长按翻转+重排"端到端断言(用 override+phaseBreak 组合模拟当前长按产出,断言后续序列)。包括:单次长按、连续两次长按、撤销场景。
  • ShiftPatternStorageTest:补充往返测试覆盖 phaseBreak。
  • 这些测试在旧模型上必须先通过,记录为"重构前行为"。

Task 1: 重构 ShiftPattern 数据模型(RephaseFlip)

  • 新增 RephaseFlip data class
  • ShiftPattern 删除 phaseBreaks,加 rephaseFlips: List<RephaseFlip>
  • 重写 kindAt:overrides → rephaseFlip 当天 → 活跃锚点(从 rephaseFlips.rephaseFrom 取)
  • 重写 activeAnchor:从 rephaseFlipsrephaseFrom <= date 的最大
  • 删除 PhaseBreak
  • 所有 Task 0 测试必须通过(行为不变)
  • 补充新测试:rephaseFlip 撤销精确匹配、多个 rephaseFlip 共存

Task 2: 重构 ShiftPatternStorage 存储格式

  • KEY_BREAKSKEY_REPHASE
  • save/load 改为序列化 rephaseFlips(日期:flippedTo:rephaseFrom)
  • parseRephase 替代 parseBreaks
  • 往返测试更新

Task 3: 重写 ShiftCalendarGrid 交互逻辑

  • toggleFlipAndRephase:产出 RephaseFlip(date, flippedTo, date+1),撤销按 date 精确匹配
  • togglePhaseBreak 删除
  • toggleOverride 加保护:若该天存在 rephaseFlip,移除整个 rephaseFlip(防止幽灵重排,解决问题 2)
  • 角标 isRephaseStart 改为检测 rephaseFlips.any { it.rephaseFrom == date }
  • ShiftDayCell 加 remember(pattern, date) { pattern.kindAt(date) } 缓存(解决问题 4)

Task 4: 修复角标颜色(问题 6)

  • 角标文字色按类别:班=onPrimary、休=onError、起=onTertiary
  • 对齐 DayCell 风格:9sp、TopEnd、CircleShape 胶囊背景

Task 5: 修复固定行数(问题 7)

  • ShiftCalendarGrid 改用 getMonthGridInfo(viewYear, viewMonth).rows 动态算行数
  • 删除硬编码 (0 until 6),改为 (0 until rows)

Task 6: "恢复默认"加撤销(问题 5)

  • 引入 SnackbarHost(项目首次使用,需在 Scaffold 加 snackbarHost 参数)
  • 点恢复默认 → 存旧 pattern 到临时变量 → 显示 Snackbar "已恢复默认,撤销" 5 秒
  • 点撤销 → 恢复旧 pattern
  • 不再用 AlertDialog 确认(Snackbar 撤销比确认对话框更现代,且防误操作)

Task 7: 全量验证

  • ./gradlew :app:assembleDebug :core:testDebugUnitTest 通过
  • ./gradlew spotlessApply
  • 手动验证清单(模拟器):长按翻转重排、再次长按撤销、连续长按、恢复默认撤销

改动文件清单

文件 Task 改动
core/.../ShiftPattern.kt 1 删 PhaseBreak,加 RephaseFlip,重写 kindAt
core/.../ShiftPatternStorage.kt 2 存储格式改
core/.../ui/ShiftCalendarGrid.kt 3,4,5 交互逻辑+角标颜色+动态行数
core/.../ui/ShiftPatternScreen.kt 6 Snackbar 撤销
core/test/.../ShiftPatternTest.kt 0,1 安全网+重构后验证
core/test/.../ShiftPatternStorageTest.kt 0,2 往返测试更新

风险

  • kindAt 行为变化:rephaseFlipsrephaseFrom 与旧 phaseBreaks.date 在"长按场景"下语义等价(都是次日),但"纯 phaseBreak"(offset!=0)不再支持。当前 UI 不产生 offset!=0 的断点(长按总是 offset=0),所以无实际影响。Task 0 测试会验证。
  • 存储不兼容:旧 KEY_BREAKS 数据被忽略,用户重置为默认。已确认可接受(个人项目,数据可重建)。
  • Snackbar 首次引入:需确认 Material3 Scaffold 的 snackbarHost 用法正确,不破坏现有布局。