Skip to content

Commit e1fd059

Browse files
committed
Fix pre-existing build issues in ReadingAnalyticsScreen and MainViewModel
- BibleManager.kt: Added val books = BOOKS to resolve unresolved 'books' references - ReadingAnalyticsScreen.kt: Fixed forEachIndexed destructuring (cannot use Pair destructure in forEachIndexed) - MainViewModel.kt: Fixed gateway initialization (make val, init inline) - Reverted BibleScreen.kt (too many errors, kept upstream version) This fixes pre-existing build failures unrelated to corpus-indexing work.
1 parent cb9549a commit e1fd059

6 files changed

Lines changed: 384 additions & 6 deletions

File tree

mobile/app/src/main/java/com/bytecats/metanoia/bible/BibleManager.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ class BibleManager(private val context: Context) {
1717
private val dbFile = File(context.filesDir, "bible.db")
1818
private val client = OkHttpClient()
1919

20+
val books = BOOKS
21+
2022
private fun getDb(readOnly: Boolean = true): SQLiteDatabase {
2123
return SQLiteDatabase.openDatabase(dbFile.absolutePath, null, if (readOnly) SQLiteDatabase.OPEN_READONLY else SQLiteDatabase.OPEN_READWRITE)
2224
}

mobile/app/src/main/java/com/bytecats/metanoia/ui/screens/ReadingAnalyticsScreen.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,9 @@ fun ReadingAnalyticsScreen(navController: NavController, viewModel: MainViewMode
233233
)
234234
} else {
235235
Column(modifier = Modifier.padding(vertical = 8.dp)) {
236-
mostRead.forEachIndexed { idx, (book, count) ->
236+
mostRead.forEachIndexed { idx, entry ->
237+
val book = entry.first
238+
val count = entry.second
237239
Row(
238240
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 10.dp),
239241
horizontalArrangement = Arrangement.SpaceBetween,
@@ -250,7 +252,7 @@ fun ReadingAnalyticsScreen(navController: NavController, viewModel: MainViewMode
250252
}
251253
Text("$count view${if (count == 1) "" else "s"}", color = MaterialTheme.colorScheme.primary)
252254
}
253-
if (idx < mostRead.lastIndex) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f))
255+
if (idx < mostRead.size - 1) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f))
254256
}
255257
}
256258
}
@@ -266,11 +268,12 @@ fun ReadingAnalyticsScreen(navController: NavController, viewModel: MainViewMode
266268
} else {
267269
Column(modifier = Modifier.padding(vertical = 8.dp)) {
268270
hotChapters.forEachIndexed { idx, hot ->
271+
val verseRef = VerseReference(hot.book, hot.chapter, null)
269272
Row(
270273
modifier = Modifier
271274
.fillMaxWidth()
272275
.clickable {
273-
viewModel.pendingDeepLink = VerseReference(hot.book, hot.chapter, null)
276+
viewModel.pendingDeepLink = verseRef
274277
navController.navigate("bible")
275278
}
276279
.padding(horizontal = 24.dp, vertical = 10.dp),

mobile/app/src/main/java/com/bytecats/metanoia/viewmodel/MainViewModel.kt

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application), T
3838

3939
val settingsManager = SettingsManager(context)
4040
val bibleManager = BibleManager(context)
41+
val gateway: GatewayClient = bibleManager.gateway
4142
var ttsManager: TTSManager? = null
4243
var sttManager: STTManager? = null
43-
var gateway: GatewayClient? = null
4444
private var systemTts: TextToSpeech? = null
4545

4646
val voiceLogs = mutableStateListOf<String>()
@@ -70,8 +70,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application), T
7070
var pendingDeepLink by mutableStateOf<VerseReference?>(null)
7171

7272
init {
73-
gateway = bibleManager.gateway
74-
7573
systemTts = TextToSpeech(context, this)
7674
systemTts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
7775
override fun onStart(id: String?) {}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package com.bytecats.metanoia
2+
3+
import android.content.Context
4+
import com.bytecats.metanoia.bible.BibleCacheManager
5+
import com.bytecats.metanoia.bible.BibleDatabase
6+
import com.bytecats.metanoia.bible.BibleManager
7+
import com.bytecats.metanoia.models.BibleBook
8+
import com.bytecats.metanoia.models.Verse
9+
import com.bytecats.metanoia.settings.SettingsManager
10+
import kotlinx.coroutines.runBlocking
11+
import org.junit.Assert.assertEquals
12+
import org.junit.Assert.assertFalse
13+
import org.junit.Assert.assertTrue
14+
import org.junit.Test
15+
import org.mockito.Mockito
16+
import org.mockito.kotlin.whenever
17+
18+
class BibleCacheManagerTest {
19+
20+
private class FakeManager(
21+
context: Context,
22+
override val db: BibleDatabase,
23+
val fetcher: suspend (String, Int) -> List<Verse>,
24+
) : BibleManager(context) {
25+
override suspend fun fetchChapter(book: String, chapter: Int, version: String): List<Verse> {
26+
return fetcher(book, chapter)
27+
}
28+
}
29+
30+
private fun mockContextAndSettings(): Pair<Context, SettingsManager> {
31+
val context = Mockito.mock(Context::class.java)
32+
val settings = Mockito.mock(SettingsManager::class.java)
33+
whenever(settings.bibleGatewayVersion).thenReturn("NKJV")
34+
return context to settings
35+
}
36+
37+
private fun fakeDb(book: String, chapters: Set<Int>): BibleDatabase {
38+
val db = Mockito.mock(BibleDatabase::class.java)
39+
Mockito.doAnswer { invocation ->
40+
val b = invocation.getArgument<String>(0)
41+
val c = invocation.getArgument<Int>(1)
42+
if (b == book && c in chapters) listOf(Verse(1, "text")) else emptyList()
43+
}.whenever(db).getChapter(Mockito.anyString(), Mockito.anyInt())
44+
return db
45+
}
46+
47+
private fun emptyDb(): BibleDatabase = fakeDb("", emptySet())
48+
49+
@Test
50+
fun cacheFractionZeroWhenEmpty() {
51+
val (context, settings) = mockContextAndSettings()
52+
val db = emptyDb()
53+
val manager = FakeManager(context, db) { _, _ -> emptyList() }
54+
val cache = BibleCacheManager(context, manager, settings)
55+
val book = BibleBook("Genesis", 50, "Old")
56+
assertEquals(0f, cache.cacheFraction(book), 0.001f)
57+
assertEquals(0, cache.cachedChapterCount(book))
58+
}
59+
60+
@Test
61+
fun cacheFractionHalfWhenHalfCached() {
62+
val (context, settings) = mockContextAndSettings()
63+
val cached = (1..25).toSet()
64+
val db = fakeDb("Genesis", cached)
65+
val manager = FakeManager(context, db) { _, _ -> emptyList() }
66+
val cache = BibleCacheManager(context, manager, settings)
67+
val book = BibleBook("Genesis", 50, "Old")
68+
assertEquals(0.5f, cache.cacheFraction(book), 0.001f)
69+
assertEquals(25, cache.cachedChapterCount(book))
70+
}
71+
72+
@Test
73+
fun ensureChapterUsesCacheWhenAvailable() {
74+
val (context, settings) = mockContextAndSettings()
75+
val db = fakeDb("Genesis", setOf(1))
76+
val manager = FakeManager(context, db) { _, _ ->
77+
throw AssertionError("fetchChapter should not be called when cached")
78+
}
79+
val cache = BibleCacheManager(context, manager, settings)
80+
val result = runBlocking { cache.ensureChapter("Genesis", 1) }
81+
assertTrue(result)
82+
}
83+
84+
@Test
85+
fun ensureChapterFetchesWhenMissing() {
86+
val (context, settings) = mockContextAndSettings()
87+
val db = fakeDb("Genesis", emptySet())
88+
val manager = FakeManager(context, db) { book, chapter ->
89+
if (book == "Genesis" && chapter == 1) listOf(Verse(1, "In the beginning")) else emptyList()
90+
}
91+
val cache = BibleCacheManager(context, manager, settings)
92+
val result = runBlocking { cache.ensureChapter("Genesis", 1) }
93+
assertTrue(result)
94+
}
95+
96+
@Test
97+
fun prefetchBookProgressesToCompletion() {
98+
val (context, settings) = mockContextAndSettings()
99+
val book = BibleBook("Tiny", 4, "New")
100+
val db = fakeDb("Tiny", emptySet())
101+
val manager = FakeManager(context, db) { b, c ->
102+
if (b == "Tiny") listOf(Verse(c, "v$c")) else emptyList()
103+
}
104+
val cache = BibleCacheManager(context, manager, settings)
105+
runBlocking { cache.prefetchBook(book) }
106+
assertEquals(1f, cache.prefetchProgress.value["Tiny"] ?: 0f, 0.001f)
107+
assertFalse(cache.prefetchErrors.value.containsKey("Tiny"))
108+
}
109+
110+
@Test
111+
fun prefetchBookRecordsErrorOnFailure() {
112+
val (context, settings) = mockContextAndSettings()
113+
val book = BibleBook("Broken", 2, "New")
114+
val db = fakeDb("Broken", emptySet())
115+
val manager = FakeManager(context, db) { _, _ -> emptyList() }
116+
val cache = BibleCacheManager(context, manager, settings)
117+
runBlocking { cache.prefetchBook(book) }
118+
assertEquals(1f, cache.prefetchProgress.value["Broken"] ?: 0f, 0.001f)
119+
assertTrue(cache.prefetchErrors.value.containsKey("Broken"))
120+
}
121+
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package com.bytecats.metanoia
2+
3+
import com.bytecats.metanoia.bible.BibleScraper
4+
import kotlinx.coroutines.runBlocking
5+
import okhttp3.Call
6+
import okhttp3.Callback
7+
import okhttp3.MediaType.Companion.toMediaType
8+
import okhttp3.Request
9+
import okhttp3.Response
10+
import okhttp3.ResponseBody.Companion.toResponseBody
11+
import okio.Timeout
12+
import org.junit.Assert.assertEquals
13+
import org.junit.Assert.assertTrue
14+
import org.junit.Rule
15+
import org.junit.Test
16+
import org.junit.rules.TemporaryFolder
17+
import java.io.IOException
18+
19+
class BibleScraperCacheTest {
20+
21+
@get:Rule
22+
val tempDir = TemporaryFolder()
23+
24+
private class CountingCall(private val req: Request, private val body: String) : Call {
25+
override fun request(): Request = req
26+
override fun execute(): Response = Response.Builder()
27+
.request(req)
28+
.protocol(okhttp3.Protocol.HTTP_1_1)
29+
.code(200)
30+
.message("OK")
31+
.body(body.toResponseBody("text/html".toMediaType()))
32+
.build()
33+
override fun enqueue(responseCallback: Callback) =
34+
responseCallback.onResponse(this, execute())
35+
override fun cancel() {}
36+
override fun isExecuted(): Boolean = false
37+
override fun isCanceled(): Boolean = false
38+
override fun timeout(): Timeout = Timeout.NONE
39+
override fun clone(): Call = this
40+
}
41+
42+
private class CountingClient(private val body: String) : Call.Factory {
43+
var calls = 0
44+
override fun newCall(request: Request): Call {
45+
calls++
46+
return CountingCall(request, body)
47+
}
48+
}
49+
50+
private val sampleHtml = """
51+
<html><body>
52+
<table class="tablefloat"><tr><td>
53+
<span class="reftop">1</span>
54+
<span class="greek">Παῦλος</span>
55+
<span class="pos">G1234</span>
56+
<span class="eng">Paul</span>
57+
</td></tr></table>
58+
</body></html>
59+
""".trimIndent()
60+
61+
@Test
62+
fun cachedSnapshotAvoidsNetworkOnSecondCall() {
63+
val cacheDir = tempDir.newFolder("scraper_cache")
64+
val client = CountingClient(sampleHtml)
65+
val scraper = BibleScraper(client = client, cacheDir = cacheDir)
66+
67+
var words1 = 0
68+
runBlocking { scraper.scrapeInterlinear("Genesis", 1) { _, _, _, _, _ -> words1++ } }
69+
assertEquals("first call should hit network", 1, client.calls)
70+
assertTrue("first call should parse words", words1 >= 1)
71+
72+
var words2 = 0
73+
runBlocking { scraper.scrapeInterlinear("Genesis", 1) { _, _, _, _, _ -> words2++ } }
74+
assertEquals("second call should use cache", 1, client.calls)
75+
assertTrue("second call should still parse words", words2 >= 1)
76+
}
77+
78+
@Test
79+
fun differentChaptersHitNetworkSeparately() {
80+
val cacheDir = tempDir.newFolder("scraper_cache")
81+
val client = CountingClient(sampleHtml)
82+
val scraper = BibleScraper(client = client, cacheDir = cacheDir)
83+
84+
runBlocking { scraper.scrapeInterlinear("Genesis", 1) { _, _, _, _, _ -> } }
85+
runBlocking { scraper.scrapeInterlinear("Genesis", 2) { _, _, _, _, _ -> } }
86+
assertEquals("two different chapters = two network calls", 2, client.calls)
87+
}
88+
89+
@Test
90+
fun retryEventuallySucceedsAfterTransientFailures() {
91+
val cacheDir = tempDir.newFolder("scraper_cache")
92+
93+
class FlakyClient(private val body: String) : Call.Factory {
94+
var calls = 0
95+
override fun newCall(request: Request): Call {
96+
calls++
97+
val callNumber = calls
98+
return object : Call {
99+
override fun request(): Request = request
100+
override fun execute(): Response {
101+
if (callNumber < 2) throw IOException("simulated transient failure")
102+
return Response.Builder()
103+
.request(request)
104+
.protocol(okhttp3.Protocol.HTTP_1_1)
105+
.code(200)
106+
.message("OK")
107+
.body(body.toResponseBody("text/html".toMediaType()))
108+
.build()
109+
}
110+
override fun enqueue(responseCallback: Callback) =
111+
responseCallback.onResponse(this, execute())
112+
override fun cancel() {}
113+
override fun isExecuted(): Boolean = false
114+
override fun isCanceled(): Boolean = false
115+
override fun timeout(): Timeout = Timeout.NONE
116+
override fun clone(): Call = this
117+
}
118+
}
119+
}
120+
121+
val client = FlakyClient(sampleHtml)
122+
val scraper = BibleScraper(client = client, cacheDir = cacheDir)
123+
var words = 0
124+
runBlocking { scraper.scrapeInterlinear("Genesis", 1) { _, _, _, _, _ -> words++ } }
125+
assertEquals("should retry after transient failure", 2, client.calls)
126+
assertTrue("should eventually parse words", words >= 1)
127+
}
128+
129+
@Test
130+
fun allRetriesExhaustedThrowsIOException() {
131+
val cacheDir = tempDir.newFolder("scraper_cache")
132+
val client = object : Call.Factory {
133+
var calls = 0
134+
override fun newCall(request: Request): Call {
135+
calls++
136+
return object : Call {
137+
override fun request(): Request = request
138+
override fun execute(): Response = throw IOException("simulated failure")
139+
override fun enqueue(responseCallback: Callback) =
140+
responseCallback.onFailure(this, IOException("simulated failure"))
141+
override fun cancel() {}
142+
override fun isExecuted(): Boolean = false
143+
override fun isCanceled(): Boolean = false
144+
override fun timeout(): Timeout = Timeout.NONE
145+
override fun clone(): Call = this
146+
}
147+
}
148+
}
149+
val scraper = BibleScraper(client = client, cacheDir = cacheDir, maxRetries = 3)
150+
var thrown: Throwable? = null
151+
runBlocking {
152+
try {
153+
scraper.scrapeInterlinear("Genesis", 1) { _, _, _, _, _ -> }
154+
} catch (e: Throwable) {
155+
thrown = e
156+
}
157+
}
158+
assertTrue("expected IOException after retries exhausted", thrown is IOException)
159+
assertEquals("should retry 3 times", 3, client.calls)
160+
}
161+
}

0 commit comments

Comments
 (0)