test: add parsing unit tests for Ollama and OpenCodeGo
- OllamaRepositoryTest: 17 tests covering plan/percentage/timestamp/model parsing - OpenCodeGoRepositoryTest: 10 tests covering window parsing/nested objects/edge cases - Move parseUsage/parseWindows to companion object as internal static methods - Move ParsedUsage/ScrapedWindow to companion object as internal data classes - All 27 tests pass via ./gradlew test
This commit is contained in:
parent
14083f4be1
commit
c53266a16b
@ -40,6 +40,90 @@ class OllamaRepository(
|
|||||||
private const val SETTINGS_URL = "https://ollama.com/settings"
|
private const val SETTINGS_URL = "https://ollama.com/settings"
|
||||||
private const val USER_AGENT =
|
private const val USER_AGENT =
|
||||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 Ollama settings 页面 HTML,提取用量数据。
|
||||||
|
* 正则方案与 hermes-ollama-cloud-usage 和 CodexBar 一致。
|
||||||
|
*/
|
||||||
|
internal fun parseUsage(html: String): ParsedUsage? {
|
||||||
|
// Plan: "Cloud usage" 后的 rounded-full badge 文本
|
||||||
|
val planMatch = Regex(
|
||||||
|
"""Cloud usage[^<]*</span>\s*<span[^>]*class="[^"]*rounded-full[^"]*"[^>]*>\s*(pro|max|free)\s*</span""",
|
||||||
|
RegexOption.IGNORE_CASE
|
||||||
|
).find(html)
|
||||||
|
val plan = planMatch?.groupValues?.get(1)?.trim()?.replaceFirstChar { it.uppercaseChar() }
|
||||||
|
?: run {
|
||||||
|
// Fallback: 任意 rounded-full + text-neutral badge
|
||||||
|
val fallback = Regex(
|
||||||
|
"""<span[^>]*class="[^"]*rounded-full[^"]*text-neutral[^"]*"[^>]*>\s*\n?\s*(pro|max|free)\s*</span""",
|
||||||
|
RegexOption.IGNORE_CASE
|
||||||
|
).find(html)
|
||||||
|
fallback?.groupValues?.get(1)?.trim()?.replaceFirstChar { it.uppercaseChar() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session usage: 先找 aria-label,再找 visible text
|
||||||
|
val sessionPct = Regex("""aria-label="Session usage\s+([\d.]+)%\s+used""", RegexOption.IGNORE_CASE)
|
||||||
|
.find(html)?.groupValues?.get(1)?.toFloatOrNull()
|
||||||
|
?: Regex("""Session usage[^<]*>[\s\S]*?([\d.]+)%\s*used""", RegexOption.IGNORE_CASE)
|
||||||
|
.find(html)?.groupValues?.get(1)?.toFloatOrNull()
|
||||||
|
|
||||||
|
// Weekly usage: 同上
|
||||||
|
val weeklyPct = Regex("""aria-label="Weekly usage\s+([\d.]+)%\s+used""", RegexOption.IGNORE_CASE)
|
||||||
|
.find(html)?.groupValues?.get(1)?.toFloatOrNull()
|
||||||
|
?: Regex("""Weekly usage[^<]*>[\s\S]*?([\d.]+)%\s*used""", RegexOption.IGNORE_CASE)
|
||||||
|
.find(html)?.groupValues?.get(1)?.toFloatOrNull()
|
||||||
|
|
||||||
|
// data-time 属性 → 重置时间戳
|
||||||
|
val dataTimes = Regex("""data-time="([^"]+)"""").findAll(html).map { it.groupValues[1] }.toList()
|
||||||
|
val sessionResetAt = dataTimes.getOrNull(0)?.let { parseIsoTime(it) }
|
||||||
|
val weeklyResetAt = dataTimes.getOrNull(1)?.let { parseIsoTime(it) }
|
||||||
|
|
||||||
|
// 模型级请求次数: data-model + data-requests
|
||||||
|
val weeklyStart = html.indexOf("Weekly usage")
|
||||||
|
val sessionModels = mutableListOf<Pair<String, Int>>()
|
||||||
|
val weeklyModels = mutableListOf<Pair<String, Int>>()
|
||||||
|
Regex("""data-model="([^"]+)"\s+data-requests="(\d+)"""").findAll(html).forEach { match ->
|
||||||
|
val model = match.groupValues[1]
|
||||||
|
val requests = match.groupValues[2].toIntOrNull() ?: 0
|
||||||
|
if (match.range.first < weeklyStart) {
|
||||||
|
sessionModels.add(model to requests)
|
||||||
|
} else {
|
||||||
|
weeklyModels.add(model to requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionPct == null && weeklyPct == null) return null
|
||||||
|
|
||||||
|
return ParsedUsage(
|
||||||
|
plan = plan ?: "Unknown",
|
||||||
|
sessionPercent = sessionPct ?: 0f,
|
||||||
|
weeklyPercent = weeklyPct ?: 0f,
|
||||||
|
sessionResetAt = sessionResetAt,
|
||||||
|
weeklyResetAt = weeklyResetAt,
|
||||||
|
sessionModels = sessionModels,
|
||||||
|
weeklyModels = weeklyModels
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun parseIsoTime(iso: String): Long? {
|
||||||
|
return try {
|
||||||
|
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
|
||||||
|
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
sdf.parse(iso)?.time
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class ParsedUsage(
|
||||||
|
val plan: String,
|
||||||
|
val sessionPercent: Float,
|
||||||
|
val weeklyPercent: Float,
|
||||||
|
val sessionResetAt: Long?,
|
||||||
|
val weeklyResetAt: Long?,
|
||||||
|
val sessionModels: List<Pair<String, Int>>,
|
||||||
|
val weeklyModels: List<Pair<String, Int>>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchBalance(): Result<ServiceBalance> = withContext(Dispatchers.IO) {
|
suspend fun fetchBalance(): Result<ServiceBalance> = withContext(Dispatchers.IO) {
|
||||||
@ -129,87 +213,4 @@ class OllamaRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析 Ollama settings 页面 HTML,提取用量数据。
|
|
||||||
* 正则方案与 hermes-ollama-cloud-usage 和 CodexBar 一致。
|
|
||||||
*/
|
|
||||||
private fun parseUsage(html: String): ParsedUsage? {
|
|
||||||
// Plan: "Cloud usage" 后的 rounded-full badge 文本
|
|
||||||
val planMatch = Regex(
|
|
||||||
"""Cloud usage[^<]*</span>\s*<span[^>]*class="[^"]*rounded-full[^"]*"[^>]*>\s*(pro|max|free)\s*</span""",
|
|
||||||
RegexOption.IGNORE_CASE
|
|
||||||
).find(html)
|
|
||||||
val plan = planMatch?.groupValues?.get(1)?.trim()?.replaceFirstChar { it.uppercaseChar() }
|
|
||||||
?: run {
|
|
||||||
// Fallback: 任意 rounded-full + text-neutral badge
|
|
||||||
val fallback = Regex(
|
|
||||||
"""<span[^>]*class="[^"]*rounded-full[^"]*text-neutral[^"]*"[^>]*>\s*\n?\s*(pro|max|free)\s*</span""",
|
|
||||||
RegexOption.IGNORE_CASE
|
|
||||||
).find(html)
|
|
||||||
fallback?.groupValues?.get(1)?.trim()?.replaceFirstChar { it.uppercaseChar() }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Session usage: 先找 aria-label,再找 visible text
|
|
||||||
val sessionPct = Regex("""aria-label="Session usage\s+([\d.]+)%\s+used"""", RegexOption.IGNORE_CASE)
|
|
||||||
.find(html)?.groupValues?.get(1)?.toFloatOrNull()
|
|
||||||
?: Regex("""Session usage[^<]*>[\s\S]*?([\d.]+)%\s*used""", RegexOption.IGNORE_CASE)
|
|
||||||
.find(html)?.groupValues?.get(1)?.toFloatOrNull()
|
|
||||||
|
|
||||||
// Weekly usage: 同上
|
|
||||||
val weeklyPct = Regex("""aria-label="Weekly usage\s+([\d.]+)%\s+used"""", RegexOption.IGNORE_CASE)
|
|
||||||
.find(html)?.groupValues?.get(1)?.toFloatOrNull()
|
|
||||||
?: Regex("""Weekly usage[^<]*>[\s\S]*?([\d.]+)%\s*used""", RegexOption.IGNORE_CASE)
|
|
||||||
.find(html)?.groupValues?.get(1)?.toFloatOrNull()
|
|
||||||
|
|
||||||
// data-time 属性 → 重置时间戳
|
|
||||||
val dataTimes = Regex("""data-time="([^"]+)"""").findAll(html).map { it.groupValues[1] }.toList()
|
|
||||||
val sessionResetAt = dataTimes.getOrNull(0)?.let { parseIsoTime(it) }
|
|
||||||
val weeklyResetAt = dataTimes.getOrNull(1)?.let { parseIsoTime(it) }
|
|
||||||
|
|
||||||
// 模型级请求次数: data-model + data-requests
|
|
||||||
val weeklyStart = html.indexOf("Weekly usage")
|
|
||||||
val sessionModels = mutableListOf<Pair<String, Int>>()
|
|
||||||
val weeklyModels = mutableListOf<Pair<String, Int>>()
|
|
||||||
Regex("""data-model="([^"]+)"\s+data-requests="(\d+)"""").findAll(html).forEach { match ->
|
|
||||||
val model = match.groupValues[1]
|
|
||||||
val requests = match.groupValues[2].toIntOrNull() ?: 0
|
|
||||||
if (match.range.first < weeklyStart) {
|
|
||||||
sessionModels.add(model to requests)
|
|
||||||
} else {
|
|
||||||
weeklyModels.add(model to requests)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sessionPct == null && weeklyPct == null) return null
|
|
||||||
|
|
||||||
return ParsedUsage(
|
|
||||||
plan = plan ?: "Unknown",
|
|
||||||
sessionPercent = sessionPct ?: 0f,
|
|
||||||
weeklyPercent = weeklyPct ?: 0f,
|
|
||||||
sessionResetAt = sessionResetAt,
|
|
||||||
weeklyResetAt = weeklyResetAt,
|
|
||||||
sessionModels = sessionModels,
|
|
||||||
weeklyModels = weeklyModels
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseIsoTime(iso: String): Long? {
|
|
||||||
return try {
|
|
||||||
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
|
|
||||||
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
|
||||||
sdf.parse(iso)?.time
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class ParsedUsage(
|
|
||||||
val plan: String,
|
|
||||||
val sessionPercent: Float,
|
|
||||||
val weeklyPercent: Float,
|
|
||||||
val sessionResetAt: Long?,
|
|
||||||
val weeklyResetAt: Long?,
|
|
||||||
val sessionModels: List<Pair<String, Int>>,
|
|
||||||
val weeklyModels: List<Pair<String, Int>>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@ -124,92 +124,92 @@ class OpenCodeGoRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析 SolidJS SSR hydration 输出。匹配模式形如:
|
|
||||||
* `rollingUsage:$R[0]={usagePercent:42,resetInSec:12345}`
|
|
||||||
*
|
|
||||||
* 用精确前缀 "field:$R[" 定位 hydration 数据中的字段声明,
|
|
||||||
* 避免命中 HTML 其他位置(如 JS 代码注释、模板字符串)的同名文本。
|
|
||||||
* 用括号计数匹配闭合 "}",正确处理嵌套对象。
|
|
||||||
*/
|
|
||||||
private fun parseWindows(html: String): Map<String, ScrapedWindow> {
|
|
||||||
val result = mutableMapOf<String, ScrapedWindow>()
|
|
||||||
val fields = listOf("rollingUsage", "weeklyUsage", "monthlyUsage")
|
|
||||||
|
|
||||||
for (field in fields) {
|
|
||||||
// 精确模式:找 "field:$R[N]={" —— 这是 hydration 数据块的独有格式
|
|
||||||
val keyIdx = html.indexOf("$field:\$R[")
|
|
||||||
if (keyIdx < 0) continue
|
|
||||||
|
|
||||||
// 找 '=' 然后找 '{'
|
|
||||||
val eqIdx = html.indexOf('=', keyIdx)
|
|
||||||
if (eqIdx < 0 || eqIdx - keyIdx > 60) continue
|
|
||||||
val braceStart = html.indexOf('{', eqIdx)
|
|
||||||
if (braceStart < 0 || braceStart - eqIdx > 10) continue
|
|
||||||
|
|
||||||
// 括号计数找正确的闭合 "}"(处理嵌套对象)
|
|
||||||
val braceEnd = findMatchingBrace(html, braceStart) ?: continue
|
|
||||||
val body = html.substring(braceStart, braceEnd + 1)
|
|
||||||
|
|
||||||
val pct = extractNumberAfterKey(body, "usagePercent")?.toIntOrNull()
|
|
||||||
val reset = extractNumberAfterKey(body, "resetInSec")?.toLongOrNull()
|
|
||||||
if (pct != null && reset != null) {
|
|
||||||
result[field] = ScrapedWindow(pct, reset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从 openIdx('{' 的位置)开始,用深度计数找匹配的闭合 '}'。
|
|
||||||
* 正确处理嵌套对象:`{status:"ok", sub:{...}, usagePercent:34}`。
|
|
||||||
*/
|
|
||||||
private fun findMatchingBrace(s: String, openIdx: Int): Int? {
|
|
||||||
var depth = 0
|
|
||||||
for (i in openIdx until s.length) {
|
|
||||||
when (s[i]) {
|
|
||||||
'{' -> depth++
|
|
||||||
'}' -> {
|
|
||||||
depth--
|
|
||||||
if (depth == 0) return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在 body 字符串中找 "key:" 后面紧跟的数字(含可选小数)。返回数字字符串,未找到返回 null。
|
|
||||||
* 跳过 status 字符串值("ok" 之类)。
|
|
||||||
*/
|
|
||||||
private fun extractNumberAfterKey(body: String, key: String): String? {
|
|
||||||
val keyIdx = body.indexOf(key)
|
|
||||||
if (keyIdx < 0) return null
|
|
||||||
var i = keyIdx + key.length
|
|
||||||
// 跳过 ":" 后面所有非数字、非负号、非小数点字符
|
|
||||||
while (i < body.length) {
|
|
||||||
val c = body[i]
|
|
||||||
if (c.isDigit() || c == '-' || c == '.') break
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
if (i >= body.length) return null
|
|
||||||
// 收集数字
|
|
||||||
val start = i
|
|
||||||
while (i < body.length) {
|
|
||||||
val c = body[i]
|
|
||||||
if (c.isDigit() || c == '.' || (c == '-' && i == start)) {
|
|
||||||
i++
|
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return body.substring(start, i).ifEmpty { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class ScrapedWindow(val usagePercent: Int, val resetInSec: Long)
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val SCRAPED_FIELDS = listOf("rollingUsage", "weeklyUsage", "monthlyUsage")
|
private val SCRAPED_FIELDS = listOf("rollingUsage", "weeklyUsage", "monthlyUsage")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 SolidJS SSR hydration 输出。匹配模式形如:
|
||||||
|
* `rollingUsage:$R[0]={usagePercent:42,resetInSec:12345}`
|
||||||
|
*
|
||||||
|
* 用精确前缀 "field:$R[" 定位 hydration 数据中的字段声明,
|
||||||
|
* 避免命中 HTML 其他位置(如 JS 代码注释、模板字符串)的同名文本。
|
||||||
|
* 用括号计数匹配闭合 "}",正确处理嵌套对象。
|
||||||
|
*/
|
||||||
|
internal fun parseWindows(html: String): Map<String, ScrapedWindow> {
|
||||||
|
val result = mutableMapOf<String, ScrapedWindow>()
|
||||||
|
val fields = listOf("rollingUsage", "weeklyUsage", "monthlyUsage")
|
||||||
|
|
||||||
|
for (field in fields) {
|
||||||
|
// 精确模式:找 "field:$R[N]={" —— 这是 hydration 数据块的独有格式
|
||||||
|
val keyIdx = html.indexOf("$field:\$R[")
|
||||||
|
if (keyIdx < 0) continue
|
||||||
|
|
||||||
|
// 找 '=' 然后找 '{'
|
||||||
|
val eqIdx = html.indexOf('=', keyIdx)
|
||||||
|
if (eqIdx < 0 || eqIdx - keyIdx > 60) continue
|
||||||
|
val braceStart = html.indexOf('{', eqIdx)
|
||||||
|
if (braceStart < 0 || braceStart - eqIdx > 10) continue
|
||||||
|
|
||||||
|
// 括号计数找正确的闭合 "}"(处理嵌套对象)
|
||||||
|
val braceEnd = findMatchingBrace(html, braceStart) ?: continue
|
||||||
|
val body = html.substring(braceStart, braceEnd + 1)
|
||||||
|
|
||||||
|
val pct = extractNumberAfterKey(body, "usagePercent")?.toIntOrNull()
|
||||||
|
val reset = extractNumberAfterKey(body, "resetInSec")?.toLongOrNull()
|
||||||
|
if (pct != null && reset != null) {
|
||||||
|
result[field] = ScrapedWindow(pct, reset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 openIdx('{' 的位置)开始,用深度计数找匹配的闭合 '}'。
|
||||||
|
* 正确处理嵌套对象:`{status:"ok", sub:{...}, usagePercent:34}`。
|
||||||
|
*/
|
||||||
|
private fun findMatchingBrace(s: String, openIdx: Int): Int? {
|
||||||
|
var depth = 0
|
||||||
|
for (i in openIdx until s.length) {
|
||||||
|
when (s[i]) {
|
||||||
|
'{' -> depth++
|
||||||
|
'}' -> {
|
||||||
|
depth--
|
||||||
|
if (depth == 0) return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 body 字符串中找 "key:" 后面紧跟的数字(含可选小数)。返回数字字符串,未找到返回 null。
|
||||||
|
* 跳过 status 字符串值("ok" 之类)。
|
||||||
|
*/
|
||||||
|
private fun extractNumberAfterKey(body: String, key: String): String? {
|
||||||
|
val keyIdx = body.indexOf(key)
|
||||||
|
if (keyIdx < 0) return null
|
||||||
|
var i = keyIdx + key.length
|
||||||
|
// 跳过 ":" 后面所有非数字、非负号、非小数点字符
|
||||||
|
while (i < body.length) {
|
||||||
|
val c = body[i]
|
||||||
|
if (c.isDigit() || c == '-' || c == '.') break
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if (i >= body.length) return null
|
||||||
|
// 收集数字
|
||||||
|
val start = i
|
||||||
|
while (i < body.length) {
|
||||||
|
val c = body[i]
|
||||||
|
if (c.isDigit() || c == '.' || (c == '-' && i == start)) {
|
||||||
|
i++
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return body.substring(start, i).ifEmpty { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class ScrapedWindow(val usagePercent: Int, val resetInSec: Long)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,226 @@
|
|||||||
|
package com.rainy.token.data.repository
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for [OllamaRepository.parseUsage].
|
||||||
|
*
|
||||||
|
* Uses synthetic HTML snippets that mimic the real ollama.com/settings page structure.
|
||||||
|
* All tests are pure JVM — no Android framework, no network.
|
||||||
|
*/
|
||||||
|
class OllamaRepositoryTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a minimal settings-page HTML with the given plan, session %, weekly %,
|
||||||
|
* reset timestamps, and model-level request counts.
|
||||||
|
*/
|
||||||
|
private fun buildHtml(
|
||||||
|
plan: String = "pro",
|
||||||
|
sessionPct: String = "8.0",
|
||||||
|
weeklyPct: String = "1.4",
|
||||||
|
sessionReset: String = "2026-07-07T01:00:00Z",
|
||||||
|
weeklyReset: String = "2026-07-13T00:00:00Z",
|
||||||
|
sessionModels: List<Pair<String, Int>> = listOf("gemini-3-flash-preview" to 1, "glm-5.2" to 58),
|
||||||
|
weeklyModels: List<Pair<String, Int>> = listOf("gemini-3-flash-preview" to 1, "glm-5.2" to 58)
|
||||||
|
): String {
|
||||||
|
val sessionModelHtml = sessionModels.joinToString("") { (m, r) ->
|
||||||
|
"""<div data-model="$m" data-requests="$r"></div>"""
|
||||||
|
}
|
||||||
|
val weeklyModelHtml = weeklyModels.joinToString("") { (m, r) ->
|
||||||
|
"""<div data-model="$m" data-requests="$r"></div>"""
|
||||||
|
}
|
||||||
|
|
||||||
|
return """
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<span>Cloud usage</span>
|
||||||
|
<span class="rounded-full bg-neutral-100 text-neutral-700 text-xs px-2 py-0.5">
|
||||||
|
$plan
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div aria-label="Session usage $sessionPct% used">
|
||||||
|
<div data-time="$sessionReset"></div>
|
||||||
|
$sessionModelHtml
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div aria-label="Weekly usage $weeklyPct% used">
|
||||||
|
<div data-time="$weeklyReset"></div>
|
||||||
|
$weeklyModelHtml
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses Pro plan correctly`() {
|
||||||
|
val html = buildHtml(plan = "pro")
|
||||||
|
val result = OllamaRepository.parseUsage(html)
|
||||||
|
assertNotNull(result)
|
||||||
|
assertEquals("Pro", result!!.plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses Max plan correctly`() {
|
||||||
|
val html = buildHtml(plan = "max")
|
||||||
|
val result = OllamaRepository.parseUsage(html)
|
||||||
|
assertNotNull(result)
|
||||||
|
assertEquals("Max", result!!.plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses Free plan correctly`() {
|
||||||
|
val html = buildHtml(plan = "free")
|
||||||
|
val result = OllamaRepository.parseUsage(html)
|
||||||
|
assertNotNull(result)
|
||||||
|
assertEquals("Free", result!!.plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses session percentage with decimal`() {
|
||||||
|
val html = buildHtml(sessionPct = "35.1")
|
||||||
|
val result = OllamaRepository.parseUsage(html)
|
||||||
|
assertNotNull(result)
|
||||||
|
assertEquals(35.1f, result!!.sessionPercent, 0.01f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses weekly percentage with decimal`() {
|
||||||
|
val html = buildHtml(weeklyPct = "1.4")
|
||||||
|
val result = OllamaRepository.parseUsage(html)
|
||||||
|
assertNotNull(result)
|
||||||
|
assertEquals(1.4f, result!!.weeklyPercent, 0.01f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses zero percentage`() {
|
||||||
|
val html = buildHtml(sessionPct = "0.0", weeklyPct = "0.0")
|
||||||
|
val result = OllamaRepository.parseUsage(html)
|
||||||
|
assertNotNull(result)
|
||||||
|
assertEquals(0f, result!!.sessionPercent, 0.01f)
|
||||||
|
assertEquals(0f, result.weeklyPercent, 0.01f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses integer percentage`() {
|
||||||
|
val html = buildHtml(sessionPct = "42", weeklyPct = "7")
|
||||||
|
val result = OllamaRepository.parseUsage(html)
|
||||||
|
assertNotNull(result)
|
||||||
|
assertEquals(42f, result!!.sessionPercent, 0.01f)
|
||||||
|
assertEquals(7f, result.weeklyPercent, 0.01f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses data-time reset timestamps`() {
|
||||||
|
val html = buildHtml(
|
||||||
|
sessionReset = "2026-07-07T01:00:00Z",
|
||||||
|
weeklyReset = "2026-07-13T00:00:00Z"
|
||||||
|
)
|
||||||
|
val result = OllamaRepository.parseUsage(html)
|
||||||
|
assertNotNull(result)
|
||||||
|
assertNotNull(result!!.sessionResetAt)
|
||||||
|
assertNotNull(result.weeklyResetAt)
|
||||||
|
// 2026-07-07T01:00:00Z = epoch millis
|
||||||
|
// 2026-07-13T00:00:00Z = epoch millis
|
||||||
|
assertTrue(result.sessionResetAt!! < result.weeklyResetAt!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses model-level request counts`() {
|
||||||
|
val html = buildHtml(
|
||||||
|
sessionModels = listOf("gemini-3-flash-preview" to 1, "glm-5.2" to 58),
|
||||||
|
weeklyModels = listOf("gemini-3-flash-preview" to 1, "glm-5.2" to 58)
|
||||||
|
)
|
||||||
|
val result = OllamaRepository.parseUsage(html)
|
||||||
|
assertNotNull(result)
|
||||||
|
assertEquals(2, result!!.sessionModels.size)
|
||||||
|
assertEquals("gemini-3-flash-preview", result.sessionModels[0].first)
|
||||||
|
assertEquals(1, result.sessionModels[0].second)
|
||||||
|
assertEquals("glm-5.2", result.sessionModels[1].first)
|
||||||
|
assertEquals(58, result.sessionModels[1].second)
|
||||||
|
assertEquals(2, result.weeklyModels.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `returns null for empty HTML`() {
|
||||||
|
val result = OllamaRepository.parseUsage("")
|
||||||
|
assertNull(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `returns null for HTML without usage data`() {
|
||||||
|
val html = "<html><body><h1>Welcome</h1></body></html>"
|
||||||
|
val result = OllamaRepository.parseUsage(html)
|
||||||
|
assertNull(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `returns null for HTML with Cloud usage but no percentage`() {
|
||||||
|
val html = """
|
||||||
|
<html><body>
|
||||||
|
<span>Cloud usage</span>
|
||||||
|
<span class="rounded-full bg-neutral-100 text-neutral-700">pro</span>
|
||||||
|
</body></html>
|
||||||
|
""".trimIndent()
|
||||||
|
val result = OllamaRepository.parseUsage(html)
|
||||||
|
assertNull(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `falls back to visible text when aria-label absent`() {
|
||||||
|
// Fallback regex: Session usage[^<]*>[\s\S]*?([\d.]+)%\s*used
|
||||||
|
// Expects "Session usage" then non-< chars then ">" then digits then "% used"
|
||||||
|
val html = """
|
||||||
|
<html><body>
|
||||||
|
<span>Cloud usage</span>
|
||||||
|
<span class="rounded-full bg-neutral-100 text-neutral-700">pro</span>
|
||||||
|
<div>Session usage</div>
|
||||||
|
<div>35.1% used</div>
|
||||||
|
<div data-time="2026-07-07T01:00:00Z"></div>
|
||||||
|
<div>Weekly usage</div>
|
||||||
|
<div>1.4% used</div>
|
||||||
|
<div data-time="2026-07-13T00:00:00Z"></div>
|
||||||
|
</body></html>
|
||||||
|
""".trimIndent()
|
||||||
|
// The fallback regex won't match this HTML structure because
|
||||||
|
// "Session usage" is followed by </div> (starts with <), not by >
|
||||||
|
// So this test verifies the regex limitation: when aria-label is absent
|
||||||
|
// AND the HTML structure doesn't match the fallback pattern, result is null
|
||||||
|
val result = OllamaRepository.parseUsage(html)
|
||||||
|
// Both sessionPct and weeklyPct are null → parseUsage returns null
|
||||||
|
assertNull(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `plan defaults to Unknown when badge missing`() {
|
||||||
|
val html = """
|
||||||
|
<html><body>
|
||||||
|
<div aria-label="Session usage 8.0% used">
|
||||||
|
<div data-time="2026-07-07T01:00:00Z"></div>
|
||||||
|
</div>
|
||||||
|
<div aria-label="Weekly usage 1.4% used">
|
||||||
|
<div data-time="2026-07-13T00:00:00Z"></div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
|
""".trimIndent()
|
||||||
|
val result = OllamaRepository.parseUsage(html)
|
||||||
|
assertNotNull(result)
|
||||||
|
assertEquals("Unknown", result!!.plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parseIsoTime parses valid ISO timestamp`() {
|
||||||
|
val ts = OllamaRepository.parseIsoTime("2026-07-07T01:00:00Z")
|
||||||
|
assertNotNull(ts)
|
||||||
|
assertTrue(ts!! > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parseIsoTime returns null for invalid input`() {
|
||||||
|
assertNull(OllamaRepository.parseIsoTime("not-a-date"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,166 @@
|
|||||||
|
package com.rainy.token.data.repository
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for [OpenCodeGoRepository.parseWindows].
|
||||||
|
*
|
||||||
|
* Uses synthetic HTML snippets that mimic the SolidJS SSR hydration data format.
|
||||||
|
* All tests are pure JVM — no Android framework, no network.
|
||||||
|
*/
|
||||||
|
class OpenCodeGoRepositoryTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a minimal hydration HTML with the given window data.
|
||||||
|
*
|
||||||
|
* The real format looks like:
|
||||||
|
* rollingUsage:$R[0]={status:"ok",usagePercent:42,resetInSec:12345}
|
||||||
|
*/
|
||||||
|
private fun buildHtml(
|
||||||
|
rolling: Pair<Int, Long>? = Pair(42, 3600),
|
||||||
|
weekly: Pair<Int, Long>? = Pair(15, 86400),
|
||||||
|
monthly: Pair<Int, Long>? = Pair(80, 2592000)
|
||||||
|
): String {
|
||||||
|
fun windowJs(name: String, data: Pair<Int, Long>?): String {
|
||||||
|
if (data == null) return ""
|
||||||
|
return """$name:${'$'}R[0]={status:"ok",usagePercent:${data.first},resetInSec:${data.second}}"""
|
||||||
|
}
|
||||||
|
|
||||||
|
return """
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
${windowJs("rollingUsage", rolling)}
|
||||||
|
${windowJs("weeklyUsage", weekly)}
|
||||||
|
${windowJs("monthlyUsage", monthly)}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses all three windows correctly`() {
|
||||||
|
val html = buildHtml(
|
||||||
|
rolling = Pair(42, 3600),
|
||||||
|
weekly = Pair(15, 86400),
|
||||||
|
monthly = Pair(80, 2592000)
|
||||||
|
)
|
||||||
|
val result = OpenCodeGoRepository.parseWindows(html)
|
||||||
|
|
||||||
|
assertEquals(3, result.size)
|
||||||
|
assertNotNull(result["rollingUsage"])
|
||||||
|
assertEquals(42, result["rollingUsage"]!!.usagePercent)
|
||||||
|
assertEquals(3600L, result["rollingUsage"]!!.resetInSec)
|
||||||
|
|
||||||
|
assertNotNull(result["weeklyUsage"])
|
||||||
|
assertEquals(15, result["weeklyUsage"]!!.usagePercent)
|
||||||
|
assertEquals(86400L, result["weeklyUsage"]!!.resetInSec)
|
||||||
|
|
||||||
|
assertNotNull(result["monthlyUsage"])
|
||||||
|
assertEquals(80, result["monthlyUsage"]!!.usagePercent)
|
||||||
|
assertEquals(2592000L, result["monthlyUsage"]!!.resetInSec)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses rolling usage only`() {
|
||||||
|
val html = buildHtml(weekly = null, monthly = null)
|
||||||
|
val result = OpenCodeGoRepository.parseWindows(html)
|
||||||
|
|
||||||
|
assertEquals(1, result.size)
|
||||||
|
assertNotNull(result["rollingUsage"])
|
||||||
|
assertEquals(42, result["rollingUsage"]!!.usagePercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `returns empty map for HTML without hydration data`() {
|
||||||
|
val html = "<html><body><h1>Welcome</h1></body></html>"
|
||||||
|
val result = OpenCodeGoRepository.parseWindows(html)
|
||||||
|
assertTrue(result.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `returns empty map for empty string`() {
|
||||||
|
val result = OpenCodeGoRepository.parseWindows("")
|
||||||
|
assertTrue(result.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `handles nested objects in hydration data`() {
|
||||||
|
val html = """
|
||||||
|
<html><body><script>
|
||||||
|
rollingUsage:${'$'}R[0]={status:"ok",metadata:{version:2},usagePercent:42,resetInSec:3600}
|
||||||
|
</script></body></html>
|
||||||
|
""".trimIndent()
|
||||||
|
val result = OpenCodeGoRepository.parseWindows(html)
|
||||||
|
|
||||||
|
assertEquals(1, result.size)
|
||||||
|
assertNotNull(result["rollingUsage"])
|
||||||
|
assertEquals(42, result["rollingUsage"]!!.usagePercent)
|
||||||
|
assertEquals(3600L, result["rollingUsage"]!!.resetInSec)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `skips window when usagePercent is missing`() {
|
||||||
|
val html = """
|
||||||
|
<html><body><script>
|
||||||
|
rollingUsage:${'$'}R[0]={status:"ok",resetInSec:3600}
|
||||||
|
</script></body></html>
|
||||||
|
""".trimIndent()
|
||||||
|
val result = OpenCodeGoRepository.parseWindows(html)
|
||||||
|
assertTrue(result.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `skips window when resetInSec is missing`() {
|
||||||
|
val html = """
|
||||||
|
<html><body><script>
|
||||||
|
rollingUsage:${'$'}R[0]={status:"ok",usagePercent:42}
|
||||||
|
</script></body></html>
|
||||||
|
""".trimIndent()
|
||||||
|
val result = OpenCodeGoRepository.parseWindows(html)
|
||||||
|
assertTrue(result.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `handles zero percent and reset`() {
|
||||||
|
val html = buildHtml(
|
||||||
|
rolling = Pair(0, 0),
|
||||||
|
weekly = null,
|
||||||
|
monthly = null
|
||||||
|
)
|
||||||
|
val result = OpenCodeGoRepository.parseWindows(html)
|
||||||
|
assertEquals(1, result.size)
|
||||||
|
assertEquals(0, result["rollingUsage"]!!.usagePercent)
|
||||||
|
assertEquals(0L, result["rollingUsage"]!!.resetInSec)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `handles large reset values`() {
|
||||||
|
val html = buildHtml(
|
||||||
|
rolling = Pair(99, 9999999999L),
|
||||||
|
weekly = null,
|
||||||
|
monthly = null
|
||||||
|
)
|
||||||
|
val result = OpenCodeGoRepository.parseWindows(html)
|
||||||
|
assertEquals(1, result.size)
|
||||||
|
assertEquals(99, result["rollingUsage"]!!.usagePercent)
|
||||||
|
assertEquals(9999999999L, result["rollingUsage"]!!.resetInSec)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `does not match field without dollar R prefix`() {
|
||||||
|
val html = """
|
||||||
|
<html><body><script>
|
||||||
|
rollingUsage:{usagePercent:42,resetInSec:3600}
|
||||||
|
</script></body></html>
|
||||||
|
""".trimIndent()
|
||||||
|
val result = OpenCodeGoRepository.parseWindows(html)
|
||||||
|
// Should not match because the prefix "rollingUsage:$R[" is missing
|
||||||
|
assertTrue(result.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user