From bf28008d17f14a14a03b9cef15716232f747b22a Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 21 May 2026 17:51:12 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20Wave=201=20=E2=80=94=20=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E6=B8=85=E7=90=86=E3=80=81=E6=B5=8B=E8=AF=95=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E3=80=81=E6=96=87=E6=A1=A3=E4=BF=AE=E6=AD=A3=E3=80=81?= =?UTF-8?q?a11y=20=E6=94=B9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build: 清理版本目录死依赖(appcompat、core、material3) - build: 精简 ProGuard keep 规则,仅保留热点路径和第三方库 - refactor: 重命名 PredictiveBackHandler → AppPredictiveBackHandler - test: formatLunarDate 精确断言 + 闰月测试 - test: 统一测试命名风格(下划线),Unconfined → StandardTestDispatcher - docs: 修正文档为纯 Android + Jetpack Compose 描述 - feat(a11y): 添加可访问性语义标注(WeekdayHeader heading、BottomCard 拖拽描述) - feat: 为 DayCell、MonthHeader、WeekdayHeader、BottomCard 添加 @Preview Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 39 +-- DEVELOPMENT.md | 267 +----------------- core/proguard-rules.pro | 95 +------ .../main/kotlin/plus/rua/project/Platform.kt | 2 +- .../kotlin/plus/rua/project/ui/BottomCard.kt | 19 ++ .../kotlin/plus/rua/project/ui/DayCell.kt | 15 + .../kotlin/plus/rua/project/ui/MonthHeader.kt | 13 + .../plus/rua/project/ui/WeekdayHeader.kt | 16 +- .../plus/rua/project/ui/YearGridView.kt | 9 +- .../rua/project/CalendarViewModelStateTest.kt | 66 +++-- .../plus/rua/project/CalendarViewModelTest.kt | 35 +-- .../rua/project/ui/CalendarUtilsExtraTest.kt | 34 +-- gradle/libs.versions.toml | 3 - 13 files changed, 177 insertions(+), 436 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6abfb39..ee1bf10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,47 +4,32 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -YaYa is a calendar app built with Kotlin Multiplatform (KMP) + Compose Multiplatform, targeting Android and iOS. The shared UI is written entirely in Compose Multiplatform with Material 3. +YaYa is a calendar app built with pure Android + Jetpack Compose, targeting Android only. The UI is written entirely in Jetpack Compose with Material 3. ## Build Commands ```bash # Build Android debug APK -./gradlew :androidApp:assembleDebug +./gradlew :app:assembleDebug # Install Android debug APK to connected device -./gradlew :androidApp:installDebug +./gradlew :app:installDebug -# Run all shared module tests -./gradlew :shared:allTests - -# Run shared module tests on Android host only -./gradlew :shared:testAndroidHostTest +# Run core module tests +./gradlew :core:testDebugUnitTest # Run a single test class -./gradlew :shared:testAndroidHostTest --tests "plus.rua.project.ui.CalendarUtilsTest" - -# Generate iOS framework (required before first Xcode open or after clean) -./gradlew :shared:generateDummyFramework - -# Build iOS app — open iosApp/iosApp.xcworkspace in Xcode and run from there +./gradlew :core:testDebugUnitTest --tests "plus.rua.project.ui.CalendarUtilsTest" ``` Gradle configuration cache and build cache are enabled by default (`gradle.properties`). ## Architecture -**Two-module structure:** -- `:shared` — all Compose UI, ViewModel, and business logic (KMP library) -- `:androidApp` — thin Android shell (`MainActivity` → `App()`) - -iOS entry point is `MainViewController.kt` in `shared/src/iosMain/`, consumed by the Xcode project in `iosApp/`. - -**Shared source sets:** -- `commonMain` — all Compose UI and ViewModel code -- `commonTest` — shared tests (run via `:shared:allTests` or `:shared:androidHostTest`) -- `androidMain` — Android-specific platform impl + preview tooling -- `iosMain` — `ComposeUIViewController` factory +**Three-module structure:** +- `:core` — all Compose UI, ViewModel, and business logic (`com.android.library`) +- `:app` — thin Android shell (`MainActivity` → `App()`) +- `:macrobenchmark` — Macrobenchmark module for Baseline Profile generation **Calendar UI composition** (all in `plus.rua.project.ui`): ``` @@ -71,7 +56,7 @@ CalendarMonthView ← top-level screen (MonthHeader + WeekdayHeader + p `CalendarViewModel` holds `selectedDate` and `isCollapsed` state, computes month day grids (6×7=42 cells) and ISO week numbers. Week starts on Monday (ISO 8601). -**Performance tracing:** `ComposeTrace.kt` provides `composeTraceBeginSection`/`composeTraceEndSection` via expect/actual — Android routes to `android.os.Trace`, iOS is a no-op. Custom markers are inserted at key points (e.g., `MonthView:Compose`, `YearView:Compose`, `VM:collapseProgress`) for Perfetto/Systrace analysis. See `DEVELOPMENT.md` for trace recording and Python parsing scripts. +**Performance tracing:** `ComposeTrace.kt` provides `composeTraceBeginSection`/`composeTraceEndSection` wrapping `android.os.Trace`. Custom markers are inserted at key points (e.g., `MonthView:Compose`, `YearView:Compose`, `VM:collapseProgress`) for Perfetto/Systrace analysis. See `DEVELOPMENT.md` for trace recording and Python parsing scripts. ## Key Dependencies @@ -85,7 +70,7 @@ CalendarMonthView ← top-level screen (MonthHeader + WeekdayHeader + p ## Conventions -- Package: `plus.rua.project` (shared), `plus.rua.project.ui` (UI composables) +- Package: `plus.rua.project` (core), `plus.rua.project.ui` (UI composables) - Version catalog at `gradle/libs.versions.toml` — all dependency versions declared there - `@Suppress("DEPRECATION")` used for `monthNumber` access on `kotlinx.datetime.LocalDate` — must include inline comment explaining reason - UI text is in Chinese (weekday labels, month header format "2026年5月") diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 4ea7984..71e9f65 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,259 +1,5 @@ # 开发指南 -## 环境要求 - -- JDK 17+ -- Android Studio (Ladybug 或更新版本) -- Xcode 16+ (仅 iOS 构建需要) -- Kotlin Multiplatform 插件 (Android Studio 内置) - -## 项目结构 - -``` -YaYa/ -├── shared/ # 共享模块 — 所有 UI 和业务逻辑 -│ ├── src/commonMain/kotlin/ # 跨平台代码 -│ │ └── plus/rua/project/ -│ │ ├── App.kt # 应用入口 -│ │ ├── CalendarViewModel.kt # 日历状态管理 -│ │ └── ui/ -│ │ ├── CalendarMonthView.kt # 顶层日历屏幕 -│ │ ├── CalendarMonthPage.kt # 单月网格页 -│ │ ├── CalendarPager.kt # 月视图无限分页 -│ │ ├── WeekPager.kt # 周视图无限分页 -│ │ ├── DayCell.kt # 单日圆圈组件 -│ │ ├── MonthHeader.kt # 年月标题 + 周数 -│ │ ├── WeekdayHeader.kt # 星期标题行 -│ │ └── BottomCard.kt # 底部拖拽卡片 -│ ├── src/commonTest/kotlin/ # 共享测试 -│ ├── src/androidMain/kotlin/ # Android 预览工具 -│ └── src/iosMain/kotlin/ # iOS ViewController 工厂 -├── androidApp/ # Android 薄壳 — MainActivity → App() -├── iosApp/ # iOS 入口 — Xcode 项目 -└── gradle/libs.versions.toml # 版本目录 — 统一管理依赖版本 -``` - -## 运行 - -### Android - -```bash -# 命令行构建 -./gradlew :androidApp:assembleDebug - -# 安装到设备 -./gradlew :androidApp:installDebug -``` - -或在 Android Studio 中选择 `androidApp` 配置直接运行。 - -### iOS - -1. 先执行一次 Gradle 同步:`./gradlew :shared:generateDummyFramework` -2. 在 Xcode 中打开 `iosApp/iosApp.xcworkspace` -3. 选择目标设备或模拟器,点击 Run - -> 首次打开可能需要等待 Xcode 索引完成。如果报 framework 错误,重新执行 Gradle 同步即可。 - -## 测试 - -```bash -# 运行所有共享模块测试 -./gradlew :shared:allTests - -# 运行单个测试类 (Android host) -./gradlew :shared:androidHostTest --tests "plus.rua.project.ComposeAppCommonTest" -``` - -## 开发约定 - -### 代码组织 - -- 所有 Compose UI 和 ViewModel 代码放在 `shared/commonMain`,不按平台拆分 -- 平台特定代码仅放在对应的 `androidMain` / `iosMain` -- UI 组件统一在 `plus.rua.project.ui` 包下 - -### Compose 规范 - -- `Modifier` 参数始终放在最后 -- 回调参数使用 `on` 前缀:`onDateClick`、`onMonthChanged` -- 公开 `@Composable` 函数需要 KDoc 注释(详见 `COMMENTS.md`) - -### 日期处理 - -- 统一使用 `kotlinx-datetime`,禁止使用 `java.util.Calendar` -- 周起始为周一 (ISO 8601) -- `monthNumber` 访问需要 `@Suppress("DEPRECATION")` 并附行内注释说明原因 - -### UI 文案 - -- 界面文字为中文(星期标题 "一二三四五六日",月份格式 "2026年5月") - -### 依赖管理 - -- 所有版本声明在 `gradle/libs.versions.toml`,不硬编码 -- 新增依赖先在版本目录添加条目,再在 `build.gradle.kts` 中引用 - -## 架构概览 - -``` -CalendarMonthView (顶层屏幕) - ├── MonthHeader 年月标签 + ISO 周数 - ├── WeekdayHeader 固定星期行 - ├── CalendarPager 月视图无限分页 (Int.MAX_VALUE 页) - │ └── CalendarMonthPage 6×7 DayCell 网格,折叠时压缩非选中行 - │ └── DayCell 单日圆圈,选中/今日状态 - ├── WeekPager 周视图无限分页 (折叠态) - │ └── DayCell - └── BottomCard 拖拽手柄,驱动折叠/展开手势 -``` - -**折叠动画:** `CalendarViewModel.collapseProgress` 控制 0f(月)↔1f(周) 过渡。`BottomCard` 捕获垂直拖拽,释放时超过 50% 则弹簧动画吸附到最近状态。完全折叠后 `WeekPager` 替代 `CalendarPager` 实现高效单周分页。 - -**分页映射:** 两个 Pager 均使用 `Int.MAX_VALUE` 页数,中心页为 `Int.MAX_VALUE / 2`。页码到日期为算术转换,无索引列表。两者均跳过初始 `snapshotFlow` 发射 (`.drop(1)`) 以保留首次渲染时的"今日"选中。 - -## 性能排查(Perfetto / Systrace) - -项目使用 `composeTraceBeginSection` / `composeTraceEndSection` 在关键代码段插入 trace marker,Android 上会被记录到系统 trace 中。iOS 为空操作。 - -已有的 trace section: - -- `MonthView:Compose` / `YearView:Compose` — 顶层重组耗时 -- `YearView→MonthView` / `MonthView→YearView` — 年视图切换动画 -- `YearGridView:$year` / `generateMiniMonthDays:$year-$month` — 年网格渲染 -- `getMonthDays:$year-$month` — 月网格数据生成 - -### 分析折叠器卡顿的方法 - -1. **录制 trace**:Android Studio → Profiler → CPU → 选择 "Trace Java Methods" 或命令行: - - ```bash - adb shell perfetto -c - --txt \<= len(pkt): break - tag = pkt[po]; po += 1 - fn = tag >> 3; wt = tag & 0x07 - if wt == 0: - _, po = read_varint(pkt, po) - elif wt == 2: - length, po = read_varint(pkt, po) - chunk = pkt[po:po + length] - if fn == 2: - # 扫描 bundle 内的 FtraceEvent (field 1, 0x0a) - eo = 0 - while eo < len(chunk): - if chunk[eo] != 0x0a: - eo += 1; continue - eo += 1 - try: - el, eno = read_varint(chunk, eo) - if el > 0 and eno + el <= len(chunk): - evt = chunk[eno:eno + el] - # 提取 timestamp (field 1, varint) - if len(evt) > 1 and evt[0] == 0x08: - ts, _ = read_varint(evt, 1) - # 搜索 marker 字符串 - for pat in [b'BC:', b'VM:', b'MonthView:']: - idx = evt.find(pat) - if idx >= 0: - me = idx - while me < len(evt) and 32 <= evt[me] < 127: - me += 1 - name = evt[idx:me].decode() - events.append((ts, name)) - break - eo = eno + el - else: - eo = eno - except: - eo += 1 - po += length - elif wt in (1, 5): - po += 8 if wt == 1 else 4 - else: - break - - events.sort() - return events - - # 使用 - events = parse_trace('cpu-perfetto-xxxx.trace') - for ts, name in events: - print(f"{ts}: {name}") - ``` - -3. **关注点**: - - **触摸事件间隔**:统计相邻 `BC:delta` marker 的时间差。理想间隔 ≤16ms;若出现 >33ms 说明丢帧,>100ms 说明触摸断流。 - - **重组耗时**:`VM:collapseProgress` → `MonthView:Compose` 的间隔,应在亚毫秒级。 - - **ViewModel → Compose 延迟**:从 `snapTo` 调用到下一帧重组完成的间隔。 - -### 已知排查结论(2026-05-19) - -对折叠器 trace 的分析显示: - -- **重组本身很快**(VM progress → Compose 约 500μs),不是卡顿来源。 -- **触摸事件采样间隔不均匀**是主要问题。某些拖拽序列中出现 30-50ms 的触摸事件间隔,偶尔有 >100ms 的断流。这属于系统/模拟器层的事件分发问题,而非 Compose 代码问题。 -- 若在真机上复现,建议检查是否有 CPU 抢占或手指短暂离屏。 - ## Baseline Profile ```bash @@ -293,3 +39,16 @@ Baseline Profile 自动生成器。 3. 日期选择 → 周视图折叠/展开 4. 关于页 → 开源许可页 5. 返回主界面 + +## 模拟器 + +``` +emulator -avd Pixel_10 \ + -no-snapshot \ + -no-boot-anim \ + -gpu host \ + -accel on \ + -cores 4 \ + -memory 4096 \ + -partition-size 2048 +``` diff --git a/core/proguard-rules.pro b/core/proguard-rules.pro index d9735b2..6b1cf60 100644 --- a/core/proguard-rules.pro +++ b/core/proguard-rules.pro @@ -29,95 +29,6 @@ public static java.util.List generateMonthDays(...); } -# CalendarMonthView --keepclassmembers class plus.rua.project.ui.CalendarMonthViewKt { - public static void CalendarMonthView(...); -} - -# CalendarPager --keepclassmembers class plus.rua.project.ui.CalendarPagerKt { - public static void CalendarPager(...); -} - -# WeekPager --keepclassmembers class plus.rua.project.ui.WeekPagerKt { - public static void WeekPager(...); -} - -# BottomCard --keepclassmembers class plus.rua.project.ui.BottomCardKt { - public static void BottomCard(...); -} - -# CalendarViewModel --keepclassmembers class plus.rua.project.CalendarViewModel { - public (...); - public kotlinx.datetime.LocalDate getSelectedDate(); - public java.util.List getMonthDays(...); -} - -# ========== 全量业务类保留 ========== -# 保留所有业务类名和方法名,确保 profile 通配规则始终匹配 - -# CalendarUtils 所有方法 --keepclassmembers class plus.rua.project.ui.CalendarUtilsKt { - public static *; -} - -# MonthHeader --keepclassmembers class plus.rua.project.ui.MonthHeaderKt { - public static *; -} - -# WeekdayHeader --keepclassmembers class plus.rua.project.ui.WeekdayHeaderKt { - public static *; -} - -# YearGridView / YearHeader / MiniMonth --keepclassmembers class plus.rua.project.ui.YearGridViewKt { - public static *; -} - -# AboutScreen --keepclassmembers class plus.rua.project.ui.AboutScreenKt { - public static *; -} - -# LicensesScreen / LicenseItem --keepclassmembers class plus.rua.project.ui.LicensesScreenKt { - public static *; -} --keepclassmembers class plus.rua.project.ui.LicensesKt { - public static *; -} - -# AnimatedGif --keepclassmembers class plus.rua.project.ui.AnimatedGifKt { - public static *; -} - -# CalendarViewModel$CalendarDay --keepclassmembers class plus.rua.project.CalendarViewModel$CalendarDay { - public *; -} - -# ShiftPattern --keepclassmembers class plus.rua.project.ShiftPattern { - public *; -} - -# AppInfo --keepclassmembers class plus.rua.project.AppInfo { - public static *; -} - -# ComposeTrace --keepclassmembers class plus.rua.project.ComposeTraceKt { - public static *; -} - -# Platform --keepclassmembers class plus.rua.project.PlatformKt { - public static *; -} +# ========== 第三方库保留 ========== +-keep class kotlinx.datetime.** { *; } +-keep class cn.tyme.** { *; } diff --git a/core/src/main/kotlin/plus/rua/project/Platform.kt b/core/src/main/kotlin/plus/rua/project/Platform.kt index c8048e6..49283fc 100644 --- a/core/src/main/kotlin/plus/rua/project/Platform.kt +++ b/core/src/main/kotlin/plus/rua/project/Platform.kt @@ -47,7 +47,7 @@ fun getAppVersion(): String { * @param onCancel 手势取消回调(滑动距离不足,回弹) */ @Composable -fun PredictiveBackHandler( +fun AppPredictiveBackHandler( enabled: Boolean = true, onProgress: (Float) -> Unit = {}, onBack: () -> Unit, diff --git a/core/src/main/kotlin/plus/rua/project/ui/BottomCard.kt b/core/src/main/kotlin/plus/rua/project/ui/BottomCard.kt index 8ff72c7..92f5285 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/BottomCard.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/BottomCard.kt @@ -23,6 +23,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.datetime.LocalDate @@ -117,6 +120,9 @@ fun BottomCard( .fillMaxWidth(0.15f) .height(4.dp) .align(Alignment.CenterHorizontally) + .semantics { + contentDescription = "拖拽以展开或折叠日历" + } ) Spacer(modifier = Modifier.height(8.dp)) // A / B / C 信息行 @@ -176,3 +182,16 @@ fun BottomCard( } } } + +@Preview +@Composable +private fun BottomCardPreview() { + val scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Unconfined) + val viewModel = CalendarViewModel(scope) + BottomCard( + viewModel = viewModel, + selectedDate = kotlinx.datetime.LocalDate(2026, 5, 21), + today = kotlinx.datetime.LocalDate(2026, 5, 21), + dragRangePx = 300f + ) +} diff --git a/core/src/main/kotlin/plus/rua/project/ui/DayCell.kt b/core/src/main/kotlin/plus/rua/project/ui/DayCell.kt index c1b262b..1c976c6 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/DayCell.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/DayCell.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex @@ -274,3 +275,17 @@ fun DayCell( } } } + +@Preview +@Composable +private fun DayCellPreview() { + DayCell( + date = LocalDate(2026, 5, 21), + isCurrentMonth = true, + isSelected = true, + isToday = true, + shiftKind = ShiftKind.WORK, + showLegalHoliday = false, + onClick = {} + ) +} diff --git a/core/src/main/kotlin/plus/rua/project/ui/MonthHeader.kt b/core/src/main/kotlin/plus/rua/project/ui/MonthHeader.kt index 7db15ff..ee3079c 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/MonthHeader.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/MonthHeader.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -108,3 +109,15 @@ fun MonthHeader( } } } + +@Preview +@Composable +private fun MonthHeaderPreview() { + MonthHeader( + year = 2026, + month = 5, + weekNumber = 21, + showToday = true, + onToday = {} + ) +} diff --git a/core/src/main/kotlin/plus/rua/project/ui/WeekdayHeader.kt b/core/src/main/kotlin/plus/rua/project/ui/WeekdayHeader.kt index e90e1a4..dfa031f 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/WeekdayHeader.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/WeekdayHeader.kt @@ -7,7 +7,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.heading import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp private val WEEKDAY_LABELS = listOf("一", "二", "三", "四", "五", "六", "日") @@ -19,7 +22,12 @@ private val WEEKDAY_LABELS = listOf("一", "二", "三", "四", "五", "六", " */ @Composable fun WeekdayHeader(modifier: Modifier = Modifier) { - Row(modifier = modifier.fillMaxWidth().padding(vertical = 12.dp)) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + .semantics { heading() } + ) { WEEKDAY_LABELS.forEach { label -> Text( text = label, @@ -31,3 +39,9 @@ fun WeekdayHeader(modifier: Modifier = Modifier) { } } } + +@Preview +@Composable +private fun WeekdayHeaderPreview() { + WeekdayHeader() +} diff --git a/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt b/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt index 4023aca..6042dae 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt @@ -39,6 +39,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import com.tyme.lunar.LunarYear import kotlinx.datetime.DatePeriod import kotlinx.datetime.LocalDate @@ -168,6 +170,7 @@ fun YearGridView( val sharedKey = "month_grid_${year}_$month" with(sharedTransitionScope) { MiniMonth( + year = year, month = month, isSelected = month == selectedMonth, today = today, @@ -205,6 +208,7 @@ fun YearGridView( */ @Composable private fun MiniMonth( + year: Int, month: Int, isSelected: Boolean, today: LocalDate, @@ -230,7 +234,10 @@ private fun MiniMonth( modifier = modifier .padding(2.dp) .clickable(onClick = onClick) - .padding(vertical = 2.dp), + .padding(vertical = 2.dp) + .semantics { + contentDescription = "$year 年 $month 月" + }, horizontalAlignment = Alignment.CenterHorizontally ) { Canvas(modifier = Modifier.fillMaxWidth().height(totalHeight)) { diff --git a/core/src/test/kotlin/plus/rua/project/CalendarViewModelStateTest.kt b/core/src/test/kotlin/plus/rua/project/CalendarViewModelStateTest.kt index 540a028..566f305 100644 --- a/core/src/test/kotlin/plus/rua/project/CalendarViewModelStateTest.kt +++ b/core/src/test/kotlin/plus/rua/project/CalendarViewModelStateTest.kt @@ -1,7 +1,11 @@ package plus.rua.project import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate @@ -23,14 +27,15 @@ private class StateTestFixedClock(private val instant: Instant) : Clock { * 动画完成的最终状态(例如 [CalendarViewModel.isCollapsed] 在 spring * 动画结束后的取值)需要 MonotonicFrameClock 驱动,不在本测试集合范围内。 */ +@OptIn(ExperimentalCoroutinesApi::class) 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) + private fun createViewModel(dispatcher: TestDispatcher = StandardTestDispatcher()): CalendarViewModel { + val scope = CoroutineScope(dispatcher) return CalendarViewModel(coroutineScope = scope, clock = testClock) } @@ -255,71 +260,92 @@ class CalendarViewModelStateTest { assertFalse(vm.showLegalHoliday) } - // ---- onDrag: 折叠拖拽(同步路径:snapTo)---- + // ---- onDrag: 折叠拖拽(异步路径:launch + snapTo)---- @Test - fun onDrag_positiveDelta_increasesProgress() { - val vm = createViewModel() + fun onDrag_positiveDelta_increasesProgress() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val vm = createViewModel(dispatcher = dispatcher) vm.onDrag(0.3f) + advanceUntilIdle() assertEquals(0.3f, vm.collapseProgress, 0.001f) } @Test - fun onDrag_accumulatesAcrossCalls() { - val vm = createViewModel() + fun onDrag_accumulatesAcrossCalls() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val vm = createViewModel(dispatcher = dispatcher) vm.onDrag(0.2f) + advanceUntilIdle() vm.onDrag(0.3f) + advanceUntilIdle() assertEquals(0.5f, vm.collapseProgress, 0.001f) } @Test - fun onDrag_clampsAtOne() { - val vm = createViewModel() + fun onDrag_clampsAtOne() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val vm = createViewModel(dispatcher = dispatcher) vm.onDrag(0.8f) + advanceUntilIdle() vm.onDrag(0.8f) + advanceUntilIdle() assertEquals(1f, vm.collapseProgress, 0.001f) } @Test - fun onDrag_clampsAtZeroWhenNegativeFromZero() { - val vm = createViewModel() + fun onDrag_clampsAtZeroWhenNegativeFromZero() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val vm = createViewModel(dispatcher = dispatcher) vm.onDrag(-0.3f) + advanceUntilIdle() assertEquals(0f, vm.collapseProgress, 0.001f) } @Test - fun onDrag_negativeAfterPositive_canDecrease() { - val vm = createViewModel() + fun onDrag_negativeAfterPositive_canDecrease() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val vm = createViewModel(dispatcher = dispatcher) vm.onDrag(0.5f) + advanceUntilIdle() vm.onDrag(-0.2f) + advanceUntilIdle() assertEquals(0.3f, vm.collapseProgress, 0.001f) } // ---- onExpandDrag: 展开拖拽 ---- @Test - fun onExpandDrag_updatesProgress() { - val vm = createViewModel() + fun onExpandDrag_updatesProgress() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val vm = createViewModel(dispatcher = dispatcher) // 先把 progress 推到 1 vm.onDrag(1f) + advanceUntilIdle() assertEquals(1f, vm.collapseProgress, 0.001f) // 展开方向:delta 为负 vm.onExpandDrag(-0.4f) + advanceUntilIdle() assertEquals(0.6f, vm.collapseProgress, 0.001f) } @Test - fun onExpandDrag_clampsAtZero() { - val vm = createViewModel() + fun onExpandDrag_clampsAtZero() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val vm = createViewModel(dispatcher = dispatcher) vm.onDrag(0.5f) + advanceUntilIdle() vm.onExpandDrag(-1f) + advanceUntilIdle() assertEquals(0f, vm.collapseProgress, 0.001f) } @Test - fun onExpandDrag_clampsAtOne() { - val vm = createViewModel() + fun onExpandDrag_clampsAtOne() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val vm = createViewModel(dispatcher = dispatcher) vm.onExpandDrag(2f) + advanceUntilIdle() assertEquals(1f, vm.collapseProgress, 0.001f) } diff --git a/core/src/test/kotlin/plus/rua/project/CalendarViewModelTest.kt b/core/src/test/kotlin/plus/rua/project/CalendarViewModelTest.kt index 81c6947..69b991c 100644 --- a/core/src/test/kotlin/plus/rua/project/CalendarViewModelTest.kt +++ b/core/src/test/kotlin/plus/rua/project/CalendarViewModelTest.kt @@ -1,7 +1,8 @@ package plus.rua.project import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate @@ -14,49 +15,51 @@ private class FixedClock(private val instant: Instant) : Clock { override fun now(): Instant = instant } +@OptIn(ExperimentalCoroutinesApi::class) 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) + val dispatcher = StandardTestDispatcher() + val scope = CoroutineScope(dispatcher) return CalendarViewModel(coroutineScope = scope, clock = testClock) } // ---- getIsoWeekNumber ---- @Test - fun getIsoWeekNumber_regularDate() { + fun getIsoWeekNumber_regular_date() { val vm = createViewModel() assertEquals(20, vm.getIsoWeekNumber(LocalDate(2026, 5, 15))) } @Test - fun getIsoWeekNumber_jan1() { + fun getIsoWeekNumber_jan_1() { val vm = createViewModel() assertEquals(1, vm.getIsoWeekNumber(LocalDate(2026, 1, 1))) } @Test - fun getIsoWeekNumber_dec31() { + fun getIsoWeekNumber_dec_31() { val vm = createViewModel() assertEquals(53, vm.getIsoWeekNumber(LocalDate(2026, 12, 31))) } @Test - fun getIsoWeekNumber_week52_boundary() { + fun getIsoWeekNumber_week_52_boundary() { val vm = createViewModel() assertEquals(52, vm.getIsoWeekNumber(LocalDate(2025, 12, 28))) } @Test - fun getIsoWeekNumber_mondayStartsWeek() { + fun getIsoWeekNumber_monday_starts_week() { val vm = createViewModel() assertEquals(20, vm.getIsoWeekNumber(LocalDate(2026, 5, 11))) } @Test - fun getIsoWeekNumber_week53_year() { + fun getIsoWeekNumber_week_53_year() { val vm = createViewModel() assertEquals(53, vm.getIsoWeekNumber(LocalDate(2020, 12, 31))) } @@ -64,7 +67,7 @@ class CalendarViewModelTest { // ---- getMonthDays ---- @Test - fun getMonthDays_returnsCorrectSize() { + fun getMonthDays_returns_correct_size() { val vm = createViewModel() // May 2026: 5 rows × 7 = 35 cells val days = vm.getMonthDays(2026, 5) @@ -72,7 +75,7 @@ class CalendarViewModelTest { } @Test - fun getMonthDays_may2026_startsOnThursday() { + fun getMonthDays_may_2026_starts_on_thursday() { val vm = createViewModel() val days = vm.getMonthDays(2026, 5) assertFalse(days[0].isCurrentMonth) @@ -82,7 +85,7 @@ class CalendarViewModelTest { } @Test - fun getMonthDays_may2026_firstDayIsMay1() { + fun getMonthDays_may_2026_first_day_is_may_1() { val vm = createViewModel() val days = vm.getMonthDays(2026, 5) assertTrue(days[4].isCurrentMonth) @@ -92,7 +95,7 @@ class CalendarViewModelTest { } @Test - fun getMonthDays_may2026_lastDayIsMay31() { + fun getMonthDays_may_2026_last_day_is_may_31() { val vm = createViewModel() val days = vm.getMonthDays(2026, 5) val may31 = days.first { it.isCurrentMonth && it.date.day == 31 } @@ -100,7 +103,7 @@ class CalendarViewModelTest { } @Test - fun getMonthDays_february2026_28days() { + fun getMonthDays_february_2026_28_days() { val vm = createViewModel() val days = vm.getMonthDays(2026, 2) val febDays = days.filter { it.isCurrentMonth } @@ -108,7 +111,7 @@ class CalendarViewModelTest { } @Test - fun getMonthDays_february2024_29days_leapYear() { + fun getMonthDays_february_2024_29_days_leap_year() { val vm = createViewModel() val days = vm.getMonthDays(2024, 2) val febDays = days.filter { it.isCurrentMonth } @@ -116,7 +119,7 @@ class CalendarViewModelTest { } @Test - fun getMonthDays_todayIsMarked() { + fun getMonthDays_today_is_marked() { val vm = createViewModel() val days = vm.getMonthDays(2026, 5) val todayCell = days.first { it.isToday } @@ -125,7 +128,7 @@ class CalendarViewModelTest { } @Test - fun getMonthDays_selectedDateIsMarked() { + fun getMonthDays_selected_date_is_marked() { val vm = createViewModel() val days = vm.getMonthDays(2026, 5) val selectedCell = days.first { it.isSelected } diff --git a/core/src/test/kotlin/plus/rua/project/ui/CalendarUtilsExtraTest.kt b/core/src/test/kotlin/plus/rua/project/ui/CalendarUtilsExtraTest.kt index cfd9f0a..adf4c05 100644 --- a/core/src/test/kotlin/plus/rua/project/ui/CalendarUtilsExtraTest.kt +++ b/core/src/test/kotlin/plus/rua/project/ui/CalendarUtilsExtraTest.kt @@ -132,19 +132,11 @@ class CalendarUtilsExtraTest { // ---- 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年农历十一月十二 + // 2026/1/1 公历 -> 农历十一月十三 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") + assertEquals("农历十一月十三", result) } @Test @@ -155,16 +147,16 @@ class CalendarUtilsExtraTest { } @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") - } + fun formatLunarDate_specificDates_returnsExactLunar() { + assertEquals("农历正月十三", formatLunarDate(LocalDate(2026, 3, 1))) + assertEquals("农历五月十六", formatLunarDate(LocalDate(2026, 6, 30))) + assertEquals("农历十一月十七", formatLunarDate(LocalDate(2026, 12, 25))) + } + + @Test + fun formatLunarDate_leapMonth_returnsLeapPrefix() { + // 2023年闰二月:2023/3/23 对应农历闰二月初二 + val result = formatLunarDate(LocalDate(2023, 3, 23)) + assertEquals("农历闰二月初二", result) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ceeb23f..1b8183c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,15 +4,12 @@ android-compileSdk = "37" android-minSdk = "24" android-targetSdk = "37" androidx-activity = "1.13.0" -androidx-appcompat = "1.7.1" -androidx-core = "1.18.0" androidx-espresso = "3.7.0" androidx-lifecycle = "2.10.0" androidx-testExt = "1.3.0" composeBom = "2025.05.01" junit = "4.13.2" kotlin = "2.3.21" -material3 = "1.10.0-alpha05" kotlinx-datetime = "0.8.0" tyme4kt = "1.4.5" sketch = "4.4.0"