refactor(shift): replace phaseBreaks with atomic RephaseFlip

数据模型重构:把"翻转+重排"从两个独立字段(override+phaseBreak)
改为单一原子结构 RephaseFlip(date, flippedTo, rephaseFrom)。

- 删除 PhaseBreak / phaseBreaks / cycleOffset(UI 未使用 offset!=0)
- kindAt:overrides → rephaseFlip 当天 → 活跃锚点(从 rephaseFrom 取)
- Storage:KEY_BREAKS → KEY_REPHASE,编码 翻转日:值:重排起点
- ShiftCalendarGrid:toggleFlipAndRephase 产出原子记录,撤销按 date 精确匹配
- toggleOverride 加保护:rephaseFlip 翻转日被点时整体移除,防幽灵重排

解决问题:撤销误删独立断点、单独点翻转留幽灵、连续长按断点链不级联。
This commit is contained in:
xfy 2026-07-09 16:33:29 +08:00
parent d8014b5154
commit f8df108bf3
6 changed files with 123 additions and 125 deletions

View File

@ -9,55 +9,67 @@ import kotlinx.datetime.daysUntil
enum class ShiftKind { WORK, OFF }
/**
* 相位断点: [date] 起重排周期相位,[cycleOffset] 指定当天对应 cycle 的第几位
* "翻转并重排"的原子记录:翻转 [date] 当天为 [flippedTo],
* 并从 [rephaseFrom] 起重排周期相位(后续按 cycle 重新顺延)
*
* @param date 断点生效日(含当天)
* @param cycleOffset 该天对应的 cycle 索引(0 = cycle[0])
* date rephaseFrom 通常相邻(rephaseFrom = date 的次日),
* 作为单一操作的两面绑定存储,撤销时整体删除,不会留下孤立数据
*
* @param date 被翻转的天
* @param flippedTo 翻转后的班次
* @param rephaseFrom 重排起点(含当天),该天起按 cycle[0] 重新循环
*/
data class PhaseBreak(
data class RephaseFlip(
val date: LocalDate,
val cycleOffset: Int
val flippedTo: ShiftKind,
val rephaseFrom: LocalDate
)
/**
* 个人轮班周期
*
* 与法定节假日完全独立某天的班/休由以下顺序决定:
* 1. [overrides] 命中该天, override (单日翻转);
* 2. 否则取该天"活跃锚点"(最近的 phaseBreak 或基础 anchorDate)起算的 cycle 索引
* 1. [overrides] 命中该天, override (单日翻转,仅当天);
* 2. 否则若 [rephaseFlips] 中有该天作为翻转日的记录, [RephaseFlip.flippedTo];
* 3. 否则取该天"活跃锚点"(最近的 rephaseFlips.rephaseFrom 或基础 anchorDate)
* 起算的 cycle 索引
*
* @param anchorDate 基础周期基准日,对应 cycle[0]
* @param cycle 一个周期内的班次序列,例如 [WORK, WORK, OFF, OFF] 表示 "2 班 2 休"
* @param overrides 单日翻转映射(调班),key 为日期
* @param phaseBreaks 相位断点列表,从某天起重排周期相位
* @param overrides 单日翻转映射(仅当天,不影响后续),key 为日期
* @param rephaseFlips 翻转并重排记录列表;每条翻转某天并从次日起重排周期
* @param name 方案名
*/
data class ShiftPattern(
val anchorDate: LocalDate,
val cycle: List<ShiftKind>,
val overrides: Map<LocalDate, ShiftKind> = emptyMap(),
val phaseBreaks: List<PhaseBreak> = emptyList(),
val rephaseFlips: List<RephaseFlip> = emptyList(),
val name: String = "默认"
) {
/**
* 返回 [date] 当天的班次优先级:overrides 活跃锚点的 cycle 索引
* 返回 [date] 当天的班次优先级:overrides rephaseFlip 当天 活跃锚点的 cycle 索引
* cycle 为空时返回 null
*/
fun kindAt(date: LocalDate): ShiftKind? {
if (cycle.isEmpty()) return null
// 1. 单日翻转优先
// 1. 单日翻转优先(仅当天)
overrides[date]?.let { return it }
// 2. 找活跃锚点:phaseBreaks 中 date <= 当天 的最大那个;无则用基础锚点
val (anchor, offset) = activeAnchor(date)
// 2. rephaseFlips 中被翻转的当天
rephaseFlips.find { it.date == date }?.let { return it.flippedTo }
// 3. 找活跃锚点:rephaseFlips 中 rephaseFrom <= 当天 的最大那个;无则用基础锚点
val anchor = activeAnchor(date)
val diff = anchor.daysUntil(date)
val size = cycle.size
val idx = (((diff + offset) % size) + size) % size
val idx = ((diff % size) + size) % size
return cycle[idx]
}
private fun activeAnchor(date: LocalDate): Pair<LocalDate, Int> {
val applicable = phaseBreaks.filter { it.date <= date }.maxByOrNull { it.date }
return if (applicable != null) applicable.date to applicable.cycleOffset
else anchorDate to 0
private fun activeAnchor(date: LocalDate): LocalDate {
return rephaseFlips
.filter { it.rephaseFrom <= date }
.maxByOrNull { it.rephaseFrom }
?.rephaseFrom
?: anchorDate
}
}

View File

@ -10,9 +10,8 @@ import kotlinx.datetime.LocalDate
* 编码格式( JSON 依赖):
* - 锚点:ISO 日期串 "2026-07-08"
* - 周期:逗号分隔的 1/0 "1,1,0,0"(1=WORK,0=OFF)
* - overrides/breaks:逗号分隔的 "日期:值"
* - overrides :1=WORK,0=OFF
* - breaks :cycleOffset(整数)
* - overrides:逗号分隔的 "日期:值" , 1=WORK,0=OFF
* - rephaseFlips:逗号分隔的 "翻转日:值:重排起点" 三元组, 1=WORK,0=OFF
*/
class ShiftPatternStorage(private val prefs: SharedPreferences) {
@ -20,7 +19,7 @@ class ShiftPatternStorage(private val prefs: SharedPreferences) {
private const val KEY_ANCHOR = "shift_anchor"
private const val KEY_CYCLE = "shift_cycle"
private const val KEY_OVERRIDES = "shift_overrides"
private const val KEY_BREAKS = "shift_breaks"
private const val KEY_REPHASE = "shift_rephase"
private const val KEY_NAME = "shift_name"
private const val SEP = ","
@ -34,13 +33,15 @@ class ShiftPatternStorage(private val prefs: SharedPreferences) {
val cycleStr = pattern.cycle.joinToString(SEP) { if (it == ShiftKind.WORK) "1" else "0" }
val overridesStr = pattern.overrides.entries
.joinToString(SEP) { "${it.key}:${if (it.value == ShiftKind.WORK) 1 else 0}" }
val breaksStr = pattern.phaseBreaks
.joinToString(SEP) { "${it.date}:${it.cycleOffset}" }
val rephaseStr = pattern.rephaseFlips
.joinToString(SEP) {
"${it.date}:${if (it.flippedTo == ShiftKind.WORK) 1 else 0}:${it.rephaseFrom}"
}
prefs.edit()
.putString(KEY_ANCHOR, pattern.anchorDate.toString())
.putString(KEY_CYCLE, cycleStr)
.putString(KEY_OVERRIDES, overridesStr)
.putString(KEY_BREAKS, breaksStr)
.putString(KEY_REPHASE, rephaseStr)
.putString(KEY_NAME, pattern.name)
.apply()
}
@ -56,8 +57,8 @@ class ShiftPatternStorage(private val prefs: SharedPreferences) {
cycleStr.split(SEP).map { if (it.trim() == "1") ShiftKind.WORK else ShiftKind.OFF }
}
val overrides = parseOverrides(prefs.getString(KEY_OVERRIDES, null))
val breaks = parseBreaks(prefs.getString(KEY_BREAKS, null))
ShiftPattern(anchor, cycle, overrides, breaks, name = prefs.getString(KEY_NAME, null) ?: "默认")
val rephaseFlips = parseRephase(prefs.getString(KEY_REPHASE, null))
ShiftPattern(anchor, cycle, overrides, rephaseFlips, name = prefs.getString(KEY_NAME, null) ?: "默认")
} catch (e: Exception) {
null
}
@ -71,11 +72,16 @@ class ShiftPatternStorage(private val prefs: SharedPreferences) {
}
}
private fun parseBreaks(s: String?): List<PhaseBreak> {
private fun parseRephase(s: String?): List<RephaseFlip> {
if (s.isNullOrBlank()) return emptyList()
return s.split(SEP).map { pair ->
val parts = pair.split(":")
PhaseBreak(LocalDate.parse(parts[0]), parts[1].trim().toInt())
return s.split(SEP).map { triple ->
// 格式:翻转日:值:重排起点(ISO 日期不含冒号,split 安全)
val colonIdx = triple.indexOf(':')
val lastColon = triple.lastIndexOf(':')
val date = LocalDate.parse(triple.substring(0, colonIdx))
val flippedTo = if (triple.substring(colonIdx + 1, lastColon).trim() == "1") ShiftKind.WORK else ShiftKind.OFF
val rephaseFrom = LocalDate.parse(triple.substring(lastColon + 1))
RephaseFlip(date, flippedTo, rephaseFrom)
}
}

View File

@ -43,7 +43,7 @@ import kotlinx.datetime.number
import kotlinx.datetime.plus
import kotlinx.datetime.todayIn
import kotlin.time.Clock
import plus.rua.project.PhaseBreak
import plus.rua.project.RephaseFlip
import plus.rua.project.ShiftKind
import plus.rua.project.ShiftPattern
@ -140,9 +140,17 @@ fun ShiftCalendarGrid(
}
/**
* 翻转某天的班/ override翻转后若与基础周期值一致,则移除 override
* 翻转某天的班/(单日 override)翻转后若与基础周期值一致,则移除 override
*
* 若该天存在 rephaseFlip(长按产生的翻转并重排),则移除整个 rephaseFlip,
* 避免留下孤立的"幽灵重排"(只清翻转但保留后续相位重排)
*/
private fun toggleOverride(pattern: ShiftPattern, date: LocalDate): ShiftPattern {
val existingFlip = pattern.rephaseFlips.find { it.date == date }
if (existingFlip != null) {
// 该天是 rephaseFlip 的翻转日:整体移除,避免幽灵重排
return pattern.copy(rephaseFlips = pattern.rephaseFlips - existingFlip)
}
val current = pattern.kindAt(date) ?: return pattern
val newVal = if (current == ShiftKind.WORK) ShiftKind.OFF else ShiftKind.WORK
val tempPattern = pattern.copy(overrides = pattern.overrides - date)
@ -155,35 +163,27 @@ private fun toggleOverride(pattern: ShiftPattern, date: LocalDate): ShiftPattern
/**
* 翻转某天的班/,并从次日起重排周期(后续按 cycle 重新顺延)
*
* 实现:翻转该天 override + [date] 次日插入 PhaseBreak(offset=0)
* 产出原子记录 [RephaseFlip]:翻转该天 + 从次日([rephaseFrom])起重排
* 次日成为新的 cycle[0],后续自动按周期顺延
*
* 撤销:再次长按同一天,若存在由该天派生的断点(次日offset=0),则移除 override 与该断点
* 撤销:再次长按同一天,[RephaseFlip.date] 精确匹配并整体移除(不会误删其他记录)
*
* @param date 被翻转并作为重排起点的日期
*/
private fun toggleFlipAndRephase(pattern: ShiftPattern, date: LocalDate): ShiftPattern {
val nextDay = date.plus(DatePeriod(days = 1))
val derivedBreak = pattern.phaseBreaks.find { it.date == nextDay && it.cycleOffset == 0 }
if (derivedBreak != null) {
// 撤销:移除该天的 override 与关联断点
return pattern.copy(
overrides = pattern.overrides - date,
phaseBreaks = pattern.phaseBreaks - derivedBreak
)
val existing = pattern.rephaseFlips.find { it.date == date }
if (existing != null) {
// 撤销:整体移除该 rephaseFlip(原子删除)
return pattern.copy(rephaseFlips = pattern.rephaseFlips - existing)
}
// 翻转该天 + 次日插入断点重排
// 翻转该天 + 次日重排
val current = pattern.kindAt(date) ?: return pattern
val newVal = if (current == ShiftKind.WORK) ShiftKind.OFF else ShiftKind.WORK
val tempPattern = pattern.copy(overrides = pattern.overrides - date)
val base = tempPattern.kindAt(date)
val newOverrides = if (newVal == base) pattern.overrides - date
else pattern.overrides + (date to newVal)
val rephaseFrom = date.plus(DatePeriod(days = 1))
return pattern.copy(
overrides = newOverrides,
phaseBreaks = pattern.phaseBreaks + PhaseBreak(nextDay, 0)
overrides = pattern.overrides - date, // 该天改由 rephaseFlip 管辖,移除可能的旧 override
rephaseFlips = pattern.rephaseFlips + RephaseFlip(date, newVal, rephaseFrom)
)
}
@ -206,8 +206,8 @@ private fun ShiftDayCell(
modifier: Modifier = Modifier
) {
val kind = pattern.kindAt(date)
// 断点在"翻转日的次日",表示该天为重排起点
val isRephaseStart = pattern.phaseBreaks.any { it.date == date && it.cycleOffset == 0 }
// rephaseFlip 的 rephaseFrom = 当天,表示该天为重排起点
val isRephaseStart = pattern.rephaseFlips.any { it.rephaseFrom == date }
val badgeColor = when {
isRephaseStart -> MaterialTheme.colorScheme.tertiary

View File

@ -145,7 +145,7 @@ fun ShiftPatternScreen(onBack: () -> Unit) {
pattern = pattern.copy(
cycle = cycle,
overrides = emptyMap(),
phaseBreaks = emptyList()
rephaseFlips = emptyList()
)
},
label = { Text(label) }

View File

@ -39,7 +39,7 @@ class ShiftPatternStorageTest {
LocalDate(2026, 7, 12) to ShiftKind.OFF,
LocalDate(2026, 7, 14) to ShiftKind.WORK
),
phaseBreaks = listOf(PhaseBreak(LocalDate(2026, 7, 18), 0))
rephaseFlips = listOf(RephaseFlip(LocalDate(2026, 7, 17), ShiftKind.OFF, LocalDate(2026, 7, 18)))
)
storage.save(pattern)
val result = storage.load()
@ -53,7 +53,7 @@ class ShiftPatternStorageTest {
anchorDate = LocalDate(2026, 5, 15),
cycle = listOf(ShiftKind.WORK, ShiftKind.OFF),
overrides = emptyMap(),
phaseBreaks = emptyList()
rephaseFlips = emptyList()
)
storage.save(pattern)
assertEquals(pattern, storage.load())

View File

@ -199,75 +199,66 @@ class ShiftPatternTest {
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 17)))
}
// ---- kindAt: 相位断点 phaseBreak ----
// ---- kindAt: 翻转并重排 rephaseFlip ----
@Test
fun kindAt_phaseBreak_restartsCycleFromBreakDate() {
// 断点设在 5/19,从这天起重新 cycle[0]
fun kindAt_rephaseFlip_restartsCycleFromRephaseDate() {
// 5/18 翻转为 OFF,5/19 起重排(rephaseFrom=5/19)
val pattern = twoOnTwoOff.copy(
phaseBreaks = listOf(PhaseBreak(LocalDate(2026, 5, 19), 0))
rephaseFlips = listOf(RephaseFlip(LocalDate(2026, 5, 18), ShiftKind.OFF, LocalDate(2026, 5, 19)))
)
// 5/19 = cycle[0] = WORK
// 5/18 = 翻转为 OFF
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 18)))
// 5/19 = cycle[0] = WORK(重排起点)
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 19)))
// 5/20 = cycle[1] = WORK
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 20)))
// 5/21 = cycle[2] = OFF
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 21)))
// 断点之前保持原相位:5/18 = (18-15)%4=3 = OFF
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 18)))
}
@Test
fun kindAt_phaseBreakWithOffset_shiftsPhase() {
// 断点 5/19,cycleOffset=2:5/19 对应 cycle[2]=OFF(而非 cycle[0]=WORK)
fun kindAt_multipleRephaseFlips_usesLatestApplicable() {
// 两个 rephaseFlip:5/18→5/19 重排,5/24→5/25 重排
val pattern = twoOnTwoOff.copy(
phaseBreaks = listOf(PhaseBreak(LocalDate(2026, 5, 19), 2))
)
// 5/19 = cycle[(0+2)%4] = cycle[2] = OFF
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 19)))
// 5/20 = cycle[(1+2)%4] = cycle[3] = OFF
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 20)))
// 5/21 = cycle[(2+2)%4] = cycle[0] = WORK
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 21)))
}
@Test
fun kindAt_multiplePhaseBreaks_usesLatestApplicable() {
// 两个断点:5/19(offset 0) 和 5/25(offset 1)
val pattern = twoOnTwoOff.copy(
phaseBreaks = listOf(
PhaseBreak(LocalDate(2026, 5, 19), 0),
PhaseBreak(LocalDate(2026, 5, 25), 1)
rephaseFlips = listOf(
RephaseFlip(LocalDate(2026, 5, 18), ShiftKind.OFF, LocalDate(2026, 5, 19)),
RephaseFlip(LocalDate(2026, 5, 24), ShiftKind.OFF, LocalDate(2026, 5, 25))
)
)
// 5/19-5/24 受第一个断点支配(offset 0)
// 5/19-5/23 受第一个重排支配(5/19 起锚)
// 5/19 = cycle[0] = WORK
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 19)))
// 5/24 = cycle[(5)%4] = cycle[1] = WORK
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 24)))
// 5/25 起受第二个断点支配(offset 1)
// 5/25 = cycle[(0+1)%4] = cycle[1] = WORK
// 5/23 = cycle[(4)%4] = cycle[0] = WORK
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 23)))
// 5/24 = 第二个 rephaseFlip 的翻转日,翻转为 OFF
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 24)))
// 5/25 起受第二个断点支配(5/25 起锚)
// 5/25 = cycle[0] = WORK
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 25)))
// 5/26 = cycle[(1+1)%4] = cycle[2] = OFF
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 26)))
// 5/26 = cycle[1] = WORK
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 26)))
// 5/27 = cycle[2] = OFF
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 27)))
}
// ---- kindAt: override + phaseBreak 组合(用户原始例子)----
// ---- kindAt: override + rephaseFlip 组合(用户原始例子)----
@Test
fun kindAt_userExample_combinedOverridesAndPhaseBreak() {
fun kindAt_userExample_combinedOverridesAndRephaseFlip() {
// 基础锚点 7/8,周期 [WORK,WORK,OFF,OFF]
val pattern = ShiftPattern(
anchorDate = LocalDate(2026, 7, 8),
cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF),
overrides = mapOf(
LocalDate(2026, 7, 12) to ShiftKind.OFF, // 班→休
LocalDate(2026, 7, 14) to ShiftKind.WORK, // 休→班
LocalDate(2026, 7, 15) to ShiftKind.WORK, // 休→班
LocalDate(2026, 7, 16) to ShiftKind.OFF, // 班→休
LocalDate(2026, 7, 17) to ShiftKind.OFF // 班→休
LocalDate(2026, 7, 12) to ShiftKind.OFF, // 班→休(单日)
LocalDate(2026, 7, 14) to ShiftKind.WORK, // 休→班(单日)
LocalDate(2026, 7, 15) to ShiftKind.WORK, // 休→班(单日)
LocalDate(2026, 7, 16) to ShiftKind.OFF, // 班→休(单日)
LocalDate(2026, 7, 17) to ShiftKind.OFF // 班→休(单日)
),
phaseBreaks = listOf(PhaseBreak(LocalDate(2026, 7, 18), 0))
// 7/17 翻转为 OFF,7/18 起重排
rephaseFlips = listOf(RephaseFlip(LocalDate(2026, 7, 17), ShiftKind.OFF, LocalDate(2026, 7, 18)))
)
// 7/10,11 = 基础周期自动算 (idx 2,3) = OFF,OFF
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 10)))
@ -279,19 +270,18 @@ class ShiftPatternTest {
// 7/14,15 = override WORK
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 14)))
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 15)))
// 7/16,17 = override OFF
// 7/16 = override OFF
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 16)))
// 7/17 = rephaseFlip 翻转为 OFF
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 17)))
// 7/18 起 phaseBreak 重排:cycle[0,1,2,3] = WORK,WORK,OFF,OFF
// 7/18 起 rephase 重排:cycle[0,1,2,3] = WORK,WORK,OFF,OFF
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 18)))
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 19)))
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 输出必须保持一致。
// ---- 长按"翻转并重排"场景(RephaseFlip 原子操作)----
/**
* 单次长按:锚点 7/10,2班2休长按 7/10 把班翻转为休,7/11 起重排
@ -299,12 +289,10 @@ class ShiftPatternTest {
*/
@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))
rephaseFlips = listOf(RephaseFlip(LocalDate(2026, 7, 10), ShiftKind.OFF, LocalDate(2026, 7, 11)))
)
// 7/10 翻转为休
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 10)))
@ -319,32 +307,28 @@ class ShiftPatternTest {
}
/**
* 连续两次长按:7/10 一次7/14 一次两个重排点共存
* 期望:7/11-13 受第一个断点支配,7/14 起受第二个断点支配
* 连续两次长按:7/10 一次7/14 一次两个 rephaseFlip 共存
* 期望:7/11-13 受第一个重排支配,7/15 起受第二个重排支配
*/
@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)
rephaseFlips = listOf(
RephaseFlip(LocalDate(2026, 7, 10), ShiftKind.OFF, LocalDate(2026, 7, 11)),
RephaseFlip(LocalDate(2026, 7, 14), ShiftKind.OFF, LocalDate(2026, 7, 15))
)
)
// 7/10 翻转为休
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 10)))
// 7/11-13 受第一个断点:班班休
// 7/11-13 受第一个重排(7/11 起):班班休
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 起受第二个断点:班班休休
// 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)))
@ -352,21 +336,17 @@ class ShiftPatternTest {
}
/**
* 撤销长按:移除 7/10 override 和关联的 7/11 断点,回到基础周期
* 撤销长按:移除整个 RephaseFlip ,回到基础周期
*/
@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()
rephaseFlips = listOf(RephaseFlip(LocalDate(2026, 7, 10), ShiftKind.OFF, LocalDate(2026, 7, 11)))
)
// 撤销:移除整个 rephaseFlip(原子删除,不会留孤立断点)
val after = before.copy(rephaseFlips = emptyList())
// 回到基础:7/10=班(锚点),7/12=休
assertEquals(ShiftKind.WORK, after.kindAt(LocalDate(2026, 7, 10)))
assertEquals(ShiftKind.WORK, after.kindAt(LocalDate(2026, 7, 11)))