diff --git a/CLAUDE.md b/CLAUDE.md index 9be761e..7e58e99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,10 +13,10 @@ YaYa is a calendar app built with Kotlin Multiplatform (KMP) + Compose Multiplat ./gradlew :androidApp:assembleDebug # Run shared module tests -./gradlew :shared:allTests +./gradlew :shared:testAndroidHostTest # Run a single test class -./gradlew :shared:androidHostTest --tests "plus.rua.project.ComposeAppCommonTest" +./gradlew :shared:testAndroidHostTest --tests "plus.rua.project.ui.CalendarUtilsTest" # Build iOS app — open iosApp/ in Xcode and run from there ``` diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 54fa44b..32b92b8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,7 @@ compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMul compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version = "1.10.2" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 7727015..a48c555 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,6 +24,7 @@ dependencyResolutionManagement { includeGroupAndSubgroups("com.google") } } + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") mavenCentral() } } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index a271404..34aaf6b 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -48,6 +48,7 @@ kotlin { } commonTest.dependencies { implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) } } } diff --git a/shared/src/commonMain/kotlin/plus/rua/project/CalendarViewModel.kt b/shared/src/commonMain/kotlin/plus/rua/project/CalendarViewModel.kt index 29b373b..da09596 100644 --- a/shared/src/commonMain/kotlin/plus/rua/project/CalendarViewModel.kt +++ b/shared/src/commonMain/kotlin/plus/rua/project/CalendarViewModel.kt @@ -30,8 +30,11 @@ data class CalendarDay( * * @param coroutineScope 协程作用域,用于驱动折叠动画 */ -class CalendarViewModel(private val coroutineScope: CoroutineScope) { - private val today: LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault()) +class CalendarViewModel( + private val coroutineScope: CoroutineScope, + private val clock: Clock = Clock.System +) { + private val today: LocalDate = clock.todayIn(TimeZone.currentSystemDefault()) var selectedDate by mutableStateOf(today) private set diff --git a/shared/src/commonTest/kotlin/plus/rua/project/CalendarViewModelTest.kt b/shared/src/commonTest/kotlin/plus/rua/project/CalendarViewModelTest.kt new file mode 100644 index 0000000..08d8cb2 --- /dev/null +++ b/shared/src/commonTest/kotlin/plus/rua/project/CalendarViewModelTest.kt @@ -0,0 +1,136 @@ +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 kotlinx.datetime.TimeZone +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +private class FixedClock(private val instant: Instant) : Clock { + override fun now(): Instant = instant +} + +class CalendarViewModelTest { + + private val fixedInstant = Instant.parse("2026-05-15T00:00:00Z") + private val testClock = FixedClock(fixedInstant) + + private fun createViewModel(): CalendarViewModel { + val scope = CoroutineScope(Dispatchers.Unconfined) + return CalendarViewModel(coroutineScope = scope, clock = testClock) + } + + // ---- getIsoWeekNumber ---- + + @Test + fun getIsoWeekNumber_regularDate() { + val vm = createViewModel() + assertEquals(20, vm.getIsoWeekNumber(LocalDate(2026, 5, 15))) + } + + @Test + fun getIsoWeekNumber_jan1() { + val vm = createViewModel() + assertEquals(1, vm.getIsoWeekNumber(LocalDate(2026, 1, 1))) + } + + @Test + fun getIsoWeekNumber_dec31() { + val vm = createViewModel() + assertEquals(53, vm.getIsoWeekNumber(LocalDate(2026, 12, 31))) + } + + @Test + fun getIsoWeekNumber_week52_boundary() { + val vm = createViewModel() + assertEquals(52, vm.getIsoWeekNumber(LocalDate(2025, 12, 28))) + } + + @Test + fun getIsoWeekNumber_mondayStartsWeek() { + val vm = createViewModel() + assertEquals(20, vm.getIsoWeekNumber(LocalDate(2026, 5, 11))) + } + + @Test + fun getIsoWeekNumber_week53_year() { + val vm = createViewModel() + assertEquals(53, vm.getIsoWeekNumber(LocalDate(2020, 12, 31))) + } + + // ---- getMonthDays ---- + + @Test + fun getMonthDays_returnsCorrectSize() { + val vm = createViewModel() + // May 2026: 5 rows × 7 = 35 cells + val days = vm.getMonthDays(2026, 5) + assertEquals(35, days.size) + } + + @Test + fun getMonthDays_may2026_startsOnThursday() { + val vm = createViewModel() + val days = vm.getMonthDays(2026, 5) + assertFalse(days[0].isCurrentMonth) + @Suppress("DEPRECATION") // monthNumber — needed for Int comparison + assertEquals(4, days[0].date.monthNumber) + assertEquals(27, days[0].date.day) + } + + @Test + fun getMonthDays_may2026_firstDayIsMay1() { + val vm = createViewModel() + val days = vm.getMonthDays(2026, 5) + assertTrue(days[4].isCurrentMonth) + assertEquals(1, days[4].date.day) + @Suppress("DEPRECATION") // monthNumber — needed for Int comparison + assertEquals(5, days[4].date.monthNumber) + } + + @Test + fun getMonthDays_may2026_lastDayIsMay31() { + val vm = createViewModel() + val days = vm.getMonthDays(2026, 5) + val may31 = days.first { it.isCurrentMonth && it.date.day == 31 } + assertEquals(31, may31.date.day) + } + + @Test + fun getMonthDays_february2026_28days() { + val vm = createViewModel() + val days = vm.getMonthDays(2026, 2) + val febDays = days.filter { it.isCurrentMonth } + assertEquals(28, febDays.size) + } + + @Test + fun getMonthDays_february2024_29days_leapYear() { + val vm = createViewModel() + val days = vm.getMonthDays(2024, 2) + val febDays = days.filter { it.isCurrentMonth } + assertEquals(29, febDays.size) + } + + @Test + fun getMonthDays_todayIsMarked() { + val vm = createViewModel() + val days = vm.getMonthDays(2026, 5) + val todayCell = days.first { it.isToday } + assertEquals(15, todayCell.date.day) + assertTrue(todayCell.isCurrentMonth) + } + + @Test + fun getMonthDays_selectedDateIsMarked() { + val vm = createViewModel() + val days = vm.getMonthDays(2026, 5) + val selectedCell = days.first { it.isSelected } + assertEquals(15, selectedCell.date.day) + } +} diff --git a/shared/src/commonTest/kotlin/plus/rua/project/ComposeAppCommonTest.kt b/shared/src/commonTest/kotlin/plus/rua/project/ComposeAppCommonTest.kt deleted file mode 100644 index 110e911..0000000 --- a/shared/src/commonTest/kotlin/plus/rua/project/ComposeAppCommonTest.kt +++ /dev/null @@ -1,12 +0,0 @@ -package plus.rua.project - -import kotlin.test.Test -import kotlin.test.assertEquals - -class ComposeAppCommonTest { - - @Test - fun example() { - assertEquals(3, 1 + 2) - } -} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/plus/rua/project/ui/CalendarUtilsTest.kt b/shared/src/commonTest/kotlin/plus/rua/project/ui/CalendarUtilsTest.kt new file mode 100644 index 0000000..77c2e4d --- /dev/null +++ b/shared/src/commonTest/kotlin/plus/rua/project/ui/CalendarUtilsTest.kt @@ -0,0 +1,254 @@ +package plus.rua.project.ui + +import kotlinx.datetime.DatePeriod +import kotlinx.datetime.LocalDate +import kotlinx.datetime.minus +import kotlinx.datetime.plus +import kotlin.test.Test +import kotlin.test.assertEquals + +class CalendarUtilsTest { + + // ---- calculateWeeksCount ---- + + @Test + fun calculateWeeksCount_normalFebruary_returns4Rows() { + // Feb 2027: starts on Monday, 28 days -> exactly 4 rows + assertEquals(4, calculateWeeksCount(2027, 2)) + } + + @Test + fun calculateWeeksCount_leapYearFebruary_returns5Rows() { + // Feb 2024: starts on Thursday, 29 days -> 5 rows + assertEquals(5, calculateWeeksCount(2024, 2)) + } + + @Test + fun calculateWeeksCount_sixRowMonth_returns6Rows() { + // Mar 2026: starts on Sunday (ordinal=6), 31 days -> 6 rows + assertEquals(6, calculateWeeksCount(2026, 3)) + } + + @Test + fun calculateWeeksCount_monthStartingMonday_31days_returns5Rows() { + // Jan 2027: starts on Friday (ordinal=4), 31 days + // offset=4, days=31, (4+31-1)/7 + 1 = 34/7 + 1 = 4+1 = 5 + assertEquals(5, calculateWeeksCount(2027, 1)) + } + + @Test + fun calculateWeeksCount_monthStartingSunday_returns6Rows() { + // Jun 2025: starts on Sunday (ordinal=6), 30 days + // offset=6, days=30, (6+30-1)/7 + 1 = 35/7 + 1 = 5+1 = 6 + assertEquals(6, calculateWeeksCount(2025, 6)) + } + + @Test + fun calculateWeeksCount_30dayMonthStartingSaturday_returns5Rows() { + // Nov 2025: starts on Saturday (ordinal=5), 30 days + // offset=5, days=30, (5+30-1)/7 + 1 = 34/7 + 1 = 4+1 = 5 + assertEquals(5, calculateWeeksCount(2025, 11)) + } + + @Test + fun calculateWeeksCount_december() { + // Dec 2026: starts on Wednesday (ordinal=2), 31 days + // offset=2, days=31, (2+31-1)/7 + 1 = 32/7 + 1 = 4+1 = 5 + assertEquals(5, calculateWeeksCount(2026, 12)) + } + + // ---- pageToYearMonth / yearMonthToPage ---- + + @Test + fun pageToYearMonth_centerPage_returnsInitialYearMonth() { + val (year, month) = pageToYearMonth(START_PAGE, 2026, 5) + assertEquals(2026, year) + assertEquals(5, month) + } + + @Test + fun pageToYearMonth_forwardOnePage_returnsNextMonth() { + val (year, month) = pageToYearMonth(START_PAGE + 1, 2026, 5) + assertEquals(2026, year) + assertEquals(6, month) + } + + @Test + fun pageToYearMonth_backwardOnePage_returnsPreviousMonth() { + val (year, month) = pageToYearMonth(START_PAGE - 1, 2026, 5) + assertEquals(2026, year) + assertEquals(4, month) + } + + @Test + fun pageToYearMonth_crossYearBoundary_forward() { + // From Dec 2026, forward 1 page -> Jan 2027 + val (year, month) = pageToYearMonth(START_PAGE + 1, 2026, 12) + assertEquals(2027, year) + assertEquals(1, month) + } + + @Test + fun pageToYearMonth_crossYearBoundary_backward() { + // From Jan 2026, backward 1 page -> Dec 2025 + val (year, month) = pageToYearMonth(START_PAGE - 1, 2026, 1) + assertEquals(2025, year) + assertEquals(12, month) + } + + @Test + fun pageToYearMonth_manyPagesForward() { + // 12 pages forward from May 2026 -> May 2027 + val (year, month) = pageToYearMonth(START_PAGE + 12, 2026, 5) + assertEquals(2027, year) + assertEquals(5, month) + } + + @Test + fun yearMonthToPage_centerMonth_returnsStartPage() { + assertEquals(START_PAGE, yearMonthToPage(2026, 5, 2026, 5)) + } + + @Test + fun yearMonthToPage_nextMonth_returnsNextPage() { + assertEquals(START_PAGE + 1, yearMonthToPage(2026, 6, 2026, 5)) + } + + @Test + fun yearMonthToPage_previousMonth_returnsPreviousPage() { + assertEquals(START_PAGE - 1, yearMonthToPage(2026, 4, 2026, 5)) + } + + @Test + fun yearMonthToPage_crossYearBoundary() { + assertEquals(START_PAGE + 1, yearMonthToPage(2027, 1, 2026, 12)) + } + + @Test + fun pageToYearMonth_yearMonthRoundTrip() { + // Converting page -> yearMonth -> page should return the original page + val initialYear = 2026 + val initialMonth = 5 + for (offset in -24..24) { + val page = START_PAGE + offset + val (y, m) = pageToYearMonth(page, initialYear, initialMonth) + val roundTrip = yearMonthToPage(y, m, initialYear, initialMonth) + assertEquals(page, roundTrip, "Round-trip failed for offset=$offset") + } + } + + // ---- LocalDate.toWeekMonday ---- + + @Test + fun toWeekMonday_monday_returnsItself() { + val monday = LocalDate(2026, 5, 11) // Monday + assertEquals(monday, monday.toWeekMonday()) + } + + @Test + fun toWeekMonday_tuesday_returnsPreviousMonday() { + val tuesday = LocalDate(2026, 5, 12) + assertEquals(LocalDate(2026, 5, 11), tuesday.toWeekMonday()) + } + + @Test + fun toWeekMonday_sunday_returnsPreviousMonday() { + val sunday = LocalDate(2026, 5, 17) // Sunday + assertEquals(LocalDate(2026, 5, 11), sunday.toWeekMonday()) + } + + @Test + fun toWeekMonday_crossMonthBoundary() { + // June 1, 2026 is a Monday - so May 31 (Sunday) should return May 25 + val sunday = LocalDate(2026, 5, 31) + assertEquals(LocalDate(2026, 5, 25), sunday.toWeekMonday()) + } + + @Test + fun toWeekMonday_crossYearBoundary() { + // Jan 1, 2026 is a Thursday. Monday of that week is Dec 29, 2025 + val thursday = LocalDate(2026, 1, 1) + assertEquals(LocalDate(2025, 12, 29), thursday.toWeekMonday()) + } + + @Test + fun toWeekMonday_saturday_returnsPreviousMonday() { + val saturday = LocalDate(2026, 5, 16) + assertEquals(LocalDate(2026, 5, 11), saturday.toWeekMonday()) + } + + @Test + fun toWeekMonday_wednesday_returnsPreviousMonday() { + val wednesday = LocalDate(2026, 5, 13) + assertEquals(LocalDate(2026, 5, 11), wednesday.toWeekMonday()) + } + + // ---- pageToWeekMonday ---- + + @Test + fun pageToWeekMonday_centerPage_returnsInitial() { + val initial = LocalDate(2026, 5, 11) // Monday + assertEquals(initial, pageToWeekMonday(START_PAGE, initial)) + } + + @Test + fun pageToWeekMonday_forwardOnePage_returnsNextWeekMonday() { + val initial = LocalDate(2026, 5, 11) + assertEquals(LocalDate(2026, 5, 18), pageToWeekMonday(START_PAGE + 1, initial)) + } + + @Test + fun pageToWeekMonday_backwardOnePage_returnsPreviousWeekMonday() { + val initial = LocalDate(2026, 5, 11) + assertEquals(LocalDate(2026, 5, 4), pageToWeekMonday(START_PAGE - 1, initial)) + } + + @Test + fun pageToWeekMonday_forwardMultiplePages() { + val initial = LocalDate(2026, 5, 11) + assertEquals(LocalDate(2026, 6, 8), pageToWeekMonday(START_PAGE + 4, initial)) + } + + @Test + fun pageToWeekMonday_backwardMultiplePages_crossMonth() { + val initial = LocalDate(2026, 5, 11) + assertEquals(LocalDate(2026, 4, 13), pageToWeekMonday(START_PAGE - 4, initial)) + } + + // ---- lerp ---- + + @Test + fun lerp_fractionZero_returnsStart() { + assertEquals(0f, lerp(0f, 100f, 0f), 0.01f) + } + + @Test + fun lerp_fractionOne_returnsEnd() { + assertEquals(100f, lerp(0f, 100f, 1f), 0.01f) + } + + @Test + fun lerp_fractionHalf_returnsMidpoint() { + assertEquals(50f, lerp(0f, 100f, 0.5f), 0.01f) + } + + @Test + fun lerp_negativeRange() { + assertEquals(0f, lerp(-50f, 50f, 0.5f), 0.01f) + } + + @Test + fun lerp_sameStartAndEnd_returnsSame() { + assertEquals(42f, lerp(42f, 42f, 0.5f), 0.01f) + } + + @Test + fun lerp_fractionGreaterThanOne() { + assertEquals(150f, lerp(0f, 100f, 1.5f), 0.01f) + } + + @Test + fun lerp_negativeFraction() { + assertEquals(-50f, lerp(0f, 100f, -0.5f), 0.01f) + } +}