From d3befaabec121ee1e7fccca42be915288cb744e9 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 19 May 2026 11:47:08 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=96=B0=E5=A2=9E=20ShiftPattern?= =?UTF-8?q?=E3=80=81CalendarUtils=E3=80=81CalendarViewModel=20=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ShiftPatternTest: 锚点前后、跨周期循环、负天数取模、空 cycle、 单元素 cycle、多样化周期、data class 属性 - CalendarUtilsExtraTest: calculateWeeksCountForPage(跨月/跨年)、 relativeDayDescription(今天/昨天/明天/N天前后/跨年月)、 formatLunarDate(农历前缀/正月初一/多日期验证) - CalendarViewModelStateTest: 初始状态、selectDate(含 currentMonth/Year 联动)、 increment/decrementYear、selectMonthFromYearView、shiftKindAt、 showLegalHoliday、onDrag/onExpandDrag progress 更新与 clamp、 getMonthDays 与 selectedDate/today 交互 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rua/project/CalendarViewModelStateTest.kt | 365 ++++++++++++++++++ .../plus/rua/project/ShiftPatternTest.kt | 179 +++++++++ .../rua/project/ui/CalendarUtilsExtraTest.kt | 155 ++++++++ 3 files changed, 699 insertions(+) create mode 100644 shared/src/commonTest/kotlin/plus/rua/project/CalendarViewModelStateTest.kt create mode 100644 shared/src/commonTest/kotlin/plus/rua/project/ShiftPatternTest.kt create mode 100644 shared/src/commonTest/kotlin/plus/rua/project/ui/CalendarUtilsExtraTest.kt diff --git a/shared/src/commonTest/kotlin/plus/rua/project/CalendarViewModelStateTest.kt b/shared/src/commonTest/kotlin/plus/rua/project/CalendarViewModelStateTest.kt new file mode 100644 index 0000000..540a028 --- /dev/null +++ b/shared/src/commonTest/kotlin/plus/rua/project/CalendarViewModelStateTest.kt @@ -0,0 +1,365 @@ +package plus.rua.project + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +private class StateTestFixedClock(private val instant: Instant) : Clock { + override fun now(): Instant = instant +} + +/** + * 覆盖 [CalendarViewModel] 中与日期选择、年视图、班次、拖拽 progress 等 + * 同步可观察状态相关的逻辑。 + * + * 动画完成的最终状态(例如 [CalendarViewModel.isCollapsed] 在 spring + * 动画结束后的取值)需要 MonotonicFrameClock 驱动,不在本测试集合范围内。 + */ +class CalendarViewModelStateTest { + + // 固定 today = 2026/5/15 + private val fixedInstant = Instant.parse("2026-05-15T00:00:00Z") + private val testClock = StateTestFixedClock(fixedInstant) + + private fun createViewModel(): CalendarViewModel { + val scope = CoroutineScope(Dispatchers.Unconfined) + return CalendarViewModel(coroutineScope = scope, clock = testClock) + } + + // ---- 初始状态 ---- + + @Test + fun init_selectedDateIsToday() { + val vm = createViewModel() + assertEquals(LocalDate(2026, 5, 15), vm.selectedDate) + } + + @Test + fun init_isCollapsedDefaultsFalse() { + assertFalse(createViewModel().isCollapsed) + } + + @Test + fun init_collapseProgressDefaultsZero() { + assertEquals(0f, createViewModel().collapseProgress, 0.001f) + } + + @Test + fun init_isYearViewDefaultsFalse() { + assertFalse(createViewModel().isYearView) + } + + @Test + fun init_yearViewProgressDefaultsZero() { + assertEquals(0f, createViewModel().yearViewProgress, 0.001f) + } + + @Test + fun init_yearViewYearDefaultsToTodayYear() { + assertEquals(2026, createViewModel().yearViewYear) + } + + @Test + fun init_showLegalHolidayDefaultsFalse() { + assertFalse(createViewModel().showLegalHoliday) + } + + @Test + fun init_shiftPatternHasDefault() { + val pattern = createViewModel().shiftPattern + assertNotNull(pattern) + assertEquals(LocalDate(2026, 5, 15), pattern.anchorDate) + assertEquals(4, pattern.cycle.size) + } + + @Test + fun init_currentMonthMatchesToday() { + assertEquals(5, createViewModel().currentMonth) + } + + @Test + fun init_currentYearMatchesToday() { + assertEquals(2026, createViewModel().currentYear) + } + + // ---- selectDate ---- + + @Test + fun selectDate_updatesSelectedDate() { + val vm = createViewModel() + vm.selectDate(LocalDate(2026, 6, 1)) + assertEquals(LocalDate(2026, 6, 1), vm.selectedDate) + } + + @Test + fun selectDate_currentMonthFollowsSelection() { + val vm = createViewModel() + vm.selectDate(LocalDate(2026, 8, 20)) + assertEquals(8, vm.currentMonth) + assertEquals(2026, vm.currentYear) + } + + @Test + fun selectDate_yearFollowsSelection() { + val vm = createViewModel() + vm.selectDate(LocalDate(2027, 1, 1)) + assertEquals(2027, vm.currentYear) + assertEquals(1, vm.currentMonth) + } + + @Test + fun selectDate_pastDate_updatesCorrectly() { + val vm = createViewModel() + vm.selectDate(LocalDate(2020, 12, 31)) + assertEquals(LocalDate(2020, 12, 31), vm.selectedDate) + assertEquals(12, vm.currentMonth) + assertEquals(2020, vm.currentYear) + } + + // ---- incrementYear / decrementYear ---- + + @Test + fun incrementYear_increasesYearViewYear() { + val vm = createViewModel() + vm.incrementYear() + assertEquals(2027, vm.yearViewYear) + } + + @Test + fun decrementYear_decreasesYearViewYear() { + val vm = createViewModel() + vm.decrementYear() + assertEquals(2025, vm.yearViewYear) + } + + @Test + fun incrementDecrementYear_consecutiveCalls() { + val vm = createViewModel() + repeat(5) { vm.incrementYear() } + assertEquals(2031, vm.yearViewYear) + repeat(3) { vm.decrementYear() } + assertEquals(2028, vm.yearViewYear) + } + + @Test + fun incrementYear_doesNotAffectSelectedDate() { + val vm = createViewModel() + val before = vm.selectedDate + vm.incrementYear() + assertEquals(before, vm.selectedDate) + } + + // ---- selectMonthFromYearView ---- + + @Test + fun selectMonthFromYearView_sameYearOtherMonth_setsFirstDayOfMonth() { + val vm = createViewModel() + vm.selectMonthFromYearView(8) + assertEquals(LocalDate(2026, 8, 1), vm.selectedDate) + } + + @Test + fun selectMonthFromYearView_currentYearAndMonth_setsToToday() { + val vm = createViewModel() + // yearViewYear = 2026, today.month = 5 + vm.selectMonthFromYearView(5) + assertEquals(LocalDate(2026, 5, 15), vm.selectedDate) + } + + @Test + fun selectMonthFromYearView_otherYear_setsFirstDay() { + val vm = createViewModel() + vm.incrementYear() // yearViewYear = 2027 + vm.selectMonthFromYearView(5) + assertEquals(LocalDate(2027, 5, 1), vm.selectedDate) + } + + @Test + fun selectMonthFromYearView_setsIsYearViewFalse() { + val vm = createViewModel() + vm.selectMonthFromYearView(3) + assertFalse(vm.isYearView) + } + + @Test + fun selectMonthFromYearView_january() { + val vm = createViewModel() + vm.selectMonthFromYearView(1) + assertEquals(LocalDate(2026, 1, 1), vm.selectedDate) + } + + @Test + fun selectMonthFromYearView_december() { + val vm = createViewModel() + vm.selectMonthFromYearView(12) + assertEquals(LocalDate(2026, 12, 1), vm.selectedDate) + } + + // ---- shiftKindAt ---- + + @Test + fun shiftKindAt_anchorDate_returnsWork() { + // default pattern: anchor 2026-05-15, cycle WORK/WORK/OFF/OFF + val vm = createViewModel() + assertEquals(ShiftKind.WORK, vm.shiftKindAt(LocalDate(2026, 5, 15))) + } + + @Test + fun shiftKindAt_dayAfterAnchor_returnsWork() { + val vm = createViewModel() + assertEquals(ShiftKind.WORK, vm.shiftKindAt(LocalDate(2026, 5, 16))) + } + + @Test + fun shiftKindAt_twoDaysAfterAnchor_returnsOff() { + val vm = createViewModel() + assertEquals(ShiftKind.OFF, vm.shiftKindAt(LocalDate(2026, 5, 17))) + } + + @Test + fun shiftKindAt_nullPattern_returnsNull() { + val vm = createViewModel() + vm.shiftPattern = null + assertNull(vm.shiftKindAt(LocalDate(2026, 5, 15))) + } + + @Test + fun shiftKindAt_customPattern_usesNewPattern() { + val vm = createViewModel() + vm.shiftPattern = ShiftPattern( + anchorDate = LocalDate(2026, 5, 15), + cycle = listOf(ShiftKind.OFF, ShiftKind.WORK) + ) + assertEquals(ShiftKind.OFF, vm.shiftKindAt(LocalDate(2026, 5, 15))) + assertEquals(ShiftKind.WORK, vm.shiftKindAt(LocalDate(2026, 5, 16))) + assertEquals(ShiftKind.OFF, vm.shiftKindAt(LocalDate(2026, 5, 17))) + } + + // ---- showLegalHoliday ---- + + @Test + fun showLegalHoliday_canBeToggled() { + val vm = createViewModel() + assertFalse(vm.showLegalHoliday) + vm.showLegalHoliday = true + assertTrue(vm.showLegalHoliday) + vm.showLegalHoliday = false + assertFalse(vm.showLegalHoliday) + } + + // ---- onDrag: 折叠拖拽(同步路径:snapTo)---- + + @Test + fun onDrag_positiveDelta_increasesProgress() { + val vm = createViewModel() + vm.onDrag(0.3f) + assertEquals(0.3f, vm.collapseProgress, 0.001f) + } + + @Test + fun onDrag_accumulatesAcrossCalls() { + val vm = createViewModel() + vm.onDrag(0.2f) + vm.onDrag(0.3f) + assertEquals(0.5f, vm.collapseProgress, 0.001f) + } + + @Test + fun onDrag_clampsAtOne() { + val vm = createViewModel() + vm.onDrag(0.8f) + vm.onDrag(0.8f) + assertEquals(1f, vm.collapseProgress, 0.001f) + } + + @Test + fun onDrag_clampsAtZeroWhenNegativeFromZero() { + val vm = createViewModel() + vm.onDrag(-0.3f) + assertEquals(0f, vm.collapseProgress, 0.001f) + } + + @Test + fun onDrag_negativeAfterPositive_canDecrease() { + val vm = createViewModel() + vm.onDrag(0.5f) + vm.onDrag(-0.2f) + assertEquals(0.3f, vm.collapseProgress, 0.001f) + } + + // ---- onExpandDrag: 展开拖拽 ---- + + @Test + fun onExpandDrag_updatesProgress() { + val vm = createViewModel() + // 先把 progress 推到 1 + vm.onDrag(1f) + assertEquals(1f, vm.collapseProgress, 0.001f) + // 展开方向:delta 为负 + vm.onExpandDrag(-0.4f) + assertEquals(0.6f, vm.collapseProgress, 0.001f) + } + + @Test + fun onExpandDrag_clampsAtZero() { + val vm = createViewModel() + vm.onDrag(0.5f) + vm.onExpandDrag(-1f) + assertEquals(0f, vm.collapseProgress, 0.001f) + } + + @Test + fun onExpandDrag_clampsAtOne() { + val vm = createViewModel() + vm.onExpandDrag(2f) + assertEquals(1f, vm.collapseProgress, 0.001f) + } + + // ---- getMonthDays 与 selectedDate 配合 ---- + + @Test + fun getMonthDays_updatesIsSelectedAfterSelectDate() { + val vm = createViewModel() + vm.selectDate(LocalDate(2026, 5, 20)) + val days = vm.getMonthDays(2026, 5) + val selectedCell = days.first { it.isSelected } + assertEquals(20, selectedCell.date.day) + } + + @Test + fun getMonthDays_noCellSelectedInOtherMonth() { + val vm = createViewModel() + // selectedDate 默认是今天(5/15),不在 2026/8 月内(含跨月填充也不可能) + val days = vm.getMonthDays(2026, 8) + assertTrue(days.none { it.isSelected }) + } + + @Test + fun getMonthDays_todayCellAlwaysReflectsTodayClock() { + val vm = createViewModel() + // 即便选中其他日期,isToday 依然根据 clock 注入的 today + vm.selectDate(LocalDate(2026, 5, 20)) + val days = vm.getMonthDays(2026, 5) + val todayCell = days.first { it.isToday } + assertEquals(15, todayCell.date.day) + } + + @Test + fun getMonthDays_returnsMultipleOfSeven() { + val vm = createViewModel() + // 任何月份,cells 数都应该是 7 的倍数 + for (month in 1..12) { + val size = vm.getMonthDays(2026, month).size + assertEquals(0, size % 7, "Month 2026/$month size=$size not multiple of 7") + assertTrue(size in 28..42, "Month 2026/$month size=$size out of [28, 42]") + } + } +} diff --git a/shared/src/commonTest/kotlin/plus/rua/project/ShiftPatternTest.kt b/shared/src/commonTest/kotlin/plus/rua/project/ShiftPatternTest.kt new file mode 100644 index 0000000..1958796 --- /dev/null +++ b/shared/src/commonTest/kotlin/plus/rua/project/ShiftPatternTest.kt @@ -0,0 +1,179 @@ +package plus.rua.project + +import kotlinx.datetime.LocalDate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ShiftPatternTest { + + private val anchor = LocalDate(2026, 5, 15) + private val twoOnTwoOff = ShiftPattern( + anchorDate = anchor, + cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF) + ) + + // ---- kindAt: 锚点与同周期内 ---- + + @Test + fun kindAt_anchorDate_returnsFirstInCycle() { + assertEquals(ShiftKind.WORK, twoOnTwoOff.kindAt(anchor)) + } + + @Test + fun kindAt_oneAfterAnchor_returnsSecondInCycle() { + assertEquals(ShiftKind.WORK, twoOnTwoOff.kindAt(LocalDate(2026, 5, 16))) + } + + @Test + fun kindAt_twoAfterAnchor_returnsThirdInCycle() { + assertEquals(ShiftKind.OFF, twoOnTwoOff.kindAt(LocalDate(2026, 5, 17))) + } + + @Test + fun kindAt_threeAfterAnchor_returnsFourthInCycle() { + assertEquals(ShiftKind.OFF, twoOnTwoOff.kindAt(LocalDate(2026, 5, 18))) + } + + // ---- kindAt: 周期循环 ---- + + @Test + fun kindAt_fourAfterAnchor_wrapsToCycleStart() { + // (5/19 - 5/15) % 4 = 0 + assertEquals(ShiftKind.WORK, twoOnTwoOff.kindAt(LocalDate(2026, 5, 19))) + } + + @Test + fun kindAt_eightAfterAnchor_wrapsTwice() { + // (5/23 - 5/15) % 4 = 0 + assertEquals(ShiftKind.WORK, twoOnTwoOff.kindAt(LocalDate(2026, 5, 23))) + } + + @Test + fun kindAt_oneCycleLater_idx2_returnsOff() { + // (5/21 - 5/15) % 4 = 2 + assertEquals(ShiftKind.OFF, twoOnTwoOff.kindAt(LocalDate(2026, 5, 21))) + } + + @Test + fun kindAt_manyCyclesLater_correctlyWraps() { + // 100天后: (100) % 4 = 0 + assertEquals(ShiftKind.WORK, twoOnTwoOff.kindAt(LocalDate(2026, 8, 23))) + } + + // ---- kindAt: 锚点之前的日期(负差值处理)---- + + @Test + fun kindAt_oneDayBeforeAnchor_returnsLastInCycle() { + // -1 mod 4 = 3 -> OFF (cycle[3]) + assertEquals(ShiftKind.OFF, twoOnTwoOff.kindAt(LocalDate(2026, 5, 14))) + } + + @Test + fun kindAt_twoDaysBeforeAnchor_returnsThirdInCycle() { + // -2 mod 4 = 2 -> OFF (cycle[2]) + assertEquals(ShiftKind.OFF, twoOnTwoOff.kindAt(LocalDate(2026, 5, 13))) + } + + @Test + fun kindAt_threeDaysBeforeAnchor_returnsSecondInCycle() { + // -3 mod 4 = 1 -> WORK (cycle[1]) + assertEquals(ShiftKind.WORK, twoOnTwoOff.kindAt(LocalDate(2026, 5, 12))) + } + + @Test + fun kindAt_fourDaysBeforeAnchor_returnsFirstInCycle() { + // -4 mod 4 = 0 -> WORK (cycle[0]) + assertEquals(ShiftKind.WORK, twoOnTwoOff.kindAt(LocalDate(2026, 5, 11))) + } + + @Test + fun kindAt_manyDaysBeforeAnchor_correctlyWraps() { + // -100 mod 4 = 0 -> WORK + assertEquals(ShiftKind.WORK, twoOnTwoOff.kindAt(LocalDate(2026, 2, 4))) + } + + // ---- kindAt: 边界情况 ---- + + @Test + fun kindAt_emptyCycle_returnsNull() { + val pattern = ShiftPattern(anchorDate = anchor, cycle = emptyList()) + assertNull(pattern.kindAt(anchor)) + assertNull(pattern.kindAt(LocalDate(2026, 5, 16))) + assertNull(pattern.kindAt(LocalDate(2026, 5, 14))) + } + + @Test + fun kindAt_singleElementCycle_alwaysReturnsThatElement() { + val pattern = ShiftPattern(anchorDate = anchor, cycle = listOf(ShiftKind.WORK)) + assertEquals(ShiftKind.WORK, pattern.kindAt(anchor)) + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 20))) + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 1, 1))) + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2027, 12, 31))) + } + + @Test + fun kindAt_singleOffCycle_alwaysReturnsOff() { + val pattern = ShiftPattern(anchorDate = anchor, cycle = listOf(ShiftKind.OFF)) + assertEquals(ShiftKind.OFF, pattern.kindAt(anchor)) + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2030, 6, 15))) + } + + // ---- kindAt: 多样化周期 ---- + + @Test + fun kindAt_threeOnOneOffCycle() { + // 4 day cycle: WORK WORK WORK OFF + val pattern = ShiftPattern( + anchorDate = anchor, + cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF) + ) + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 15))) // 0 + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 16))) // 1 + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 17))) // 2 + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 18))) // 3 + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 19))) // 0 + } + + @Test + fun kindAt_weekCycle_returnsCorrectDay() { + // 7天周期:4天上班3天休息 + val pattern = ShiftPattern( + anchorDate = anchor, + cycle = listOf( + ShiftKind.WORK, ShiftKind.WORK, ShiftKind.WORK, ShiftKind.WORK, + ShiftKind.OFF, ShiftKind.OFF, ShiftKind.OFF + ) + ) + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 18))) // idx 3 + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 19))) // idx 4 + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 21))) // idx 6 + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 22))) // idx 0 (next cycle) + } + + // ---- ShiftPattern: 元数据 ---- + + @Test + fun shiftPattern_defaultNameIsChinese() { + val pattern = ShiftPattern(anchorDate = anchor, cycle = listOf(ShiftKind.WORK)) + assertEquals("默认", pattern.name) + } + + @Test + fun shiftPattern_customNameIsPreserved() { + val pattern = ShiftPattern( + anchorDate = anchor, + cycle = listOf(ShiftKind.WORK), + name = "夜班" + ) + assertEquals("夜班", pattern.name) + } + + @Test + fun shiftPattern_dataClassEquality() { + val a = ShiftPattern(anchor, listOf(ShiftKind.WORK, ShiftKind.OFF)) + val b = ShiftPattern(anchor, listOf(ShiftKind.WORK, ShiftKind.OFF)) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } +} diff --git a/shared/src/commonTest/kotlin/plus/rua/project/ui/CalendarUtilsExtraTest.kt b/shared/src/commonTest/kotlin/plus/rua/project/ui/CalendarUtilsExtraTest.kt new file mode 100644 index 0000000..15cffaa --- /dev/null +++ b/shared/src/commonTest/kotlin/plus/rua/project/ui/CalendarUtilsExtraTest.kt @@ -0,0 +1,155 @@ +package plus.rua.project.ui + +import kotlinx.datetime.LocalDate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * 测试 CalendarUtils 中尚未被 [CalendarUtilsTest] 覆盖的函数: + * - [calculateWeeksCountForPage] + * - [relativeDayDescription] + * - [formatLunarDate] + */ +class CalendarUtilsExtraTest { + + // ---- calculateWeeksCountForPage ---- + + @Test + fun calculateWeeksCountForPage_centerPage_returnsCurrentMonthRows() { + // today = 2026/5/15 (May), May 2026 has 5 rows + val today = LocalDate(2026, 5, 15) + assertEquals(calculateWeeksCount(2026, 5), calculateWeeksCountForPage(START_PAGE, today)) + } + + @Test + fun calculateWeeksCountForPage_forwardOnePage_returnsNextMonthRows() { + // From May 2026, +1 -> June 2026 + val today = LocalDate(2026, 5, 15) + assertEquals(calculateWeeksCount(2026, 6), calculateWeeksCountForPage(START_PAGE + 1, today)) + } + + @Test + fun calculateWeeksCountForPage_backwardOnePage_returnsPreviousMonthRows() { + // From May 2026, -1 -> April 2026 + val today = LocalDate(2026, 5, 15) + assertEquals(calculateWeeksCount(2026, 4), calculateWeeksCountForPage(START_PAGE - 1, today)) + } + + @Test + fun calculateWeeksCountForPage_crossYearForward() { + // From December 2026, +1 -> January 2027 + val today = LocalDate(2026, 12, 10) + assertEquals(calculateWeeksCount(2027, 1), calculateWeeksCountForPage(START_PAGE + 1, today)) + } + + @Test + fun calculateWeeksCountForPage_crossYearBackward() { + // From January 2026, -1 -> December 2025 + val today = LocalDate(2026, 1, 10) + assertEquals(calculateWeeksCount(2025, 12), calculateWeeksCountForPage(START_PAGE - 1, today)) + } + + @Test + fun calculateWeeksCountForPage_twelvePagesForward_returnsSameMonthOfNextYear() { + val today = LocalDate(2026, 5, 15) + // +12 -> May 2027 + assertEquals(calculateWeeksCount(2027, 5), calculateWeeksCountForPage(START_PAGE + 12, today)) + } + + // ---- relativeDayDescription ---- + + @Test + fun relativeDayDescription_today_returnsToday() { + val today = LocalDate(2026, 5, 19) + assertEquals("今天", relativeDayDescription(today, today)) + } + + @Test + fun relativeDayDescription_yesterday_returnsYesterday() { + val today = LocalDate(2026, 5, 19) + val yesterday = LocalDate(2026, 5, 18) + assertEquals("昨天", relativeDayDescription(yesterday, today)) + } + + @Test + fun relativeDayDescription_tomorrow_returnsTomorrow() { + val today = LocalDate(2026, 5, 19) + val tomorrow = LocalDate(2026, 5, 20) + assertEquals("明天", relativeDayDescription(tomorrow, today)) + } + + @Test + fun relativeDayDescription_twoDaysBefore_returnsXDaysAgo() { + val today = LocalDate(2026, 5, 19) + assertEquals("2天前", relativeDayDescription(LocalDate(2026, 5, 17), today)) + } + + @Test + fun relativeDayDescription_twoDaysAfter_returnsXDaysLater() { + val today = LocalDate(2026, 5, 19) + assertEquals("2天后", relativeDayDescription(LocalDate(2026, 5, 21), today)) + } + + @Test + fun relativeDayDescription_aWeekBefore_returnsCorrectDays() { + val today = LocalDate(2026, 5, 19) + assertEquals("7天前", relativeDayDescription(LocalDate(2026, 5, 12), today)) + } + + @Test + fun relativeDayDescription_thirtyDaysAfter_returnsCorrectDays() { + val today = LocalDate(2026, 5, 1) + assertEquals("30天后", relativeDayDescription(LocalDate(2026, 5, 31), today)) + } + + @Test + fun relativeDayDescription_crossMonthBackward_returnsCorrectDays() { + val today = LocalDate(2026, 5, 2) + assertEquals("3天前", relativeDayDescription(LocalDate(2026, 4, 29), today)) + } + + @Test + fun relativeDayDescription_crossYearForward_returnsCorrectDays() { + val today = LocalDate(2025, 12, 30) + assertEquals("5天后", relativeDayDescription(LocalDate(2026, 1, 4), today)) + } + + // ---- formatLunarDate ---- + + @Test + fun formatLunarDate_startsWithLunarPrefix() { + val result = formatLunarDate(LocalDate(2026, 5, 19)) + assertTrue(result.startsWith("农历"), "Expected to start with '农历', got: $result") + } + + @Test + fun formatLunarDate_january1_2026_returnsCorrectLunar() { + // 2026/1/1 公历 -> 2025年农历十一月十二 + val result = formatLunarDate(LocalDate(2026, 1, 1)) + assertTrue(result.startsWith("农历"), "Expected '农历' prefix, got: $result") + // 验证不是空字符串 + assertTrue(result.length > 2, "Lunar date description should contain month and day") + } + + @Test + fun formatLunarDate_lunarNewYear2026_returnsFirstDayOfFirstMonth() { + // 2026年农历正月初一 = 2026/2/17 公历 + val result = formatLunarDate(LocalDate(2026, 2, 17)) + assertEquals("农历正月初一", result) + } + + @Test + fun formatLunarDate_anyDate_containsMonthAndDayNames() { + // 仅验证格式:农历 + 月 + 日 + for (day in listOf( + LocalDate(2026, 3, 1), + LocalDate(2026, 6, 30), + LocalDate(2026, 12, 25) + )) { + val result = formatLunarDate(day) + assertTrue(result.startsWith("农历"), "Expected '农历' prefix for $day, got: $result") + assertTrue(result.length >= 5, "Result for $day too short: $result") + } + } +}