diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f27e18..0947191 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 \ No newline at end of file + 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 \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 24cfcdc..b54bf67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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=`(等号形式),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=` 拆分为 `--resource-path-shortening-map` `` 两个独立 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 无需额外配置 ``` ## 数据流 diff --git a/README.md b/README.md index d04bf5c..c7bb517 100644 --- a/README.md +++ b/README.md @@ -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=` 等号参数,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,无需额外操作。 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bdeb62c..28895d0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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= +// 等号形式,ARM64 AAPT2 不接受此语法(只接受空格分隔),exit code=1 但 AGP 不检查, +// 导致 optimized .ap_ 缺失,packageRelease 生成无资源残缺 APK。 +// +// 修复链: +// 1. ~/.gradle/gradle.properties → android.aapt2FromMavenOverride 指向 wrapper +// wrapper 将 --resource-path-shortening-map= 拆分为两个独立 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") } } diff --git a/app/src/androidTest/java/com/java/myapplication/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/java/myapplication/ExampleInstrumentedTest.kt deleted file mode 100644 index 81134b9..0000000 --- a/app/src/androidTest/java/com/java/myapplication/ExampleInstrumentedTest.kt +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt index a1b42cb..c399033 100644 --- a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt @@ -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 { + val result = mutableListOf() + 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 = 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 { - val result = mutableListOf() - 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 diff --git a/app/src/test/java/com/java/myapplication/ExampleUnitTest.kt b/app/src/test/java/com/java/myapplication/ExampleUnitTest.kt deleted file mode 100644 index e1bacc5..0000000 --- a/app/src/test/java/com/java/myapplication/ExampleUnitTest.kt +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/app/src/test/java/com/rainy/token/data/repository/CodexRepositoryTest.kt b/app/src/test/java/com/rainy/token/data/repository/CodexRepositoryTest.kt new file mode 100644 index 0000000..aea46e1 --- /dev/null +++ b/app/src/test/java/com/rainy/token/data/repository/CodexRepositoryTest.kt @@ -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("✓ 激活成功")) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/rainy/token/ui/components/FormatUtilsTest.kt b/app/src/test/java/com/rainy/token/ui/components/FormatUtilsTest.kt new file mode 100644 index 0000000..79df352 --- /dev/null +++ b/app/src/test/java/com/rainy/token/ui/components/FormatUtilsTest.kt @@ -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("")) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/rainy/token/ui/dashboard/FormatCodexPrimaryLabelTest.kt b/app/src/test/java/com/rainy/token/ui/dashboard/FormatCodexPrimaryLabelTest.kt new file mode 100644 index 0000000..0175223 --- /dev/null +++ b/app/src/test/java/com/rainy/token/ui/dashboard/FormatCodexPrimaryLabelTest.kt @@ -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")) + } +} \ No newline at end of file