Merge pull request #1 from CATMIAOZHI/feat/unit-tests
test: 新增 52 个单元测试 + 删除模板残留 + 整理构建修复
This commit is contained in:
commit
79edb053cc
109
.github/workflows/ci.yml
vendored
109
.github/workflows/ci.yml
vendored
@ -6,7 +6,8 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
test:
|
||||||
|
name: Unit Tests & Lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@ -17,9 +18,109 @@ jobs:
|
|||||||
- uses: android-actions/setup-android@v3
|
- uses: android-actions/setup-android@v3
|
||||||
with:
|
with:
|
||||||
packages: 'platforms;android-35 build-tools;35.0.0'
|
packages: 'platforms;android-35 build-tools;35.0.0'
|
||||||
|
|
||||||
|
- name: Grant execute permission for gradlew
|
||||||
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: |
|
run: ./gradlew testDebugUnitTest
|
||||||
chmod +x gradlew
|
|
||||||
./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
|
- 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
|
||||||
49
AGENTS.md
49
AGENTS.md
@ -74,6 +74,17 @@ CommandCode Go 走 JSON API 抓取用量数据,Codex / ChatGPT Plus 通过 aut
|
|||||||
> CCGO 路由中 `UsageChartViewModel` 和 `UsageViewModel` 用相同 key 会导致类型碰撞、加载失败。
|
> CCGO 路由中 `UsageChartViewModel` 和 `UsageViewModel` 用相同 key 会导致类型碰撞、加载失败。
|
||||||
> 当前方案:`chartVm` 用 `"ccgo_chart_$wid"`,`usageVm` 用 `"ccgo_$wid"`(与 Dashboard 首页 `CommandCodeUsageStatsCard` 共享实例)。
|
> 当前方案:`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`)。
|
> Dashboard 使用 `PullToRefreshBox` → `Column` + `verticalScroll`(非 `LazyColumn`)。
|
||||||
@ -154,6 +165,33 @@ Expanded(平板,≥840dp):
|
|||||||
|
|
||||||
> `StackedBarChart` / `LineChart` 的 `tooltipBucket` 初始值为 `buckets.lastOrNull()`,进入图表页即可看到最新时段数值详情,暗示图表可交互。
|
> `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 兼容性红线
|
## RemoteViews 兼容性红线
|
||||||
|
|
||||||
以下元素在 Widget 布局中**不可用**,会导致「载入出现问题」:
|
以下元素在 Widget 布局中**不可用**,会导致「载入出现问题」:
|
||||||
@ -172,8 +210,17 @@ Expanded(平板,≥840dp):
|
|||||||
cd /data/user/0/com.ai.assistance.operit/files/workspace/Rainytoken
|
cd /data/user/0/com.ai.assistance.operit/files/workspace/Rainytoken
|
||||||
export ANDROID_HOME=$HOME/Android
|
export ANDROID_HOME=$HOME/Android
|
||||||
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-arm64
|
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-arm64
|
||||||
|
|
||||||
|
./gradlew testDebugUnitTest
|
||||||
|
# 78 个单元测试,纯 JVM,无设备依赖
|
||||||
|
|
||||||
./gradlew assembleDebug
|
./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 无需额外配置
|
||||||
```
|
```
|
||||||
|
|
||||||
## 数据流
|
## 数据流
|
||||||
|
|||||||
10
README.md
10
README.md
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
> *"AI 用量,尽在掌握 — AI Balance & Usage at a Glance"* 🐱✨
|
> *"AI 用量,尽在掌握 — AI Balance & Usage at a Glance"* 🐱✨
|
||||||
|
|
||||||
|
[](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/ci.yml)
|
||||||
[](https://github.com/CATMIAOZHI/Rainytoken/actions)
|
[](https://github.com/CATMIAOZHI/Rainytoken/actions)
|
||||||
[](https://github.com/CATMIAOZHI/Rainytoken/releases)
|
[](https://github.com/CATMIAOZHI/Rainytoken/releases)
|
||||||
Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go、CommandCode Go、Codex / ChatGPT Plus、Ollama Pro 的余额与用量配额。粉色调品牌 UI,配套桌面小组件。
|
Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go、CommandCode Go、Codex / ChatGPT Plus、Ollama Pro 的余额与用量配额。粉色调品牌 UI,配套桌面小组件。
|
||||||
@ -141,6 +142,9 @@ Rainytoken/
|
|||||||
|
|
||||||
### 方式二:命令行
|
### 方式二:命令行
|
||||||
```bash
|
```bash
|
||||||
|
# 运行单元测试
|
||||||
|
./gradlew testDebugUnitTest
|
||||||
|
|
||||||
# 构建 Debug APK
|
# 构建 Debug APK
|
||||||
./gradlew assembleDebug
|
./gradlew assembleDebug
|
||||||
# APK 输出:app/build/outputs/apk/debug/app-debug.apk
|
# APK 输出:app/build/outputs/apk/debug/app-debug.apk
|
||||||
@ -158,6 +162,12 @@ chmod +x ./setup_android_env.sh
|
|||||||
./setup_android_env.sh
|
./setup_android_env.sh
|
||||||
```
|
```
|
||||||
该脚本会配置 `$ANDROID_HOME` 并使用项目内置的 ARM64 build-tools。
|
该脚本会配置 `$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,无需额外操作。
|
> 在 x86_64 环境(GitHub Actions / 普通 Linux)中会自动走官方 AAPT2,无需额外操作。
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
@ -27,10 +27,22 @@ android {
|
|||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
create("release") {
|
create("release") {
|
||||||
storeFile = rootProject.file("release.jks")
|
// 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!"
|
storePassword = System.getenv("KEYSTORE_PASSWORD") ?: "RainyToken2026!"
|
||||||
keyAlias = System.getenv("KEYSTORE_ALIAS") ?: "rainy"
|
keyAlias = System.getenv("KEYSTORE_ALIAS") ?: "rainy"
|
||||||
keyPassword = System.getenv("KEY_PASSWORD") ?: "RainyToken2026!"
|
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 {
|
// noCompress workaround removed — no longer needed
|
||||||
// AGP 9.0 ARM64 Proot: noCompress workaround (manifest still needs post-process)
|
|
||||||
noCompress += "xml"
|
|
||||||
}
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
@ -66,87 +75,80 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════
|
||||||
// AGP 9.0 ARM64 Proot Workaround: packageRelease 丢失
|
// AGP 9.0 ARM64 Proot: Release resource guard + fallback
|
||||||
// AndroidManifest.xml
|
|
||||||
//
|
//
|
||||||
// 修复:assembleRelease 后检查 APK 是否含 manifest,
|
// 根因:AGP 9.0 的 optimizeReleaseResources 传入 --resource-path-shortening-map=<path>
|
||||||
// 缺失则注入并重新签名。仅 aarch64 环境触发。
|
// 等号形式,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") {
|
if (System.getProperty("os.arch") == "aarch64") {
|
||||||
tasks.register("fixReleaseManifest") {
|
tasks.register("guardReleaseResources") {
|
||||||
dependsOn("assembleRelease")
|
dependsOn("optimizeReleaseResources")
|
||||||
doLast {
|
doLast {
|
||||||
val apk = layout.buildDirectory.file("outputs/apk/release/app-release.apk").get().asFile
|
val linkedAp = layout.buildDirectory
|
||||||
val manifest = layout.buildDirectory
|
|
||||||
.file("intermediates/linked_resources_binary_format/release/processReleaseResources/linked-resources-binary-format-release.ap_")
|
.file("intermediates/linked_resources_binary_format/release/processReleaseResources/linked-resources-binary-format-release.ap_")
|
||||||
.get().asFile
|
.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
|
if (optimizedAp.exists() && optimizedAp.length() > 0) {
|
||||||
val checkResult = providers.exec {
|
// optimized .ap_ 存在,验证内容完整性
|
||||||
commandLine("unzip", "-l", apk.absolutePath)
|
val zipList = providers.exec {
|
||||||
|
commandLine("unzip", "-l", optimizedAp.absolutePath)
|
||||||
isIgnoreExitValue = true
|
isIgnoreExitValue = true
|
||||||
}.standardOutput.asText.get()
|
}.standardOutput.asText.get()
|
||||||
|
|
||||||
if (!checkResult.contains("AndroidManifest.xml")) {
|
val hasManifest = zipList.contains("AndroidManifest.xml")
|
||||||
logger.lifecycle("FixManifestTask: injecting AndroidManifest.xml into release APK")
|
val hasArsc = zipList.contains("resources.arsc")
|
||||||
// 从 .ap_ 提取 binary AndroidManifest.xml 到临时文件
|
val hasRes = zipList.contains("res/")
|
||||||
val tmpManifest = File(apk.parentFile, "AndroidManifest.xml")
|
|
||||||
val extractProcess = Runtime.getRuntime().exec(
|
if (hasManifest && hasArsc && hasRes) {
|
||||||
arrayOf("unzip", "-o", manifest.absolutePath, "AndroidManifest.xml", "-d", apk.parentFile!!.absolutePath)
|
logger.lifecycle("GuardReleaseResources: optimized .ap_ OK (${optimizedAp.length()} bytes)")
|
||||||
)
|
return@doLast
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
logger.warn("GuardReleaseResources: optimized .ap_ incomplete (manifest=$hasManifest arsc=$hasArsc res=$hasRes)")
|
||||||
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 {
|
} else {
|
||||||
logger.warn("FixManifestTask: apksigner not found, APK not re-signed")
|
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()
|
||||||
|
|
||||||
|
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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
// packageRelease 必须依赖此防护任务
|
||||||
project.tasks.matching { it.name == "assembleRelease" }.configureEach {
|
project.tasks.matching { it.name == "packageRelease" }.configureEach {
|
||||||
finalizedBy("fixReleaseManifest")
|
dependsOn("guardReleaseResources")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -41,6 +41,28 @@ class CodexRepository(
|
|||||||
private const val CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
private const val CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||||
private const val REFRESH_BUFFER_MS = 60L * 60 * 1000
|
private const val REFRESH_BUFFER_MS = 60L * 60 * 1000
|
||||||
private const val TAG = "Codex"
|
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) {
|
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)
|
@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 流响应解析为简洁的文本摘要。
|
* 将 SSE 流响应解析为简洁的文本摘要。
|
||||||
* 提取:模型回复文本、用量统计(input/output tokens)。
|
* 提取:模型回复文本、用量统计(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 sseJson = Json { ignoreUnknownKeys = true }
|
||||||
val outputText = StringBuilder()
|
val outputText = StringBuilder()
|
||||||
var inputTokens: String? = null
|
var inputTokens: String? = 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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("✓ 激活成功"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(""))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user