test(shift): add safety-net tests for long-press rephase scenarios
锁定重构前行为:单次长按重排、连续两次长按、撤销。 作为 RephaseFlip 重构的回归基线。
This commit is contained in:
parent
66a054f2d8
commit
d8014b5154
137
.zcode/plans/plan-sess_ace6e49c-6557-47c4-9e42-2b0bbfd83e00.md
Normal file
137
.zcode/plans/plan-sess_ace6e49c-6557-47c4-9e42-2b0bbfd83e00.md
Normal 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 用法正确,不破坏现有布局。
|
||||
@ -288,4 +288,88 @@ class ShiftPatternTest {
|
||||
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 20)))
|
||||
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)))
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user