test(shift): add safety-net tests for long-press rephase scenarios

锁定重构前行为:单次长按重排、连续两次长按、撤销。
作为 RephaseFlip 重构的回归基线。
This commit is contained in:
xfy 2026-07-09 16:25:23 +08:00
parent 66a054f2d8
commit d8014b5154
2 changed files with 221 additions and 0 deletions

View File

@ -0,0 +1,137 @@
## 目标
修复班次设置页的 6 个问题:
1. 长按撤销误删巧合重合的独立断点
2. 长按产生的 override 与 phaseBreak 解耦,单独点翻转留幽灵重排
3. 连续长按产生断点链,撤销老断点不级联
4. `kindAt` 每格每次重组重算(无 remember 缓存)
5. "恢复默认"无撤销,立即落盘
6. 角标颜色硬编码 `onPrimary`、固定 6 行
根因:问题 1-3 都是"长按产生的 override + phaseBreak 是两个独立数据,但语义上是原子操作"。彻底解法是重构数据模型。
## 总体方案
**引入 `RephaseFlip` 原子结构**,把"翻转某天 + 从次日起重排"绑定成一个不可分割的数据单元。`overrides`(纯单日翻转)和 `phaseBreaks` 两字段保留,但长按操作改为产出 `RephaseFlip` 而非拆成两个独立字段。
### 新数据模型
```kotlin
// 新增:原子化的"翻转并重排"操作记录
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` 改为从 `rephaseFlips``rephaseFrom` 作锚点
- 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_BREAKS``KEY_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`:从 `rephaseFlips``rephaseFrom <= date` 的最大
- 删除 `PhaseBreak`
- **所有 Task 0 测试必须通过**(行为不变)
- 补充新测试:rephaseFlip 撤销精确匹配、多个 rephaseFlip 共存
### Task 2: 重构 ShiftPatternStorage 存储格式
- `KEY_BREAKS``KEY_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 行为变化**:`rephaseFlips``rephaseFrom` 与旧 `phaseBreaks.date` 在"长按场景"下语义等价(都是次日),但"纯 phaseBreak"(offset!=0)不再支持。当前 UI 不产生 offset!=0 的断点(长按总是 offset=0),所以无实际影响。Task 0 测试会验证。
- **存储不兼容**:旧 `KEY_BREAKS` 数据被忽略,用户重置为默认。已确认可接受(个人项目,数据可重建)。
- **Snackbar 首次引入**:需确认 Material3 Scaffold 的 snackbarHost 用法正确,不破坏现有布局。

View File

@ -288,4 +288,88 @@ class ShiftPatternTest {
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 20))) assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 20)))
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 21))) assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 21)))
} }
// ---- 长按"翻转并重排"场景(重构安全网)----
// 以下测试模拟当前 UI 长按产出:override[date] + PhaseBreak(date+1, 0)
// 重构为 RephaseFlip 后,这些场景的 kindAt 输出必须保持一致。
/**
* 单次长按:锚点 7/10,2班2休长按 7/10 把班翻转为休,7/11 起重排
* 期望:7/10=,7/11-12=,7/13-14=(后续按 cycle 顺延)
*/
@Test
fun longPress_singleFlip_dateFlippedAndRephased() {
// 锚点 7/10,7/10=班(基础)
val pattern = ShiftPattern(
anchorDate = LocalDate(2026, 7, 10),
cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF),
overrides = mapOf(LocalDate(2026, 7, 10) to ShiftKind.OFF),
phaseBreaks = listOf(PhaseBreak(LocalDate(2026, 7, 11), 0))
)
// 7/10 翻转为休
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 10)))
// 7/11 起重排:cycle[0,1]=班班
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 11)))
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 12)))
// cycle[2,3]=休休
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 13)))
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 14)))
// 继续循环:7/15=cycle[0]=班
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 15)))
}
/**
* 连续两次长按:7/10 一次7/14 一次两个重排点共存
* 期望:7/11-13 受第一个断点支配,7/14 起受第二个断点支配
*/
@Test
fun longPress_twoFlips_bothRephasesApplied() {
val pattern = ShiftPattern(
anchorDate = LocalDate(2026, 7, 10),
cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF),
overrides = mapOf(
LocalDate(2026, 7, 10) to ShiftKind.OFF,
LocalDate(2026, 7, 14) to ShiftKind.OFF
),
phaseBreaks = listOf(
PhaseBreak(LocalDate(2026, 7, 11), 0),
PhaseBreak(LocalDate(2026, 7, 15), 0)
)
)
// 7/10 翻转为休
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 10)))
// 7/11-13 受第一个断点:班班休
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 11)))
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 12)))
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 13)))
// 7/14 翻转为休
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 14)))
// 7/15 起受第二个断点:班班休休
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 15)))
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 16)))
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 17)))
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 18)))
}
/**
* 撤销长按:移除 7/10 override 和关联的 7/11 断点后,回到基础周期
*/
@Test
fun longPress_undo_returnsToBaseCycle() {
val before = ShiftPattern(
anchorDate = LocalDate(2026, 7, 10),
cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF),
overrides = mapOf(LocalDate(2026, 7, 10) to ShiftKind.OFF),
phaseBreaks = listOf(PhaseBreak(LocalDate(2026, 7, 11), 0))
)
// 撤销:移除 override 和断点
val after = before.copy(
overrides = before.overrides - LocalDate(2026, 7, 10),
phaseBreaks = emptyList()
)
// 回到基础:7/10=班(锚点),7/12=休
assertEquals(ShiftKind.WORK, after.kindAt(LocalDate(2026, 7, 10)))
assertEquals(ShiftKind.WORK, after.kindAt(LocalDate(2026, 7, 11)))
assertEquals(ShiftKind.OFF, after.kindAt(LocalDate(2026, 7, 12)))
}
} }