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

View File

@ -10,9 +10,8 @@ import kotlinx.datetime.LocalDate
* 编码格式( JSON 依赖): * 编码格式( JSON 依赖):
* - 锚点:ISO 日期串 "2026-07-08" * - 锚点:ISO 日期串 "2026-07-08"
* - 周期:逗号分隔的 1/0 "1,1,0,0"(1=WORK,0=OFF) * - 周期:逗号分隔的 1/0 "1,1,0,0"(1=WORK,0=OFF)
* - overrides/breaks:逗号分隔的 "日期:值" * - overrides:逗号分隔的 "日期:值" , 1=WORK,0=OFF
* - overrides :1=WORK,0=OFF * - rephaseFlips:逗号分隔的 "翻转日:值:重排起点" 三元组, 1=WORK,0=OFF
* - breaks :cycleOffset(整数)
*/ */
class ShiftPatternStorage(private val prefs: SharedPreferences) { 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_ANCHOR = "shift_anchor"
private const val KEY_CYCLE = "shift_cycle" private const val KEY_CYCLE = "shift_cycle"
private const val KEY_OVERRIDES = "shift_overrides" 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 KEY_NAME = "shift_name"
private const val SEP = "," 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 cycleStr = pattern.cycle.joinToString(SEP) { if (it == ShiftKind.WORK) "1" else "0" }
val overridesStr = pattern.overrides.entries val overridesStr = pattern.overrides.entries
.joinToString(SEP) { "${it.key}:${if (it.value == ShiftKind.WORK) 1 else 0}" } .joinToString(SEP) { "${it.key}:${if (it.value == ShiftKind.WORK) 1 else 0}" }
val breaksStr = pattern.phaseBreaks val rephaseStr = pattern.rephaseFlips
.joinToString(SEP) { "${it.date}:${it.cycleOffset}" } .joinToString(SEP) {
"${it.date}:${if (it.flippedTo == ShiftKind.WORK) 1 else 0}:${it.rephaseFrom}"
}
prefs.edit() prefs.edit()
.putString(KEY_ANCHOR, pattern.anchorDate.toString()) .putString(KEY_ANCHOR, pattern.anchorDate.toString())
.putString(KEY_CYCLE, cycleStr) .putString(KEY_CYCLE, cycleStr)
.putString(KEY_OVERRIDES, overridesStr) .putString(KEY_OVERRIDES, overridesStr)
.putString(KEY_BREAKS, breaksStr) .putString(KEY_REPHASE, rephaseStr)
.putString(KEY_NAME, pattern.name) .putString(KEY_NAME, pattern.name)
.apply() .apply()
} }
@ -56,8 +57,8 @@ class ShiftPatternStorage(private val prefs: SharedPreferences) {
cycleStr.split(SEP).map { if (it.trim() == "1") ShiftKind.WORK else ShiftKind.OFF } cycleStr.split(SEP).map { if (it.trim() == "1") ShiftKind.WORK else ShiftKind.OFF }
} }
val overrides = parseOverrides(prefs.getString(KEY_OVERRIDES, null)) val overrides = parseOverrides(prefs.getString(KEY_OVERRIDES, null))
val breaks = parseBreaks(prefs.getString(KEY_BREAKS, null)) val rephaseFlips = parseRephase(prefs.getString(KEY_REPHASE, null))
ShiftPattern(anchor, cycle, overrides, breaks, name = prefs.getString(KEY_NAME, null) ?: "默认") ShiftPattern(anchor, cycle, overrides, rephaseFlips, name = prefs.getString(KEY_NAME, null) ?: "默认")
} catch (e: Exception) { } catch (e: Exception) {
null 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() if (s.isNullOrBlank()) return emptyList()
return s.split(SEP).map { pair -> return s.split(SEP).map { triple ->
val parts = pair.split(":") // 格式:翻转日:值:重排起点(ISO 日期不含冒号,split 安全)
PhaseBreak(LocalDate.parse(parts[0]), parts[1].trim().toInt()) 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.plus
import kotlinx.datetime.todayIn import kotlinx.datetime.todayIn
import kotlin.time.Clock import kotlin.time.Clock
import plus.rua.project.PhaseBreak import plus.rua.project.RephaseFlip
import plus.rua.project.ShiftKind import plus.rua.project.ShiftKind
import plus.rua.project.ShiftPattern 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 { 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 current = pattern.kindAt(date) ?: return pattern
val newVal = if (current == ShiftKind.WORK) ShiftKind.OFF else ShiftKind.WORK val newVal = if (current == ShiftKind.WORK) ShiftKind.OFF else ShiftKind.WORK
val tempPattern = pattern.copy(overrides = pattern.overrides - date) val tempPattern = pattern.copy(overrides = pattern.overrides - date)
@ -155,35 +163,27 @@ private fun toggleOverride(pattern: ShiftPattern, date: LocalDate): ShiftPattern
/** /**
* 翻转某天的班/,并从次日起重排周期(后续按 cycle 重新顺延) * 翻转某天的班/,并从次日起重排周期(后续按 cycle 重新顺延)
* *
* 实现:翻转该天 override + [date] 次日插入 PhaseBreak(offset=0) * 产出原子记录 [RephaseFlip]:翻转该天 + 从次日([rephaseFrom])起重排
* 次日成为新的 cycle[0],后续自动按周期顺延 * 次日成为新的 cycle[0],后续自动按周期顺延
* *
* 撤销:再次长按同一天,若存在由该天派生的断点(次日offset=0),则移除 override 与该断点 * 撤销:再次长按同一天,[RephaseFlip.date] 精确匹配并整体移除(不会误删其他记录)
* *
* @param date 被翻转并作为重排起点的日期 * @param date 被翻转并作为重排起点的日期
*/ */
private fun toggleFlipAndRephase(pattern: ShiftPattern, date: LocalDate): ShiftPattern { private fun toggleFlipAndRephase(pattern: ShiftPattern, date: LocalDate): ShiftPattern {
val nextDay = date.plus(DatePeriod(days = 1)) val existing = pattern.rephaseFlips.find { it.date == date }
val derivedBreak = pattern.phaseBreaks.find { it.date == nextDay && it.cycleOffset == 0 } if (existing != null) {
// 撤销:整体移除该 rephaseFlip(原子删除)
if (derivedBreak != null) { return pattern.copy(rephaseFlips = pattern.rephaseFlips - existing)
// 撤销:移除该天的 override 与关联断点
return pattern.copy(
overrides = pattern.overrides - date,
phaseBreaks = pattern.phaseBreaks - derivedBreak
)
} }
// 翻转该天 + 次日插入断点重排 // 翻转该天 + 次日重排
val current = pattern.kindAt(date) ?: return pattern val current = pattern.kindAt(date) ?: return pattern
val newVal = if (current == ShiftKind.WORK) ShiftKind.OFF else ShiftKind.WORK val newVal = if (current == ShiftKind.WORK) ShiftKind.OFF else ShiftKind.WORK
val tempPattern = pattern.copy(overrides = pattern.overrides - date) val rephaseFrom = date.plus(DatePeriod(days = 1))
val base = tempPattern.kindAt(date)
val newOverrides = if (newVal == base) pattern.overrides - date
else pattern.overrides + (date to newVal)
return pattern.copy( return pattern.copy(
overrides = newOverrides, overrides = pattern.overrides - date, // 该天改由 rephaseFlip 管辖,移除可能的旧 override
phaseBreaks = pattern.phaseBreaks + PhaseBreak(nextDay, 0) rephaseFlips = pattern.rephaseFlips + RephaseFlip(date, newVal, rephaseFrom)
) )
} }
@ -206,8 +206,8 @@ private fun ShiftDayCell(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val kind = pattern.kindAt(date) val kind = pattern.kindAt(date)
// 断点在"翻转日的次日",表示该天为重排起点 // rephaseFlip 的 rephaseFrom = 当天,表示该天为重排起点
val isRephaseStart = pattern.phaseBreaks.any { it.date == date && it.cycleOffset == 0 } val isRephaseStart = pattern.rephaseFlips.any { it.rephaseFrom == date }
val badgeColor = when { val badgeColor = when {
isRephaseStart -> MaterialTheme.colorScheme.tertiary isRephaseStart -> MaterialTheme.colorScheme.tertiary

View File

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

View File

@ -39,7 +39,7 @@ class ShiftPatternStorageTest {
LocalDate(2026, 7, 12) to ShiftKind.OFF, LocalDate(2026, 7, 12) to ShiftKind.OFF,
LocalDate(2026, 7, 14) to ShiftKind.WORK 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) storage.save(pattern)
val result = storage.load() val result = storage.load()
@ -53,7 +53,7 @@ class ShiftPatternStorageTest {
anchorDate = LocalDate(2026, 5, 15), anchorDate = LocalDate(2026, 5, 15),
cycle = listOf(ShiftKind.WORK, ShiftKind.OFF), cycle = listOf(ShiftKind.WORK, ShiftKind.OFF),
overrides = emptyMap(), overrides = emptyMap(),
phaseBreaks = emptyList() rephaseFlips = emptyList()
) )
storage.save(pattern) storage.save(pattern)
assertEquals(pattern, storage.load()) assertEquals(pattern, storage.load())

View File

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