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:
parent
d8014b5154
commit
f8df108bf3
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) }
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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)))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user