feat(chat): add per-message read-aloud TTS playback (#1305)
This commit is contained in:
14 files changed
+2370
No files matched your search
@@ -0,0 +1,140 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TtsTest {
|
||||
private val json =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `serialize TtsSpeakRequest with literal text`() {
|
||||
val request = TtsSpeakRequest(text = "Hello, world!")
|
||||
val encoded = json.encodeToString(request)
|
||||
|
||||
assertEquals("""{"text":"Hello, world!"}""", encoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize TtsSpeakResponse with all fields present`() {
|
||||
val rawJson =
|
||||
"""
|
||||
{
|
||||
"ok": true,
|
||||
"data_url": "data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=",
|
||||
"mime_type": "audio/wav",
|
||||
"provider": "piper"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
|
||||
|
||||
assertTrue(response.ok)
|
||||
assertEquals(
|
||||
"data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=",
|
||||
response.data_url,
|
||||
)
|
||||
assertEquals("audio/wav", response.mime_type)
|
||||
assertEquals("piper", response.provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize TtsSpeakResponse with optional provider missing`() {
|
||||
val rawJson =
|
||||
"""
|
||||
{
|
||||
"ok": true,
|
||||
"data_url": "data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjMuMTAwAAAAAAAAAAAAAAD/",
|
||||
"mime_type": "audio/mp3"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
|
||||
|
||||
assertTrue(response.ok)
|
||||
assertEquals(
|
||||
"data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjMuMTAwAAAAAAAAAAAAAAD/",
|
||||
response.data_url,
|
||||
)
|
||||
assertEquals("audio/mp3", response.mime_type)
|
||||
assertNull(response.provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize TtsSpeakResponse with provider explicitly null`() {
|
||||
val rawJson =
|
||||
"""
|
||||
{
|
||||
"ok": true,
|
||||
"data_url": "data:audio/ogg;base64,T2dnUwACAAAAAAAAAAA=",
|
||||
"mime_type": "audio/ogg",
|
||||
"provider": null
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
|
||||
|
||||
assertTrue(response.ok)
|
||||
assertEquals("data:audio/ogg;base64,T2dnUwACAAAAAAAAAAA=", response.data_url)
|
||||
assertEquals("audio/ogg", response.mime_type)
|
||||
assertNull(response.provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize TtsSpeakResponse with missing ok defaults to false`() {
|
||||
val rawJson =
|
||||
"""
|
||||
{
|
||||
"data_url": null,
|
||||
"mime_type": null
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
|
||||
|
||||
assertFalse(response.ok)
|
||||
assertNull(response.data_url)
|
||||
assertNull(response.mime_type)
|
||||
assertNull(response.provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize TtsSpeakResponse with empty json defaults to ok false and nulls`() {
|
||||
val rawJson = "{}"
|
||||
|
||||
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
|
||||
|
||||
assertFalse(response.ok)
|
||||
assertNull(response.data_url)
|
||||
assertNull(response.mime_type)
|
||||
assertNull(response.provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize TtsSpeakResponse with unknown fields ignores extra properties`() {
|
||||
val rawJson =
|
||||
"""
|
||||
{
|
||||
"ok": false,
|
||||
"error": "Provider not reachable",
|
||||
"status_code": 500
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
|
||||
|
||||
assertFalse(response.ok)
|
||||
assertNull(response.data_url)
|
||||
assertNull(response.mime_type)
|
||||
assertNull(response.provider)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import android.content.Context
|
||||
import com.m57.hermescontrol.data.model.TtsSpeakRequest
|
||||
import com.m57.hermescontrol.data.model.TtsSpeakResponse
|
||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import retrofit2.Response
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.Base64
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AndroidSpeechTest {
|
||||
@get:Rule
|
||||
val tempFolder = TemporaryFolder()
|
||||
|
||||
private fun createContext(): Context {
|
||||
val mockContext = mockk<Context>()
|
||||
every { mockContext.applicationContext } returns mockContext
|
||||
every { mockContext.cacheDir } returns tempFolder.root
|
||||
return mockContext
|
||||
}
|
||||
|
||||
private fun listTempFiles(): List<File> =
|
||||
tempFolder.root
|
||||
.walkTopDown()
|
||||
.filter { it.isFile }
|
||||
.toList()
|
||||
|
||||
@Test
|
||||
fun `synthesize decodes small real base64 audio bytes to exact file bytes and passes text and profile`() =
|
||||
runTest {
|
||||
val context = createContext()
|
||||
val api = mockk<HermesApiService>()
|
||||
val expectedBytes = byteArrayOf(0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00)
|
||||
val base64Data = Base64.getEncoder().encodeToString(expectedBytes)
|
||||
val dataUrl = "data:audio/wav;base64,$base64Data"
|
||||
|
||||
coEvery { api.speakText(any(), any()) } returns
|
||||
Response.success(
|
||||
TtsSpeakResponse(
|
||||
ok = true,
|
||||
data_url = dataUrl,
|
||||
mime_type = "audio/wav",
|
||||
provider = "piper",
|
||||
),
|
||||
)
|
||||
|
||||
val synthesizer: SpeechSynthesizer =
|
||||
AndroidSpeechSynthesizer(
|
||||
context = context,
|
||||
api = api,
|
||||
profile = "work-profile",
|
||||
)
|
||||
|
||||
val request =
|
||||
SpeechRequest(
|
||||
scopeKey = "session-42",
|
||||
messageId = "msg-101",
|
||||
text = "Hello world",
|
||||
)
|
||||
|
||||
val resultAudio: SpeechAudio = synthesizer.synthesize(request)
|
||||
assertTrue("Expected result to be FileSpeechAudio", resultAudio is FileSpeechAudio)
|
||||
|
||||
val fileAudio = resultAudio as FileSpeechAudio
|
||||
val audioFile = fileAudio.file
|
||||
|
||||
assertTrue("Synthesized audio file must exist", audioFile.exists())
|
||||
assertArrayEquals("Decoded file bytes must match input bytes", expectedBytes, audioFile.readBytes())
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
api.speakText(
|
||||
request = TtsSpeakRequest(text = "Hello world"),
|
||||
profile = "work-profile",
|
||||
)
|
||||
}
|
||||
|
||||
fileAudio.dispose()
|
||||
assertFalse("File must be deleted after dispose()", audioFile.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `synthesize passes null profile when synthesizer constructed without profile`() =
|
||||
runTest {
|
||||
val context = createContext()
|
||||
val api = mockk<HermesApiService>()
|
||||
val rawBytes = byteArrayOf(0x01, 0x02, 0x03, 0x04)
|
||||
val base64Data = Base64.getEncoder().encodeToString(rawBytes)
|
||||
|
||||
coEvery { api.speakText(any(), any()) } returns
|
||||
Response.success(
|
||||
TtsSpeakResponse(
|
||||
ok = true,
|
||||
data_url = "data:audio/mp3;base64,$base64Data",
|
||||
mime_type = "audio/mp3",
|
||||
provider = null,
|
||||
),
|
||||
)
|
||||
|
||||
val synthesizer: SpeechSynthesizer =
|
||||
AndroidSpeechSynthesizer(
|
||||
context = context,
|
||||
api = api,
|
||||
profile = null,
|
||||
)
|
||||
|
||||
val request =
|
||||
SpeechRequest(
|
||||
scopeKey = "session-1",
|
||||
messageId = "msg-1",
|
||||
text = "Testing null profile",
|
||||
)
|
||||
|
||||
val audio = synthesizer.synthesize(request)
|
||||
assertTrue(audio is FileSpeechAudio)
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
api.speakText(
|
||||
request = TtsSpeakRequest(text = "Testing null profile"),
|
||||
profile = null,
|
||||
)
|
||||
}
|
||||
|
||||
audio.dispose()
|
||||
assertEquals(0, listTempFiles().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `HTTP or network failure throws exception and leaves no temp files`() =
|
||||
runTest {
|
||||
val context = createContext()
|
||||
val api = mockk<HermesApiService>()
|
||||
|
||||
coEvery { api.speakText(any(), any()) } throws IOException("Network connection lost")
|
||||
|
||||
val synthesizer: SpeechSynthesizer =
|
||||
AndroidSpeechSynthesizer(
|
||||
context = context,
|
||||
api = api,
|
||||
profile = "test-profile",
|
||||
)
|
||||
|
||||
val request =
|
||||
SpeechRequest(
|
||||
scopeKey = "session-err",
|
||||
messageId = "msg-err",
|
||||
text = "Network fail",
|
||||
)
|
||||
|
||||
try {
|
||||
synthesizer.synthesize(request)
|
||||
fail("Expected IOException to be thrown")
|
||||
} catch (e: Exception) {
|
||||
assertNotNull(e)
|
||||
}
|
||||
|
||||
assertEquals("No temp files should remain after network failure", 0, listTempFiles().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `response with ok false throws exception and leaves no temp files`() =
|
||||
runTest {
|
||||
val context = createContext()
|
||||
val api = mockk<HermesApiService>()
|
||||
|
||||
coEvery { api.speakText(any(), any()) } returns
|
||||
Response.success(
|
||||
TtsSpeakResponse(
|
||||
ok = false,
|
||||
data_url = null,
|
||||
mime_type = null,
|
||||
provider = null,
|
||||
),
|
||||
)
|
||||
|
||||
val synthesizer: SpeechSynthesizer =
|
||||
AndroidSpeechSynthesizer(
|
||||
context = context,
|
||||
api = api,
|
||||
profile = "profile-1",
|
||||
)
|
||||
|
||||
val request =
|
||||
SpeechRequest(
|
||||
scopeKey = "s1",
|
||||
messageId = "m1",
|
||||
text = "Failed ok=false",
|
||||
)
|
||||
|
||||
try {
|
||||
synthesizer.synthesize(request)
|
||||
fail("Expected synthesize to fail when ok is false")
|
||||
} catch (e: Exception) {
|
||||
assertNotNull(e)
|
||||
}
|
||||
|
||||
assertEquals("No temp files should remain after ok=false", 0, listTempFiles().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed data URI scheme throws exception and leaves no temp files`() =
|
||||
runTest {
|
||||
val context = createContext()
|
||||
val api = mockk<HermesApiService>()
|
||||
|
||||
coEvery { api.speakText(any(), any()) } returns
|
||||
Response.success(
|
||||
TtsSpeakResponse(
|
||||
ok = true,
|
||||
data_url = "not-a-data-uri",
|
||||
mime_type = "audio/wav",
|
||||
provider = "piper",
|
||||
),
|
||||
)
|
||||
|
||||
val synthesizer: SpeechSynthesizer =
|
||||
AndroidSpeechSynthesizer(
|
||||
context = context,
|
||||
api = api,
|
||||
profile = null,
|
||||
)
|
||||
|
||||
val request =
|
||||
SpeechRequest(
|
||||
scopeKey = "s1",
|
||||
messageId = "m1",
|
||||
text = "Malformed scheme",
|
||||
)
|
||||
|
||||
try {
|
||||
synthesizer.synthesize(request)
|
||||
fail("Expected failure for malformed data URI")
|
||||
} catch (e: Exception) {
|
||||
assertNotNull(e)
|
||||
}
|
||||
|
||||
assertEquals("No temp files should remain after malformed URI failure", 0, listTempFiles().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-audio data URI throws exception and leaves no temp files`() =
|
||||
runTest {
|
||||
val context = createContext()
|
||||
val api = mockk<HermesApiService>()
|
||||
val textBase64 = Base64.getEncoder().encodeToString("hello world".toByteArray())
|
||||
|
||||
coEvery { api.speakText(any(), any()) } returns
|
||||
Response.success(
|
||||
TtsSpeakResponse(
|
||||
ok = true,
|
||||
data_url = "data:text/plain;base64,$textBase64",
|
||||
mime_type = "text/plain",
|
||||
provider = "text",
|
||||
),
|
||||
)
|
||||
|
||||
val synthesizer: SpeechSynthesizer =
|
||||
AndroidSpeechSynthesizer(
|
||||
context = context,
|
||||
api = api,
|
||||
profile = null,
|
||||
)
|
||||
|
||||
val request =
|
||||
SpeechRequest(
|
||||
scopeKey = "s1",
|
||||
messageId = "m1",
|
||||
text = "Non audio data URI",
|
||||
)
|
||||
|
||||
try {
|
||||
synthesizer.synthesize(request)
|
||||
fail("Expected failure for non-audio data URI")
|
||||
} catch (e: Exception) {
|
||||
assertNotNull(e)
|
||||
}
|
||||
|
||||
assertEquals("No temp files should remain after non-audio data URI failure", 0, listTempFiles().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid base64 payload throws exception and leaves no temp files`() =
|
||||
runTest {
|
||||
val context = createContext()
|
||||
val api = mockk<HermesApiService>()
|
||||
|
||||
coEvery { api.speakText(any(), any()) } returns
|
||||
Response.success(
|
||||
TtsSpeakResponse(
|
||||
ok = true,
|
||||
data_url = "data:audio/wav;base64,!!!NotBase64Chars!!!",
|
||||
mime_type = "audio/wav",
|
||||
provider = "piper",
|
||||
),
|
||||
)
|
||||
|
||||
val synthesizer: SpeechSynthesizer =
|
||||
AndroidSpeechSynthesizer(
|
||||
context = context,
|
||||
api = api,
|
||||
profile = null,
|
||||
)
|
||||
|
||||
val request =
|
||||
SpeechRequest(
|
||||
scopeKey = "s1",
|
||||
messageId = "m1",
|
||||
text = "Invalid base64",
|
||||
)
|
||||
|
||||
try {
|
||||
synthesizer.synthesize(request)
|
||||
fail("Expected failure for invalid base64")
|
||||
} catch (e: Exception) {
|
||||
assertNotNull(e)
|
||||
}
|
||||
|
||||
assertEquals("No temp files should remain after base64 decoding failure", 0, listTempFiles().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty data URI payload throws exception and leaves no temp files`() =
|
||||
runTest {
|
||||
val context = createContext()
|
||||
val api = mockk<HermesApiService>()
|
||||
|
||||
coEvery { api.speakText(any(), any()) } returns
|
||||
Response.success(
|
||||
TtsSpeakResponse(
|
||||
ok = true,
|
||||
data_url = "data:audio/wav;base64,",
|
||||
mime_type = "audio/wav",
|
||||
provider = "piper",
|
||||
),
|
||||
)
|
||||
|
||||
val synthesizer: SpeechSynthesizer =
|
||||
AndroidSpeechSynthesizer(
|
||||
context = context,
|
||||
api = api,
|
||||
profile = null,
|
||||
)
|
||||
|
||||
val request =
|
||||
SpeechRequest(
|
||||
scopeKey = "s1",
|
||||
messageId = "m1",
|
||||
text = "Empty payload",
|
||||
)
|
||||
|
||||
try {
|
||||
synthesizer.synthesize(request)
|
||||
fail("Expected failure for empty base64 payload")
|
||||
} catch (e: Exception) {
|
||||
assertNotNull(e)
|
||||
}
|
||||
|
||||
assertEquals("No temp files should remain after empty payload failure", 0, listTempFiles().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null data_url throws exception and leaves no temp files`() =
|
||||
runTest {
|
||||
val context = createContext()
|
||||
val api = mockk<HermesApiService>()
|
||||
|
||||
coEvery { api.speakText(any(), any()) } returns
|
||||
Response.success(
|
||||
TtsSpeakResponse(
|
||||
ok = true,
|
||||
data_url = null,
|
||||
mime_type = "audio/wav",
|
||||
provider = "piper",
|
||||
),
|
||||
)
|
||||
|
||||
val synthesizer: SpeechSynthesizer =
|
||||
AndroidSpeechSynthesizer(
|
||||
context = context,
|
||||
api = api,
|
||||
profile = null,
|
||||
)
|
||||
|
||||
val request =
|
||||
SpeechRequest(
|
||||
scopeKey = "s1",
|
||||
messageId = "m1",
|
||||
text = "Null data url",
|
||||
)
|
||||
|
||||
try {
|
||||
synthesizer.synthesize(request)
|
||||
fail("Expected failure when data_url is null")
|
||||
} catch (e: Exception) {
|
||||
assertNotNull(e)
|
||||
}
|
||||
|
||||
assertEquals("No temp files should remain after null data_url failure", 0, listTempFiles().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `padded valid base64 followed by garbage throws exception and leaves no temp files`() =
|
||||
runTest {
|
||||
val context = createContext()
|
||||
val api = mockk<HermesApiService>()
|
||||
val validPaddedBase64 = "AQ==" // 1 byte decoded (0x01), padded with ==
|
||||
val payloadWithTrailingGarbage = validPaddedBase64 + "garbage"
|
||||
|
||||
coEvery { api.speakText(any(), any()) } returns
|
||||
Response.success(
|
||||
TtsSpeakResponse(
|
||||
ok = true,
|
||||
data_url = "data:audio/wav;base64,$payloadWithTrailingGarbage",
|
||||
mime_type = "audio/wav",
|
||||
provider = "piper",
|
||||
),
|
||||
)
|
||||
|
||||
val synthesizer: SpeechSynthesizer =
|
||||
AndroidSpeechSynthesizer(
|
||||
context = context,
|
||||
api = api,
|
||||
profile = null,
|
||||
)
|
||||
|
||||
val request =
|
||||
SpeechRequest(
|
||||
scopeKey = "s1",
|
||||
messageId = "m1",
|
||||
text = "Garbage after padding",
|
||||
)
|
||||
|
||||
try {
|
||||
synthesizer.synthesize(request)
|
||||
fail("Expected failure for base64 with trailing garbage after padding")
|
||||
} catch (e: Exception) {
|
||||
assertNotNull(e)
|
||||
}
|
||||
|
||||
assertEquals("No temp files should remain after trailing garbage failure", 0, listTempFiles().size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,738 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SpeechControllerTest {
|
||||
private class FakeAudio(
|
||||
val tag: String,
|
||||
) : SpeechAudio {
|
||||
var isDisposed: Boolean = false
|
||||
private set
|
||||
|
||||
override fun dispose() {
|
||||
isDisposed = true
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeSynthesizer : SpeechSynthesizer {
|
||||
var calls: MutableList<SpeechRequest> = mutableListOf()
|
||||
var synthesizeBlock: (suspend (SpeechRequest) -> SpeechAudio)? = null
|
||||
|
||||
override suspend fun synthesize(request: SpeechRequest): SpeechAudio {
|
||||
calls.add(request)
|
||||
return synthesizeBlock?.invoke(request) ?: FakeAudio(request.text)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakePlayer : SpeechPlayer {
|
||||
var currentlyPlaying: SpeechAudio? = null
|
||||
var lastEndedCallback: (() -> Unit)? = null
|
||||
var lastErrorCallback: (() -> Unit)? = null
|
||||
var playCallCount: Int = 0
|
||||
var stopCallCount: Int = 0
|
||||
var playThrows: Throwable? = null
|
||||
|
||||
override fun play(
|
||||
audio: SpeechAudio,
|
||||
onEnded: () -> Unit,
|
||||
onError: () -> Unit,
|
||||
) {
|
||||
playCallCount++
|
||||
currentlyPlaying = audio
|
||||
lastEndedCallback = onEnded
|
||||
lastErrorCallback = onError
|
||||
playThrows?.let { throw it }
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
stopCallCount++
|
||||
currentlyPlaying = null
|
||||
lastEndedCallback = null
|
||||
lastErrorCallback = null
|
||||
}
|
||||
}
|
||||
|
||||
private class Fixture(
|
||||
val scope: TestScope,
|
||||
) {
|
||||
val synthesizer = FakeSynthesizer()
|
||||
val player = FakePlayer()
|
||||
val errors = mutableListOf<Unit>()
|
||||
|
||||
val controller =
|
||||
SpeechController(
|
||||
scope = scope,
|
||||
synthesizer = synthesizer,
|
||||
player = player,
|
||||
onError = { errors.add(Unit) },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggle with blank text is a no-op`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val blankReq = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = " ")
|
||||
|
||||
fixture.controller.toggle(blankReq)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertEquals(0, fixture.synthesizer.calls.size)
|
||||
assertEquals(0, fixture.player.playCallCount)
|
||||
assertEquals(0, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggle with empty text is a no-op`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val emptyReq = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "")
|
||||
|
||||
fixture.controller.toggle(emptyReq)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertEquals(0, fixture.synthesizer.calls.size)
|
||||
assertEquals(0, fixture.player.playCallCount)
|
||||
assertEquals(0, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggle synthesizes and plays audio, setting speakingId`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
|
||||
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
assertEquals(1, fixture.synthesizer.calls.size)
|
||||
assertEquals(req, fixture.synthesizer.calls[0])
|
||||
assertEquals(1, fixture.player.playCallCount)
|
||||
assertEquals(0, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggle on same active request stops playback and clears speakingId`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
|
||||
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertTrue(fixture.player.stopCallCount >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggle on same active request while still synthesizing stops and cancels synthesis`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val gate = CompletableDeferred<SpeechAudio>()
|
||||
fixture.synthesizer.synthesizeBlock = { gate.await() }
|
||||
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
|
||||
fixture.controller.toggle(req)
|
||||
runCurrent()
|
||||
|
||||
// Toggle same request while synthesis is inflight
|
||||
fixture.controller.toggle(req)
|
||||
runCurrent()
|
||||
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
|
||||
// Even if synthesis completes later, generation fencing ensures it never plays
|
||||
val audio = FakeAudio("Hello world")
|
||||
gate.complete(audio)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(0, fixture.player.playCallCount)
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertEquals(0, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `noncooperative synthesis cancelled during synthesis still disposes returned audio without playing`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val gate = CompletableDeferred<Unit>()
|
||||
val resumeGate = CompletableDeferred<Unit>()
|
||||
val audio = FakeAudio("Stale noncooperative audio")
|
||||
|
||||
fixture.synthesizer.synthesizeBlock = {
|
||||
try {
|
||||
gate.await()
|
||||
} catch (t: Throwable) {
|
||||
withContext(NonCancellable) {
|
||||
resumeGate.await()
|
||||
}
|
||||
}
|
||||
audio
|
||||
}
|
||||
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text")
|
||||
fixture.controller.toggle(req)
|
||||
runCurrent()
|
||||
|
||||
// Cancel by stopping
|
||||
fixture.controller.stop()
|
||||
runCurrent()
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
|
||||
// Now let noncooperative synthesis produce audio after cancellation
|
||||
resumeGate.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Audio must be disposed, never played, no error emitted, and speakingId remains null
|
||||
assertTrue(audio.isDisposed)
|
||||
assertEquals(0, fixture.player.playCallCount)
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertEquals(0, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `noncooperative synthesis superseded by replacement disposes stale audio without touching newer state`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val gate1 = CompletableDeferred<Unit>()
|
||||
val resumeGate1 = CompletableDeferred<Unit>()
|
||||
val audio1 = FakeAudio("Stale noncooperative audio 1")
|
||||
val audio2 = FakeAudio("New active audio 2")
|
||||
|
||||
fixture.synthesizer.synthesizeBlock = { req ->
|
||||
if (req.messageId == "msg-1") {
|
||||
try {
|
||||
gate1.await()
|
||||
} catch (t: Throwable) {
|
||||
withContext(NonCancellable) {
|
||||
resumeGate1.await()
|
||||
}
|
||||
}
|
||||
audio1
|
||||
} else {
|
||||
audio2
|
||||
}
|
||||
}
|
||||
|
||||
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text")
|
||||
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text")
|
||||
|
||||
fixture.controller.toggle(req1)
|
||||
runCurrent()
|
||||
|
||||
// Supersede with req2
|
||||
fixture.controller.toggle(req2)
|
||||
advanceUntilIdle()
|
||||
|
||||
// req2 should now be playing
|
||||
assertEquals("msg-2", fixture.controller.speakingId.value)
|
||||
assertEquals(audio2, fixture.player.currentlyPlaying)
|
||||
assertEquals(1, fixture.player.playCallCount)
|
||||
|
||||
// Now release stale noncooperative synthesis from req1
|
||||
resumeGate1.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Stale audio1 must be disposed, no extra play calls, speakingId untouched, no errors
|
||||
assertTrue(audio1.isDisposed)
|
||||
assertFalse(audio2.isDisposed)
|
||||
assertEquals("msg-2", fixture.controller.speakingId.value)
|
||||
assertEquals(audio2, fixture.player.currentlyPlaying)
|
||||
assertEquals(1, fixture.player.playCallCount)
|
||||
assertEquals(0, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `noncooperative synthesis throwing failure after replacement is ignored and does not invoke onError`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val gate1 = CompletableDeferred<Unit>()
|
||||
val resumeGate1 = CompletableDeferred<Unit>()
|
||||
val audio2 = FakeAudio("Active audio 2")
|
||||
|
||||
fixture.synthesizer.synthesizeBlock = { req ->
|
||||
if (req.messageId == "msg-1") {
|
||||
try {
|
||||
gate1.await()
|
||||
} catch (t: Throwable) {
|
||||
withContext(NonCancellable) {
|
||||
resumeGate1.await()
|
||||
}
|
||||
}
|
||||
throw IllegalStateException("Stale synthesis explosion")
|
||||
} else {
|
||||
audio2
|
||||
}
|
||||
}
|
||||
|
||||
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text")
|
||||
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text")
|
||||
|
||||
fixture.controller.toggle(req1)
|
||||
runCurrent()
|
||||
|
||||
// Replace with req2
|
||||
fixture.controller.toggle(req2)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("msg-2", fixture.controller.speakingId.value)
|
||||
assertEquals(0, fixture.errors.size)
|
||||
|
||||
// Release stale failure
|
||||
resumeGate1.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Stale failure must not invoke onError or clear msg-2
|
||||
assertEquals(0, fixture.errors.size)
|
||||
assertEquals("msg-2", fixture.controller.speakingId.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stop and restart race on same request ensures stale inflight completion does not disrupt new cycle`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val gate1 = CompletableDeferred<Unit>()
|
||||
val resumeGate1 = CompletableDeferred<Unit>()
|
||||
val audio1 = FakeAudio("Cycle 1 audio")
|
||||
val audio2 = FakeAudio("Cycle 2 audio")
|
||||
|
||||
var callIndex = 0
|
||||
fixture.synthesizer.synthesizeBlock = {
|
||||
val idx = ++callIndex
|
||||
if (idx == 1) {
|
||||
try {
|
||||
gate1.await()
|
||||
} catch (t: Throwable) {
|
||||
withContext(NonCancellable) {
|
||||
resumeGate1.await()
|
||||
}
|
||||
}
|
||||
audio1
|
||||
} else {
|
||||
audio2
|
||||
}
|
||||
}
|
||||
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Same text")
|
||||
|
||||
// Cycle 1: start synthesis
|
||||
fixture.controller.toggle(req)
|
||||
runCurrent()
|
||||
|
||||
// Toggle same request while synthesizing -> cancels cycle 1
|
||||
fixture.controller.toggle(req)
|
||||
runCurrent()
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
|
||||
// Cycle 2: restart SAME request immediately
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Cycle 2 has synthesized audio2 and is playing it
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
assertEquals(audio2, fixture.player.currentlyPlaying)
|
||||
assertEquals(1, fixture.player.playCallCount)
|
||||
|
||||
// Stale cycle 1 finally returns audio1
|
||||
resumeGate1.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Stale audio1 must be disposed and cycle 2 remains active and playing audio2
|
||||
assertTrue(audio1.isDisposed)
|
||||
assertFalse(audio2.isDisposed)
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
assertEquals(audio2, fixture.player.currentlyPlaying)
|
||||
assertEquals(1, fixture.player.playCallCount)
|
||||
assertEquals(0, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stop stops playback and clears speakingId`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
|
||||
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
|
||||
val stopCallsBefore = fixture.player.stopCallCount
|
||||
fixture.controller.stop()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertTrue(fixture.player.stopCallCount > stopCallsBefore)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replacement immediately stops active playback and cancels old request`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text")
|
||||
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text")
|
||||
|
||||
fixture.controller.toggle(req1)
|
||||
advanceUntilIdle()
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
|
||||
fixture.controller.toggle(req2)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("msg-2", fixture.controller.speakingId.value)
|
||||
assertEquals(2, fixture.synthesizer.calls.size)
|
||||
assertEquals(2, fixture.player.playCallCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replacement cancels inflight synthesis and generation fencing rejects stale completion`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val gate1 = CompletableDeferred<SpeechAudio>()
|
||||
val gate2 = CompletableDeferred<SpeechAudio>()
|
||||
|
||||
fixture.synthesizer.synthesizeBlock = { req ->
|
||||
if (req.messageId == "msg-1") gate1.await() else gate2.await()
|
||||
}
|
||||
|
||||
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text")
|
||||
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text")
|
||||
|
||||
fixture.controller.toggle(req1)
|
||||
runCurrent()
|
||||
|
||||
fixture.controller.toggle(req2)
|
||||
runCurrent()
|
||||
|
||||
// Complete old stale synthesis
|
||||
val audio1 = FakeAudio("First text")
|
||||
gate1.complete(audio1)
|
||||
runCurrent()
|
||||
|
||||
// Complete new synthesis
|
||||
val audio2 = FakeAudio("Second text")
|
||||
gate2.complete(audio2)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("msg-2", fixture.controller.speakingId.value)
|
||||
assertEquals(1, fixture.player.playCallCount)
|
||||
assertEquals(audio2, fixture.player.currentlyPlaying)
|
||||
assertEquals(0, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `player onEnded callback resets speakingId to null`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
|
||||
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
|
||||
val onEnded = fixture.player.lastEndedCallback
|
||||
onEnded?.invoke()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertEquals(0, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale onEnded callback from superseded generation does not clear active speakingId`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First")
|
||||
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second")
|
||||
|
||||
fixture.controller.toggle(req1)
|
||||
advanceUntilIdle()
|
||||
val staleOnEnded = fixture.player.lastEndedCallback
|
||||
|
||||
fixture.controller.toggle(req2)
|
||||
advanceUntilIdle()
|
||||
assertEquals("msg-2", fixture.controller.speakingId.value)
|
||||
|
||||
staleOnEnded?.invoke()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Still speaking msg-2!
|
||||
assertEquals("msg-2", fixture.controller.speakingId.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale onError callback from superseded generation is ignored and does not invoke onError`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First")
|
||||
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second")
|
||||
|
||||
fixture.controller.toggle(req1)
|
||||
advanceUntilIdle()
|
||||
val staleOnError = fixture.player.lastErrorCallback
|
||||
|
||||
fixture.controller.toggle(req2)
|
||||
advanceUntilIdle()
|
||||
assertEquals("msg-2", fixture.controller.speakingId.value)
|
||||
|
||||
staleOnError?.invoke()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("msg-2", fixture.controller.speakingId.value)
|
||||
assertEquals(0, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `player onError callback clears speakingId and triggers onError`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
|
||||
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
|
||||
val onError = fixture.player.lastErrorCallback
|
||||
onError?.invoke()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertEquals(1, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `synthesizer failure triggers onError and clears speakingId`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
fixture.synthesizer.synthesizeBlock = {
|
||||
throw IllegalStateException("Synthesis failed")
|
||||
}
|
||||
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertEquals(1, fixture.errors.size)
|
||||
assertEquals(0, fixture.player.playCallCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancellations during synthesis do not trigger onError`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val gate = CompletableDeferred<SpeechAudio>()
|
||||
fixture.synthesizer.synthesizeBlock = { gate.await() }
|
||||
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
|
||||
fixture.controller.toggle(req)
|
||||
runCurrent()
|
||||
|
||||
// Stop cancels the inflight coroutine
|
||||
fixture.controller.stop()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertEquals(0, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replaying same request uses cached audio without re-synthesizing and stop preserves cache`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Cached audio")
|
||||
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
assertEquals(1, fixture.synthesizer.calls.size)
|
||||
assertEquals(1, fixture.player.playCallCount)
|
||||
|
||||
// Stop playback — must preserve replay cache
|
||||
fixture.controller.stop()
|
||||
advanceUntilIdle()
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
|
||||
// Toggle again with same request -> should replay from cache without re-synthesizing
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
assertEquals(1, fixture.synthesizer.calls.size)
|
||||
assertEquals(2, fixture.player.playCallCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `request with different text invalidates cache and disposes old audio`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val audio1 = FakeAudio("Text 1")
|
||||
val audio2 = FakeAudio("Text 2")
|
||||
fixture.synthesizer.synthesizeBlock = { req ->
|
||||
if (req.text == "Text 1") audio1 else audio2
|
||||
}
|
||||
|
||||
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Text 1")
|
||||
fixture.controller.toggle(req1)
|
||||
advanceUntilIdle()
|
||||
assertEquals(audio1, fixture.player.currentlyPlaying)
|
||||
assertFalse(audio1.isDisposed)
|
||||
|
||||
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Text 2")
|
||||
fixture.controller.toggle(req2)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(audio2, fixture.player.currentlyPlaying)
|
||||
assertTrue(audio1.isDisposed)
|
||||
assertEquals(2, fixture.synthesizer.calls.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `request with different scope invalidates cache and disposes old audio`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val audio1 = FakeAudio("Scope 1 audio")
|
||||
val audio2 = FakeAudio("Scope 2 audio")
|
||||
fixture.synthesizer.synthesizeBlock = { req ->
|
||||
if (req.scopeKey == "session-1") audio1 else audio2
|
||||
}
|
||||
|
||||
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello")
|
||||
fixture.controller.toggle(req1)
|
||||
advanceUntilIdle()
|
||||
assertFalse(audio1.isDisposed)
|
||||
|
||||
val req2 = SpeechRequest(scopeKey = "session-2", messageId = "msg-1", text = "Hello")
|
||||
fixture.controller.toggle(req2)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(audio2, fixture.player.currentlyPlaying)
|
||||
assertTrue(audio1.isDisposed)
|
||||
assertEquals(2, fixture.synthesizer.calls.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replaying cached request when player throws does not escape toggle and clears speakingId`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Cached text")
|
||||
|
||||
// Initial successful playback
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
assertEquals(1, fixture.synthesizer.calls.size)
|
||||
assertEquals(1, fixture.player.playCallCount)
|
||||
|
||||
// Complete initial playback
|
||||
fixture.player.lastEndedCallback?.invoke()
|
||||
advanceUntilIdle()
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertEquals(0, fixture.errors.size)
|
||||
|
||||
// Configure player to throw synchronously on replay
|
||||
val stopCallsBeforeReplay = fixture.player.stopCallCount
|
||||
fixture.player.playThrows = IllegalStateException("Player init failed")
|
||||
|
||||
// Replay exact cached request
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Must NOT escape toggle, speakingId null, onError exactly once,
|
||||
// partially initialized player stopped, no resynthesis
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertEquals(1, fixture.errors.size)
|
||||
assertTrue(fixture.player.stopCallCount > stopCallsBeforeReplay)
|
||||
assertEquals(1, fixture.synthesizer.calls.size)
|
||||
assertEquals(2, fixture.player.playCallCount)
|
||||
|
||||
// Replay cache is retained: if player recovers, replaying exact request does not re-synthesize
|
||||
fixture.player.playThrows = null
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
assertEquals(1, fixture.synthesizer.calls.size)
|
||||
assertEquals(3, fixture.player.playCallCount)
|
||||
assertEquals(1, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial play when player throws does not escape toggle and clears speakingId`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Initial throw text")
|
||||
|
||||
fixture.player.playThrows = IllegalStateException("Player failed synchronously on first play")
|
||||
|
||||
// Initial noncached play throw
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Must NOT escape toggle, speakingId null, onError exactly once, stopped partially initialized player
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertEquals(1, fixture.errors.size)
|
||||
assertTrue(fixture.player.stopCallCount >= 1)
|
||||
assertEquals(1, fixture.synthesizer.calls.size)
|
||||
assertEquals(1, fixture.player.playCallCount)
|
||||
|
||||
// Replay cache retained: if player recovers, next toggle on exact
|
||||
// request uses cached audio without re-synthesizing
|
||||
fixture.player.playThrows = null
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
assertEquals(1, fixture.synthesizer.calls.size)
|
||||
assertEquals(2, fixture.player.playCallCount)
|
||||
assertEquals(1, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset clears speakingId, stops player, and disposes cached audio`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val audio = FakeAudio("Reset audio")
|
||||
fixture.synthesizer.synthesizeBlock = { audio }
|
||||
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Reset audio")
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
assertFalse(audio.isDisposed)
|
||||
|
||||
fixture.controller.reset()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertTrue(audio.isDisposed)
|
||||
assertTrue(fixture.player.stopCallCount >= 1)
|
||||
|
||||
// Re-toggling same request after reset must re-synthesize because cache was cleared/disposed
|
||||
val audio2 = FakeAudio("Reset audio 2")
|
||||
fixture.synthesizer.synthesizeBlock = { audio2 }
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(2, fixture.synthesizer.calls.size)
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SpeechTextTest {
|
||||
@Test
|
||||
fun `blank input returns blank string`() {
|
||||
assertEquals("", SpeechText.stripMarkdownForSpeech(""))
|
||||
assertEquals("", SpeechText.stripMarkdownForSpeech(" "))
|
||||
assertEquals("", SpeechText.stripMarkdownForSpeech("\n\t\n "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plain text is preserved without changes`() {
|
||||
val input = "Hello world this is a test"
|
||||
val result = SpeechText.stripMarkdownForSpeech(input)
|
||||
assertEquals("Hello world this is a test", result.trim())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bold and italic markers are removed keeping prose`() {
|
||||
val input = "This is **bold** text and *italic* text and ***bold italic*** text"
|
||||
val result = SpeechText.stripMarkdownForSpeech(input)
|
||||
assertFalse(result.contains("*"))
|
||||
assertTrue(result.contains("bold"))
|
||||
assertTrue(result.contains("italic"))
|
||||
assertTrue(result.contains("bold italic"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `strikethrough markers are removed keeping inner text`() {
|
||||
val input = "This is ~~cancelled~~ completed"
|
||||
val result = SpeechText.stripMarkdownForSpeech(input)
|
||||
assertFalse(result.contains("~"))
|
||||
assertTrue(result.contains("cancelled"))
|
||||
assertTrue(result.contains("completed"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `headers keep title text without hash characters`() {
|
||||
val input =
|
||||
"""
|
||||
# Header 1
|
||||
## Header 2
|
||||
### Header 3
|
||||
""".trimIndent()
|
||||
val result = SpeechText.stripMarkdownForSpeech(input)
|
||||
assertFalse(result.contains("#"))
|
||||
assertTrue(result.contains("Header 1"))
|
||||
assertTrue(result.contains("Header 2"))
|
||||
assertTrue(result.contains("Header 3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inline code keeps code content without backticks`() {
|
||||
val input = "Run `val my_value = 42` to configure"
|
||||
val result = SpeechText.stripMarkdownForSpeech(input)
|
||||
assertFalse(result.contains("`"))
|
||||
assertTrue(result.contains("val my_value = 42"))
|
||||
assertTrue(result.contains("my_value"))
|
||||
assertTrue(result.contains("to configure"))
|
||||
assertFalse(result.contains(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fenced code block keeps code body and removes fences and language identifier`() {
|
||||
val input =
|
||||
"""
|
||||
Here is some code:
|
||||
```kotlin
|
||||
fun main() {
|
||||
println("Hello")
|
||||
}
|
||||
```
|
||||
Done.
|
||||
""".trimIndent()
|
||||
val result = SpeechText.stripMarkdownForSpeech(input)
|
||||
assertFalse(result.contains("```"))
|
||||
assertFalse(result.contains("kotlin"))
|
||||
assertTrue(result.contains("fun main()"))
|
||||
assertTrue(result.contains("""println("Hello")"""))
|
||||
assertTrue(result.contains("Here is some code:"))
|
||||
assertTrue(result.contains("Done."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fenced code block body retains underscores, generics, multiplication, and pipes`() {
|
||||
val input =
|
||||
"""
|
||||
Here is complex code:
|
||||
```typescript
|
||||
const a_b_c: List<T> = items.map(x => x * 2 | 0);
|
||||
```
|
||||
Done...
|
||||
""".trimIndent()
|
||||
val result = SpeechText.stripMarkdownForSpeech(input)
|
||||
assertFalse(result.contains("```"))
|
||||
assertFalse(result.contains("typescript"))
|
||||
assertTrue(result.contains("a_b_c"))
|
||||
assertTrue(result.contains("<T>"))
|
||||
assertTrue(result.contains("* 2"))
|
||||
assertTrue(result.contains("| 0"))
|
||||
assertTrue(result.contains("Here is complex code:"))
|
||||
assertTrue(result.contains("Done..."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `markdown links keep label text and strip destination url`() {
|
||||
val input = "Check out [Hermes Documentation](https://hermes-agent.nousresearch.com/docs) for info"
|
||||
val result = SpeechText.stripMarkdownForSpeech(input)
|
||||
assertTrue(result.contains("Hermes Documentation"))
|
||||
assertFalse(result.contains("https://hermes-agent.nousresearch.com/docs"))
|
||||
assertFalse(result.contains("[") || result.contains("]"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `image markdown keeps alt text and strips image url`() {
|
||||
val input = "Here is a diagram: "
|
||||
val result = SpeechText.stripMarkdownForSpeech(input)
|
||||
assertTrue(result.contains("Architecture diagram"))
|
||||
assertFalse(result.contains("https://example.com/arch.png"))
|
||||
assertFalse(result.contains("!["))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bare urls are removed`() {
|
||||
val input = "Visit https://example.com or http://test.org/path?arg=1 today"
|
||||
val result = SpeechText.stripMarkdownForSpeech(input)
|
||||
assertFalse(result.contains("https://example.com"))
|
||||
assertFalse(result.contains("http://test.org/path?arg=1"))
|
||||
assertTrue(result.contains("Visit"))
|
||||
assertTrue(result.contains("today"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `html tags are stripped keeping inner text`() {
|
||||
val input = "<p>This is a <b>bold</b> paragraph with a <br/> break.</p>"
|
||||
val result = SpeechText.stripMarkdownForSpeech(input)
|
||||
assertFalse(result.contains("<"))
|
||||
assertFalse(result.contains(">"))
|
||||
assertTrue(result.contains("This is a"))
|
||||
assertTrue(result.contains("bold"))
|
||||
assertTrue(result.contains("paragraph with a"))
|
||||
assertTrue(result.contains("break."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blockquote markers are removed keeping content`() {
|
||||
val input =
|
||||
"""
|
||||
> This is a quote.
|
||||
> It spans multiple lines.
|
||||
""".trimIndent()
|
||||
val result = SpeechText.stripMarkdownForSpeech(input)
|
||||
assertFalse(result.contains(">"))
|
||||
assertTrue(result.contains("This is a quote."))
|
||||
assertTrue(result.contains("It spans multiple lines."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unordered and ordered list markers are handled sensibly`() {
|
||||
val input =
|
||||
"""
|
||||
Items:
|
||||
* First item
|
||||
* Second item
|
||||
1. Step one
|
||||
2. Step two
|
||||
""".trimIndent()
|
||||
val result = SpeechText.stripMarkdownForSpeech(input)
|
||||
assertFalse(result.contains("*"))
|
||||
assertFalse(result.contains("1."))
|
||||
assertFalse(result.contains("2."))
|
||||
assertTrue(result.contains("First item"))
|
||||
assertTrue(result.contains("Second item"))
|
||||
assertTrue(result.contains("Step one"))
|
||||
assertTrue(result.contains("Step two"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `arabic text is preserved intact`() {
|
||||
val input = "مرحبا بك في تطبيق هيرمس! **أهلا وسهلا**"
|
||||
val result = SpeechText.stripMarkdownForSpeech(input)
|
||||
assertFalse(result.contains("*"))
|
||||
assertTrue(result.contains("مرحبا بك في تطبيق هيرمس!"))
|
||||
assertTrue(result.contains("أهلا وسهلا"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unicode and emoji are preserved`() {
|
||||
val input = "Hello 👋 world! 🚀 Enjoy the day ☀️"
|
||||
val result = SpeechText.stripMarkdownForSpeech(input)
|
||||
assertTrue(result.contains("👋"))
|
||||
assertTrue(result.contains("🚀"))
|
||||
assertTrue(result.contains("☀️"))
|
||||
assertTrue(result.contains("Hello"))
|
||||
assertTrue(result.contains("world!"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `punctuation and code punctuation edge cases are handled without double punctuation`() {
|
||||
val input = "Wait... is that it?! Yes, it is: done."
|
||||
val result = SpeechText.stripMarkdownForSpeech(input)
|
||||
assertTrue(result.contains("Wait..."))
|
||||
assertTrue(result.contains("done."))
|
||||
assertFalse(result.contains("..,"))
|
||||
assertFalse(result.contains(",,"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `newlines retain punctuation without introducing duplicate or mixed punctuation and normalize whitespace`() {
|
||||
val input =
|
||||
"""
|
||||
Done.
|
||||
Next!
|
||||
مرحبا؟
|
||||
Again
|
||||
""".trimIndent()
|
||||
val result = SpeechText.stripMarkdownForSpeech(input)
|
||||
assertFalse(result.contains(". ."))
|
||||
assertFalse(result.contains("!."))
|
||||
assertFalse(result.contains("؟."))
|
||||
assertFalse(result.contains("\n\n"))
|
||||
assertTrue(result.contains("Done."))
|
||||
assertTrue(result.contains("Next!"))
|
||||
assertTrue(result.contains("مرحبا؟"))
|
||||
assertTrue(result.contains("Again"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `horizontal rules are removed`() {
|
||||
val input =
|
||||
"""
|
||||
Section 1
|
||||
---
|
||||
Section 2
|
||||
***
|
||||
Section 3
|
||||
""".trimIndent()
|
||||
val result = SpeechText.stripMarkdownForSpeech(input)
|
||||
assertTrue(result.contains("Section 1"))
|
||||
assertTrue(result.contains("Section 2"))
|
||||
assertTrue(result.contains("Section 3"))
|
||||
assertFalse(result.contains("---"))
|
||||
assertFalse(result.contains("***"))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user