Skip to content

Commit d748c55

Browse files
brvale97claude
andcommitted
Add unit tests, undo feature, fix word replacement regex (issue #2, #3)
Tests (32 tests, all passing): - WavHeaderTest: RIFF signature, fmt subchunk, data sizes, zero/single sample - ApiClientTest: 200/401/403/429/500 responses, retry, fallback, auth header - WordReplacementTest: case sensitivity, word boundaries, special chars, Unicode Bug fix: - Word replacement regex now handles special characters (dots, parens, asterisks) correctly by only applying \b where pattern starts/ends with word chars - Regex.escapeReplacement() prevents $ in replacement strings breaking regex Features: - Undo last transcription: tap status text in keyboard to remove last insert - Configurable API URL in GroqApiClient (enables testing + future self-hosted) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9cf88e7 commit d748c55

7 files changed

Lines changed: 456 additions & 7 deletions

File tree

app/build.gradle.kts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ android {
5151
kotlinOptions {
5252
jvmTarget = "17"
5353
}
54+
55+
testOptions {
56+
unitTests.isReturnDefaultValues = true
57+
}
5458
}
5559

5660
dependencies {
@@ -61,4 +65,8 @@ dependencies {
6165
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
6266
implementation("androidx.security:security-crypto:1.1.0-alpha06")
6367

68+
testImplementation("junit:junit:4.13.2")
69+
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
70+
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
71+
testImplementation("org.json:json:20231013")
6472
}

app/src/main/java/com/groqandroid/GroqApiClient.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,13 @@ import kotlin.coroutines.resumeWithException
2323
* Client for the Groq Whisper transcription API.
2424
* Supports automatic model fallback when the primary model fails.
2525
*/
26-
class GroqApiClient(private val apiKey: String) {
26+
class GroqApiClient(
27+
private val apiKey: String,
28+
private val apiUrl: String = DEFAULT_API_URL
29+
) {
2730

2831
companion object {
29-
private const val API_URL = "https://api.groq.com/openai/v1/audio/transcriptions"
32+
const val DEFAULT_API_URL = "https://api.groq.com/openai/v1/audio/transcriptions"
3033
private val FALLBACK_MODELS = listOf("whisper-large-v3-turbo", "whisper-large-v3")
3134
private const val MAX_RETRIES = 3
3235
private val RETRY_DELAYS_MS = longArrayOf(1000, 2000, 4000) // exponential backoff
@@ -122,7 +125,7 @@ class GroqApiClient(private val apiKey: String) {
122125
val requestBody = builder.build()
123126

124127
val request = Request.Builder()
125-
.url(API_URL)
128+
.url(apiUrl)
126129
.header("Authorization", "Bearer $apiKey")
127130
.post(requestBody)
128131
.build()

app/src/main/java/com/groqandroid/GroqIME.kt

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ class GroqIME : InputMethodService() {
9898

9999
micButton = view.findViewById(R.id.micButton)
100100
statusText = view.findViewById(R.id.statusText)
101+
statusText?.setOnClickListener { undoLastTranscription() }
101102

102103
val switchButton = view.findViewById<ImageButton>(R.id.switchButton)
103104
val settingsButton = view.findViewById<ImageButton>(R.id.settingsButton)
@@ -388,6 +389,18 @@ class GroqIME : InputMethodService() {
388389
}
389390
}
390391

392+
private fun undoLastTranscription() {
393+
val text = lastInsertedText ?: return
394+
if (state != State.IDLE) return
395+
try {
396+
val ic = currentInputConnection ?: return
397+
// Delete the last inserted text by sending backspace for each character
398+
ic.deleteSurroundingText(text.length, 0)
399+
setStatus("Undo: removed last transcription")
400+
lastInsertedText = null
401+
} catch (_: Exception) {}
402+
}
403+
391404
private fun updateUI() {
392405
micButton?.setBackgroundResource(
393406
when (state) {
@@ -465,8 +478,12 @@ class GroqIME : InputMethodService() {
465478
private fun applyReplacements(text: String): String {
466479
var result = text
467480
for ((from, to) in getReplacements()) {
468-
val pattern = Regex("\\b${Regex.escape(from)}\\b", RegexOption.IGNORE_CASE)
469-
result = pattern.replace(result, to)
481+
val escaped = Regex.escape(from)
482+
// Only use \b where the pattern starts/ends with a word character
483+
val prefix = if (from.first().isLetterOrDigit() || from.first() == '_') "\\b" else ""
484+
val suffix = if (from.last().isLetterOrDigit() || from.last() == '_') "\\b" else ""
485+
val pattern = Regex("$prefix$escaped$suffix", RegexOption.IGNORE_CASE)
486+
result = pattern.replace(result, Regex.escapeReplacement(to))
470487
}
471488
return result
472489
}

app/src/main/java/com/groqandroid/TranscriptionOverlayService.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -856,8 +856,11 @@ class TranscriptionOverlayService : AccessibilityService() {
856856
private fun applyReplacements(text: String): String {
857857
var result = text
858858
for ((from, to) in getReplacements()) {
859-
val pattern = Regex("\\b${Regex.escape(from)}\\b", RegexOption.IGNORE_CASE)
860-
result = pattern.replace(result, to)
859+
val escaped = Regex.escape(from)
860+
val prefix = if (from.first().isLetterOrDigit() || from.first() == '_') "\\b" else ""
861+
val suffix = if (from.last().isLetterOrDigit() || from.last() == '_') "\\b" else ""
862+
val pattern = Regex("$prefix$escaped$suffix", RegexOption.IGNORE_CASE)
863+
result = pattern.replace(result, Regex.escapeReplacement(to))
861864
}
862865
return result
863866
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package com.groqandroid
2+
3+
import kotlinx.coroutines.test.runTest
4+
import okhttp3.mockwebserver.MockResponse
5+
import okhttp3.mockwebserver.MockWebServer
6+
import org.junit.After
7+
import org.junit.Assert.*
8+
import org.junit.Before
9+
import org.junit.Test
10+
import java.io.File
11+
12+
/**
13+
* Tests for GroqApiClient error handling.
14+
* Uses MockWebServer to simulate various API responses.
15+
*/
16+
class ApiClientTest {
17+
18+
private lateinit var server: MockWebServer
19+
private lateinit var tempFile: File
20+
21+
@Before
22+
fun setup() {
23+
server = MockWebServer()
24+
server.start()
25+
26+
// Create a minimal valid WAV file for testing
27+
tempFile = File.createTempFile("test_audio", ".wav")
28+
tempFile.writeBytes(ByteArray(44 + 100))
29+
}
30+
31+
@After
32+
fun teardown() {
33+
server.shutdown()
34+
tempFile.delete()
35+
}
36+
37+
private fun createClient(): GroqApiClient {
38+
return GroqApiClient("test-api-key", server.url("/v1/audio/transcriptions").toString())
39+
}
40+
41+
@Test
42+
fun `successful transcription returns text`() = runTest {
43+
server.enqueue(MockResponse()
44+
.setResponseCode(200)
45+
.setBody("""{"text": "Hello world"}"""))
46+
47+
val result = createClient().transcribe(tempFile)
48+
assertEquals("Hello world", result)
49+
}
50+
51+
@Test
52+
fun `empty transcription returns empty string`() = runTest {
53+
server.enqueue(MockResponse()
54+
.setResponseCode(200)
55+
.setBody("""{"text": ""}"""))
56+
57+
val result = createClient().transcribe(tempFile)
58+
assertEquals("", result)
59+
}
60+
61+
@Test(expected = AuthenticationException::class)
62+
fun `401 throws AuthenticationException`() = runTest {
63+
server.enqueue(MockResponse()
64+
.setResponseCode(401)
65+
.setBody("""{"error": {"message": "Invalid API key"}}"""))
66+
67+
createClient().transcribe(tempFile)
68+
}
69+
70+
@Test(expected = AuthenticationException::class)
71+
fun `403 throws AuthenticationException`() = runTest {
72+
server.enqueue(MockResponse()
73+
.setResponseCode(403)
74+
.setBody("""{"error": {"message": "Forbidden"}}"""))
75+
76+
createClient().transcribe(tempFile)
77+
}
78+
79+
@Test
80+
fun `429 retries with backoff then succeeds`() = runTest {
81+
server.enqueue(MockResponse().setResponseCode(429).setBody("""{"error": {"message": "Rate limited"}}"""))
82+
server.enqueue(MockResponse().setResponseCode(200).setBody("""{"text": "After retry"}"""))
83+
84+
val result = createClient().transcribe(tempFile)
85+
assertEquals("After retry", result)
86+
assertEquals(2, server.requestCount)
87+
}
88+
89+
@Test
90+
fun `500 retries with backoff then succeeds`() = runTest {
91+
server.enqueue(MockResponse().setResponseCode(500).setBody("""{"error": {"message": "Server error"}}"""))
92+
server.enqueue(MockResponse().setResponseCode(200).setBody("""{"text": "Recovered"}"""))
93+
94+
val result = createClient().transcribe(tempFile)
95+
assertEquals("Recovered", result)
96+
}
97+
98+
@Test
99+
fun `401 does not retry or try fallback models`() = runTest {
100+
server.enqueue(MockResponse()
101+
.setResponseCode(401)
102+
.setBody("""{"error": {"message": "Invalid key"}}"""))
103+
104+
try {
105+
createClient().transcribe(tempFile)
106+
fail("Should have thrown AuthenticationException")
107+
} catch (_: AuthenticationException) {
108+
assertEquals(1, server.requestCount)
109+
}
110+
}
111+
112+
@Test
113+
fun `response with missing text field returns empty`() = runTest {
114+
server.enqueue(MockResponse()
115+
.setResponseCode(200)
116+
.setBody("""{"duration": 1.5}"""))
117+
118+
val result = createClient().transcribe(tempFile)
119+
assertEquals("", result)
120+
}
121+
122+
@Test
123+
fun `model fallback on non-auth error`() = runTest {
124+
// First model fails with 400 (non-retryable, goes to next model)
125+
server.enqueue(MockResponse().setResponseCode(400).setBody("""{"error": {"message": "Bad model"}}"""))
126+
// Fallback model succeeds
127+
server.enqueue(MockResponse().setResponseCode(200).setBody("""{"text": "Fallback worked"}"""))
128+
129+
val result = createClient().transcribe(tempFile)
130+
assertEquals("Fallback worked", result)
131+
}
132+
133+
@Test
134+
fun `authorization header is sent`() = runTest {
135+
server.enqueue(MockResponse()
136+
.setResponseCode(200)
137+
.setBody("""{"text": "test"}"""))
138+
139+
createClient().transcribe(tempFile)
140+
141+
val request = server.takeRequest()
142+
assertEquals("Bearer test-api-key", request.getHeader("Authorization"))
143+
}
144+
}

0 commit comments

Comments
 (0)