test: 新增 52 个单元测试,删除模板残留,整理构建修复
## 变更内容 ### 新增测试(3 文件,52 用例) - FormatUtilsTest (18): formatAmount/formatResetInSec/formatResetForWidget/normalizeWindowLabel - FormatCodexPrimaryLabelTest (9): Codex 窗口标签翻译,含 null fallback/大小写/未知值 - CodexRepositoryTest (25): durationLabel 阈值/parseUsageWindows JsonNull 安全/parseSseResponse SSE 解析 ### 删除模板残留 - ExampleUnitTest.kt (包名 com.java.myapplication) - ExampleInstrumentedTest.kt (包名 com.java.myapplication) ### 生产代码可见性调整 (CodexRepository.kt) - UsageWindow/parseUsageWindows/durationLabel: private to internal, 移入 companion object - parseSseResponse: private to internal 顶层函数 ### 构建修复 (上一轮会话成果) - build.gradle.kts: 删除旧 fixReleaseManifest workaround,替换为 guardReleaseResources 任务 - AGENTS.md: 新增 ARM64 Proot Release 构建红线段落 - README.md: 补充 Release 构建额外配置说明 ## 测试结果 78 tests, 0 failures, 0 errors
This commit is contained in:
parent
5ead63a8a3
commit
b7639a733e
18
AGENTS.md
18
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=<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`)。
|
||||
@ -173,7 +184,12 @@ 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 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 无需额外配置
|
||||
```
|
||||
|
||||
## 数据流
|
||||
|
||||
@ -158,6 +158,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>
|
||||
|
||||
|
||||
@ -47,10 +47,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 +63,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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 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
|
||||
|
||||
@ -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