Merge pull request #1 from CATMIAOZHI/feat/unit-tests

test: 新增 52 个单元测试 + 删除模板残留 + 整理构建修复
This commit is contained in:
WaterRain 2026-07-20 17:27:01 +08:00 committed by GitHub
commit 79edb053cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 775 additions and 146 deletions

View File

@ -6,7 +6,8 @@ on:
pull_request:
jobs:
build:
test:
name: Unit Tests & Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -17,9 +18,109 @@ jobs:
- uses: android-actions/setup-android@v3
with:
packages: 'platforms;android-35 build-tools;35.0.0'
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Run unit tests
run: |
chmod +x gradlew
./gradlew testDebugUnitTest
run: ./gradlew testDebugUnitTest
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-test-results
path: app/build/test-results/testDebugUnitTest/*.xml
retention-days: 14
- name: Run lint
run: ./gradlew lintDebug
- name: Upload lint results
if: always()
uses: actions/upload-artifact@v4
with:
name: lint-results
path: app/build/reports/lint-results-debug.*
retention-days: 14
build-debug:
name: Debug APK
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- uses: android-actions/setup-android@v3
with:
packages: 'platforms;android-35 build-tools;35.0.0'
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Assemble Debug
run: ./gradlew assembleDebug
run: ./gradlew assembleDebug
- name: Upload Debug APK
uses: actions/upload-artifact@v4
with:
name: debug-apk
path: app/build/outputs/apk/debug/app-debug.apk
retention-days: 14
build-release:
name: Release APK (debug-signed)
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- uses: android-actions/setup-android@v3
with:
packages: 'platforms;android-35 build-tools;35.0.0'
- name: Grant execute permission for gradlew
run: chmod +x gradlew
# 确保存在 debug keystore(某些 runner 镜像可能没有)
- name: Ensure debug keystore
run: |
KS="$HOME/.android/debug.keystore"
mkdir -p "$HOME/.android"
if [ ! -f "$KS" ]; then
keytool -genkeypair -v -keystore "$KS" -storepass android -alias androiddebugkey -keypass android -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Android Debug,O=Android,C=US"
echo "Generated debug keystore"
else
echo "Debug keystore exists"
fi
# CI 无 release.jks;build.gradle.kts 自动 fallback 到 debug keystore
# 只验证编译 + 资源完整性,签名由 Release workflow 用 secrets 完成
- name: Assemble Release
run: ./gradlew assembleRelease
- name: Verify Release APK
run: |
APK=app/build/outputs/apk/release/app-release.apk
if [ ! -f "$APK" ]; then
echo "❌ Release APK not found"
exit 1
fi
echo "✅ Release APK size: $(du -h "$APK" | cut -f1)"
unzip -l "$APK" | grep -q "AndroidManifest.xml" || { echo "❌ AndroidManifest.xml missing"; exit 1; }
unzip -l "$APK" | grep -q "resources.arsc" || { echo "❌ resources.arsc missing"; exit 1; }
unzip -l "$APK" | grep -q "res/" || { echo "❌ res/ directory missing"; exit 1; }
echo "✅ APK integrity verified: manifest + arsc + res/ all present"
- name: Upload Release APK
uses: actions/upload-artifact@v4
with:
name: release-apk
path: app/build/outputs/apk/release/app-release.apk
retention-days: 14

View File

@ -74,6 +74,17 @@ CommandCode Go 走 JSON API 抓取用量数据,Codex / ChatGPT Plus 通过 aut
> CCGO 路由中 `UsageChartViewModel` 和 `UsageViewModel` 用相同 key 会导致类型碰撞、加载失败。
> 当前方案:`chartVm` 用 `"ccgo_chart_$wid"`,`usageVm` 用 `"ccgo_$wid"`(与 Dashboard 首页 `CommandCodeUsageStatsCard` 共享实例)。
**ARM64 Proot Release 构建红线**:
> ⚠️ AGP 9.0 的 `optimizeReleaseResources` 在 ARM64 Proot 下传入 `--resource-path-shortening-map=<path>`(等号形式),ARM64 AAPT2 不接受此语法(只接受空格分隔),exit code=1 但 AGP `invokeAapt()` 只调 `rethrowFailure()` 不调 `assertNormalExitValue()`,失败被静默吞掉。导致 optimized `.ap_` 缺失,`packageRelease` 生成无 `AndroidManifest.xml` / `resources.arsc` / `res/` 的残缺 APK(BUILD SUCCESSFUL 但 APK 不可用)。
>
> 修复链(不可删除任一环节):
> 1. `~/.gradle/gradle.properties`(全局,不提交项目仓库)→ `android.aapt2FromMavenOverride` 指向 `~/.local/lib/android-aapt2-wrapper/aapt2` wrapper 脚本,将 `--resource-path-shortening-map=<path>` 拆分为 `--resource-path-shortening-map` `<path>` 两个独立 argv
> 2. `app/build.gradle.kts` 中 `guardReleaseResources` 任务:`optimizeReleaseResources` 之后验证 optimized `.ap_` 完整性(manifest + arsc + res/),缺失则复制 linked `.ap_` 作为 fallback;`packageRelease` 依赖此任务
> 3. `app/build.gradle.kts` 中 `resolutionStrategy` 强制 `aapt2:linux-aarch64` classifier
>
> CI(GitHub Actions x86_64)不受影响——全局 `~/.gradle/gradle.properties` 不在项目仓库中。Debug 不经过 `optimizeReleaseResources`,无此问题。
**首页布局**:
> Dashboard 使用 `PullToRefreshBox` → `Column` + `verticalScroll`(非 `LazyColumn`)。
@ -154,6 +165,33 @@ Expanded(平板,≥840dp):
> `StackedBarChart` / `LineChart` 的 `tooltipBucket` 初始值为 `buckets.lastOrNull()`,进入图表页即可看到最新时段数值详情,暗示图表可交互。
**测试体系**:
- 纯 JVM 单元测试(`testDebugUnitTest`),无 Android 框架依赖,无网络
- 5 个测试文件,78 个用例:
- `OllamaRepositoryTest`(16)— HTML 解析:plan/百分比/时间戳/模型次数/空输入
- `OpenCodeGoRepositoryTest`(10)— SSR hydration 解析:三窗口/嵌套/缺失字段
- `FormatUtilsTest`(18)— `formatAmount`/`formatResetInSec`/`formatResetForWidget`/`normalizeWindowLabel`
- `FormatCodexPrimaryLabelTest`(9)— `formatCodexPrimaryLabel`:null fallback/大小写/未知值
- `CodexRepositoryTest`(25)— `durationLabel` 阈值 + `parseUsageWindows` JsonNull 安全 + `parseSseResponse` SSE 解析
- CodexRepository 中 `parseUsageWindows`/`durationLabel`/`UsageWindow` 为 `internal`(companion object),`parseSseResponse` 为 `internal` 顶层函数——不使用实例状态,便于测试直接调用
**CI workflow(`.github/workflows/ci.yml`)**:
- 触发:push main / 所有 PR
- 3 个 job 串行(test 通过后才构建):
1. `test`:`testDebugUnitTest` + `lintDebug`,上传 XML 测试报告 + lint 报告 artifact
2. `build-debug`:`assembleDebug`,上传 Debug APK artifact
3. `build-release`:`assembleRelease` + APK 完整性验证(`AndroidManifest.xml` + `resources.arsc` + `res/`),上传 Release APK artifact
- Release 签名 fallback:CI 无 `release.jks`,`build.gradle.kts` 自动 fallback 到 `~/.android/debug.keystore`;CI 额外步骤确保 debug keystore 存在
- artifact 保留 14 天
**重大修改 PR 审计红线**:
> ⚠️ 重大修改(构建配置、签名、CI、架构变更、新服务接入)必须通过 PR 提交,不能直接 push main。
> PR 触发 CI 三 job 全部通过后才可合并:单元测试 + lint + Debug/Release APK 构建验证。
> Release APK 完整性验证(manifest + arsc + res/)是防止 ARM64 Proot 构建问题再次发生的最后防线。
## RemoteViews 兼容性红线
以下元素在 Widget 布局中**不可用**,会导致「载入出现问题」:
@ -172,8 +210,17 @@ Expanded(平板,≥840dp):
cd /data/user/0/com.ai.assistance.operit/files/workspace/Rainytoken
export ANDROID_HOME=$HOME/Android
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-arm64
./gradlew testDebugUnitTest
# 78 个单元测试,纯 JVM,无设备依赖
./gradlew assembleDebug
# APK: app/build/outputs/apk/debug/app-debug.apk
# Debug APK: app/build/outputs/apk/debug/app-debug.apk
./gradlew assembleRelease
# Release APK: app/build/outputs/apk/release/app-release.apk
# ARM64 Proot 需 ~/.gradle/gradle.properties 配置 aapt2FromMavenOverride(见红线)
# CI x86_64 无需额外配置
```
## 数据流

View File

@ -2,6 +2,7 @@
> *"AI 用量,尽在掌握 — AI Balance & Usage at a Glance"* 🐱✨
[![CI](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/ci.yml/badge.svg)](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/ci.yml)
[![Release](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/release.yml/badge.svg)](https://github.com/CATMIAOZHI/Rainytoken/actions)
[![Version](https://img.shields.io/github/v/release/CATMIAOZHI/Rainytoken?color=ff85a2)](https://github.com/CATMIAOZHI/Rainytoken/releases)
Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go、CommandCode Go、Codex / ChatGPT Plus、Ollama Pro 的余额与用量配额。粉色调品牌 UI,配套桌面小组件。
@ -141,6 +142,9 @@ Rainytoken/
### 方式二:命令行
```bash
# 运行单元测试
./gradlew testDebugUnitTest
# 构建 Debug APK
./gradlew assembleDebug
# APK 输出:app/build/outputs/apk/debug/app-debug.apk
@ -158,6 +162,12 @@ chmod +x ./setup_android_env.sh
./setup_android_env.sh
```
该脚本会配置 `$ANDROID_HOME` 并使用项目内置的 ARM64 build-tools。
**Release 构建额外配置**:ARM64 Proot 下 AGP 9.0 的 `optimizeReleaseResources` 传入 `--resource-path-shortening-map=<path>` 等号参数,ARM64 AAPT2 不接受此语法,需在 `~/.gradle/gradle.properties`(全局,不提交项目仓库)中配置 AAPT2 wrapper:
```properties
android.aapt2FromMavenOverride=/path/to/android-aapt2-wrapper/aapt2
```
wrapper 脚本将等号形式拆分为空格分隔的两个独立 argv。项目 `build.gradle.kts` 中已内置 `guardReleaseResources` 任务作为构建防护(optimized `.ap_` 缺失时自动 fallback 到 linked `.ap_`)。
> 在 x86_64 环境(GitHub Actions / 普通 Linux)中会自动走官方 AAPT2,无需额外操作。
</details>

View File

@ -27,10 +27,22 @@ android {
signingConfigs {
create("release") {
storeFile = rootProject.file("release.jks")
storePassword = System.getenv("KEYSTORE_PASSWORD") ?: "RainyToken2026!"
keyAlias = System.getenv("KEYSTORE_ALIAS") ?: "rainy"
keyPassword = System.getenv("KEY_PASSWORD") ?: "RainyToken2026!"
// CI 环境没有 release.jks,自动 fallback 到 debug keystore
// Release workflow 通过 Secret 注入 release.jks
val keystoreFile = rootProject.file("release.jks")
if (keystoreFile.exists()) {
storeFile = keystoreFile
storePassword = System.getenv("KEYSTORE_PASSWORD") ?: "RainyToken2026!"
keyAlias = System.getenv("KEYSTORE_ALIAS") ?: "rainy"
keyPassword = System.getenv("KEY_PASSWORD") ?: "RainyToken2026!"
} else {
// Fallback: 使用 SDK 默认 debug keystore(CI 构建验证用)
val debugKeystore = file("${System.getProperty("user.home")}/.android/debug.keystore")
storeFile = debugKeystore
storePassword = "android"
keyAlias = "androiddebugkey"
keyPassword = "android"
}
}
}
@ -47,10 +59,7 @@ android {
)
}
}
androidResources {
// AGP 9.0 ARM64 Proot: noCompress workaround (manifest still needs post-process)
noCompress += "xml"
}
// noCompress workaround removed — no longer needed
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
@ -66,87 +75,80 @@ android {
}
// ═══════════════════════════════════════════════════════
// AGP 9.0 ARM64 Proot Workaround: packageRelease 丢失
// AndroidManifest.xml
// AGP 9.0 ARM64 Proot: Release resource guard + fallback
//
// 修复:assembleRelease 后检查 APK 是否含 manifest,
// 缺失则注入并重新签名。仅 aarch64 环境触发。
// 根因:AGP 9.0 的 optimizeReleaseResources 传入 --resource-path-shortening-map=<path>
// 等号形式,ARM64 AAPT2 不接受此语法(只接受空格分隔),exit code=1 但 AGP 不检查,
// 导致 optimized .ap_ 缺失,packageRelease 生成无资源残缺 APK。
//
// 修复链:
// 1. ~/.gradle/gradle.properties → android.aapt2FromMavenOverride 指向 wrapper
// wrapper 将 --resource-path-shortening-map=<path> 拆分为两个独立 argv
// 2. 本任务作为构建防护:optimizeReleaseResources 之后验证 optimized .ap_ 完整性,
// 若缺失则复制 linked .ap_ 作为 fallback(功能正确,仅跳过 path shortening)
// 3. packageRelease 依赖此任务,确保消费到完整资源
// ═══════════════════════════════════════════════════════
if (System.getProperty("os.arch") == "aarch64") {
tasks.register("fixReleaseManifest") {
dependsOn("assembleRelease")
tasks.register("guardReleaseResources") {
dependsOn("optimizeReleaseResources")
doLast {
val apk = layout.buildDirectory.file("outputs/apk/release/app-release.apk").get().asFile
val manifest = layout.buildDirectory
val linkedAp = layout.buildDirectory
.file("intermediates/linked_resources_binary_format/release/processReleaseResources/linked-resources-binary-format-release.ap_")
.get().asFile
if (!apk.exists() || !manifest.exists()) return@doLast
val optimizedDir = layout.buildDirectory
.dir("intermediates/optimized_processed_res/release/optimizeReleaseResources")
.get().asFile
val optimizedAp = File(optimizedDir, "resources-release-optimize.ap_")
// 检查 APK 是否已包含 manifest
val checkResult = providers.exec {
commandLine("unzip", "-l", apk.absolutePath)
if (optimizedAp.exists() && optimizedAp.length() > 0) {
// optimized .ap_ 存在,验证内容完整性
val zipList = providers.exec {
commandLine("unzip", "-l", optimizedAp.absolutePath)
isIgnoreExitValue = true
}.standardOutput.asText.get()
val hasManifest = zipList.contains("AndroidManifest.xml")
val hasArsc = zipList.contains("resources.arsc")
val hasRes = zipList.contains("res/")
if (hasManifest && hasArsc && hasRes) {
logger.lifecycle("GuardReleaseResources: optimized .ap_ OK (${optimizedAp.length()} bytes)")
return@doLast
}
logger.warn("GuardReleaseResources: optimized .ap_ incomplete (manifest=$hasManifest arsc=$hasArsc res=$hasRes)")
} else {
logger.warn("GuardReleaseResources: optimized .ap_ missing or empty")
}
// Fallback: copy linked .ap_ → optimized .ap_
if (!linkedAp.exists() || linkedAp.length() == 0L) {
throw GradleException("GuardReleaseResources: linked .ap_ also missing or empty — cannot recover")
}
optimizedDir.mkdirs()
linkedAp.copyTo(optimizedAp, overwrite = true)
// Verify the copy
val verifyList = providers.exec {
commandLine("unzip", "-l", optimizedAp.absolutePath)
isIgnoreExitValue = true
}.standardOutput.asText.get()
if (!checkResult.contains("AndroidManifest.xml")) {
logger.lifecycle("FixManifestTask: injecting AndroidManifest.xml into release APK")
// 从 .ap_ 提取 binary AndroidManifest.xml 到临时文件
val tmpManifest = File(apk.parentFile, "AndroidManifest.xml")
val extractProcess = Runtime.getRuntime().exec(
arrayOf("unzip", "-o", manifest.absolutePath, "AndroidManifest.xml", "-d", apk.parentFile!!.absolutePath)
)
extractProcess.waitFor()
logger.lifecycle("FixManifestTask: extract from .ap_ exit code = ${extractProcess.exitValue()}")
// 用 zip 命令注入 binary manifest
val zipProcess = Runtime.getRuntime().exec(arrayOf("zip", "-j", apk.absolutePath, tmpManifest.absolutePath))
zipProcess.waitFor()
logger.lifecycle("FixManifestTask: zip exit code = ${zipProcess.exitValue()}")
// 找 apksigner 并重新签名
val androidHome = System.getenv("ANDROID_HOME") ?: System.getenv("ANDROID_SDK_ROOT")
var signerFile: File? = null
if (androidHome != null) {
val btDir = File(androidHome, "build-tools")
val latest = btDir.listFiles()?.filter { it.isDirectory }?.maxByOrNull { it.name }
if (latest != null) {
val signer = File(latest, "apksigner")
if (signer.exists()) signerFile = signer
}
}
val sf = signerFile
if (sf != null && sf.exists()) {
// 从 android extension 拿签名配置
val androidExt = project.extensions.findByName("android")
val signingConfig = (androidExt as? com.android.build.api.dsl.ApplicationExtension)
?.signingConfigs?.findByName("release")
val ksFile = signingConfig?.storeFile?.absolutePath ?: ""
val ksPass = signingConfig?.storePassword ?: ""
val ksAlias = signingConfig?.keyAlias ?: ""
val ksKeyPass = signingConfig?.keyPassword ?: ""
val signCmd = arrayOf(
sf.absolutePath, "sign",
"--ks", ksFile,
"--ks-pass", "pass:$ksPass",
"--ks-key-alias", ksAlias,
"--key-pass", "pass:$ksKeyPass",
"--v2-signing-enabled", "true",
"--v3-signing-enabled", "false",
"--min-sdk-version", "31",
apk.absolutePath
)
val signProcess = Runtime.getRuntime().exec(signCmd)
val errorText = signProcess.errorStream.bufferedReader().readText()
signProcess.waitFor()
logger.lifecycle("FixManifestTask: apksigner exit code = ${signProcess.exitValue()}")
if (errorText.isNotBlank()) logger.lifecycle("FixManifestTask: apksigner stderr = $errorText")
logger.lifecycle("FixManifestTask: APK re-signed successfully")
} else {
logger.warn("FixManifestTask: apksigner not found, APK not re-signed")
}
val vManifest = verifyList.contains("AndroidManifest.xml")
val vArsc = verifyList.contains("resources.arsc")
val vRes = verifyList.contains("res/")
if (!vManifest || !vArsc || !vRes) {
throw GradleException("GuardReleaseResources: fallback copy verification failed (manifest=$vManifest arsc=$vArsc res=$vRes)")
}
logger.lifecycle("GuardReleaseResources: fallback — copied linked .ap_ → optimized .ap_ (${optimizedAp.length()} bytes, path shortening skipped)")
}
}
project.tasks.matching { it.name == "assembleRelease" }.configureEach {
finalizedBy("fixReleaseManifest")
// packageRelease 必须依赖此防护任务
project.tasks.matching { it.name == "packageRelease" }.configureEach {
dependsOn("guardReleaseResources")
}
}

View File

@ -1,24 +0,0 @@
package com.java.myapplication
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.java.myapplication", appContext.packageName)
}
}

View File

@ -41,6 +41,28 @@ class CodexRepository(
private const val CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
private const val REFRESH_BUFFER_MS = 60L * 60 * 1000
private const val TAG = "Codex"
internal data class UsageWindow(val label: String, val remainingPct: Int, val resetAt: Long?)
internal fun parseUsageWindows(data: JsonObject): List<UsageWindow> {
val result = mutableListOf<UsageWindow>()
fun addWindows(rl: JsonObject?) {
if (rl == null) return
for (key in listOf("primary_window", "secondary_window")) {
val w = rl[key] as? JsonObject ?: continue
val usedPct = (w["used_percent"] as? JsonPrimitive)?.floatOrNull ?: continue
val remaining = (100 - usedPct).toInt().coerceIn(0, 100)
result.add(UsageWindow(durationLabel((w["limit_window_seconds"] as? JsonPrimitive)?.longOrNull), remaining, (w["reset_at"] as? JsonPrimitive)?.longOrNull?.times(1000L)))
}
}
addWindows(data["rate_limit"] as? JsonObject)
(data["additional_rate_limits"] as? kotlinx.serialization.json.JsonArray)?.forEach { item ->
if (item is JsonObject) addWindows(item["rate_limit"] as? JsonObject)
}
return result
}
internal fun durationLabel(seconds: Long?): String = when { seconds == null -> "Usage"; seconds / 60.0 >= 10079 -> "每周"; seconds / 60.0 >= 1439 -> "${(seconds / 86400).toInt()}d"; seconds / 60.0 >= 60 -> "${(seconds / 3600).toInt()}h"; else -> "${maxOf(1, (seconds / 60).toInt())}m" }
}
suspend fun fetchBalance(): Result<ServiceBalance> = withContext(Dispatchers.IO) {
@ -368,28 +390,6 @@ class CodexRepository(
}
}
private data class UsageWindow(val label: String, val remainingPct: Int, val resetAt: Long?)
private fun parseUsageWindows(data: JsonObject): List<UsageWindow> {
val result = mutableListOf<UsageWindow>()
fun addWindows(rl: JsonObject?) {
if (rl == null) return
for (key in listOf("primary_window", "secondary_window")) {
val w = rl[key] as? JsonObject ?: continue
val usedPct = (w["used_percent"] as? JsonPrimitive)?.floatOrNull ?: continue
val remaining = (100 - usedPct).toInt().coerceIn(0, 100)
result.add(UsageWindow(durationLabel((w["limit_window_seconds"] as? JsonPrimitive)?.longOrNull), remaining, (w["reset_at"] as? JsonPrimitive)?.longOrNull?.times(1000L)))
}
}
addWindows(data["rate_limit"] as? JsonObject)
(data["additional_rate_limits"] as? kotlinx.serialization.json.JsonArray)?.forEach { item ->
if (item is JsonObject) addWindows(item["rate_limit"] as? JsonObject)
}
return result
}
private fun durationLabel(seconds: Long?): String = when { seconds == null -> "Usage"; seconds / 60.0 >= 10079 -> "每周"; seconds / 60.0 >= 1439 -> "${(seconds / 86400).toInt()}d"; seconds / 60.0 >= 60 -> "${(seconds / 3600).toInt()}h"; else -> "${maxOf(1, (seconds / 60).toInt())}m" }
@Serializable data class OAuthRefreshResponse(@kotlinx.serialization.SerialName("access_token") val accessToken: String, @kotlinx.serialization.SerialName("refresh_token") val refreshToken: String? = null, @kotlinx.serialization.SerialName("expires_in") val expiresIn: Long = 3600, @kotlinx.serialization.SerialName("token_type") val tokenType: String? = null)
}
@ -400,7 +400,7 @@ class TriggerError(val summary: String, val responseBody: String) : Exception(su
* 将 SSE 流响应解析为简洁的文本摘要。
* 提取:模型回复文本、用量统计(input/output tokens)。
*/
private fun parseSseResponse(sseText: String, model: String): String {
internal fun parseSseResponse(sseText: String, model: String): String {
val sseJson = Json { ignoreUnknownKeys = true }
val outputText = StringBuilder()
var inputTokens: String? = null

View File

@ -1,17 +0,0 @@
package com.java.myapplication
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View File

@ -0,0 +1,321 @@
package com.rainy.token.data.repository
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.JsonNull
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Unit tests for [CodexRepository.parseUsageWindows], [CodexRepository.durationLabel],
* and [parseSseResponse].
*
* **JsonNull safety red line**: tests verify that explicit JSON null values
* (used_percent = null) are handled correctly via `as? JsonPrimitive` / `as? JsonObject`
* casts, not `?.jsonPrimitive` which would throw on JsonNull.
*
* All tests are pure JVM — no Android framework, no network.
*/
class CodexRepositoryTest {
// ── durationLabel ──
@Test
fun `durationLabel null returns Usage`() {
assertEquals("Usage", CodexRepository.durationLabel(null))
}
@Test
fun `durationLabel weekly threshold`() {
// 10079 minutes = ~7 days. 7 * 86400 = 604800 seconds = 10080 minutes ≥ 10079
assertEquals("每周", CodexRepository.durationLabel(604800L))
}
@Test
fun `durationLabel just under weekly stays as days`() {
// 10078 minutes = 604680 seconds → 604680/86400 = 6.99... → 6d
assertEquals("6d", CodexRepository.durationLabel(604680L))
}
@Test
fun `durationLabel daily`() {
assertEquals("1d", CodexRepository.durationLabel(86400L))
assertEquals("2d", CodexRepository.durationLabel(2 * 86400L))
}
@Test
fun `durationLabel just under daily stays as hours`() {
// 1438 minutes = 86280 seconds → 86280/3600 = 23.96 → 23h
assertEquals("23h", CodexRepository.durationLabel(86280L))
}
@Test
fun `durationLabel hourly`() {
assertEquals("1h", CodexRepository.durationLabel(3600L))
assertEquals("5h", CodexRepository.durationLabel(5 * 3600L))
}
@Test
fun `durationLabel just under hourly stays as minutes`() {
// 59 minutes = 3540 seconds → 3540/60 = 59 → 59m
assertEquals("59m", CodexRepository.durationLabel(3540L))
}
@Test
fun `durationLabel sub-minute defaults to 1m`() {
assertEquals("1m", CodexRepository.durationLabel(30L))
assertEquals("1m", CodexRepository.durationLabel(1L))
}
// ── parseUsageWindows ──
@Test
fun `parseUsageWindows extracts primary and secondary windows`() {
val window = JsonObject(mapOf(
"primary_window" to JsonObject(mapOf(
"used_percent" to JsonPrimitive(42.0),
"reset_at" to JsonPrimitive(1719500400L),
"limit_window_seconds" to JsonPrimitive(18000L)
)),
"secondary_window" to JsonObject(mapOf(
"used_percent" to JsonPrimitive(15.0),
"reset_at" to JsonPrimitive(1719932400L),
"limit_window_seconds" to JsonPrimitive(604800L)
))
))
val data = JsonObject(mapOf("rate_limit" to window))
val windows = CodexRepository.parseUsageWindows(data)
assertEquals(2, windows.size)
assertEquals("5h", windows[0].label)
assertEquals(58, windows[0].remainingPct) // 100 - 42 = 58
assertEquals("每周", windows[1].label)
assertEquals(85, windows[1].remainingPct) // 100 - 15 = 85
}
@Test
fun `parseUsageWindows handles used_percent null via JsonNull`() {
// JsonNull red line: used_percent = null should be skipped (as? JsonPrimitive returns null)
val window = JsonObject(mapOf(
"primary_window" to JsonObject(mapOf(
"used_percent" to JsonNull,
"reset_at" to JsonPrimitive(1719500400L),
"limit_window_seconds" to JsonPrimitive(18000L)
)),
"secondary_window" to JsonObject(mapOf(
"used_percent" to JsonPrimitive(15.0),
"reset_at" to JsonPrimitive(1719932400L),
"limit_window_seconds" to JsonPrimitive(604800L)
))
))
val data = JsonObject(mapOf("rate_limit" to window))
val windows = CodexRepository.parseUsageWindows(data)
// Only secondary_window should be parsed (primary skipped due to JsonNull used_percent)
assertEquals(1, windows.size)
assertEquals("每周", windows[0].label)
}
@Test
fun `parseUsageWindows handles missing rate_limit`() {
val data = JsonObject(emptyMap())
val windows = CodexRepository.parseUsageWindows(data)
assertTrue(windows.isEmpty())
}
@Test
fun `parseUsageWindows handles null rate_limit`() {
val data = JsonObject(mapOf("rate_limit" to JsonNull))
val windows = CodexRepository.parseUsageWindows(data)
assertTrue(windows.isEmpty())
}
@Test
fun `parseUsageWindows calculates remaining percentage`() {
val window = JsonObject(mapOf(
"primary_window" to JsonObject(mapOf(
"used_percent" to JsonPrimitive(42.0),
"reset_at" to JsonPrimitive(1719500400L),
"limit_window_seconds" to JsonPrimitive(18000L)
))
))
val data = JsonObject(mapOf("rate_limit" to window))
val windows = CodexRepository.parseUsageWindows(data)
assertEquals(1, windows.size)
assertEquals(58, windows[0].remainingPct) // 100 - 42 = 58
assertEquals("5h", windows[0].label)
}
@Test
fun `parseUsageWindows clamps remaining to 0-100`() {
val window = JsonObject(mapOf(
"primary_window" to JsonObject(mapOf(
"used_percent" to JsonPrimitive(150.0), // over 100%
"reset_at" to JsonPrimitive(1719500400L),
"limit_window_seconds" to JsonPrimitive(18000L)
))
))
val data = JsonObject(mapOf("rate_limit" to window))
val windows = CodexRepository.parseUsageWindows(data)
assertEquals(1, windows.size)
assertEquals(0, windows[0].remainingPct) // (100 - 150).coerceIn(0, 100) = 0
}
@Test
fun `parseUsageWindows extracts reset_at timestamp in millis`() {
val resetAtSec = 1719500400L
val window = JsonObject(mapOf(
"primary_window" to JsonObject(mapOf(
"used_percent" to JsonPrimitive(42.0),
"reset_at" to JsonPrimitive(resetAtSec),
"limit_window_seconds" to JsonPrimitive(18000L)
))
))
val data = JsonObject(mapOf("rate_limit" to window))
val windows = CodexRepository.parseUsageWindows(data)
assertEquals(1, windows.size)
assertEquals(resetAtSec * 1000L, windows[0].resetAt)
}
@Test
fun `parseUsageWindows handles null reset_at`() {
val window = JsonObject(mapOf(
"primary_window" to JsonObject(mapOf(
"used_percent" to JsonPrimitive(42.0),
"reset_at" to JsonNull,
"limit_window_seconds" to JsonPrimitive(18000L)
))
))
val data = JsonObject(mapOf("rate_limit" to window))
val windows = CodexRepository.parseUsageWindows(data)
assertEquals(1, windows.size)
assertEquals(null, windows[0].resetAt)
}
@Test
fun `parseUsageWindows parses additional_rate_limits`() {
val primaryWindow = JsonObject(mapOf(
"primary_window" to JsonObject(mapOf(
"used_percent" to JsonPrimitive(10.0),
"reset_at" to JsonPrimitive(1719500400L),
"limit_window_seconds" to JsonPrimitive(18000L)
))
))
val additionalRl = JsonObject(mapOf(
"primary_window" to JsonObject(mapOf(
"used_percent" to JsonPrimitive(20.0),
"reset_at" to JsonPrimitive(1719932400L),
"limit_window_seconds" to JsonPrimitive(604800L)
))
))
val data = JsonObject(mapOf(
"rate_limit" to primaryWindow,
"additional_rate_limits" to kotlinx.serialization.json.JsonArray(
listOf(JsonObject(mapOf("rate_limit" to additionalRl)))
)
))
val windows = CodexRepository.parseUsageWindows(data)
assertEquals(2, windows.size)
}
@Test
fun `parseUsageWindows skips missing window keys`() {
// rate_limit with no primary_window or secondary_window
val data = JsonObject(mapOf("rate_limit" to JsonObject(emptyMap())))
val windows = CodexRepository.parseUsageWindows(data)
assertTrue(windows.isEmpty())
}
// ── parseSseResponse ──
@Test
fun `parseSseResponse extracts output text and usage from completed event`() {
val sse = """
data: {"type":"response.output_text.done","text":"Hello, world!"}
data: {"type":"response.completed","response":{"id":"resp_123","usage":{"input_tokens":"100","output_tokens":"50"}}}
data: [DONE]
""".trimIndent()
val result = parseSseResponse(sse, "gpt-5.6")
assertTrue(result.contains("Hello, world!"))
assertTrue(result.contains("input=100"))
assertTrue(result.contains("output=50"))
assertTrue(result.contains("resp_123"))
assertTrue(result.contains("gpt-5.6"))
}
@Test
fun `parseSseResponse handles empty output text`() {
val sse = """
data: {"type":"response.completed","response":{"id":"resp_456","usage":{"input_tokens":"10","output_tokens":"5"}}}
data: [DONE]
""".trimIndent()
val result = parseSseResponse(sse, "o4-mini")
assertTrue(result.contains("(空)"))
assertTrue(result.contains("input=10"))
assertTrue(result.contains("output=5"))
}
@Test
fun `parseSseResponse handles no usage data`() {
val sse = """
data: {"type":"response.output_text.done","text":"Hi there"}
data: {"type":"response.completed","response":{"id":"resp_789"}}
data: [DONE]
""".trimIndent()
val result = parseSseResponse(sse, "gpt-5.6")
assertTrue(result.contains("Hi there"))
// Should not contain usage line when no usage data
assertTrue(!result.contains("用量:"))
}
@Test
fun `parseSseResponse handles empty input`() {
val result = parseSseResponse("", "gpt-5.6")
assertTrue(result.contains("(空)"))
assertTrue(result.contains("gpt-5.6"))
}
@Test
fun `parseSseResponse skips non-data lines`() {
val sse = """
event: response.completed
data: {"type":"response.output_text.done","text":"Test reply"}
data: {"type":"response.completed","response":{"id":"resp_abc","usage":{"input_tokens":"200","output_tokens":"100"}}}
data: [DONE]
""".trimIndent()
val result = parseSseResponse(sse, "o4-mini")
assertTrue(result.contains("Test reply"))
assertTrue(result.contains("input=200"))
assertTrue(result.contains("output=100"))
}
@Test
fun `parseSseResponse handles malformed JSON gracefully`() {
val sse = """
data: {invalid json}
data: {"type":"response.output_text.done","text":"Good"}
data: {"type":"response.completed","response":{"id":"resp_ok","usage":{"input_tokens":"1","output_tokens":"1"}}}
data: [DONE]
""".trimIndent()
val result = parseSseResponse(sse, "gpt-5.6")
// Malformed line should be skipped, valid ones parsed
assertTrue(result.contains("Good"))
assertTrue(result.contains("input=1"))
}
@Test
fun `parseSseResponse contains activation success header`() {
val sse = """
data: {"type":"response.completed","response":{"id":"r1","usage":{"input_tokens":"0","output_tokens":"0"}}}
data: [DONE]
""".trimIndent()
val result = parseSseResponse(sse, "gpt-5.6")
assertTrue(result.contains("✓ 激活成功"))
}
}

View File

@ -0,0 +1,128 @@
package com.rainy.token.ui.components
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Unit tests for [formatAmount], [formatResetInSec], [formatResetForWidget], [normalizeWindowLabel].
*
* All tests are pure JVM — no Android framework, no network.
*/
class FormatUtilsTest {
// ── formatAmount ──
@Test
fun `formatAmount integer has no decimal point`() {
assertEquals("5", formatAmount(5.0))
assertEquals("0", formatAmount(0.0))
assertEquals("100", formatAmount(100.0))
}
@Test
fun `formatAmount decimal keeps 2 places`() {
assertEquals("5.20", formatAmount(5.20))
assertEquals("0.50", formatAmount(0.5))
assertEquals("99.99", formatAmount(99.99))
}
@Test
fun `formatAmount rounds to 2 places`() {
assertEquals("1.23", formatAmount(1.234))
assertEquals("1.24", formatAmount(1.235))
}
@Test
fun `formatAmount large integer`() {
assertEquals("10000", formatAmount(10000.0))
}
@Test
fun `formatAmount negative integer`() {
assertEquals("-5", formatAmount(-5.0))
}
@Test
fun `formatAmount negative decimal`() {
assertEquals("-5.20", formatAmount(-5.2))
}
// ── formatResetInSec ──
@Test
fun `formatResetInSec zero returns dash`() {
assertEquals("—", formatResetInSec(0))
}
@Test
fun `formatResetInSec negative returns dash`() {
assertEquals("—", formatResetInSec(-1))
assertEquals("—", formatResetInSec(-100))
}
@Test
fun `formatResetInSec minutes only`() {
assertEquals("5 分", formatResetInSec(300))
assertEquals("1 分", formatResetInSec(60))
assertEquals("59 分", formatResetInSec(59 * 60))
}
@Test
fun `formatResetInSec hours and minutes`() {
assertEquals("1 小时 0 分", formatResetInSec(3600))
assertEquals("1 小时 30 分", formatResetInSec(3600 + 1800))
assertEquals("23 小时 59 分", formatResetInSec(86399))
}
@Test
fun `formatResetInSec days and hours`() {
assertEquals("1 天 0 小时", formatResetInSec(86400))
assertEquals("1 天 1 小时", formatResetInSec(86400 + 3600))
assertEquals("7 天 12 小时", formatResetInSec(7 * 86400 + 12 * 3600))
}
@Test
fun `formatResetInSec large value`() {
assertEquals("30 天 0 小时", formatResetInSec(30 * 86400L))
}
// ── formatResetForWidget ──
@Test
fun `formatResetForWidget zero returns empty string`() {
assertEquals("", formatResetForWidget(0))
}
@Test
fun `formatResetForWidget negative returns empty string`() {
assertEquals("", formatResetForWidget(-1))
}
@Test
fun `formatResetForWidget same logic as formatResetInSec for positive values`() {
assertEquals("5 分", formatResetForWidget(300))
assertEquals("1 小时 0 分", formatResetForWidget(3600))
assertEquals("1 天 0 小时", formatResetForWidget(86400))
}
// ── normalizeWindowLabel ──
@Test
fun `normalizeWindowLabel weekly to Chinese`() {
assertEquals("每周", normalizeWindowLabel("weekly"))
assertEquals("每周", normalizeWindowLabel("Weekly"))
assertEquals("每周", normalizeWindowLabel("WEEKLY"))
}
@Test
fun `normalizeWindowLabel non-weekly passes through`() {
assertEquals("5h", normalizeWindowLabel("5h"))
assertEquals("monthly", normalizeWindowLabel("monthly"))
assertEquals("30d", normalizeWindowLabel("30d"))
}
@Test
fun `normalizeWindowLabel empty string`() {
assertEquals("", normalizeWindowLabel(""))
}
}

View File

@ -0,0 +1,61 @@
package com.rainy.token.ui.dashboard
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Unit tests for [formatCodexPrimaryLabel].
*
* Tests the Codex primary window label translation logic,
* including null/unknown fallback behavior.
*/
class FormatCodexPrimaryLabelTest {
@Test
fun `5h stays as 5h`() {
assertEquals("5h", formatCodexPrimaryLabel("5h"))
}
@Test
fun `7d maps to weekly Chinese`() {
assertEquals("每周", formatCodexPrimaryLabel("7d"))
}
@Test
fun `weekly Chinese stays`() {
assertEquals("每周", formatCodexPrimaryLabel("每周"))
}
@Test
fun `30d maps to monthly Chinese`() {
assertEquals("每月", formatCodexPrimaryLabel("30d"))
}
@Test
fun `monthly Chinese stays`() {
assertEquals("每月", formatCodexPrimaryLabel("每月"))
}
@Test
fun `usage maps to Chinese`() {
assertEquals("用量", formatCodexPrimaryLabel("usage"))
}
@Test
fun `null defaults to 5h`() {
assertEquals("5h", formatCodexPrimaryLabel(null))
}
@Test
fun `unknown label passes through`() {
assertEquals("custom", formatCodexPrimaryLabel("custom"))
assertEquals("15d", formatCodexPrimaryLabel("15d"))
}
@Test
fun `case insensitive`() {
assertEquals("5h", formatCodexPrimaryLabel("5H"))
assertEquals("每周", formatCodexPrimaryLabel("7D"))
assertEquals("每月", formatCodexPrimaryLabel("30D"))
}
}