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:
WaterRain 2026-07-20 09:07:54 +00:00
parent 5ead63a8a3
commit b7639a733e
No known key found for this signature in database
9 changed files with 618 additions and 137 deletions

View File

@ -74,6 +74,17 @@ CommandCode Go 走 JSON API 抓取用量数据,Codex / ChatGPT Plus 通过 aut
> CCGO 路由中 `UsageChartViewModel` 和 `UsageViewModel` 用相同 key 会导致类型碰撞、加载失败。
> 当前方案:`chartVm` 用 `"ccgo_chart_$wid"`,`usageVm` 用 `"ccgo_$wid"`(与 Dashboard 首页 `CommandCodeUsageStatsCard` 共享实例)。
**ARM64 Proot Release 构建红线**:
> ⚠️ AGP 9.0 的 `optimizeReleaseResources` 在 ARM64 Proot 下传入 `--resource-path-shortening-map=<path>`(等号形式),ARM64 AAPT2 不接受此语法(只接受空格分隔),exit code=1 但 AGP `invokeAapt()` 只调 `rethrowFailure()` 不调 `assertNormalExitValue()`,失败被静默吞掉。导致 optimized `.ap_` 缺失,`packageRelease` 生成无 `AndroidManifest.xml` / `resources.arsc` / `res/` 的残缺 APK(BUILD SUCCESSFUL 但 APK 不可用)。
>
> 修复链(不可删除任一环节):
> 1. `~/.gradle/gradle.properties`(全局,不提交项目仓库)→ `android.aapt2FromMavenOverride` 指向 `~/.local/lib/android-aapt2-wrapper/aapt2` wrapper 脚本,将 `--resource-path-shortening-map=<path>` 拆分为 `--resource-path-shortening-map` `<path>` 两个独立 argv
> 2. `app/build.gradle.kts` 中 `guardReleaseResources` 任务:`optimizeReleaseResources` 之后验证 optimized `.ap_` 完整性(manifest + arsc + res/),缺失则复制 linked `.ap_` 作为 fallback;`packageRelease` 依赖此任务
> 3. `app/build.gradle.kts` 中 `resolutionStrategy` 强制 `aapt2:linux-aarch64` classifier
>
> CI(GitHub Actions x86_64)不受影响——全局 `~/.gradle/gradle.properties` 不在项目仓库中。Debug 不经过 `optimizeReleaseResources`,无此问题。
**首页布局**:
> Dashboard 使用 `PullToRefreshBox` → `Column` + `verticalScroll`(非 `LazyColumn`)。
@ -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 无需额外配置
```
## 数据流

View File

@ -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>

View File

@ -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")
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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