refactor: Wave 1 — 构建清理、测试修复、文档修正、a11y 改进

- 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) <noreply@anthropic.com>
This commit is contained in:
xfy 2026-05-21 17:51:12 +08:00
parent 2bd7d5ee19
commit bf28008d17
13 changed files with 177 additions and 436 deletions

View File

@ -4,47 +4,32 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## 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 ## Build Commands
```bash ```bash
# Build Android debug APK # Build Android debug APK
./gradlew :androidApp:assembleDebug ./gradlew :app:assembleDebug
# Install Android debug APK to connected device # Install Android debug APK to connected device
./gradlew :androidApp:installDebug ./gradlew :app:installDebug
# Run all shared module tests # Run core module tests
./gradlew :shared:allTests ./gradlew :core:testDebugUnitTest
# Run shared module tests on Android host only
./gradlew :shared:testAndroidHostTest
# Run a single test class # Run a single test class
./gradlew :shared:testAndroidHostTest --tests "plus.rua.project.ui.CalendarUtilsTest" ./gradlew :core:testDebugUnitTest --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
``` ```
Gradle configuration cache and build cache are enabled by default (`gradle.properties`). Gradle configuration cache and build cache are enabled by default (`gradle.properties`).
## Architecture ## Architecture
**Two-module structure:** **Three-module structure:**
- `:shared` — all Compose UI, ViewModel, and business logic (KMP library) - `:core` — all Compose UI, ViewModel, and business logic (`com.android.library`)
- `:androidApp` — thin Android shell (`MainActivity``App()`) - `:app` — thin Android shell (`MainActivity``App()`)
- `:macrobenchmark` — Macrobenchmark module for Baseline Profile generation
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
**Calendar UI composition** (all in `plus.rua.project.ui`): **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). `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 ## Key Dependencies
@ -85,7 +70,7 @@ CalendarMonthView ← top-level screen (MonthHeader + WeekdayHeader + p
## Conventions ## 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 - 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 - `@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月") - UI text is in Chinese (weekday labels, month header format "2026年5月")

View File

@ -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 markerAndroid 上会被记录到系统 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 \<<EOF
buffers: { size_kb: 65536 }
data_sources: {
config {
name: "linux.ftrace"
ftrace_config {
ftrace_events: "ftrace/print"
ftrace_events: "sched/sched_switch"
buffer_size_kb: 8192
}
}
}
data_sources: {
config {
name: "android.packages_list"
}
}
duration_ms: 10000
EOF
```
2. **用 Python 分析 trace**(无需 Perfetto UI
```python
def read_varint(data, offset):
result = 0; shift = 0
while offset < len(data):
byte = data[offset]
result |= (byte & 0x7F) << shift
offset += 1
if not (byte & 0x80): break
shift += 7
return result, offset
def parse_trace(path):
with open(path, 'rb') as f:
data = f.read()
# 1) 读取所有 TracePacket
packets = []
offset = 0
while offset < len(data):
if data[offset] != 0x0a:
offset += 1; continue
offset += 1
try:
length, new_offset = read_varint(data, offset)
if 0 < length < 1_000_000 and new_offset + length <= len(data):
packets.append(data[new_offset:new_offset + length])
offset = new_offset + length
else:
offset = new_offset
except:
offset += 1
# 2) 在 ftrace_events 中搜索自定义 marker
events = []
for pkt in packets:
# 找 field 2 (ftrace_events bundle)
po = 0
while po < len(pkt):
if po >= 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 ## Baseline Profile
```bash ```bash
@ -293,3 +39,16 @@ Baseline Profile 自动生成器。
3. 日期选择 → 周视图折叠/展开 3. 日期选择 → 周视图折叠/展开
4. 关于页 → 开源许可页 4. 关于页 → 开源许可页
5. 返回主界面 5. 返回主界面
## 模拟器
```
emulator -avd Pixel_10 \
-no-snapshot \
-no-boot-anim \
-gpu host \
-accel on \
-cores 4 \
-memory 4096 \
-partition-size 2048
```

View File

@ -29,95 +29,6 @@
public static java.util.List generateMonthDays(...); public static java.util.List generateMonthDays(...);
} }
# CalendarMonthView # ========== 第三方库保留 ==========
-keepclassmembers class plus.rua.project.ui.CalendarMonthViewKt { -keep class kotlinx.datetime.** { *; }
public static void CalendarMonthView(...); -keep class cn.tyme.** { *; }
}
# 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 <init>(...);
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 *;
}

View File

@ -47,7 +47,7 @@ fun getAppVersion(): String {
* @param onCancel 手势取消回调滑动距离不足回弹 * @param onCancel 手势取消回调滑动距离不足回弹
*/ */
@Composable @Composable
fun PredictiveBackHandler( fun AppPredictiveBackHandler(
enabled: Boolean = true, enabled: Boolean = true,
onProgress: (Float) -> Unit = {}, onProgress: (Float) -> Unit = {},
onBack: () -> Unit, onBack: () -> Unit,

View File

@ -23,6 +23,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@ -117,6 +120,9 @@ fun BottomCard(
.fillMaxWidth(0.15f) .fillMaxWidth(0.15f)
.height(4.dp) .height(4.dp)
.align(Alignment.CenterHorizontally) .align(Alignment.CenterHorizontally)
.semantics {
contentDescription = "拖拽以展开或折叠日历"
}
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// A / B / C 信息行 // 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
)
}

View File

@ -31,6 +31,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex 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 = {}
)
}

View File

@ -23,6 +23,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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 = {}
)
}

View File

@ -7,7 +7,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier 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.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
private val WEEKDAY_LABELS = listOf("", "", "", "", "", "", "") private val WEEKDAY_LABELS = listOf("", "", "", "", "", "", "")
@ -19,7 +22,12 @@ private val WEEKDAY_LABELS = listOf("一", "二", "三", "四", "五", "六", "
*/ */
@Composable @Composable
fun WeekdayHeader(modifier: Modifier = Modifier) { 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 -> WEEKDAY_LABELS.forEach { label ->
Text( Text(
text = label, text = label,
@ -31,3 +39,9 @@ fun WeekdayHeader(modifier: Modifier = Modifier) {
} }
} }
} }
@Preview
@Composable
private fun WeekdayHeaderPreview() {
WeekdayHeader()
}

View File

@ -39,6 +39,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import com.tyme.lunar.LunarYear import com.tyme.lunar.LunarYear
import kotlinx.datetime.DatePeriod import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@ -168,6 +170,7 @@ fun YearGridView(
val sharedKey = "month_grid_${year}_$month" val sharedKey = "month_grid_${year}_$month"
with(sharedTransitionScope) { with(sharedTransitionScope) {
MiniMonth( MiniMonth(
year = year,
month = month, month = month,
isSelected = month == selectedMonth, isSelected = month == selectedMonth,
today = today, today = today,
@ -205,6 +208,7 @@ fun YearGridView(
*/ */
@Composable @Composable
private fun MiniMonth( private fun MiniMonth(
year: Int,
month: Int, month: Int,
isSelected: Boolean, isSelected: Boolean,
today: LocalDate, today: LocalDate,
@ -230,7 +234,10 @@ private fun MiniMonth(
modifier = modifier modifier = modifier
.padding(2.dp) .padding(2.dp)
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(vertical = 2.dp), .padding(vertical = 2.dp)
.semantics {
contentDescription = "$year$month"
},
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Canvas(modifier = Modifier.fillMaxWidth().height(totalHeight)) { Canvas(modifier = Modifier.fillMaxWidth().height(totalHeight)) {

View File

@ -1,7 +1,11 @@
package plus.rua.project package plus.rua.project
import kotlinx.coroutines.CoroutineScope 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.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@ -23,14 +27,15 @@ private class StateTestFixedClock(private val instant: Instant) : Clock {
* 动画完成的最终状态例如 [CalendarViewModel.isCollapsed] spring * 动画完成的最终状态例如 [CalendarViewModel.isCollapsed] spring
* 动画结束后的取值需要 MonotonicFrameClock 驱动不在本测试集合范围内 * 动画结束后的取值需要 MonotonicFrameClock 驱动不在本测试集合范围内
*/ */
@OptIn(ExperimentalCoroutinesApi::class)
class CalendarViewModelStateTest { class CalendarViewModelStateTest {
// 固定 today = 2026/5/15 // 固定 today = 2026/5/15
private val fixedInstant = Instant.parse("2026-05-15T00:00:00Z") private val fixedInstant = Instant.parse("2026-05-15T00:00:00Z")
private val testClock = StateTestFixedClock(fixedInstant) private val testClock = StateTestFixedClock(fixedInstant)
private fun createViewModel(): CalendarViewModel { private fun createViewModel(dispatcher: TestDispatcher = StandardTestDispatcher()): CalendarViewModel {
val scope = CoroutineScope(Dispatchers.Unconfined) val scope = CoroutineScope(dispatcher)
return CalendarViewModel(coroutineScope = scope, clock = testClock) return CalendarViewModel(coroutineScope = scope, clock = testClock)
} }
@ -255,71 +260,92 @@ class CalendarViewModelStateTest {
assertFalse(vm.showLegalHoliday) assertFalse(vm.showLegalHoliday)
} }
// ---- onDrag: 折叠拖拽(同步路径:snapTo---- // ---- onDrag: 折叠拖拽(异步路径launch + snapTo----
@Test @Test
fun onDrag_positiveDelta_increasesProgress() { fun onDrag_positiveDelta_increasesProgress() = runTest {
val vm = createViewModel() val dispatcher = StandardTestDispatcher(testScheduler)
val vm = createViewModel(dispatcher = dispatcher)
vm.onDrag(0.3f) vm.onDrag(0.3f)
advanceUntilIdle()
assertEquals(0.3f, vm.collapseProgress, 0.001f) assertEquals(0.3f, vm.collapseProgress, 0.001f)
} }
@Test @Test
fun onDrag_accumulatesAcrossCalls() { fun onDrag_accumulatesAcrossCalls() = runTest {
val vm = createViewModel() val dispatcher = StandardTestDispatcher(testScheduler)
val vm = createViewModel(dispatcher = dispatcher)
vm.onDrag(0.2f) vm.onDrag(0.2f)
advanceUntilIdle()
vm.onDrag(0.3f) vm.onDrag(0.3f)
advanceUntilIdle()
assertEquals(0.5f, vm.collapseProgress, 0.001f) assertEquals(0.5f, vm.collapseProgress, 0.001f)
} }
@Test @Test
fun onDrag_clampsAtOne() { fun onDrag_clampsAtOne() = runTest {
val vm = createViewModel() val dispatcher = StandardTestDispatcher(testScheduler)
val vm = createViewModel(dispatcher = dispatcher)
vm.onDrag(0.8f) vm.onDrag(0.8f)
advanceUntilIdle()
vm.onDrag(0.8f) vm.onDrag(0.8f)
advanceUntilIdle()
assertEquals(1f, vm.collapseProgress, 0.001f) assertEquals(1f, vm.collapseProgress, 0.001f)
} }
@Test @Test
fun onDrag_clampsAtZeroWhenNegativeFromZero() { fun onDrag_clampsAtZeroWhenNegativeFromZero() = runTest {
val vm = createViewModel() val dispatcher = StandardTestDispatcher(testScheduler)
val vm = createViewModel(dispatcher = dispatcher)
vm.onDrag(-0.3f) vm.onDrag(-0.3f)
advanceUntilIdle()
assertEquals(0f, vm.collapseProgress, 0.001f) assertEquals(0f, vm.collapseProgress, 0.001f)
} }
@Test @Test
fun onDrag_negativeAfterPositive_canDecrease() { fun onDrag_negativeAfterPositive_canDecrease() = runTest {
val vm = createViewModel() val dispatcher = StandardTestDispatcher(testScheduler)
val vm = createViewModel(dispatcher = dispatcher)
vm.onDrag(0.5f) vm.onDrag(0.5f)
advanceUntilIdle()
vm.onDrag(-0.2f) vm.onDrag(-0.2f)
advanceUntilIdle()
assertEquals(0.3f, vm.collapseProgress, 0.001f) assertEquals(0.3f, vm.collapseProgress, 0.001f)
} }
// ---- onExpandDrag: 展开拖拽 ---- // ---- onExpandDrag: 展开拖拽 ----
@Test @Test
fun onExpandDrag_updatesProgress() { fun onExpandDrag_updatesProgress() = runTest {
val vm = createViewModel() val dispatcher = StandardTestDispatcher(testScheduler)
val vm = createViewModel(dispatcher = dispatcher)
// 先把 progress 推到 1 // 先把 progress 推到 1
vm.onDrag(1f) vm.onDrag(1f)
advanceUntilIdle()
assertEquals(1f, vm.collapseProgress, 0.001f) assertEquals(1f, vm.collapseProgress, 0.001f)
// 展开方向delta 为负 // 展开方向delta 为负
vm.onExpandDrag(-0.4f) vm.onExpandDrag(-0.4f)
advanceUntilIdle()
assertEquals(0.6f, vm.collapseProgress, 0.001f) assertEquals(0.6f, vm.collapseProgress, 0.001f)
} }
@Test @Test
fun onExpandDrag_clampsAtZero() { fun onExpandDrag_clampsAtZero() = runTest {
val vm = createViewModel() val dispatcher = StandardTestDispatcher(testScheduler)
val vm = createViewModel(dispatcher = dispatcher)
vm.onDrag(0.5f) vm.onDrag(0.5f)
advanceUntilIdle()
vm.onExpandDrag(-1f) vm.onExpandDrag(-1f)
advanceUntilIdle()
assertEquals(0f, vm.collapseProgress, 0.001f) assertEquals(0f, vm.collapseProgress, 0.001f)
} }
@Test @Test
fun onExpandDrag_clampsAtOne() { fun onExpandDrag_clampsAtOne() = runTest {
val vm = createViewModel() val dispatcher = StandardTestDispatcher(testScheduler)
val vm = createViewModel(dispatcher = dispatcher)
vm.onExpandDrag(2f) vm.onExpandDrag(2f)
advanceUntilIdle()
assertEquals(1f, vm.collapseProgress, 0.001f) assertEquals(1f, vm.collapseProgress, 0.001f)
} }

View File

@ -1,7 +1,8 @@
package plus.rua.project package plus.rua.project
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@ -14,49 +15,51 @@ private class FixedClock(private val instant: Instant) : Clock {
override fun now(): Instant = instant override fun now(): Instant = instant
} }
@OptIn(ExperimentalCoroutinesApi::class)
class CalendarViewModelTest { class CalendarViewModelTest {
private val fixedInstant = Instant.parse("2026-05-15T00:00:00Z") private val fixedInstant = Instant.parse("2026-05-15T00:00:00Z")
private val testClock = FixedClock(fixedInstant) private val testClock = FixedClock(fixedInstant)
private fun createViewModel(): CalendarViewModel { private fun createViewModel(): CalendarViewModel {
val scope = CoroutineScope(Dispatchers.Unconfined) val dispatcher = StandardTestDispatcher()
val scope = CoroutineScope(dispatcher)
return CalendarViewModel(coroutineScope = scope, clock = testClock) return CalendarViewModel(coroutineScope = scope, clock = testClock)
} }
// ---- getIsoWeekNumber ---- // ---- getIsoWeekNumber ----
@Test @Test
fun getIsoWeekNumber_regularDate() { fun getIsoWeekNumber_regular_date() {
val vm = createViewModel() val vm = createViewModel()
assertEquals(20, vm.getIsoWeekNumber(LocalDate(2026, 5, 15))) assertEquals(20, vm.getIsoWeekNumber(LocalDate(2026, 5, 15)))
} }
@Test @Test
fun getIsoWeekNumber_jan1() { fun getIsoWeekNumber_jan_1() {
val vm = createViewModel() val vm = createViewModel()
assertEquals(1, vm.getIsoWeekNumber(LocalDate(2026, 1, 1))) assertEquals(1, vm.getIsoWeekNumber(LocalDate(2026, 1, 1)))
} }
@Test @Test
fun getIsoWeekNumber_dec31() { fun getIsoWeekNumber_dec_31() {
val vm = createViewModel() val vm = createViewModel()
assertEquals(53, vm.getIsoWeekNumber(LocalDate(2026, 12, 31))) assertEquals(53, vm.getIsoWeekNumber(LocalDate(2026, 12, 31)))
} }
@Test @Test
fun getIsoWeekNumber_week52_boundary() { fun getIsoWeekNumber_week_52_boundary() {
val vm = createViewModel() val vm = createViewModel()
assertEquals(52, vm.getIsoWeekNumber(LocalDate(2025, 12, 28))) assertEquals(52, vm.getIsoWeekNumber(LocalDate(2025, 12, 28)))
} }
@Test @Test
fun getIsoWeekNumber_mondayStartsWeek() { fun getIsoWeekNumber_monday_starts_week() {
val vm = createViewModel() val vm = createViewModel()
assertEquals(20, vm.getIsoWeekNumber(LocalDate(2026, 5, 11))) assertEquals(20, vm.getIsoWeekNumber(LocalDate(2026, 5, 11)))
} }
@Test @Test
fun getIsoWeekNumber_week53_year() { fun getIsoWeekNumber_week_53_year() {
val vm = createViewModel() val vm = createViewModel()
assertEquals(53, vm.getIsoWeekNumber(LocalDate(2020, 12, 31))) assertEquals(53, vm.getIsoWeekNumber(LocalDate(2020, 12, 31)))
} }
@ -64,7 +67,7 @@ class CalendarViewModelTest {
// ---- getMonthDays ---- // ---- getMonthDays ----
@Test @Test
fun getMonthDays_returnsCorrectSize() { fun getMonthDays_returns_correct_size() {
val vm = createViewModel() val vm = createViewModel()
// May 2026: 5 rows × 7 = 35 cells // May 2026: 5 rows × 7 = 35 cells
val days = vm.getMonthDays(2026, 5) val days = vm.getMonthDays(2026, 5)
@ -72,7 +75,7 @@ class CalendarViewModelTest {
} }
@Test @Test
fun getMonthDays_may2026_startsOnThursday() { fun getMonthDays_may_2026_starts_on_thursday() {
val vm = createViewModel() val vm = createViewModel()
val days = vm.getMonthDays(2026, 5) val days = vm.getMonthDays(2026, 5)
assertFalse(days[0].isCurrentMonth) assertFalse(days[0].isCurrentMonth)
@ -82,7 +85,7 @@ class CalendarViewModelTest {
} }
@Test @Test
fun getMonthDays_may2026_firstDayIsMay1() { fun getMonthDays_may_2026_first_day_is_may_1() {
val vm = createViewModel() val vm = createViewModel()
val days = vm.getMonthDays(2026, 5) val days = vm.getMonthDays(2026, 5)
assertTrue(days[4].isCurrentMonth) assertTrue(days[4].isCurrentMonth)
@ -92,7 +95,7 @@ class CalendarViewModelTest {
} }
@Test @Test
fun getMonthDays_may2026_lastDayIsMay31() { fun getMonthDays_may_2026_last_day_is_may_31() {
val vm = createViewModel() val vm = createViewModel()
val days = vm.getMonthDays(2026, 5) val days = vm.getMonthDays(2026, 5)
val may31 = days.first { it.isCurrentMonth && it.date.day == 31 } val may31 = days.first { it.isCurrentMonth && it.date.day == 31 }
@ -100,7 +103,7 @@ class CalendarViewModelTest {
} }
@Test @Test
fun getMonthDays_february2026_28days() { fun getMonthDays_february_2026_28_days() {
val vm = createViewModel() val vm = createViewModel()
val days = vm.getMonthDays(2026, 2) val days = vm.getMonthDays(2026, 2)
val febDays = days.filter { it.isCurrentMonth } val febDays = days.filter { it.isCurrentMonth }
@ -108,7 +111,7 @@ class CalendarViewModelTest {
} }
@Test @Test
fun getMonthDays_february2024_29days_leapYear() { fun getMonthDays_february_2024_29_days_leap_year() {
val vm = createViewModel() val vm = createViewModel()
val days = vm.getMonthDays(2024, 2) val days = vm.getMonthDays(2024, 2)
val febDays = days.filter { it.isCurrentMonth } val febDays = days.filter { it.isCurrentMonth }
@ -116,7 +119,7 @@ class CalendarViewModelTest {
} }
@Test @Test
fun getMonthDays_todayIsMarked() { fun getMonthDays_today_is_marked() {
val vm = createViewModel() val vm = createViewModel()
val days = vm.getMonthDays(2026, 5) val days = vm.getMonthDays(2026, 5)
val todayCell = days.first { it.isToday } val todayCell = days.first { it.isToday }
@ -125,7 +128,7 @@ class CalendarViewModelTest {
} }
@Test @Test
fun getMonthDays_selectedDateIsMarked() { fun getMonthDays_selected_date_is_marked() {
val vm = createViewModel() val vm = createViewModel()
val days = vm.getMonthDays(2026, 5) val days = vm.getMonthDays(2026, 5)
val selectedCell = days.first { it.isSelected } val selectedCell = days.first { it.isSelected }

View File

@ -132,19 +132,11 @@ class CalendarUtilsExtraTest {
// ---- formatLunarDate ---- // ---- formatLunarDate ----
@Test
fun formatLunarDate_startsWithLunarPrefix() {
val result = formatLunarDate(LocalDate(2026, 5, 19))
assertTrue(result.startsWith("农历"), "Expected to start with '农历', got: $result")
}
@Test @Test
fun formatLunarDate_january1_2026_returnsCorrectLunar() { fun formatLunarDate_january1_2026_returnsCorrectLunar() {
// 2026/1/1 公历 -> 2025年农历十一月十二 // 2026/1/1 公历 -> 农历十一月十三
val result = formatLunarDate(LocalDate(2026, 1, 1)) val result = formatLunarDate(LocalDate(2026, 1, 1))
assertTrue(result.startsWith("农历"), "Expected '农历' prefix, got: $result") assertEquals("农历十一月十三", result)
// 验证不是空字符串
assertTrue(result.length > 2, "Lunar date description should contain month and day")
} }
@Test @Test
@ -155,16 +147,16 @@ class CalendarUtilsExtraTest {
} }
@Test @Test
fun formatLunarDate_anyDate_containsMonthAndDayNames() { fun formatLunarDate_specificDates_returnsExactLunar() {
// 仅验证格式:农历 + 月 + 日 assertEquals("农历正月十三", formatLunarDate(LocalDate(2026, 3, 1)))
for (day in listOf( assertEquals("农历五月十六", formatLunarDate(LocalDate(2026, 6, 30)))
LocalDate(2026, 3, 1), assertEquals("农历十一月十七", formatLunarDate(LocalDate(2026, 12, 25)))
LocalDate(2026, 6, 30), }
LocalDate(2026, 12, 25)
)) { @Test
val result = formatLunarDate(day) fun formatLunarDate_leapMonth_returnsLeapPrefix() {
assertTrue(result.startsWith("农历"), "Expected '农历' prefix for $day, got: $result") // 2023年闰二月2023/3/23 对应农历闰二月初二
assertTrue(result.length >= 5, "Result for $day too short: $result") val result = formatLunarDate(LocalDate(2023, 3, 23))
} assertEquals("农历闰二月初二", result)
} }
} }

View File

@ -4,15 +4,12 @@ android-compileSdk = "37"
android-minSdk = "24" android-minSdk = "24"
android-targetSdk = "37" android-targetSdk = "37"
androidx-activity = "1.13.0" androidx-activity = "1.13.0"
androidx-appcompat = "1.7.1"
androidx-core = "1.18.0"
androidx-espresso = "3.7.0" androidx-espresso = "3.7.0"
androidx-lifecycle = "2.10.0" androidx-lifecycle = "2.10.0"
androidx-testExt = "1.3.0" androidx-testExt = "1.3.0"
composeBom = "2025.05.01" composeBom = "2025.05.01"
junit = "4.13.2" junit = "4.13.2"
kotlin = "2.3.21" kotlin = "2.3.21"
material3 = "1.10.0-alpha05"
kotlinx-datetime = "0.8.0" kotlinx-datetime = "0.8.0"
tyme4kt = "1.4.5" tyme4kt = "1.4.5"
sketch = "4.4.0" sketch = "4.4.0"