diff --git a/core/src/main/kotlin/plus/rua/project/ShiftPattern.kt b/core/src/main/kotlin/plus/rua/project/ShiftPattern.kt index 37a8c61..13bda2e 100644 --- a/core/src/main/kotlin/plus/rua/project/ShiftPattern.kt +++ b/core/src/main/kotlin/plus/rua/project/ShiftPattern.kt @@ -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, val overrides: Map = emptyMap(), - val phaseBreaks: List = emptyList(), + val rephaseFlips: List = 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 { - 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 } } diff --git a/core/src/main/kotlin/plus/rua/project/ShiftPatternStorage.kt b/core/src/main/kotlin/plus/rua/project/ShiftPatternStorage.kt index 600e9bc..29b49be 100644 --- a/core/src/main/kotlin/plus/rua/project/ShiftPatternStorage.kt +++ b/core/src/main/kotlin/plus/rua/project/ShiftPatternStorage.kt @@ -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 { + private fun parseRephase(s: String?): List { 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) } } diff --git a/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt b/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt index 286f7a5..b5ad972 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt @@ -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 diff --git a/core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt b/core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt index 8898101..9139b36 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt @@ -145,7 +145,7 @@ fun ShiftPatternScreen(onBack: () -> Unit) { pattern = pattern.copy( cycle = cycle, overrides = emptyMap(), - phaseBreaks = emptyList() + rephaseFlips = emptyList() ) }, label = { Text(label) } diff --git a/core/src/test/kotlin/plus/rua/project/ShiftPatternStorageTest.kt b/core/src/test/kotlin/plus/rua/project/ShiftPatternStorageTest.kt index 238188a..3bd3cce 100644 --- a/core/src/test/kotlin/plus/rua/project/ShiftPatternStorageTest.kt +++ b/core/src/test/kotlin/plus/rua/project/ShiftPatternStorageTest.kt @@ -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()) diff --git a/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt b/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt index 765fa53..cdb59ad 100644 --- a/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt +++ b/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt @@ -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)))