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,6 +124,9 @@ class OpenCodeGoRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val SCRAPED_FIELDS = listOf("rollingUsage", "weeklyUsage", "monthlyUsage")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析 SolidJS SSR hydration 输出。匹配模式形如:
|
* 解析 SolidJS SSR hydration 输出。匹配模式形如:
|
||||||
* `rollingUsage:$R[0]={usagePercent:42,resetInSec:12345}`
|
* `rollingUsage:$R[0]={usagePercent:42,resetInSec:12345}`
|
||||||
@ -132,7 +135,7 @@ class OpenCodeGoRepository(
|
|||||||
* 避免命中 HTML 其他位置(如 JS 代码注释、模板字符串)的同名文本。
|
* 避免命中 HTML 其他位置(如 JS 代码注释、模板字符串)的同名文本。
|
||||||
* 用括号计数匹配闭合 "}",正确处理嵌套对象。
|
* 用括号计数匹配闭合 "}",正确处理嵌套对象。
|
||||||
*/
|
*/
|
||||||
private fun parseWindows(html: String): Map<String, ScrapedWindow> {
|
internal fun parseWindows(html: String): Map<String, ScrapedWindow> {
|
||||||
val result = mutableMapOf<String, ScrapedWindow>()
|
val result = mutableMapOf<String, ScrapedWindow>()
|
||||||
val fields = listOf("rollingUsage", "weeklyUsage", "monthlyUsage")
|
val fields = listOf("rollingUsage", "weeklyUsage", "monthlyUsage")
|
||||||
|
|
||||||
@ -207,9 +210,6 @@ class OpenCodeGoRepository(
|
|||||||
return body.substring(start, i).ifEmpty { null }
|
return body.substring(start, i).ifEmpty { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class ScrapedWindow(val usagePercent: Int, val resetInSec: Long)
|
internal data class ScrapedWindow(val usagePercent: Int, val resetInSec: Long)
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val SCRAPED_FIELDS = listOf("rollingUsage", "weeklyUsage", "monthlyUsage")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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