Add unit tests for CalendarViewModel and CalendarUtils

Inject Clock into CalendarViewModel for testability, add kotlinx-coroutines-test dependency, replace placeholder test with real coverage for ISO week numbers, month day grids, page mapping, and utility functions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xfy 2026-05-15 15:42:01 +08:00
parent 3599ff448c
commit 9648afc561
8 changed files with 400 additions and 16 deletions

View File

@ -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
```

View File

@ -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" }

View File

@ -24,6 +24,7 @@ dependencyResolutionManagement {
includeGroupAndSubgroups("com.google")
}
}
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
mavenCentral()
}
}

View File

@ -48,6 +48,7 @@ kotlin {
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
}
}
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}