release: promote v1.31.0-rc.2 to main (#1354)
Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Lx-204 <15377891490@163.com> Co-authored-by: AlexDev_ <56083016+alexdev03@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Nikhil Nair <32665919+nikhilnair31@users.noreply.github.com> Co-authored-by: marciodps <95321123+marciodps@users.noreply.github.com> Co-authored-by: ThomasCrund <39323104+ThomasCrund@users.noreply.github.com>
This commit is contained in:
111 files changed
+10563
-998
No files matched your search
@@ -35,6 +35,7 @@ class AuthManagerTest {
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockPrefs = mockk(relaxed = true)
|
||||
every { mockPrefs.getString("server_custom_headers", null) } returns null
|
||||
mockEditor = mockk(relaxed = true)
|
||||
mockContext = mockk(relaxed = true)
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ class Issue647ProfileUrlTest {
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockPrefs = mockk(relaxed = true)
|
||||
every { mockPrefs.getString("server_custom_headers", null) } returns null
|
||||
mockEditor = mockk(relaxed = true)
|
||||
mockContext = mockk(relaxed = true)
|
||||
|
||||
|
||||
@@ -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,297 @@
|
||||
package com.m57.hermescontrol.data.remote
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ServerHeadersTest {
|
||||
private val preferences = mockk<SharedPreferences>()
|
||||
private val editor = mockk<SharedPreferences.Editor>()
|
||||
private var saved: String? = null
|
||||
private lateinit var server: MockWebServer
|
||||
private val headers =
|
||||
CustomHeaders.parse(
|
||||
listOf(
|
||||
"CF-Access-Client-Id" to "test-id",
|
||||
"CF-Access-Client-Secret" to "secret",
|
||||
),
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
every { preferences.getString(any(), null) } answers { saved }
|
||||
every { preferences.edit() } returns editor
|
||||
every { editor.putString(any(), any()) } answers {
|
||||
saved = secondArg()
|
||||
editor
|
||||
}
|
||||
every { editor.commit() } returns true
|
||||
ServerHeaders.initialize(preferences)
|
||||
CookieManager.setJarForTest(buildFakePersistentCookieJar())
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
saved = null
|
||||
ServerHeaders.initialize(preferences)
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `protected probe login ticket API and media requests require both headers`() {
|
||||
server.dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse =
|
||||
if (request.getHeader("CF-Access-Client-Id") == "test-id" &&
|
||||
request.getHeader("CF-Access-Client-Secret") == "secret"
|
||||
) {
|
||||
MockResponse().setBody("{}")
|
||||
} else {
|
||||
MockResponse().setResponseCode(403)
|
||||
}
|
||||
}
|
||||
val url = server.url("/hermes/")
|
||||
val probe = Request.Builder().url(server.url("/hermes/api/status")).build()
|
||||
OkHttpProvider.probe
|
||||
.newCall(probe)
|
||||
.execute()
|
||||
.use { assertEquals(403, it.code) }
|
||||
ServerHeaders.save(url, headers)
|
||||
val paths = listOf("api/status", "auth/password-login", "api/auth/ws-ticket", "api/sessions", "files/image.png")
|
||||
paths.forEach { path ->
|
||||
val builder = Request.Builder().url(server.url("/hermes/$path"))
|
||||
if (path.contains("login") || path.contains("ticket")) builder.post("{}".toRequestBody())
|
||||
listOf(OkHttpProvider.probe, OkHttpProvider.base).forEach { client ->
|
||||
client.newCall(builder.build()).execute().use { assertEquals(200, it.code) }
|
||||
}
|
||||
}
|
||||
ServerHeaders.save(url, CustomHeaders.EMPTY)
|
||||
OkHttpProvider.probe
|
||||
.newCall(probe)
|
||||
.execute()
|
||||
.use { assertEquals(403, it.code) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `custom headers coexist with Hermes bearer and cookie authentication`() {
|
||||
ServerHeaders.save(server.url("/"), headers)
|
||||
server.enqueue(MockResponse())
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(server.url("/api/status"))
|
||||
.header("Authorization", "Bearer hermes-token")
|
||||
.header("Cookie", "hermes_session_at=session")
|
||||
.build()
|
||||
OkHttpProvider.base
|
||||
.newBuilder()
|
||||
.cookieJar(CookieJar.NO_COOKIES)
|
||||
.build()
|
||||
.newCall(request)
|
||||
.execute()
|
||||
.close()
|
||||
val recorded = server.takeRequest()
|
||||
assertEquals("test-id", recorded.getHeader("CF-Access-Client-Id"))
|
||||
assertEquals("Bearer hermes-token", recorded.getHeader("Authorization"))
|
||||
assertEquals("hermes_session_at=session", recorded.getHeader("Cookie"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WebSocket upgrade carries custom headers and receives a message`() {
|
||||
ServerHeaders.save(server.url("/hermes/"), headers)
|
||||
val message = CountDownLatch(1)
|
||||
server.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) {
|
||||
webSocket.send("connected")
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
val socket =
|
||||
OkHttpProvider.websocket.newWebSocket(
|
||||
Request.Builder().url(server.url("/hermes/api/ws?ticket=test")).build(),
|
||||
object : WebSocketListener() {
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
if (text == "connected") message.countDown()
|
||||
}
|
||||
},
|
||||
)
|
||||
try {
|
||||
assertTrue(message.await(5, TimeUnit.SECONDS))
|
||||
val recorded = server.takeRequest()
|
||||
assertEquals("test-id", recorded.getHeader("CF-Access-Client-Id"))
|
||||
assertEquals("secret", recorded.getHeader("CF-Access-Client-Secret"))
|
||||
assertEquals("websocket", recorded.getHeader("Upgrade"))
|
||||
} finally {
|
||||
socket.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `HTTP redirect to another configured server sends neither servers credentials`() {
|
||||
MockWebServer().use { other ->
|
||||
other.start()
|
||||
ServerHeaders.save(server.url("/"), headers)
|
||||
ServerHeaders.save(other.url("/"), CustomHeaders.parse(listOf("X-Other-Secret" to "other-secret")))
|
||||
server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", other.url("/api/status")))
|
||||
other.enqueue(MockResponse())
|
||||
OkHttpProvider.base
|
||||
.newCall(Request.Builder().url(server.url("/api/status")).build())
|
||||
.execute()
|
||||
.close()
|
||||
assertEquals("secret", server.takeRequest().getHeader("CF-Access-Client-Secret"))
|
||||
val redirected = other.takeRequest()
|
||||
assertNull(redirected.getHeader("CF-Access-Client-Secret"))
|
||||
assertNull(redirected.getHeader("CF-Access-Client-Id"))
|
||||
assertNull(redirected.getHeader("X-Other-Secret"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same-server redirect preserves headers but escaping path prefix removes them`() {
|
||||
ServerHeaders.save(server.url("/hermes/"), headers)
|
||||
server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", server.url("/hermes/next")))
|
||||
server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", server.url("/outside")))
|
||||
server.enqueue(MockResponse())
|
||||
OkHttpProvider.base
|
||||
.newCall(Request.Builder().url(server.url("/hermes/start")).build())
|
||||
.execute()
|
||||
.close()
|
||||
assertEquals("secret", server.takeRequest().getHeader("CF-Access-Client-Secret"))
|
||||
assertEquals("secret", server.takeRequest().getHeader("CF-Access-Client-Secret"))
|
||||
assertNull(server.takeRequest().getHeader("CF-Access-Client-Secret"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `redirect into a separately configured path does not inherit parent credentials`() {
|
||||
ServerHeaders.save(server.url("/"), headers)
|
||||
ServerHeaders.save(server.url("/public/"), CustomHeaders.EMPTY)
|
||||
server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", server.url("/public/api/status")))
|
||||
server.enqueue(MockResponse())
|
||||
OkHttpProvider.base
|
||||
.newCall(Request.Builder().url(server.url("/start")).build())
|
||||
.execute()
|
||||
.close()
|
||||
assertEquals("secret", server.takeRequest().getHeader("CF-Access-Client-Secret"))
|
||||
assertNull(server.takeRequest().getHeader("CF-Access-Client-Secret"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WebSocket redirect is not followed`() {
|
||||
MockWebServer().use { other ->
|
||||
other.start()
|
||||
ServerHeaders.save(server.url("/"), headers)
|
||||
server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", other.url("/api/ws")))
|
||||
val failed = CountDownLatch(1)
|
||||
val socket =
|
||||
OkHttpProvider.websocket.newWebSocket(
|
||||
Request.Builder().url(server.url("/api/ws")).build(),
|
||||
object : WebSocketListener() {
|
||||
override fun onFailure(
|
||||
webSocket: WebSocket,
|
||||
t: Throwable,
|
||||
response: Response?,
|
||||
) {
|
||||
if (response?.code == 302) failed.countDown()
|
||||
}
|
||||
},
|
||||
)
|
||||
try {
|
||||
assertTrue(failed.await(5, TimeUnit.SECONDS))
|
||||
assertEquals(0, other.requestCount)
|
||||
} finally {
|
||||
socket.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scope matches scheme host port and complete path segment`() {
|
||||
val scope = ScopedServerHeaders("https://hermes.example/app/".toHttpUrl(), headers)
|
||||
assertTrue(scope.contains("https://hermes.example/app/api/status".toHttpUrl()))
|
||||
assertTrue(scope.contains("https://hermes.example/app".toHttpUrl()))
|
||||
listOf(
|
||||
"http://hermes.example/app/api/status",
|
||||
"https://other.example/app/api/status",
|
||||
"https://hermes.example:8443/app/api/status",
|
||||
"https://hermes.example/application/api/status",
|
||||
).forEach { assertFalse(scope.contains(it.toHttpUrl())) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saved credentials survive reload and an empty child scope blocks parent credentials`() {
|
||||
ServerHeaders.save(server.url("/"), headers)
|
||||
ServerHeaders.save(server.url("/public/"), CustomHeaders.EMPTY)
|
||||
ServerHeaders.initialize(preferences)
|
||||
assertEquals("secret", ServerHeaders.get(server.url("/")).entries["CF-Access-Client-Secret"])
|
||||
assertTrue(
|
||||
ServerHeaders
|
||||
.forRequest(server.url("/public/api/status"))!!
|
||||
.headers.entries
|
||||
.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed storage write does not publish new credentials`() {
|
||||
every { editor.commit() } returns false
|
||||
assertThrows(IOException::class.java) { ServerHeaders.save(server.url("/"), headers) }
|
||||
assertTrue(ServerHeaders.get(server.url("/")).entries.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed stored JSON does not expose credentials in errors`() {
|
||||
saved = "{\"secret\": broken"
|
||||
val error = assertThrows(IOException::class.java) { ServerHeaders.initialize(preferences) }
|
||||
assertFalse(error.stackTraceToString().contains("secret"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reject invalid duplicate and managed headers without revealing values`() {
|
||||
listOf(
|
||||
listOf("Bad Name" to "secret"),
|
||||
listOf("X-Test" to "secret\r\nInjected: bad"),
|
||||
listOf("X-Test" to "secret", "x-test" to "secret"),
|
||||
listOf("Authorization" to "secret"),
|
||||
listOf("Cookie" to "secret"),
|
||||
listOf("Host" to "secret"),
|
||||
listOf("Sec-WebSocket-Key" to "secret"),
|
||||
).forEach { rows ->
|
||||
val error = assertThrows(IllegalArgumentException::class.java) { CustomHeaders.parse(rows) }
|
||||
assertFalse(error.message.orEmpty().contains("secret"))
|
||||
}
|
||||
assertFalse(headers.toString().contains("secret"))
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,9 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import retrofit2.Response
|
||||
import java.io.File
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
|
||||
class VoiceNoteRepositoryTest {
|
||||
private val api = mockk<HermesApiService>()
|
||||
@@ -73,6 +75,40 @@ class VoiceNoteRepositoryTest {
|
||||
coVerify(exactly = 1) { api.transcribeAudio(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcribe_retriesOnceWhenTheConnectNeverEstablished() =
|
||||
runTest {
|
||||
// A connect failure means the request never reached the server, so
|
||||
// one re-attempt is safe and rescues Wi-Fi/cellular handoffs that
|
||||
// surface as "Network is unreachable" (device follow-up, #1247).
|
||||
val file = tempVoiceFile()
|
||||
var attempts = 0
|
||||
coEvery { api.transcribeAudio(any()) } coAnswers {
|
||||
attempts++
|
||||
if (attempts == 1) {
|
||||
throw ConnectException("failed to connect to /192.168.1.10:8000")
|
||||
}
|
||||
Response.success(AudioTranscriptionResponse(ok = true, transcript = " hi "))
|
||||
}
|
||||
|
||||
val result = VoiceNoteRepository { _ -> api }.transcribe(file)
|
||||
|
||||
assertEquals("hi", (result as NetworkResult.Success).data)
|
||||
coVerify(exactly = 2) { api.transcribeAudio(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcribe_givesUpAfterOneConnectRetry() =
|
||||
runTest {
|
||||
val file = tempVoiceFile()
|
||||
coEvery { api.transcribeAudio(any()) } throws UnknownHostException("no dns")
|
||||
|
||||
val result = VoiceNoteRepository { _ -> api }.transcribe(file)
|
||||
|
||||
assertTrue(result is NetworkResult.Failure)
|
||||
coVerify(exactly = 2) { api.transcribeAudio(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcribe_resolvesTheServiceAtCallTime_soAConnectionSwitchUsesTheNewService() =
|
||||
runTest {
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.AccountConnectorResult
|
||||
import com.m57.hermescontrol.data.model.ConnectorTool
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class AccountConnectorRepositoryTest {
|
||||
@Before fun setup() {
|
||||
mockkObject(AuthManager)
|
||||
every { AuthManager.activeProfileId } returns MutableStateFlow("work")
|
||||
every { AuthManager.currentDataScope() } returns null
|
||||
}
|
||||
|
||||
@After fun teardown() = unmockkAll()
|
||||
|
||||
@Test fun `connect decodes operation snapshot and preserves authorization link without a session`() =
|
||||
runTest {
|
||||
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||
val repo =
|
||||
HermesAccountConnectorRepository { method, params ->
|
||||
calls += method to params
|
||||
operationFixture()
|
||||
}
|
||||
val result = repo.connect(listOf("drive"), true) as AccountConnectorResult.Success
|
||||
assertTrue(result.value.accountOwned)
|
||||
assertEquals("op-a", result.value.opId)
|
||||
assertEquals(
|
||||
"https://example.com/authorize",
|
||||
result.value.targets
|
||||
.single()
|
||||
.safeConnectUrl,
|
||||
)
|
||||
assertEquals(
|
||||
mapOf(
|
||||
"profile" to "work",
|
||||
"owner" to mapOf("type" to "account"),
|
||||
"connectors" to listOf("drive"),
|
||||
"reconnect" to true,
|
||||
),
|
||||
calls.single().second,
|
||||
)
|
||||
assertEquals(WsMethods.CONNECTORS_CONNECT, calls.single().first)
|
||||
}
|
||||
|
||||
@Test fun `account operation respond status and wake retain profile and owner`() =
|
||||
runTest {
|
||||
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||
val repo =
|
||||
HermesAccountConnectorRepository { method, params ->
|
||||
calls += method to params
|
||||
operationFixture()
|
||||
}
|
||||
repo.operationStatus("op-a")
|
||||
repo.operationRequest(
|
||||
WsMethods.CONNECTION_RESPOND,
|
||||
mapOf(
|
||||
"op_id" to "op-a",
|
||||
"result" to mapOf("settled_by" to "continue"),
|
||||
),
|
||||
)
|
||||
repo.operationRequest(WsMethods.CONNECTORS_OPERATION_WAKE, mapOf("op_id" to "op-a"))
|
||||
assertEquals(
|
||||
listOf(
|
||||
WsMethods.CONNECTORS_OPERATION_STATUS,
|
||||
WsMethods.CONNECTION_RESPOND,
|
||||
WsMethods.CONNECTORS_OPERATION_WAKE,
|
||||
),
|
||||
calls.map {
|
||||
it.first
|
||||
},
|
||||
)
|
||||
calls.forEach { (_, params) ->
|
||||
assertEquals("work", params["profile"])
|
||||
assertEquals(mapOf("type" to "account"), params["owner"])
|
||||
assertEquals("op-a", params["op_id"])
|
||||
assertFalse("session_id" in params)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `catalog accounts tools and removal use native account endpoints`() =
|
||||
runTest {
|
||||
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||
val repo =
|
||||
HermesAccountConnectorRepository { method, params ->
|
||||
calls += method to params
|
||||
when (method) {
|
||||
WsMethods.CONNECTORS_CATALOG -> {
|
||||
mapOf(
|
||||
"connectors" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"slug" to "drive",
|
||||
"name" to "Google Drive",
|
||||
"description" to "Files",
|
||||
"category" to "storage",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
WsMethods.CONNECTORS_ACCOUNTS -> {
|
||||
mapOf(
|
||||
"accounts" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"connection_id" to "conn-1",
|
||||
"connector" to "drive",
|
||||
"label" to "Work account",
|
||||
"status" to "active",
|
||||
"active" to true,
|
||||
"alias" to null,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
WsMethods.CONNECTORS_TOOLS -> {
|
||||
mapOf(
|
||||
"tools" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"slug" to "delete",
|
||||
"name" to "Delete file",
|
||||
"hints" to listOf("destructive"),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
WsMethods.CONNECTORS_ACCOUNTS_REMOVE -> {
|
||||
mapOf(
|
||||
"connection_id" to "conn-1",
|
||||
"connector" to "drive",
|
||||
"status" to "removed",
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
error(method)
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals("Google Drive", (repo.catalog() as AccountConnectorResult.Success).value.single().name)
|
||||
assertEquals("Work account", (repo.accounts() as AccountConnectorResult.Success).value.single().label)
|
||||
assertEquals(
|
||||
"Delete file",
|
||||
(repo.tools("drive", true) as AccountConnectorResult.Success).value.single().name,
|
||||
)
|
||||
assertTrue(repo.remove("conn-1") is AccountConnectorResult.Success)
|
||||
assertTrue(calls.all { "owner" !in it.second && it.second["profile"] == "work" })
|
||||
assertEquals(mapOf("profile" to "work", "slug" to "drive", "refresh" to true), calls[2].second)
|
||||
assertEquals(mapOf("profile" to "work", "connection_id" to "conn-1"), calls[3].second)
|
||||
}
|
||||
|
||||
@Test fun `policy keeps member revision separate from effective and respects inherited rules`() =
|
||||
runTest {
|
||||
val repo = HermesAccountConnectorRepository { _, _ -> policyFixture() }
|
||||
val policy = (repo.policy() as AccountConnectorResult.Success).value
|
||||
assertEquals("effective-revision", policy.revision)
|
||||
assertEquals("01ARZ3NDEKTSV4RRFFQ69G5FAV", policy.member?.revision)
|
||||
assertEquals(listOf("mine"), policy.member?.tools?.get("drive"))
|
||||
val destructive = ConnectorTool("delete", "Delete", "", "destructive", false, listOf("destructive"))
|
||||
assertFalse(policy.inherited.single().toolEnabled("drive", destructive))
|
||||
assertFalse(policy.copy(mode = "deny-all").connectorEnabled("drive"))
|
||||
assertFalse(policy.copy(mode = "future-mode").connectorEnabled("drive"))
|
||||
}
|
||||
|
||||
@Test fun `writes use observed member revision not effective revision or fresh overwritten version`() =
|
||||
runTest {
|
||||
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||
val repo =
|
||||
HermesAccountConnectorRepository { method, params ->
|
||||
calls += method to params
|
||||
if (method == WsMethods.CONNECTORS_POLICY_GET) {
|
||||
policyFixture()
|
||||
} else {
|
||||
mapOf(
|
||||
"revision" to "new-member-revision",
|
||||
"effective" to mapOf("mode" to "deny-all", "revision" to "effective-revision"),
|
||||
)
|
||||
}
|
||||
}
|
||||
assertTrue(
|
||||
repo.setDisabledTools(
|
||||
"drive",
|
||||
listOf("delete"),
|
||||
"01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
) is AccountConnectorResult.Success,
|
||||
)
|
||||
assertEquals(WsMethods.CONNECTORS_POLICY_SET, calls.first().first)
|
||||
assertEquals("01ARZ3NDEKTSV4RRFFQ69G5FAV", calls.first().second["expected_revision"])
|
||||
assertEquals(
|
||||
mapOf("type" to "tools", "connector" to "drive", "disabled_tools" to listOf("delete")),
|
||||
calls.first().second["change"],
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `malformed authorization does not look like a successful connect`() =
|
||||
runTest {
|
||||
val repo = HermesAccountConnectorRepository { _, _ -> mapOf("status" to "initiated") }
|
||||
assertTrue(repo.connect(listOf("drive")) is AccountConnectorResult.Failure)
|
||||
}
|
||||
|
||||
private fun operationFixture() =
|
||||
mapOf(
|
||||
"op_id" to "op-a",
|
||||
"seq" to 1,
|
||||
"deadline_at" to 2000000000.0,
|
||||
"settled" to false,
|
||||
"targets" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"name" to "drive",
|
||||
"kind" to "connector",
|
||||
"action" to "authorize",
|
||||
"state" to "initiated",
|
||||
"connect_url" to "https://example.com/authorize",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun policyFixture() =
|
||||
mapOf(
|
||||
"effective" to
|
||||
mapOf(
|
||||
"mode" to "deny",
|
||||
"revision" to "effective-revision",
|
||||
"disabled_connectors" to emptyList<String>(),
|
||||
),
|
||||
"layers" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"kind" to "member",
|
||||
"revision" to "01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
"body" to mapOf("mode" to "deny", "tools" to mapOf("drive" to listOf("mine"))),
|
||||
),
|
||||
mapOf(
|
||||
"kind" to "org",
|
||||
"revision" to "org-revision",
|
||||
"body" to mapOf("mode" to "deny", "tags" to mapOf("disable" to listOf("destructive"))),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@@ -63,6 +64,39 @@ class ConnectionOperationEventParserTest {
|
||||
assertEquals("", (event as WsEvent.Unknown).raw)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectionUpdate_bindsToSessionOwner() {
|
||||
val event =
|
||||
EventParser.parseParams(
|
||||
mapOf(
|
||||
"type" to "connection.update",
|
||||
"payload" to
|
||||
operationPayload(seq = 2L) +
|
||||
mapOf("owner" to mapOf("type" to "session", "session_id" to "owner-session")),
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(event is WsEvent.ConnectionUpdate)
|
||||
assertEquals("owner-session", (event as WsEvent.ConnectionUpdate).snapshot.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectionUpdate_accountOwnerNeverBindsToSession() {
|
||||
val event =
|
||||
EventParser.parseParams(
|
||||
mapOf(
|
||||
"type" to "connection.update",
|
||||
"session_id" to "",
|
||||
"payload" to
|
||||
operationPayload(seq = 2L) +
|
||||
mapOf("owner" to mapOf("type" to "account"), "session_id" to "stale-session"),
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(event is WsEvent.ConnectionUpdate)
|
||||
assertNull((event as WsEvent.ConnectionUpdate).snapshot.sessionId)
|
||||
}
|
||||
|
||||
private fun operationPayload(seq: Long): Map<String, Any?> =
|
||||
mapOf(
|
||||
"op_id" to "op-1",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.model.CatalogScanStatus
|
||||
import com.m57.hermescontrol.data.model.ConnectionTargetAction
|
||||
import com.m57.hermescontrol.data.model.ConnectionTargetKind
|
||||
import com.m57.hermescontrol.data.model.ConnectionTargetState
|
||||
@@ -60,6 +61,73 @@ class ConnectionOperationParserTest {
|
||||
assertEquals(ConnectionTargetState.UNKNOWN, target.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pluginAndSkillTargets_parseKindAndCatalogFacts() {
|
||||
val payload =
|
||||
completePayload() +
|
||||
(
|
||||
"targets" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"name" to "linear",
|
||||
"kind" to "plugin",
|
||||
"action" to "install",
|
||||
"state" to "pending",
|
||||
"display" to " Linear ",
|
||||
"description" to "Linear issues.",
|
||||
"tier" to "official",
|
||||
"repo" to "NousResearch/hermes-plugins",
|
||||
"sha" to "0123456789abcdef0123456789abcdef01234567",
|
||||
"subdir" to "linear",
|
||||
"scan" to mapOf("status" to "warnings", "summary" to "1 advisory"),
|
||||
"requirements" to listOf("Hermes >=0.21", "LINEAR_API_KEY environment variable"),
|
||||
"platforms" to listOf("linux", ""),
|
||||
"target_profile" to "work",
|
||||
"required_env" to
|
||||
listOf(mapOf("name" to "LINEAR_API_KEY", "required" to false, "secret" to true)),
|
||||
),
|
||||
mapOf(
|
||||
"name" to "owner/skills/review",
|
||||
"kind" to "skill",
|
||||
"action" to "install",
|
||||
"state" to "connected",
|
||||
"skill" to "review",
|
||||
"scan" to mapOf("status" to "future-status"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
val (plugin, skill) = ConnectionOperationParser.parse(payload, "runtime-session")!!.targets
|
||||
|
||||
assertEquals(ConnectionTargetKind.PLUGIN, plugin.kind)
|
||||
val catalog = plugin.catalog!!
|
||||
assertEquals("Linear", catalog.display)
|
||||
assertEquals("official", catalog.tier)
|
||||
assertEquals("NousResearch/hermes-plugins", catalog.repo)
|
||||
assertEquals("0123456789abcdef0123456789abcdef01234567", catalog.sha)
|
||||
assertEquals("linear", catalog.subdir)
|
||||
assertEquals(CatalogScanStatus.WARNINGS, catalog.scan?.status)
|
||||
assertEquals("1 advisory", catalog.scan?.summary)
|
||||
assertEquals(2, catalog.requirements.size)
|
||||
assertEquals(listOf("linux"), catalog.platforms)
|
||||
assertEquals("work", catalog.targetProfile)
|
||||
assertEquals(listOf("LINEAR_API_KEY"), plugin.requiredEnv.map { it.name })
|
||||
|
||||
assertEquals(ConnectionTargetKind.SKILL, skill.kind)
|
||||
assertEquals("owner/skills/review", skill.catalog?.display)
|
||||
assertEquals("default", skill.catalog?.targetProfile)
|
||||
assertEquals("review", skill.catalog?.skill)
|
||||
assertEquals(CatalogScanStatus.UNKNOWN, skill.catalog?.scan?.status)
|
||||
assertNull(skill.catalog?.repo)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonCatalogTargets_carryNoCatalogFacts() {
|
||||
val targets = ConnectionOperationParser.parse(completePayload(), "runtime-session")!!.targets
|
||||
|
||||
assertTrue(targets.all { it.catalog == null })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformedIdentityOrDeadline_isRejected() {
|
||||
assertNull(ConnectionOperationParser.parse(completePayload() - "op_id", "runtime-session"))
|
||||
|
||||
@@ -44,7 +44,10 @@ class ConnectorRepositoryTest {
|
||||
val result = repo.listConnectors("sess-abc-123")
|
||||
assertTrue(result is ConnectorListResult.Success)
|
||||
assertEquals(WsMethods.CONNECTORS_LIST, recordedMethod)
|
||||
assertEquals(mapOf("session_id" to "sess-abc-123"), recordedParams)
|
||||
assertEquals(
|
||||
mapOf("owner" to mapOf("type" to "session", "session_id" to "sess-abc-123")),
|
||||
recordedParams,
|
||||
)
|
||||
// Verify no profile or singular slug mutations
|
||||
assertFalse(recordedParams?.containsKey("profile") == true)
|
||||
assertFalse(recordedParams?.containsKey("connector") == true)
|
||||
@@ -80,9 +83,14 @@ class ConnectorRepositoryTest {
|
||||
|
||||
assertTrue(result is ConnectorConnectResult.Success)
|
||||
assertEquals(WsMethods.CONNECTORS_CONNECT, recordedMethod)
|
||||
assertEquals("sess-456", recordedParams?.get("session_id"))
|
||||
assertEquals(listOf("linear", "github_app-1"), recordedParams?.get("connectors"))
|
||||
assertEquals(true, recordedParams?.get("reconnect"))
|
||||
assertEquals(
|
||||
mapOf(
|
||||
"owner" to mapOf("type" to "session", "session_id" to "sess-456"),
|
||||
"connectors" to listOf("linear", "github_app-1"),
|
||||
"reconnect" to true,
|
||||
),
|
||||
recordedParams,
|
||||
)
|
||||
|
||||
// Verify no singular slug key mutation
|
||||
assertFalse(recordedParams?.containsKey("connector") == true)
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
class PluginManageModelsTest {
|
||||
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types.
|
||||
@Test
|
||||
fun `PluginSettingField decodes and encodes string type correctly`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"key": "api_host",
|
||||
"type": "string",
|
||||
"label": "API Host",
|
||||
"description": "Hostname for API",
|
||||
"required": true,
|
||||
"value": "https://api.example.com",
|
||||
"default": "https://localhost",
|
||||
"choices": null,
|
||||
"env": "API_HOST",
|
||||
"has_value": true
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val field = OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
|
||||
assertEquals("api_host", field.key)
|
||||
assertEquals(PluginSettingFieldType.STRING, field.type)
|
||||
assertEquals("API Host", field.label)
|
||||
assertEquals("Hostname for API", field.description)
|
||||
assertTrue(field.required)
|
||||
assertEquals(JsonPrimitive("https://api.example.com"), field.value)
|
||||
assertEquals(JsonPrimitive("https://localhost"), field.default)
|
||||
assertNull(field.choices)
|
||||
assertEquals("API_HOST", field.env)
|
||||
assertEquals(true, field.hasValue)
|
||||
|
||||
val encoded = OkHttpProvider.json.encodeToString(field)
|
||||
val roundTripped = OkHttpProvider.json.decodeFromString<PluginSettingField>(encoded)
|
||||
assertEquals(field, roundTripped)
|
||||
}
|
||||
|
||||
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types.
|
||||
@Test
|
||||
fun `PluginSettingField decodes and encodes number type with numeric value and default`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"key": "port",
|
||||
"type": "number",
|
||||
"label": "Port Number",
|
||||
"value": 8080,
|
||||
"default": 3000
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val field = OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
|
||||
assertEquals("port", field.key)
|
||||
assertEquals(PluginSettingFieldType.NUMBER, field.type)
|
||||
assertEquals(JsonPrimitive(8080), field.value)
|
||||
assertEquals(JsonPrimitive(3000), field.default)
|
||||
assertEquals("", field.description)
|
||||
assertFalse(field.required)
|
||||
assertNull(field.choices)
|
||||
assertNull(field.env)
|
||||
assertNull(field.hasValue)
|
||||
}
|
||||
|
||||
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types.
|
||||
@Test
|
||||
fun `PluginSettingField decodes and encodes boolean type with default false`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"key": "enabled",
|
||||
"type": "boolean",
|
||||
"label": "Enabled",
|
||||
"value": true,
|
||||
"default": false
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val field = OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
|
||||
assertEquals("enabled", field.key)
|
||||
assertEquals(PluginSettingFieldType.BOOLEAN, field.type)
|
||||
assertEquals(JsonPrimitive(true), field.value)
|
||||
assertEquals(JsonPrimitive(false), field.default)
|
||||
}
|
||||
|
||||
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types.
|
||||
@Test
|
||||
fun `PluginSettingField decodes enum type with choices`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"key": "mode",
|
||||
"type": "enum",
|
||||
"label": "Execution Mode",
|
||||
"choices": ["fast", "balanced", "thorough"],
|
||||
"value": "fast",
|
||||
"default": "balanced"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val field = OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
|
||||
assertEquals("mode", field.key)
|
||||
assertEquals(PluginSettingFieldType.ENUM, field.type)
|
||||
assertEquals(listOf("fast", "balanced", "thorough"), field.choices)
|
||||
assertEquals(JsonPrimitive("fast"), field.value)
|
||||
assertEquals(JsonPrimitive("balanced"), field.default)
|
||||
}
|
||||
|
||||
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types.
|
||||
@Test
|
||||
fun `PluginSettingField decodes secret type with masked or null value and has_value flag`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"key": "api_token",
|
||||
"type": "secret",
|
||||
"label": "API Token",
|
||||
"value": null,
|
||||
"env": "SECRET_TOKEN",
|
||||
"has_value": true
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val field = OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
|
||||
assertEquals("api_token", field.key)
|
||||
assertEquals(PluginSettingFieldType.SECRET, field.type)
|
||||
assertEquals(null, field.value)
|
||||
assertEquals("SECRET_TOKEN", field.env)
|
||||
assertEquals(true, field.hasValue)
|
||||
}
|
||||
|
||||
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types.
|
||||
@Test
|
||||
fun `PluginSettingField decodes json type with object or array values`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"key": "extra_config",
|
||||
"type": "json",
|
||||
"label": "Extra Config",
|
||||
"value": {"timeout": 30, "retries": 3},
|
||||
"default": {}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val field = OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
|
||||
assertEquals("extra_config", field.key)
|
||||
assertEquals(PluginSettingFieldType.JSON, field.type)
|
||||
assertTrue(field.value is JsonObject)
|
||||
val valueObj = field.value as JsonObject
|
||||
assertEquals(JsonPrimitive(30), valueObj["timeout"])
|
||||
assertEquals(JsonPrimitive(3), valueObj["retries"])
|
||||
assertTrue(field.default is JsonObject)
|
||||
}
|
||||
|
||||
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types (error case).
|
||||
@Test
|
||||
fun `PluginSettingField decoding fails on unknown type value`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"key": "invalid_field",
|
||||
"type": "unknown_future_type",
|
||||
"label": "Invalid"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
try {
|
||||
OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
|
||||
fail("Expected SerializationException for unknown type")
|
||||
} catch (_: SerializationException) {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
|
||||
// CONTRACT: PluginServerState (CONNECTED, APP_NOT_RUNNING, ENDPOINT_UNAVAILABLE, VERSION_TOO_OLD, MISSING_APP, UNKNOWN).
|
||||
@Test
|
||||
fun `PluginServerState decodes all enum variants from snake_case strings`() {
|
||||
val expectedStates =
|
||||
mapOf(
|
||||
"connected" to PluginServerState.CONNECTED,
|
||||
"app_not_running" to PluginServerState.APP_NOT_RUNNING,
|
||||
"endpoint_unavailable" to PluginServerState.ENDPOINT_UNAVAILABLE,
|
||||
"no_interactive_session" to PluginServerState.NO_INTERACTIVE_SESSION,
|
||||
"version_too_old" to PluginServerState.VERSION_TOO_OLD,
|
||||
"missing_app" to PluginServerState.MISSING_APP,
|
||||
"unknown" to PluginServerState.UNKNOWN,
|
||||
)
|
||||
|
||||
for ((serialized, expectedEnum) in expectedStates) {
|
||||
val decoded = OkHttpProvider.json.decodeFromString<PluginServerState>("\"$serialized\"")
|
||||
assertEquals("Mismatch for $serialized", expectedEnum, decoded)
|
||||
val reEncoded = OkHttpProvider.json.encodeToString(expectedEnum)
|
||||
assertEquals("\"$serialized\"", reEncoded)
|
||||
}
|
||||
}
|
||||
|
||||
// CONTRACT: PluginServerState (CONNECTED, APP_NOT_RUNNING, ENDPOINT_UNAVAILABLE, VERSION_TOO_OLD, MISSING_APP, UNKNOWN) via PluginServerRow.
|
||||
@Test
|
||||
fun `PluginServerRow decodes name state and sentence correctly`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"name": "camofox_service",
|
||||
"state": "app_not_running",
|
||||
"sentence": "Camofox browser is not running on port 9333."
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val row = OkHttpProvider.json.decodeFromString<PluginServerRow>(jsonString)
|
||||
assertEquals("camofox_service", row.name)
|
||||
assertEquals(PluginServerState.APP_NOT_RUNNING, row.state)
|
||||
assertEquals("Camofox browser is not running on port 9333.", row.sentence)
|
||||
}
|
||||
|
||||
// CONTRACT: AgentPluginRow decoding from backend plugins.manage list payload shape.
|
||||
@Test
|
||||
fun `AgentPluginRow decodes full payload shape with servers and settings schema`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"name": "camofox",
|
||||
"key": "camofox-browser",
|
||||
"version": "1.2.0",
|
||||
"description": "Anti-detect browser automation",
|
||||
"source": "installed",
|
||||
"status": "active",
|
||||
"portable": true,
|
||||
"install_dir": "/home/user/.hermes/plugins/camofox",
|
||||
"has_desktop_half": true,
|
||||
"servers": [
|
||||
{
|
||||
"name": "cdp",
|
||||
"state": "connected",
|
||||
"sentence": "CDP connected"
|
||||
}
|
||||
],
|
||||
"settings_schema": [
|
||||
{
|
||||
"key": "port",
|
||||
"type": "number",
|
||||
"label": "CDP Port",
|
||||
"value": 9222
|
||||
}
|
||||
],
|
||||
"catalog_name": "camofox",
|
||||
"catalog_tier": "community",
|
||||
"installed_sha": "abc1234",
|
||||
"catalog_sha": "def5678",
|
||||
"catalog_version": "1.3.0",
|
||||
"update_available": true,
|
||||
"pinned_sha": "abc1234"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val row = OkHttpProvider.json.decodeFromString<AgentPluginRow>(jsonString)
|
||||
assertEquals("camofox", row.name)
|
||||
assertEquals("camofox-browser", row.key)
|
||||
assertEquals("1.2.0", row.version)
|
||||
assertEquals("Anti-detect browser automation", row.description)
|
||||
assertEquals("installed", row.source)
|
||||
assertEquals("status" to "active", "status" to row.status)
|
||||
assertTrue(row.portable)
|
||||
assertEquals("/home/user/.hermes/plugins/camofox", row.installDir)
|
||||
assertTrue(row.hasDesktopHalf)
|
||||
assertEquals(1, row.servers.size)
|
||||
assertEquals("cdp", row.servers[0].name)
|
||||
assertEquals(PluginServerState.CONNECTED, row.servers[0].state)
|
||||
assertEquals(1, row.settingsSchema?.size)
|
||||
assertEquals("port", row.settingsSchema?.get(0)?.key)
|
||||
assertEquals(PluginSettingFieldType.NUMBER, row.settingsSchema?.get(0)?.type)
|
||||
assertEquals("camofox", row.catalogName)
|
||||
assertEquals("community", row.catalogTier)
|
||||
assertEquals("abc1234", row.installedSha)
|
||||
assertEquals("def5678", row.catalogSha)
|
||||
assertEquals("1.3.0", row.catalogVersion)
|
||||
assertEquals(true, row.updateAvailable)
|
||||
assertEquals("abc1234", row.pinnedSha)
|
||||
}
|
||||
|
||||
// CONTRACT: AgentPluginRow decoding from backend plugins.manage list payload shape (defaults/nulls).
|
||||
@Test
|
||||
fun `AgentPluginRow decodes minimal required payload using defaults`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"name": "minimal_plugin"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val row = OkHttpProvider.json.decodeFromString<AgentPluginRow>(jsonString)
|
||||
assertEquals("minimal_plugin", row.name)
|
||||
assertNull(row.key)
|
||||
assertEquals("", row.version)
|
||||
assertEquals("", row.description)
|
||||
assertEquals("", row.source)
|
||||
assertEquals("", row.status)
|
||||
assertFalse(row.portable)
|
||||
assertEquals("", row.installDir)
|
||||
assertFalse(row.hasDesktopHalf)
|
||||
assertTrue(row.servers.isEmpty())
|
||||
assertNull(row.settingsSchema)
|
||||
assertNull(row.catalogName)
|
||||
assertNull(row.catalogTier)
|
||||
assertNull(row.installedSha)
|
||||
assertNull(row.catalogSha)
|
||||
assertNull(row.catalogVersion)
|
||||
assertNull(row.updateAvailable)
|
||||
assertNull(row.pinnedSha)
|
||||
}
|
||||
|
||||
// CONTRACT: PluginsManageSettingsResult decoding happy path and error cases.
|
||||
@Test
|
||||
fun `PluginsManageSettingsResult decodes success with updated plugin and written keys`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"ok": true,
|
||||
"name": "camofox",
|
||||
"written": ["port", "headless"],
|
||||
"plugin": {
|
||||
"name": "camofox",
|
||||
"version": "1.2.0"
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = OkHttpProvider.json.decodeFromString<PluginsManageSettingsResult>(jsonString)
|
||||
assertTrue(result.ok)
|
||||
assertEquals("camofox", result.name)
|
||||
assertEquals(listOf("port", "headless"), result.written)
|
||||
assertNotNull(result.plugin)
|
||||
assertEquals("camofox", result.plugin?.name)
|
||||
assertNull(result.error)
|
||||
}
|
||||
|
||||
// CONTRACT: PluginsManageSettingsResult decoding happy path and error cases.
|
||||
@Test
|
||||
fun `PluginsManageSettingsResult decodes failure result with error message`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"ok": false,
|
||||
"name": "camofox",
|
||||
"error": "Validation failed: port must be an integer"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = OkHttpProvider.json.decodeFromString<PluginsManageSettingsResult>(jsonString)
|
||||
assertFalse(result.ok)
|
||||
assertEquals("camofox", result.name)
|
||||
assertEquals("Validation failed: port must be an integer", result.error)
|
||||
assertNull(result.written)
|
||||
assertNull(result.plugin)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
class PluginManageRepositoryTest {
|
||||
// CONTRACT: `listPlugins(profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "list"} (and profile if set) and decodes plugins list.
|
||||
@Test
|
||||
fun `listPlugins without profile calls PLUGINS_MANAGE action list and decodes plugins list`() =
|
||||
runTest {
|
||||
var capturedMethod = ""
|
||||
var capturedParams: Map<String, Any?> = emptyMap()
|
||||
|
||||
val mockPayload =
|
||||
OkHttpProvider.json.parseToJsonElement(
|
||||
"""
|
||||
{
|
||||
"plugins": [
|
||||
{
|
||||
"name": "plugin-alpha",
|
||||
"version": "1.0.0",
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"name": "plugin-beta",
|
||||
"version": "2.1.0",
|
||||
"status": "disabled"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val repository =
|
||||
PluginManageRepository { method, params ->
|
||||
capturedMethod = method
|
||||
capturedParams = params
|
||||
mockPayload
|
||||
}
|
||||
|
||||
val plugins = repository.listPlugins()
|
||||
|
||||
assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod)
|
||||
assertEquals(mapOf("action" to "list"), capturedParams)
|
||||
assertEquals(2, plugins.size)
|
||||
assertEquals("plugin-alpha", plugins[0].name)
|
||||
assertEquals("1.0.0", plugins[0].version)
|
||||
assertEquals("active", plugins[0].status)
|
||||
assertEquals("plugin-beta", plugins[1].name)
|
||||
assertEquals("2.1.0", plugins[1].version)
|
||||
assertEquals("disabled", plugins[1].status)
|
||||
}
|
||||
|
||||
// CONTRACT: `listPlugins(profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "list"} (and profile if set) and decodes plugins list.
|
||||
@Test
|
||||
fun `listPlugins with non-blank profile includes profile parameter in request`() =
|
||||
runTest {
|
||||
var capturedMethod = ""
|
||||
var capturedParams: Map<String, Any?> = emptyMap()
|
||||
|
||||
val mockPayload =
|
||||
OkHttpProvider.json.parseToJsonElement(
|
||||
"""
|
||||
{
|
||||
"plugins": []
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val repository =
|
||||
PluginManageRepository { method, params ->
|
||||
capturedMethod = method
|
||||
capturedParams = params
|
||||
mockPayload
|
||||
}
|
||||
|
||||
val plugins = repository.listPlugins(profile = "work")
|
||||
|
||||
assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod)
|
||||
assertEquals(mapOf("action" to "list", "profile" to "work"), capturedParams)
|
||||
assertTrue(plugins.isEmpty())
|
||||
}
|
||||
|
||||
// CONTRACT: `listPlugins(profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "list"} (and profile if set) and decodes plugins list.
|
||||
@Test
|
||||
fun `listPlugins returns empty list when plugins key is missing or response is not a JsonObject`() =
|
||||
runTest {
|
||||
val emptyObjRepo =
|
||||
PluginManageRepository { _, _ ->
|
||||
OkHttpProvider.json.parseToJsonElement("{}")
|
||||
}
|
||||
assertTrue(emptyObjRepo.listPlugins().isEmpty())
|
||||
|
||||
val nonObjRepo =
|
||||
PluginManageRepository { _, _ ->
|
||||
OkHttpProvider.json.parseToJsonElement("[]")
|
||||
}
|
||||
assertTrue(nonObjRepo.listPlugins().isEmpty())
|
||||
|
||||
val nullRepo =
|
||||
PluginManageRepository { _, _ ->
|
||||
null
|
||||
}
|
||||
assertTrue(nullRepo.listPlugins().isEmpty())
|
||||
}
|
||||
|
||||
// CONTRACT: `saveSettings(key, values, profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "settings", "key": key, "values": values} and decodes result.
|
||||
@Test
|
||||
fun `saveSettings without profile calls PLUGINS_MANAGE with action settings key and JsonObject values`() =
|
||||
runTest {
|
||||
var capturedMethod = ""
|
||||
var capturedParams: Map<String, Any?> = emptyMap()
|
||||
|
||||
val mockResponse =
|
||||
OkHttpProvider.json.parseToJsonElement(
|
||||
"""
|
||||
{
|
||||
"ok": true,
|
||||
"name": "camofox",
|
||||
"written": ["port", "headless"],
|
||||
"plugin": {
|
||||
"name": "camofox",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val repository =
|
||||
PluginManageRepository { method, params ->
|
||||
capturedMethod = method
|
||||
capturedParams = params
|
||||
mockResponse
|
||||
}
|
||||
|
||||
val valuesToSave: Map<String, JsonElement> =
|
||||
mapOf(
|
||||
"port" to JsonPrimitive(9222),
|
||||
"headless" to JsonPrimitive(true),
|
||||
)
|
||||
|
||||
val result = repository.saveSettings(key = "camofox", values = valuesToSave)
|
||||
|
||||
assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod)
|
||||
assertEquals("settings", capturedParams["action"])
|
||||
assertEquals("camofox", capturedParams["key"])
|
||||
assertEquals(JsonObject(valuesToSave), capturedParams["values"])
|
||||
assertFalse(capturedParams.containsKey("profile"))
|
||||
|
||||
assertTrue(result.ok)
|
||||
assertEquals("camofox", result.name)
|
||||
assertEquals(listOf("port", "headless"), result.written)
|
||||
assertNotNull(result.plugin)
|
||||
assertEquals("camofox", result.plugin?.name)
|
||||
assertNull(result.error)
|
||||
}
|
||||
|
||||
// CONTRACT: `saveSettings(key, values, profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "settings", "key": key, "values": values} and decodes result.
|
||||
@Test
|
||||
fun `saveSettings with profile includes profile in request and handles error result`() =
|
||||
runTest {
|
||||
var capturedMethod = ""
|
||||
var capturedParams: Map<String, Any?> = emptyMap()
|
||||
|
||||
val mockResponse =
|
||||
OkHttpProvider.json.parseToJsonElement(
|
||||
"""
|
||||
{
|
||||
"ok": false,
|
||||
"name": "camofox",
|
||||
"error": "Port already in use"
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val repository =
|
||||
PluginManageRepository { method, params ->
|
||||
capturedMethod = method
|
||||
capturedParams = params
|
||||
mockResponse
|
||||
}
|
||||
|
||||
val valuesToSave: Map<String, JsonElement> =
|
||||
mapOf(
|
||||
"port" to JsonPrimitive(8080),
|
||||
)
|
||||
|
||||
val result = repository.saveSettings(key = "camofox", values = valuesToSave, profile = "staging")
|
||||
|
||||
assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod)
|
||||
assertEquals(
|
||||
mapOf(
|
||||
"action" to "settings",
|
||||
"key" to "camofox",
|
||||
"values" to JsonObject(valuesToSave),
|
||||
"profile" to "staging",
|
||||
),
|
||||
capturedParams,
|
||||
)
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("camofox", result.name)
|
||||
assertEquals("Port already in use", result.error)
|
||||
assertNull(result.written)
|
||||
assertNull(result.plugin)
|
||||
}
|
||||
|
||||
// CONTRACT: `saveSettings(key, values, profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "settings", "key": key, "values": values} and decodes result (error case).
|
||||
@Test
|
||||
fun `saveSettings throws SerializationException when response is malformed`() =
|
||||
runTest {
|
||||
val repository =
|
||||
PluginManageRepository { _, _ ->
|
||||
OkHttpProvider.json.parseToJsonElement("{\"ok\": \"not-a-boolean\"}")
|
||||
}
|
||||
|
||||
try {
|
||||
repository.saveSettings("camofox", emptyMap())
|
||||
fail("Expected SerializationException when response JSON is malformed")
|
||||
} catch (_: SerializationException) {
|
||||
// Expected
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,7 +289,7 @@ class WsProfileParamsTest {
|
||||
* home) without being listed here, this test fails — the set cannot drift.
|
||||
*
|
||||
* Methods that operate on an ALREADY-BOUND session (session.usage,
|
||||
* session.interrupt, session.branch, session.redirect,
|
||||
* session.interrupt, session.branch, session.branch_whole, session.redirect,
|
||||
* session.context_breakdown, prompt.submit, slash/command dispatch,
|
||||
* approval/clarify/secret/sudo responses, file attach, process.*,
|
||||
* subscription.*, commands_catalog, usage.bars) resolve via the
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.app.Application
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.HermesDatabase
|
||||
import com.m57.hermescontrol.data.model.CompressionSummary
|
||||
import com.m57.hermescontrol.data.model.SessionCompressResponse
|
||||
import com.m57.hermescontrol.data.model.SessionMessage
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
|
||||
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ChatCompressRpcTest {
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val mockEventsFlow = MutableSharedFlow<WsEvent>(extraBufferCapacity = 64)
|
||||
private val mockConnectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED)
|
||||
private lateinit var app: Application
|
||||
private lateinit var fakeRepo: FakeChatPersistenceRepository
|
||||
private var reqCount = 0
|
||||
private var sessionCreateRequestId: String? = null
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
reqCount = 0
|
||||
sessionCreateRequestId = null
|
||||
|
||||
mockkStatic(android.util.Log::class)
|
||||
every { android.util.Log.d(any(), any()) } returns 0
|
||||
every { android.util.Log.i(any(), any()) } returns 0
|
||||
every { android.util.Log.w(any(), any<String>()) } returns 0
|
||||
every { android.util.Log.e(any(), any(), any()) } returns 0
|
||||
|
||||
mockkObject(AuthManager)
|
||||
every { AuthManager.getPinnedModels() } returns emptyList()
|
||||
every { AuthManager.dataScopeFlow } returns MutableStateFlow<DataScope?>(null)
|
||||
every { AuthManager.activeProfileId } returns MutableStateFlow<String?>(null)
|
||||
every { AuthManager.currentDataScope() } returns null
|
||||
every { AuthManager.getToken() } returns "test-token"
|
||||
every { AuthManager.getBaseUrl() } returns "http://test.local/"
|
||||
every { AuthManager.getSelectedProfileId() } returns null
|
||||
every { AuthManager.getBusySendMode() } returns com.m57.hermescontrol.data.model.BusySendMode.CORRECT
|
||||
every { AuthManager.isTypingEffectEnabled() } returns true
|
||||
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
||||
every { AuthManager.isMessageStatsEnabled() } returns false
|
||||
every { AuthManager.isUserMessageTokensEnabled() } returns true
|
||||
every { AuthManager.isAssistantMessageTokensEnabled() } returns true
|
||||
every { AuthManager.isTokensPerSecondEnabled() } returns true
|
||||
every { AuthManager.isModelProviderShown() } returns false
|
||||
every { AuthManager.isAutoReconnect() } returns false
|
||||
every { AuthManager.isRestoreLastSession() } returns false
|
||||
every { AuthManager.getLastOpenedSessionId() } returns null
|
||||
every { AuthManager.setLastOpenedSessionId(any()) } returns Unit
|
||||
every { AuthManager.clearLastOpenedSessionId() } returns Unit
|
||||
|
||||
mockkObject(HermesWsClient)
|
||||
every { HermesWsClient.events } returns mockEventsFlow
|
||||
every { HermesWsClient.connectionStatus } returns mockConnectionStatus
|
||||
every { HermesWsClient.connect() } answers {
|
||||
mockConnectionStatus.value = ConnectionStatus.CONNECTING
|
||||
}
|
||||
every { HermesWsClient.disconnect() } returns Unit
|
||||
|
||||
every { HermesWsClient.send(any(), any(), any()) } answers {
|
||||
reqCount++
|
||||
val id = "req-id-$reqCount"
|
||||
if (arg<String>(0) == WsMethods.SESSION_CREATE) {
|
||||
sessionCreateRequestId = id
|
||||
}
|
||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||
id
|
||||
}
|
||||
every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers {
|
||||
reqCount++
|
||||
val id = "req-msg-$reqCount"
|
||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||
id
|
||||
}
|
||||
every { HermesWsClient.request(any(), any(), any()) } answers {
|
||||
HermesWsClient.send(arg(0), arg(1)) {}
|
||||
CompletableDeferred<Any?>(Unit)
|
||||
}
|
||||
|
||||
mockkObject(ApiClient)
|
||||
coEvery { ApiClient.hermesApi.getModelInfo() } returns retrofit2.Response.success(mockk(relaxed = true))
|
||||
coEvery { ApiClient.hermesApi.getProfiles() } returns
|
||||
retrofit2.Response.success(
|
||||
com.m57.hermescontrol.data.model
|
||||
.ProfilesResponse(emptyList()),
|
||||
)
|
||||
|
||||
mockkObject(HermesDatabase)
|
||||
mockkObject(ProfileSwitchCoordinator)
|
||||
every { ProfileSwitchCoordinator.switched } returns MutableSharedFlow<String>()
|
||||
every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow<String>()
|
||||
|
||||
app = mockk(relaxed = true)
|
||||
fakeRepo = FakeChatPersistenceRepository()
|
||||
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
private suspend fun TestScope.createViewModelWithSession(): Pair<ChatViewModel, String> {
|
||||
val vm =
|
||||
ChatViewModel(
|
||||
app,
|
||||
startCleanup = false,
|
||||
repo = fakeRepo,
|
||||
slashUsageStore = FakeSlashUsageStore(),
|
||||
searchDispatcher = testDispatcher,
|
||||
ioDispatcher = testDispatcher,
|
||||
historyDispatcher = testDispatcher,
|
||||
sendStore = ChatSendStore(),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
mockConnectionStatus.value = ConnectionStatus.CONNECTED
|
||||
mockEventsFlow.emit(WsEvent.GatewayReady(null))
|
||||
advanceUntilIdle()
|
||||
val createRequestId = requireNotNull(sessionCreateRequestId) { "session.create was not sent" }
|
||||
mockEventsFlow.emit(WsEvent.RpcResult(createRequestId, mapOf("session_id" to "session-xyz")))
|
||||
advanceUntilIdle()
|
||||
return Pair(vm, "session-xyz")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compress calls SESSION_COMPRESS not command dispatch or slash exec`() =
|
||||
runTest {
|
||||
val (vm, _) = createViewModelWithSession()
|
||||
|
||||
val methodCalls = mutableListOf<String>()
|
||||
every {
|
||||
HermesWsClient.request(capture(methodCalls), any(), any())
|
||||
} answers {
|
||||
CompletableDeferred<Any?>(null)
|
||||
}
|
||||
|
||||
vm.sendMessage("/compress")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue("expected SESSION_COMPRESS in $methodCalls", WsMethods.SESSION_COMPRESS in methodCalls)
|
||||
assertTrue("expected NO command.dispatch", WsMethods.COMMAND_DISPATCH !in methodCalls)
|
||||
assertTrue("expected NO slash.exec", "slash.exec" !in methodCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compress request parameters contain session_id and omit focus_topic when blank`() =
|
||||
runTest {
|
||||
val (vm, sessionId) = createViewModelWithSession()
|
||||
|
||||
val methodCalls = mutableListOf<String>()
|
||||
val paramsCalls = mutableListOf<Map<String, Any>>()
|
||||
every {
|
||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
||||
} answers {
|
||||
CompletableDeferred<Any?>(null)
|
||||
}
|
||||
|
||||
vm.sendMessage("/compress")
|
||||
advanceUntilIdle()
|
||||
|
||||
val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS)
|
||||
assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0)
|
||||
val params = paramsCalls[compressIndex]
|
||||
assertEquals(sessionId, params["session_id"])
|
||||
assertFalse("focus_topic should be omitted when blank", params.containsKey("focus_topic"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compress request parameters forward focus_topic when non-blank`() =
|
||||
runTest {
|
||||
val (vm, sessionId) = createViewModelWithSession()
|
||||
|
||||
val methodCalls = mutableListOf<String>()
|
||||
val paramsCalls = mutableListOf<Map<String, Any>>()
|
||||
every {
|
||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
||||
} answers {
|
||||
CompletableDeferred<Any?>(null)
|
||||
}
|
||||
|
||||
vm.sendMessage("/compress auth decisions")
|
||||
advanceUntilIdle()
|
||||
|
||||
val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS)
|
||||
assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0)
|
||||
val params = paramsCalls[compressIndex]
|
||||
assertEquals(sessionId, params["session_id"])
|
||||
assertEquals("auth decisions", params["focus_topic"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compact behaves identically to compress omitting focus_topic when blank`() =
|
||||
runTest {
|
||||
val (vm, sessionId) = createViewModelWithSession()
|
||||
|
||||
val methodCalls = mutableListOf<String>()
|
||||
val paramsCalls = mutableListOf<Map<String, Any>>()
|
||||
every {
|
||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
||||
} answers {
|
||||
CompletableDeferred<Any?>(null)
|
||||
}
|
||||
|
||||
vm.sendMessage("/compact")
|
||||
advanceUntilIdle()
|
||||
|
||||
val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS)
|
||||
assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0)
|
||||
val params = paramsCalls[compressIndex]
|
||||
assertEquals(sessionId, params["session_id"])
|
||||
assertFalse("focus_topic should be omitted when blank", params.containsKey("focus_topic"))
|
||||
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compact forwards focus_topic when non-blank`() =
|
||||
runTest {
|
||||
val (vm, sessionId) = createViewModelWithSession()
|
||||
|
||||
val methodCalls = mutableListOf<String>()
|
||||
val paramsCalls = mutableListOf<Map<String, Any>>()
|
||||
every {
|
||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
||||
} answers {
|
||||
CompletableDeferred<Any?>(null)
|
||||
}
|
||||
|
||||
vm.sendMessage("/compact architecture details")
|
||||
advanceUntilIdle()
|
||||
|
||||
val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS)
|
||||
assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0)
|
||||
val params = paramsCalls[compressIndex]
|
||||
assertEquals(sessionId, params["session_id"])
|
||||
assertEquals("architecture details", params["focus_topic"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `final response summary headline token_line note added to messages`() =
|
||||
runTest {
|
||||
val (vm, _) = createViewModelWithSession()
|
||||
|
||||
val responseJson =
|
||||
buildJsonObject {
|
||||
put("status", JsonPrimitive("ok"))
|
||||
put(
|
||||
"summary",
|
||||
buildJsonObject {
|
||||
put("headline", JsonPrimitive("Context compressed"))
|
||||
put("token_line", JsonPrimitive("Tokens: 120k -> 35k (70% saved)"))
|
||||
put("note", JsonPrimitive("Trimmed 14 earlier turns"))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
every {
|
||||
HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any())
|
||||
} answers {
|
||||
CompletableDeferred<Any?>(responseJson)
|
||||
}
|
||||
|
||||
vm.sendMessage("/compress")
|
||||
advanceUntilIdle()
|
||||
|
||||
val lastAssistantMessage =
|
||||
vm.uiState.value.messages
|
||||
.lastOrNull { it.role == MessageRole.ASSISTANT }
|
||||
val expectedContent = "Context compressed\nTokens: 120k -> 35k (70% saved)\nTrimmed 14 earlier turns"
|
||||
assertEquals(expectedContent, lastAssistantMessage?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returned messages replace and reconcile visible live transcript`() =
|
||||
runTest {
|
||||
val (vm, _) = createViewModelWithSession()
|
||||
|
||||
val serverMessages =
|
||||
listOf(
|
||||
SessionMessage(
|
||||
id = 101,
|
||||
role = "user",
|
||||
content = JsonPrimitive("Original prompt"),
|
||||
),
|
||||
SessionMessage(
|
||||
id = 102,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("Compressed response"),
|
||||
),
|
||||
)
|
||||
|
||||
val responseJson =
|
||||
buildJsonObject {
|
||||
put("status", JsonPrimitive("ok"))
|
||||
put(
|
||||
"summary",
|
||||
buildJsonObject {
|
||||
put("headline", JsonPrimitive("Summary headline"))
|
||||
},
|
||||
)
|
||||
put(
|
||||
"messages",
|
||||
buildJsonArray {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("id", JsonPrimitive(101))
|
||||
put("role", JsonPrimitive("user"))
|
||||
put("content", JsonPrimitive("Original prompt"))
|
||||
},
|
||||
)
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("id", JsonPrimitive(102))
|
||||
put("role", JsonPrimitive("assistant"))
|
||||
put("content", JsonPrimitive("Compressed response"))
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
every {
|
||||
HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any())
|
||||
} answers {
|
||||
CompletableDeferred<Any?>(responseJson)
|
||||
}
|
||||
|
||||
vm.sendMessage("/compress")
|
||||
advanceUntilIdle()
|
||||
|
||||
val messages = vm.uiState.value.messages
|
||||
// The transcript contains serverMessages, plus the assistant summary feedback
|
||||
val userMsg = messages.firstOrNull { it.role == MessageRole.USER && it.content == "Original prompt" }
|
||||
assertEquals("Original prompt", userMsg?.content)
|
||||
val serverAssistantMsg =
|
||||
messages.firstOrNull {
|
||||
it.role == MessageRole.ASSISTANT && it.content == "Compressed response"
|
||||
}
|
||||
assertTrue("expected replacement assistant message", serverAssistantMsg != null)
|
||||
val summaryMsg = messages.lastOrNull { it.role == MessageRole.ASSISTANT }
|
||||
assertEquals("Summary headline", summaryMsg?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetchContextUsage is triggered after successful compression`() =
|
||||
runTest {
|
||||
val (vm, sessionId) = createViewModelWithSession()
|
||||
|
||||
val methodCalls = mutableListOf<String>()
|
||||
every {
|
||||
HermesWsClient.request(capture(methodCalls), any(), any())
|
||||
} answers {
|
||||
val method = arg<String>(0)
|
||||
if (method == WsMethods.SESSION_COMPRESS) {
|
||||
val responseJson =
|
||||
buildJsonObject {
|
||||
put("status", JsonPrimitive("ok"))
|
||||
put("message", JsonPrimitive("Context compressed."))
|
||||
}
|
||||
CompletableDeferred<Any?>(responseJson)
|
||||
} else {
|
||||
CompletableDeferred<Any?>(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
methodCalls.clear()
|
||||
|
||||
vm.sendMessage("/compress")
|
||||
advanceUntilIdle()
|
||||
|
||||
// fetchContextUsage calls SESSION_CONTEXT_BREAKDOWN and SESSION_USAGE with sessionId
|
||||
assertTrue(
|
||||
"expected SESSION_CONTEXT_BREAKDOWN in $methodCalls",
|
||||
WsMethods.SESSION_CONTEXT_BREAKDOWN in methodCalls,
|
||||
)
|
||||
assertTrue(
|
||||
"expected SESSION_USAGE in $methodCalls",
|
||||
WsMethods.SESSION_USAGE in methodCalls,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isCompressing is reset to false on success`() =
|
||||
runTest {
|
||||
val (vm, _) = createViewModelWithSession()
|
||||
|
||||
val deferred = CompletableDeferred<Any?>()
|
||||
every {
|
||||
HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any())
|
||||
} returns deferred
|
||||
|
||||
vm.sendMessage("/compress")
|
||||
// Progress before completion
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
assertTrue("isCompressing should be true while in flight", vm.uiState.value.isCompressing)
|
||||
assertEquals("⏳ Compressing context...", vm.uiState.value.compressionStatus)
|
||||
|
||||
deferred.complete(
|
||||
buildJsonObject {
|
||||
put("status", JsonPrimitive("ok"))
|
||||
},
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertFalse("isCompressing should be reset to false", vm.uiState.value.isCompressing)
|
||||
assertEquals(null, vm.uiState.value.compressionStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isCompressing is reset to false on RPC error`() =
|
||||
runTest {
|
||||
val (vm, _) = createViewModelWithSession()
|
||||
|
||||
val deferred = CompletableDeferred<Any?>()
|
||||
every {
|
||||
HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any())
|
||||
} returns deferred
|
||||
|
||||
vm.sendMessage("/compress")
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
assertTrue(vm.uiState.value.isCompressing)
|
||||
|
||||
deferred.completeExceptionally(
|
||||
HermesWsClient.HermesRpcException("compression failed on backend", 4018),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertFalse("isCompressing should be false on RPC error", vm.uiState.value.isCompressing)
|
||||
assertEquals(null, vm.uiState.value.compressionStatus)
|
||||
val errorMsg =
|
||||
vm.uiState.value.messages
|
||||
.lastOrNull { it.role == MessageRole.ASSISTANT }
|
||||
assertTrue(errorMsg?.content?.contains("compression failed on backend") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isCompressing is reset to false on generic exception`() =
|
||||
runTest {
|
||||
val (vm, _) = createViewModelWithSession()
|
||||
|
||||
val deferred = CompletableDeferred<Any?>()
|
||||
every {
|
||||
HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any())
|
||||
} returns deferred
|
||||
|
||||
vm.sendMessage("/compress")
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
assertTrue(vm.uiState.value.isCompressing)
|
||||
|
||||
deferred.completeExceptionally(RuntimeException("network disconnected"))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertFalse("isCompressing should be false on generic exception", vm.uiState.value.isCompressing)
|
||||
assertEquals(null, vm.uiState.value.compressionStatus)
|
||||
val errorMsg =
|
||||
vm.uiState.value.messages
|
||||
.lastOrNull { it.role == MessageRole.ASSISTANT }
|
||||
assertTrue(errorMsg?.content?.contains("network disconnected") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate compress while isCompressing is true is a no-op`() =
|
||||
runTest {
|
||||
val (vm, _) = createViewModelWithSession()
|
||||
|
||||
var compressCalls = 0
|
||||
val deferred = CompletableDeferred<Any?>()
|
||||
every {
|
||||
HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any())
|
||||
} answers {
|
||||
compressCalls++
|
||||
deferred
|
||||
}
|
||||
|
||||
vm.sendMessage("/compress")
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
assertTrue(vm.uiState.value.isCompressing)
|
||||
assertEquals(1, compressCalls)
|
||||
|
||||
// Second invocation while in flight
|
||||
vm.sendMessage("/compress")
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
assertEquals("duplicate call should not trigger another RPC", 1, compressCalls)
|
||||
|
||||
// Also check /compact is blocked while in flight
|
||||
vm.sendMessage("/compact")
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
assertEquals("compact while compressing should not trigger another RPC", 1, compressCalls)
|
||||
|
||||
deferred.complete(null)
|
||||
advanceUntilIdle()
|
||||
assertFalse(vm.uiState.value.isCompressing)
|
||||
}
|
||||
}
|
||||
+58
-3
@@ -13,6 +13,8 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
@@ -109,7 +111,8 @@ class ChatConnectionOperationDelegateTest {
|
||||
assertEquals(1, requester.calls.size)
|
||||
val call = requester.calls.single()
|
||||
assertEquals(WsMethods.CONNECTION_RESPOND, call.first)
|
||||
assertEquals("session-a", call.second["session_id"])
|
||||
assertEquals(setOf("owner", "op_id", "result"), call.second.keys)
|
||||
assertEquals(mapOf("type" to "session", "session_id" to "session-a"), call.second["owner"])
|
||||
assertEquals("op-a", call.second["op_id"])
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val result = call.second["result"] as Map<String, Any>
|
||||
@@ -177,11 +180,63 @@ class ChatConnectionOperationDelegateTest {
|
||||
continueDelegate.continueOperation()
|
||||
|
||||
assertEquals(
|
||||
mapOf("settled_by" to "continue"),
|
||||
continueRequester.calls.single().second["result"],
|
||||
mapOf(
|
||||
"owner" to mapOf("type" to "session", "session_id" to "session-a"),
|
||||
"op_id" to "op-a",
|
||||
"result" to mapOf("settled_by" to "continue"),
|
||||
),
|
||||
continueRequester.calls.single().second,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wakePayload_sendsSessionOwnerNotTopLevelSessionId() =
|
||||
runTest {
|
||||
val requester = RecordingRequester()
|
||||
val delegate = delegate(requester)
|
||||
delegate.reset("session-a")
|
||||
delegate.acceptRequest(snapshot())
|
||||
|
||||
delegate.wake("op-a")
|
||||
|
||||
val call = requester.calls.single()
|
||||
assertEquals(WsMethods.CONNECTORS_OPERATION_WAKE, call.first)
|
||||
assertEquals(
|
||||
mapOf(
|
||||
"owner" to mapOf("type" to "session", "session_id" to "session-a"),
|
||||
"op_id" to "op-a",
|
||||
),
|
||||
call.second,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ownershipAndRuntimeFailures_mapToTypedNonRetryableErrors() =
|
||||
runTest {
|
||||
val cases =
|
||||
listOf(
|
||||
HermesWsClient.HermesRpcException("x", code = 4001) to
|
||||
ChatConnectionOperationDelegate.NOT_OWNER,
|
||||
HermesWsClient.HermesRpcException(
|
||||
"x",
|
||||
code = 5033,
|
||||
data = JsonObject(mapOf("reason" to JsonPrimitive("UNSUPPORTED_RUNTIME"))),
|
||||
) to ChatConnectionOperationDelegate.UNSUPPORTED_RUNTIME,
|
||||
)
|
||||
for ((failure, expected) in cases) {
|
||||
val delegate = delegate(RecordingRequester(failure = failure))
|
||||
delegate.reset("session-a")
|
||||
delegate.acceptRequest(snapshot())
|
||||
|
||||
delegate.continueOperation()
|
||||
|
||||
val error = delegate.state.value.error
|
||||
assertEquals(expected, error?.message)
|
||||
assertFalse(error?.retryable ?: true)
|
||||
assertNull(delegate.state.value.pendingAction)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rpcFailureUnlocksWithSanitizedError() =
|
||||
runTest {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.model.SessionMessage
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
/** Issue #1284: history rows carry display_commentary / display_reasoning projections. */
|
||||
class ChatHistoryCommentaryTest {
|
||||
private fun row(
|
||||
content: String,
|
||||
reasoning: String? = null,
|
||||
commentary: List<String>? = null,
|
||||
displayReasoning: String? = null,
|
||||
id: Int = 7,
|
||||
) = SessionMessage(
|
||||
id = id,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive(content),
|
||||
reasoning = reasoning?.let { JsonPrimitive(it) },
|
||||
display_commentary = commentary?.let { list -> JsonArray(list.map { JsonPrimitive(it) }) },
|
||||
display_reasoning = displayReasoning?.let { JsonPrimitive(it) as JsonElement },
|
||||
)
|
||||
|
||||
private fun map(vararg rows: SessionMessage) = mapServerMessages("s", rows.toList(), 0, true, emptyList())
|
||||
|
||||
private fun countOf(
|
||||
haystack: String,
|
||||
needle: String,
|
||||
) = haystack.windowed(needle.length).count { it == needle }
|
||||
|
||||
@Test
|
||||
fun commentaryRendersOnceBeforeReplyAndLeavesReasoning() {
|
||||
val commentary = "I will inspect the files."
|
||||
val message =
|
||||
map(
|
||||
row(
|
||||
content = "Done: two files changed.",
|
||||
reasoning = "Private plan.\n\n$commentary",
|
||||
commentary = listOf(commentary),
|
||||
displayReasoning = "Private plan.",
|
||||
),
|
||||
).single()
|
||||
|
||||
assertEquals("$commentary\n\nDone: two files changed.", message.content)
|
||||
assertEquals("Private plan.", message.reasoningText)
|
||||
assertEquals(1, countOf(message.content + message.reasoningText, commentary))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyDisplayReasoningWinsOverRawReasoning() {
|
||||
val commentary = "Checking."
|
||||
val message =
|
||||
map(row(content = "", reasoning = commentary, commentary = listOf(commentary), displayReasoning = ""))
|
||||
.single()
|
||||
|
||||
assertEquals(commentary, message.content)
|
||||
assertEquals("", message.reasoningText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun commentaryEqualToReplyIsNotDuplicated() {
|
||||
val text = "Looking at it now."
|
||||
val message = map(row(content = text, commentary = listOf(text), displayReasoning = "")).single()
|
||||
|
||||
assertEquals(text, message.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disabledCommentaryKeepsItOutOfTranscriptAndReasoning() {
|
||||
// Backend policy off: empty list, display_reasoning still strips the raw copy.
|
||||
val message =
|
||||
map(
|
||||
row(
|
||||
content = "Answer",
|
||||
reasoning = "Plan.\nSecret interim",
|
||||
commentary = emptyList(),
|
||||
displayReasoning = "Plan.",
|
||||
),
|
||||
).single()
|
||||
|
||||
assertEquals("Answer", message.content)
|
||||
assertEquals("Plan.", message.reasoningText)
|
||||
assertFalse(message.content.contains("Secret interim"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun olderGatewayFallsBackToRawReasoning() {
|
||||
val message = map(row(content = "Answer", reasoning = "Raw trace")).single()
|
||||
|
||||
assertEquals("Answer", message.content)
|
||||
assertEquals("Raw trace", message.reasoningText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun liveSealedCommentaryBubbleIsCoveredByRestRowNotDuplicated() {
|
||||
val commentary = "I will inspect the files."
|
||||
val liveOrphan = ChatMessage(id = "live-orphan", role = MessageRole.ASSISTANT, content = commentary)
|
||||
val rest =
|
||||
map(row(content = "", reasoning = commentary, commentary = listOf(commentary), displayReasoning = ""))
|
||||
|
||||
val merged = mergeTranscriptWithLive(rest, listOf(liveOrphan), preserveLiveIds = true)
|
||||
|
||||
assertEquals(1, merged.count { it.content.contains(commentary) })
|
||||
assertEquals("", merged.single().reasoningText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleCommentaryItemsKeepOrderAndSkipBlanks() {
|
||||
val message =
|
||||
map(row(content = "Final", commentary = listOf("First.", " ", "Second."), displayReasoning = "")).single()
|
||||
|
||||
assertEquals("First.\n\nSecond.\n\nFinal", message.content)
|
||||
}
|
||||
}
|
||||
@@ -316,7 +316,7 @@ class ChatPagingMergeTest {
|
||||
|
||||
val mapped = mapServerMessages("s", listOf(serverTool(42, "call-42")), 0, true, listOf(live, echo))
|
||||
|
||||
assertEquals(listOf(live.copy(restId = "rest-s-42")), mapped)
|
||||
assertEquals(listOf(live.copy(restId = "rest-s-42", serverRowId = 42L)), mapped)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -328,7 +328,10 @@ class ChatPagingMergeTest {
|
||||
val mapped = mapServerMessages("s", listOf(serverTool(42), serverTool(43)), 0, true, cache)
|
||||
|
||||
assertEquals(
|
||||
listOf(first.copy(restId = "rest-s-42"), second.copy(restId = "rest-s-43")),
|
||||
listOf(
|
||||
first.copy(restId = "rest-s-42", serverRowId = 42L),
|
||||
second.copy(restId = "rest-s-43", serverRowId = 43L),
|
||||
),
|
||||
mapped,
|
||||
)
|
||||
assertEquals(2, mapped.map { it.id }.toSet().size)
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** #1285: persisted-turn receipts are addresses, never negative acknowledgements. */
|
||||
class ChatPersistedReceiptsTest {
|
||||
@Test
|
||||
fun parsesCompleteReceiptWithMixedNumberTypes() {
|
||||
val turn =
|
||||
parsePersistedTurn(
|
||||
mapOf(
|
||||
"persisted_turn" to
|
||||
mapOf(
|
||||
"row_ids" to listOf(41, 42L, 43.0),
|
||||
"complete" to true,
|
||||
"user_row_id" to 41,
|
||||
"final_assistant_row_id" to 43L,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(PersistedTurn(true, 41L, 43L), turn)
|
||||
assertEquals(
|
||||
43L,
|
||||
parsePersistedTurn(mapOf("persisted_turn" to mapOf("final_assistant_row_id" to 43.0)))?.finalAssistantRowId,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingIdsStayUnprovenAndMalformedValuesAreIgnored() {
|
||||
val turn =
|
||||
parsePersistedTurn(
|
||||
mapOf(
|
||||
"persisted_turn" to
|
||||
mapOf("complete" to "true", "user_row_id" to true, "final_assistant_row_id" to 1.5),
|
||||
),
|
||||
)!!
|
||||
|
||||
assertFalse(turn.complete)
|
||||
assertNull(positiveRowId("8"))
|
||||
assertNull(positiveRowId(0))
|
||||
assertNull(positiveRowId(-1L))
|
||||
assertNull(turn.userRowId)
|
||||
assertNull(turn.finalAssistantRowId)
|
||||
assertNull(parsePersistedTurn(mapOf("text" to "done")))
|
||||
assertNull(parsePersistedTurn(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rowIdMatchesUserEchoEvenWhenServerTextDiffers() {
|
||||
val local = ChatMessage(id = "local", role = MessageRole.USER, content = "hi", serverRowId = 42)
|
||||
val rest =
|
||||
ChatMessage(id = "rest-s-42", role = MessageRole.USER, content = "hi\n\n--- rewritten", serverRowId = 42)
|
||||
|
||||
val merged = mergeTranscriptWithLive(listOf(rest), listOf(local), preserveLiveIds = true)
|
||||
|
||||
assertEquals(listOf("local"), merged.map { it.id })
|
||||
assertEquals("rest-s-42", merged.single().restId)
|
||||
assertEquals(42L, merged.single().serverRowId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun differentRowIdsKeepRepeatedTextAsSeparateTurns() {
|
||||
val local = ChatMessage(id = "local", role = MessageRole.USER, content = "again", serverRowId = 43)
|
||||
val older = ChatMessage(id = "rest-s-42", role = MessageRole.USER, content = "again", serverRowId = 42)
|
||||
|
||||
val merged = mergeTranscriptWithLive(listOf(older), listOf(local), preserveLiveIds = true)
|
||||
|
||||
assertEquals(setOf("local", "rest-s-42"), merged.map { it.id }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingRowIdFallsBackToContentMatching() {
|
||||
val local = ChatMessage(id = "local", role = MessageRole.USER, content = "hello")
|
||||
val rest = ChatMessage(id = "rest-s-9", role = MessageRole.USER, content = "hello", serverRowId = 9)
|
||||
|
||||
val merged = mergeTranscriptWithLive(listOf(rest), listOf(local), preserveLiveIds = true)
|
||||
|
||||
assertEquals(listOf("local"), merged.map { it.id })
|
||||
assertEquals(9L, merged.single().serverRowId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completeReceiptBindsFinalAssistantRow() {
|
||||
val result =
|
||||
complete(
|
||||
"answer",
|
||||
mapOf(
|
||||
"row_ids" to listOf(1, 2),
|
||||
"complete" to true,
|
||||
"final_assistant_row_id" to 2,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
2L,
|
||||
result.state.messages
|
||||
.single()
|
||||
.serverRowId,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun partialReceiptStillBindsProvenFinalRowButKeepsLocalReply() {
|
||||
val result =
|
||||
complete("answer", mapOf("row_ids" to listOf(2), "complete" to false, "final_assistant_row_id" to 2))
|
||||
|
||||
assertEquals(
|
||||
"answer",
|
||||
result.state.messages
|
||||
.single()
|
||||
.content,
|
||||
)
|
||||
assertEquals(
|
||||
2L,
|
||||
result.state.messages
|
||||
.single()
|
||||
.serverRowId,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun receiptWithoutFinalIdLeavesReplyUnbound() {
|
||||
val result = complete("answer", mapOf("row_ids" to listOf(1), "complete" to false, "user_row_id" to 1))
|
||||
|
||||
assertEquals(
|
||||
"answer",
|
||||
result.state.messages
|
||||
.single()
|
||||
.content,
|
||||
)
|
||||
assertNull(
|
||||
result.state.messages
|
||||
.single()
|
||||
.serverRowId,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun strippedCommentaryPrefixDoesNotClaimFinalRow() {
|
||||
val sealed = ChatMessage(id = "sealed", role = MessageRole.ASSISTANT, content = "Looking. ")
|
||||
val result =
|
||||
ChatWsEventReducer.reduce(
|
||||
state = ChatUiState(currentSessionId = "session-1", messages = listOf(sealed)),
|
||||
streamingState = StreamingState(sealedOrphanIds = listOf("sealed")),
|
||||
event =
|
||||
WsEvent.MessageComplete(
|
||||
"Looking. Done",
|
||||
"session-1",
|
||||
rawPayload =
|
||||
mapOf(
|
||||
"persisted_turn" to
|
||||
mapOf("row_ids" to listOf(5), "complete" to true, "final_assistant_row_id" to 5),
|
||||
),
|
||||
),
|
||||
currentSessionId = "session-1",
|
||||
)
|
||||
|
||||
val reply = result.state.messages.single { it.id != "sealed" }
|
||||
assertEquals("Done", reply.content)
|
||||
assertNull(reply.serverRowId)
|
||||
assertTrue(result.state.messages.none { it.serverRowId == 5L })
|
||||
}
|
||||
|
||||
private fun complete(
|
||||
text: String,
|
||||
receipt: Map<String, Any?>,
|
||||
) = ChatWsEventReducer.reduce(
|
||||
state = ChatUiState(currentSessionId = "session-1"),
|
||||
streamingState = StreamingState(),
|
||||
event = WsEvent.MessageComplete(text, "session-1", rawPayload = mapOf("persisted_turn" to receipt)),
|
||||
currentSessionId = "session-1",
|
||||
)
|
||||
}
|
||||
@@ -64,6 +64,28 @@ class ChatPromptProtocolTest {
|
||||
assertEquals("new answer", answers?.get("q1")?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun legacyClarifyBatchReplay_skipsLockedAnswers_withoutEmptyOverwrite() {
|
||||
val responses =
|
||||
buildLegacyClarifyBatchResponses(
|
||||
questions =
|
||||
listOf(
|
||||
ClarifyQuestionUi(qid = "q0", question = "Already answered?"),
|
||||
ClarifyQuestionUi(qid = "q1", question = "Still waiting?"),
|
||||
ClarifyQuestionUi(qid = "q2", question = "Optional skip?"),
|
||||
),
|
||||
answers =
|
||||
mapOf(
|
||||
"q0" to "",
|
||||
"q1" to " new answer ",
|
||||
"q2" to "",
|
||||
),
|
||||
lockedAnswers = mapOf("q0" to "yes"),
|
||||
)
|
||||
|
||||
assertEquals(listOf("q1" to "new answer", "q2" to ""), responses)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun credentialPrompt_serverRequestId_controls_response_and_cancellation() {
|
||||
val scope = TestScope(dispatcher)
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatQueuePresentationTest {
|
||||
private val message = ChatMessage("queued-id", MessageRole.USER, "Later")
|
||||
|
||||
private fun pending(state: PendingSendState) =
|
||||
PendingSend(message.id, "scope", "session", message.content, mode = BusySendMode.QUEUE, state = state)
|
||||
|
||||
@Test
|
||||
fun queuedAndParkedMessagesRemainInternalUntilDispatch() {
|
||||
val messages = listOf(message)
|
||||
for (state in listOf(PendingSendState.QUEUED, PendingSendState.PARKED)) {
|
||||
val receipts = listOf(pending(state))
|
||||
assertTrue(messagesWithoutUnsentQueue(messages, receipts).isEmpty())
|
||||
assertEquals(listOf(message), messages)
|
||||
assertEquals(state, receipts.single().state)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun queuedMessageAppearsOnceWhenSendingStarts() {
|
||||
val messages = listOf(message)
|
||||
assertTrue(messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED))).isEmpty())
|
||||
for (state in listOf(PendingSendState.SENDING, PendingSendState.ACCEPTED)) {
|
||||
assertEquals(messages, messagesWithoutUnsentQueue(messages, listOf(pending(state))))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ordinarySendAndOtherMessagesAreUnchanged() {
|
||||
val other = ChatMessage("other", MessageRole.USER, "Send now")
|
||||
val messages = listOf(other, message)
|
||||
assertSame(messages, messagesWithoutUnsentQueue(messages, emptyList()))
|
||||
assertEquals(listOf(other), messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canonicalHistoryIsNeverHiddenByStaleQueueReceipt() {
|
||||
val messages = listOf(message.copy(restId = "rest-session-12"))
|
||||
assertEquals(messages, messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED))))
|
||||
}
|
||||
}
|
||||
@@ -761,12 +761,12 @@ class ChatViewModelTest {
|
||||
id
|
||||
}
|
||||
|
||||
// /fork with an optional branch title.
|
||||
// /fork with an optional branch title sends session.branch_whole.
|
||||
viewModel.sendMessage("/fork my-fork")
|
||||
advanceUntilIdle()
|
||||
|
||||
val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH }
|
||||
assertNotNull("session.branch should be dispatched for /fork", branchSent)
|
||||
val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE }
|
||||
assertNotNull("session.branch_whole should be dispatched for /fork", branchSent)
|
||||
assertEquals(sessionId, branchSent!!.second["session_id"])
|
||||
assertEquals("my-fork", branchSent.second["name"])
|
||||
}
|
||||
@@ -811,12 +811,147 @@ class ChatViewModelTest {
|
||||
viewModel.sendMessage("/fork")
|
||||
advanceUntilIdle()
|
||||
|
||||
val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH }
|
||||
assertNotNull("session.branch should be dispatched for /fork", branchSent)
|
||||
val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE }
|
||||
assertNotNull("session.branch_whole should be dispatched for /fork", branchSent)
|
||||
assertEquals(sessionId, branchSent!!.second["session_id"])
|
||||
assertFalse("name param should be omitted when no title given", branchSent.second.containsKey("name"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSlashCommand_fork_branchWholeSuccess_handledLikeSessionBranch() =
|
||||
runTest {
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
val captured = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||
every { HermesWsClient.send(any(), any(), any()) } answers {
|
||||
val id = "req-${captured.size + 1}"
|
||||
captured.add(arg<String>(0) to (arg<Map<String, Any>>(1)))
|
||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||
id
|
||||
}
|
||||
|
||||
viewModel.sendMessage("/fork")
|
||||
advanceUntilIdle()
|
||||
|
||||
val branchWholeSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE }
|
||||
assertNotNull("session.branch_whole dispatched", branchWholeSent)
|
||||
|
||||
mockEventsFlow.emit(
|
||||
WsEvent.RpcResult(
|
||||
"req-1",
|
||||
mapOf(
|
||||
"session_id" to "runtime-branch-whole",
|
||||
"stored_session_id" to "stored-branch-whole",
|
||||
"title" to "Whole Branch",
|
||||
),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("runtime-branch-whole", ActiveSessionHolder.activeSessionId.value)
|
||||
assertEquals("stored-branch-whole", viewModel.uiState.value.currentSessionId)
|
||||
assertTrue(
|
||||
"System message 'Session branched' should be added",
|
||||
viewModel.uiState.value.messages
|
||||
.any { it.content.contains("Session branched") },
|
||||
)
|
||||
// No fallback was sent
|
||||
assertEquals(0, captured.count { it.first == WsMethods.SESSION_BRANCH })
|
||||
assertEquals(1, captured.count { it.first == WsMethods.SESSION_BRANCH_WHOLE })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSlashCommand_fork_branchWholeError32601_retriesWithSessionBranch() =
|
||||
runTest {
|
||||
val (viewModel, sessionId) = createViewModelWithSession()
|
||||
val captured = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||
every { HermesWsClient.send(any(), any(), any()) } answers {
|
||||
val id = "req-${captured.size + 1}"
|
||||
captured.add(arg<String>(0) to (arg<Map<String, Any>>(1)))
|
||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||
id
|
||||
}
|
||||
|
||||
viewModel.sendMessage("/fork my-retry-branch")
|
||||
advanceUntilIdle()
|
||||
|
||||
val branchWholeSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE }
|
||||
assertNotNull("session.branch_whole should be sent", branchWholeSent)
|
||||
assertEquals("my-retry-branch", branchWholeSent!!.second["name"])
|
||||
assertEquals(sessionId, branchWholeSent.second["session_id"])
|
||||
|
||||
// Backend answers -32601 unknown method
|
||||
mockEventsFlow.emit(
|
||||
WsEvent.RpcError(
|
||||
"req-1",
|
||||
JsonRpcError(code = -32601, message = "unknown method: session.branch_whole"),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Retries ONCE with session.branch using same params
|
||||
val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH }
|
||||
assertNotNull("session.branch fallback should be sent", branchSent)
|
||||
assertEquals("my-retry-branch", branchSent!!.second["name"])
|
||||
assertEquals(sessionId, branchSent.second["session_id"])
|
||||
assertEquals(1, captured.count { it.first == WsMethods.SESSION_BRANCH })
|
||||
|
||||
// session.branch result is then handled
|
||||
mockEventsFlow.emit(
|
||||
WsEvent.RpcResult(
|
||||
"req-2",
|
||||
mapOf(
|
||||
"session_id" to "runtime-branch-fallback",
|
||||
"stored_session_id" to "stored-branch-fallback",
|
||||
"title" to "my-retry-branch",
|
||||
),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("runtime-branch-fallback", ActiveSessionHolder.activeSessionId.value)
|
||||
assertEquals("stored-branch-fallback", viewModel.uiState.value.currentSessionId)
|
||||
assertTrue(
|
||||
"System message 'Session branched' should be added",
|
||||
viewModel.uiState.value.messages
|
||||
.any { it.content.contains("Session branched") },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSlashCommand_fork_branchWholeOtherError_noFallback() =
|
||||
runTest {
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
val captured = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||
every { HermesWsClient.send(any(), any(), any()) } answers {
|
||||
val id = "req-${captured.size + 1}"
|
||||
captured.add(arg<String>(0) to (arg<Map<String, Any>>(1)))
|
||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||
id
|
||||
}
|
||||
|
||||
viewModel.sendMessage("/fork")
|
||||
advanceUntilIdle()
|
||||
|
||||
val branchWholeSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE }
|
||||
assertNotNull("session.branch_whole should be sent", branchWholeSent)
|
||||
|
||||
// Backend answers with code other than -32601
|
||||
mockEventsFlow.emit(
|
||||
WsEvent.RpcError(
|
||||
"req-1",
|
||||
JsonRpcError(code = -32600, message = "invalid request"),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
// No retry/fallback sent
|
||||
assertEquals(0, captured.count { it.first == WsMethods.SESSION_BRANCH })
|
||||
assertTrue(
|
||||
viewModel.uiState.value.errorMessage
|
||||
?.contains("session.branch_whole") == true,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSlashCommand_stop_sendsInterrupt() =
|
||||
runTest {
|
||||
@@ -3516,6 +3651,112 @@ class ChatViewModelTest {
|
||||
assertEquals(PendingSendState.UNKNOWN, sendStore.all().single().state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completePersistedTurnRetiresOnlyTheReceiptBoundToItsUserRow() =
|
||||
runTest {
|
||||
val sendStore = ChatSendStore()
|
||||
val (viewModel, sessionId) = createViewModelWithSession(sendStore = sendStore)
|
||||
every { HermesWsClient.sendMessage(sessionId, "Durable", any(), any()) } answers {
|
||||
arg<((String) -> Unit)?>(2)?.invoke("durable-submit")
|
||||
"durable-submit"
|
||||
}
|
||||
viewModel.sendMessage("Durable")
|
||||
advanceUntilIdle()
|
||||
mockEventsFlow.emit(
|
||||
WsEvent.RpcResult("durable-submit", mapOf("status" to "streaming", "user_row_id" to 77)),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
val receipt = sendStore.all().single()
|
||||
assertEquals(77L, receipt.userRowId)
|
||||
assertEquals(
|
||||
77L,
|
||||
viewModel.uiState.value.messages
|
||||
.single { it.id == receipt.id }
|
||||
.serverRowId,
|
||||
)
|
||||
|
||||
// Partial and foreign receipts never retire the local turn.
|
||||
mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
|
||||
mockEventsFlow.emit(
|
||||
WsEvent.MessageComplete(
|
||||
"Reply",
|
||||
sessionId,
|
||||
rawPayload =
|
||||
mapOf(
|
||||
"persisted_turn" to
|
||||
mapOf(
|
||||
"row_ids" to listOf(77),
|
||||
"complete" to false,
|
||||
"user_row_id" to 77,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf(receipt.id), sendStore.all().map { it.id })
|
||||
mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
|
||||
mockEventsFlow.emit(
|
||||
WsEvent.MessageComplete(
|
||||
"Reply",
|
||||
sessionId,
|
||||
rawPayload =
|
||||
mapOf(
|
||||
"persisted_turn" to mapOf("row_ids" to listOf(78), "complete" to true, "user_row_id" to 78),
|
||||
),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf(receipt.id), sendStore.all().map { it.id })
|
||||
|
||||
mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
|
||||
mockEventsFlow.emit(
|
||||
WsEvent.MessageComplete(
|
||||
"Reply",
|
||||
sessionId,
|
||||
rawPayload =
|
||||
mapOf(
|
||||
"persisted_turn" to
|
||||
mapOf(
|
||||
"row_ids" to listOf(77, 79),
|
||||
"complete" to true,
|
||||
"user_row_id" to 77,
|
||||
"final_assistant_row_id" to 79,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
assertTrue(sendStore.all().isEmpty())
|
||||
assertTrue(
|
||||
viewModel.uiState.value.messages
|
||||
.any { it.id == receipt.id },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun submitAckWithoutUserRowIdLeavesReceiptUnproven() =
|
||||
runTest {
|
||||
val sendStore = ChatSendStore()
|
||||
val (viewModel, sessionId) = createViewModelWithSession(sendStore = sendStore)
|
||||
every { HermesWsClient.sendMessage(sessionId, "Legacy", any(), any()) } answers {
|
||||
arg<((String) -> Unit)?>(2)?.invoke("legacy-submit")
|
||||
"legacy-submit"
|
||||
}
|
||||
viewModel.sendMessage("Legacy")
|
||||
advanceUntilIdle()
|
||||
mockEventsFlow.emit(WsEvent.RpcResult("legacy-submit", mapOf("status" to "streaming")))
|
||||
advanceUntilIdle()
|
||||
|
||||
val receipt = sendStore.all().single()
|
||||
assertNull(receipt.userRowId)
|
||||
assertEquals(PendingSendState.ACCEPTED, receipt.state)
|
||||
assertTrue(
|
||||
viewModel.uiState.value.messages
|
||||
.any { it.id == receipt.id && it.serverRowId == null },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun busyGuideUsesSteerAndUnsupportedFallsBackToQueueWithoutInterrupt() =
|
||||
runTest {
|
||||
@@ -5128,7 +5369,7 @@ class ChatViewModelTest {
|
||||
|
||||
viewModel.sendMessage("/fork")
|
||||
runCurrent()
|
||||
val branchRequest = captured.last { it.first == WsMethods.SESSION_BRANCH }.second
|
||||
val branchRequest = captured.last { it.first == WsMethods.SESSION_BRANCH_WHOLE }.second
|
||||
mockEventsFlow.emit(
|
||||
WsEvent.RpcResult(
|
||||
branchRequest,
|
||||
@@ -5962,11 +6203,11 @@ class ChatViewModelTest {
|
||||
.SessionMessagesResponse(messages = emptyList()),
|
||||
)
|
||||
|
||||
// /fork sends session.branch keyed on the runtime id.
|
||||
// /fork sends session.branch_whole keyed on the runtime id.
|
||||
viewModel.sendMessage("/fork")
|
||||
advanceUntilIdle()
|
||||
|
||||
val branchId = captured.last { it.first == WsMethods.SESSION_BRANCH }.second
|
||||
val branchId = captured.last { it.first == WsMethods.SESSION_BRANCH_WHOLE }.second
|
||||
val branchStorage = "branch-storage-1"
|
||||
mockEventsFlow.emit(
|
||||
WsEvent.RpcResult(
|
||||
|
||||
@@ -1478,4 +1478,131 @@ class ChatWsEventReducerTest {
|
||||
assertTrue(result.state.messages.none { it.id != "orphan" })
|
||||
assertTrue(!result.streamingState.turnUsageBaselineCaptured)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testStatusUpdate_compressingState() {
|
||||
val initialMessage = ChatMessage(role = MessageRole.USER, content = "hello")
|
||||
val state =
|
||||
ChatUiState(
|
||||
currentSessionId = "session-1",
|
||||
messages = listOf(initialMessage),
|
||||
isCompressing = false,
|
||||
compressionStatus = null,
|
||||
)
|
||||
val event =
|
||||
WsEvent.StatusUpdate(
|
||||
status = null,
|
||||
data =
|
||||
mapOf(
|
||||
"kind" to "compressing",
|
||||
"text" to "⏳ Compressing context...",
|
||||
),
|
||||
)
|
||||
|
||||
val result =
|
||||
ChatWsEventReducer.reduce(
|
||||
state = state,
|
||||
streamingState = StreamingState(),
|
||||
event = event,
|
||||
currentSessionId = "session-1",
|
||||
)
|
||||
|
||||
assertTrue(result.state.isCompressing)
|
||||
assertEquals("⏳ Compressing context...", result.state.compressionStatus)
|
||||
assertEquals(listOf(initialMessage), result.state.messages)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testStatusUpdate_compactingState() {
|
||||
val state =
|
||||
ChatUiState(
|
||||
currentSessionId = "session-1",
|
||||
isCompressing = false,
|
||||
compressionStatus = null,
|
||||
)
|
||||
val event =
|
||||
WsEvent.StatusUpdate(
|
||||
status = null,
|
||||
data =
|
||||
mapOf(
|
||||
"kind" to "compacting",
|
||||
"text" to "🗜️ Compacting...",
|
||||
),
|
||||
)
|
||||
|
||||
val result =
|
||||
ChatWsEventReducer.reduce(
|
||||
state = state,
|
||||
streamingState = StreamingState(),
|
||||
event = event,
|
||||
currentSessionId = "session-1",
|
||||
)
|
||||
|
||||
assertTrue(result.state.isCompressing)
|
||||
assertEquals("🗜️ Compacting...", result.state.compressionStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testStatusUpdate_compactedState() {
|
||||
val state =
|
||||
ChatUiState(
|
||||
currentSessionId = "session-1",
|
||||
isCompressing = true,
|
||||
compressionStatus = "🗜️ Compacting...",
|
||||
)
|
||||
val event =
|
||||
WsEvent.StatusUpdate(
|
||||
status = null,
|
||||
data =
|
||||
mapOf(
|
||||
"kind" to "compacted",
|
||||
"text" to "Finished",
|
||||
),
|
||||
)
|
||||
|
||||
val result =
|
||||
ChatWsEventReducer.reduce(
|
||||
state = state,
|
||||
streamingState = StreamingState(),
|
||||
event = event,
|
||||
currentSessionId = "session-1",
|
||||
)
|
||||
|
||||
assertFalse(result.state.isCompressing)
|
||||
// Accepts either "Finished" or null if cleared
|
||||
assertTrue(result.state.compressionStatus == "Finished" || result.state.compressionStatus == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testStatusUpdate_unrelatedKindIgnored() {
|
||||
val initialMessages = listOf(ChatMessage(role = MessageRole.USER, content = "test"))
|
||||
val state =
|
||||
ChatUiState(
|
||||
currentSessionId = "session-1",
|
||||
messages = initialMessages,
|
||||
isCompressing = false,
|
||||
compressionStatus = "idle",
|
||||
)
|
||||
val event =
|
||||
WsEvent.StatusUpdate(
|
||||
status = null,
|
||||
data =
|
||||
mapOf(
|
||||
"kind" to "other",
|
||||
"text" to "Something else",
|
||||
),
|
||||
)
|
||||
|
||||
val result =
|
||||
ChatWsEventReducer.reduce(
|
||||
state = state,
|
||||
streamingState = StreamingState(),
|
||||
event = event,
|
||||
currentSessionId = "session-1",
|
||||
)
|
||||
|
||||
assertFalse(result.state.isCompressing)
|
||||
assertEquals("idle", result.state.compressionStatus)
|
||||
assertEquals(initialMessages, result.state.messages)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.model.SessionMessage
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests verifying that persisted edit previews survive transcript loading into [ChatMessage]
|
||||
* and subsequent tool diff parsing via [parseToolOutput].
|
||||
*/
|
||||
class PersistedEditPreviewHydrationTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private fun parseJson(raw: String): JsonElement = json.parseToJsonElement(raw)
|
||||
|
||||
private fun toolSessionMessage(
|
||||
content: String,
|
||||
displayMetadata: JsonElement? = null,
|
||||
id: Int = 101,
|
||||
toolCallId: String = "call-101",
|
||||
): SessionMessage =
|
||||
SessionMessage(
|
||||
id = id,
|
||||
role = "tool",
|
||||
content = JsonPrimitive(content),
|
||||
tool_call_id = toolCallId,
|
||||
display_metadata = displayMetadata,
|
||||
)
|
||||
|
||||
private fun mapSingle(msg: SessionMessage): ChatMessage =
|
||||
mapServerMessages(
|
||||
sessionId = "sess-edit-preview",
|
||||
messages = listOf(msg),
|
||||
offset = 0,
|
||||
latestPaging = true,
|
||||
liveMessages = emptyList(),
|
||||
).single()
|
||||
|
||||
// ── Contract 1: ChatServerMessageMapper enrichment ────────────────────────
|
||||
|
||||
@Test
|
||||
fun mapServerMessages_enrichesToolContentWithDisplayMetadataInlineDiff() {
|
||||
val diffPayload =
|
||||
"""
|
||||
{
|
||||
"tool_result_metadata": {
|
||||
"inline_diff": "@@ -1 +1 @@\n-old\n+new"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
val originalContent = """{"output":"File updated successfully"}"""
|
||||
val serverMsg =
|
||||
toolSessionMessage(
|
||||
content = originalContent,
|
||||
displayMetadata = parseJson(diffPayload),
|
||||
)
|
||||
|
||||
val chatMessage = mapSingle(serverMsg)
|
||||
|
||||
val enriched = parseJson(chatMessage.content) as? JsonObject
|
||||
assertNotNull("Expected enriched content to parse as JSON object", enriched)
|
||||
assertEquals(
|
||||
"File updated successfully",
|
||||
(enriched?.get("output") as? JsonPrimitive)?.content,
|
||||
)
|
||||
val displayMeta = enriched?.get("display_metadata") as? JsonObject
|
||||
assertNotNull("Expected display_metadata to be added to content", displayMeta)
|
||||
val toolResultMeta = displayMeta?.get("tool_result_metadata") as? JsonObject
|
||||
assertNotNull("Expected tool_result_metadata inside display_metadata", toolResultMeta)
|
||||
val diff = (toolResultMeta?.get("inline_diff") as? JsonPrimitive)?.content
|
||||
assertEquals("@@ -1 +1 @@\n-old\n+new", diff)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapServerMessages_doesNotEnrichWhenDisplayMetadataIsNull() {
|
||||
val originalContent = """{"output":"no metadata"}"""
|
||||
val serverMsg =
|
||||
toolSessionMessage(
|
||||
content = originalContent,
|
||||
displayMetadata = null,
|
||||
)
|
||||
|
||||
val chatMessage = mapSingle(serverMsg)
|
||||
|
||||
assertEquals(originalContent, chatMessage.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapServerMessages_doesNotEnrichWhenRoleIsNotTool() {
|
||||
val metadata =
|
||||
"""
|
||||
{
|
||||
"tool_result_metadata": {
|
||||
"inline_diff": "@@ -1 +1 @@\n-old\n+new"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
val originalContent = "Assistant says hello"
|
||||
val serverMsg =
|
||||
SessionMessage(
|
||||
id = 202,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive(originalContent),
|
||||
display_metadata = parseJson(metadata),
|
||||
)
|
||||
|
||||
val chatMessage = mapSingle(serverMsg)
|
||||
|
||||
assertEquals(originalContent, chatMessage.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapServerMessages_doesNotOverwriteExistingDisplayMetadataInContent() {
|
||||
val content =
|
||||
"""
|
||||
{
|
||||
"output": "ok",
|
||||
"display_metadata": {
|
||||
"existing": true
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
val externalMetadata =
|
||||
"""
|
||||
{
|
||||
"tool_result_metadata": {
|
||||
"inline_diff": "@@ -1 +1 @@\n-old\n+new"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
val serverMsg =
|
||||
toolSessionMessage(
|
||||
content = content,
|
||||
displayMetadata = parseJson(externalMetadata),
|
||||
)
|
||||
|
||||
val chatMessage = mapSingle(serverMsg)
|
||||
|
||||
val parsed = parseJson(chatMessage.content) as? JsonObject
|
||||
assertNotNull(parsed)
|
||||
val meta = parsed?.get("display_metadata") as? JsonObject
|
||||
assertEquals("true", (meta?.get("existing") as? JsonPrimitive)?.content)
|
||||
assertNull(meta?.get("tool_result_metadata"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapServerMessages_ignoresNonJsonObjectContent() {
|
||||
val nonJsonContent = "Plain text output from tool"
|
||||
val metadata =
|
||||
"""
|
||||
{
|
||||
"tool_result_metadata": {
|
||||
"inline_diff": "@@ -1 +1 @@\n-old\n+new"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
val serverMsg =
|
||||
toolSessionMessage(
|
||||
content = nonJsonContent,
|
||||
displayMetadata = parseJson(metadata),
|
||||
)
|
||||
|
||||
val chatMessage = mapSingle(serverMsg)
|
||||
|
||||
assertEquals(nonJsonContent, chatMessage.content)
|
||||
}
|
||||
|
||||
// ── Contract 2: ToolResultParser / FileRenderers inline diff extraction ──
|
||||
|
||||
@Test
|
||||
fun parseToolOutput_extractsInlineDiffFromNestedToolResultMetadata() {
|
||||
val rawDiff = "@@ -1,3 +1,3 @@\n-old line\n+new line"
|
||||
val content =
|
||||
"""
|
||||
{
|
||||
"name": "patch",
|
||||
"args": {
|
||||
"path": "app/src/main/Utils.kt"
|
||||
},
|
||||
"display_metadata": {
|
||||
"tool_result_metadata": {
|
||||
"inline_diff": "@@ -1,3 +1,3 @@\n-old line\n+new line"
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val view = parseToolOutput(content = content, toolName = "patch", isRunning = false)
|
||||
|
||||
assertNotNull("Expected parseToolOutput to return a ToolView", view)
|
||||
assertEquals(rawDiff, view?.inlineDiff)
|
||||
assertEquals("app/src/main/Utils.kt", view?.diffPath)
|
||||
assertEquals(1, view?.diffStats?.added)
|
||||
assertEquals(1, view?.diffStats?.removed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseToolOutput_extractsInlineDiffFromFlatDisplayMetadata() {
|
||||
val rawDiff = "--- a/test.kt\n+++ b/test.kt\n@@ -1 +1 @@\n-alpha\n+beta"
|
||||
val content =
|
||||
"""
|
||||
{
|
||||
"name": "edit_file",
|
||||
"args": {
|
||||
"path": "test.kt"
|
||||
},
|
||||
"display_metadata": {
|
||||
"inline_diff": "--- a/test.kt\n+++ b/test.kt\n@@ -1 +1 @@\n-alpha\n+beta"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val view = parseToolOutput(content = content, toolName = "edit_file", isRunning = false)
|
||||
|
||||
assertNotNull(view)
|
||||
assertEquals(rawDiff, view?.inlineDiff)
|
||||
assertEquals("test.kt", view?.diffPath)
|
||||
assertEquals(1, view?.diffStats?.added)
|
||||
assertEquals(1, view?.diffStats?.removed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseToolOutput_extractsInlineDiffFromResultObjectDirectly() {
|
||||
val rawDiff = "@@ -1 +1 @@\n-before\n+after"
|
||||
val content =
|
||||
"""
|
||||
{
|
||||
"name": "patch",
|
||||
"args": {
|
||||
"path": "file.txt"
|
||||
},
|
||||
"result": {
|
||||
"inline_diff": "@@ -1 +1 @@\n-before\n+after"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val view = parseToolOutput(content = content, toolName = "patch", isRunning = false)
|
||||
|
||||
assertNotNull(view)
|
||||
assertEquals(rawDiff, view?.inlineDiff)
|
||||
assertEquals("file.txt", view?.diffPath)
|
||||
assertEquals(1, view?.diffStats?.added)
|
||||
assertEquals(1, view?.diffStats?.removed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseToolOutput_absentOrEmptyMetadataYieldsNullInlineDiff() {
|
||||
val emptyDiffContent =
|
||||
"""
|
||||
{
|
||||
"name": "patch",
|
||||
"args": {
|
||||
"path": "file.txt"
|
||||
},
|
||||
"display_metadata": {
|
||||
"tool_result_metadata": {
|
||||
"inline_diff": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
val noDiffView = parseToolOutput(content = emptyDiffContent, toolName = "patch", isRunning = false)
|
||||
assertNotNull(noDiffView)
|
||||
assertNull(noDiffView?.inlineDiff)
|
||||
|
||||
val missingDiffContent =
|
||||
"""
|
||||
{
|
||||
"name": "patch",
|
||||
"args": {
|
||||
"path": "file.txt"
|
||||
},
|
||||
"result": {
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
val missingDiffView = parseToolOutput(content = missingDiffContent, toolName = "patch", isRunning = false)
|
||||
assertNotNull(missingDiffView)
|
||||
assertNull(missingDiffView?.inlineDiff)
|
||||
}
|
||||
|
||||
// ── End-to-end integration: Transcript hydration -> ToolView diff parsing ─
|
||||
|
||||
@Test
|
||||
fun endToEnd_hydratedTranscriptProducesToolViewWithInlineDiff() {
|
||||
val serverDiff = "@@ -1,2 +1,2 @@\n-val x = 1\n+val x = 2"
|
||||
val sessionMsg =
|
||||
toolSessionMessage(
|
||||
content = """{"name":"patch","args":{"path":"src/Config.kt"},"result":{"success":true}}""",
|
||||
displayMetadata =
|
||||
parseJson(
|
||||
"""
|
||||
{
|
||||
"tool_result_metadata": {
|
||||
"inline_diff": "@@ -1,2 +1,2 @@\n-val x = 1\n+val x = 2"
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
|
||||
val chatMessage = mapSingle(sessionMsg)
|
||||
assertTrue(chatMessage.content.contains("display_metadata"))
|
||||
|
||||
val toolView =
|
||||
parseToolOutput(
|
||||
content = chatMessage.content,
|
||||
toolName = "patch",
|
||||
isRunning = false,
|
||||
)
|
||||
|
||||
assertNotNull("Expected parseToolOutput to succeed for hydrated message content", toolView)
|
||||
assertEquals(serverDiff, toolView?.inlineDiff)
|
||||
assertEquals("src/Config.kt", toolView?.diffPath)
|
||||
assertEquals(1, toolView?.diffStats?.added)
|
||||
assertEquals(1, toolView?.diffStats?.removed)
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,15 @@ class SlashCommandDispatcherTest {
|
||||
assertEquals(SlashResult.Undo(""), dispatcher.dispatch("/Undo"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compress and compact route to Compress`() {
|
||||
assertEquals(SlashResult.Compress(""), dispatcher.dispatch("/compress"))
|
||||
assertEquals(SlashResult.Compress(""), dispatcher.dispatch("/compact"))
|
||||
assertEquals(SlashResult.Compress("auth decisions"), dispatcher.dispatch("/compress auth decisions"))
|
||||
assertEquals(SlashResult.Compress("auth decisions"), dispatcher.dispatch("/compact auth decisions"))
|
||||
assertEquals(SlashResult.Compress(""), dispatcher.dispatch("/compress "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NEW uppercase still routes to NewSession`() {
|
||||
// Dispatcher lower-cases before matching, so case must not matter.
|
||||
|
||||
@@ -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("***"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ConnectorToolLabelsTest {
|
||||
@Test fun `gateway envelope labels reach the visible header without replacing real tool arguments`() {
|
||||
val content = """{
|
||||
"name":"tool_call",
|
||||
"args":{"labels":["bug"]},
|
||||
"labels":[{"app":"GitHub","text":"Create issue"}],
|
||||
"result":{}
|
||||
}"""
|
||||
val view =
|
||||
com.m57.hermescontrol.ui.chat
|
||||
.parseToolOutput(content, "tool_call", false)
|
||||
assertEquals("GitHub · Create issue", view?.serverDisplayName)
|
||||
assertEquals("GitHub · Create issue", view?.title)
|
||||
}
|
||||
|
||||
@Test fun `server connector labels title both live and restored tool calls`() {
|
||||
val args =
|
||||
Json.parseToJsonElement(
|
||||
"""{"hermes_tool_labels":[{"app":"Google Drive","text":"List files","kind":"connector"}]}""",
|
||||
)
|
||||
val running = ToolViewBuilder.build("tool_call", args, null, running = true)
|
||||
val restored = ToolViewBuilder.build("tool_call", args, Json.parseToJsonElement("{}"))
|
||||
assertEquals("Google Drive · List files", running.title)
|
||||
assertEquals(running.title, restored.title)
|
||||
}
|
||||
|
||||
@Test fun `malformed metadata and actual labels argument keep fallback title`() {
|
||||
val args = Json.parseToJsonElement("""{"labels":["bug"],"hermes_tool_labels":[null,42,{"app":"GitHub"}]}""")
|
||||
val view = ToolViewBuilder.build("tool_call", args, null)
|
||||
assertTrue(view.title.isNotBlank())
|
||||
assertEquals(ToolViewBuilder.build("tool_call", null, null).title, view.title)
|
||||
}
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
package com.m57.hermescontrol.ui.connectors
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.model.AccountConnectorResult
|
||||
import com.m57.hermescontrol.data.model.ConnectorCatalogEntry
|
||||
import com.m57.hermescontrol.data.model.ConnectorError
|
||||
import com.m57.hermescontrol.data.model.ConnectorListResult
|
||||
import com.m57.hermescontrol.data.model.ConnectorPolicy
|
||||
import com.m57.hermescontrol.data.ws.AccountConnectorRepository
|
||||
import com.m57.hermescontrol.data.ws.ConnectionOperationParser
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AccountConnectorsViewModelTest {
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
private val repo = mockk<AccountConnectorRepository>()
|
||||
private val scopes = MutableStateFlow<DataScope?>(DataScope("server", "http://host", "work", 1))
|
||||
private val events = MutableSharedFlow<WsEvent>()
|
||||
private lateinit var vm: AccountConnectorsViewModel
|
||||
private val policy = ConnectorPolicy("effective", "unrestricted", emptyList(), emptyList(), emptyMap())
|
||||
|
||||
@Before fun setup() {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
coEvery { repo.listConnectors() } returns ConnectorListResult.Success(true, emptyList())
|
||||
coEvery { repo.catalog() } returns
|
||||
AccountConnectorResult.Success(listOf(ConnectorCatalogEntry("drive", "Google Drive", "Files", "storage")))
|
||||
coEvery { repo.accounts() } returns AccountConnectorResult.Success(emptyList())
|
||||
coEvery { repo.policy() } returns
|
||||
AccountConnectorResult.Success(policy.copy(member = policy.copy(revision = "member")))
|
||||
coEvery { repo.operationStatus("op-a") } returns AccountConnectorResult.Success(snapshot())
|
||||
vm = AccountConnectorsViewModel(repo, scopes, { scopes.value }, events)
|
||||
}
|
||||
|
||||
@After fun teardown() {
|
||||
vm.viewModelScope.cancel()
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test fun `no session required and connect retains the browser operation`() =
|
||||
runTest(dispatcher) {
|
||||
advanceUntilIdle()
|
||||
assertFalse(vm.state.value.loading)
|
||||
assertEquals(
|
||||
"Google Drive",
|
||||
vm.state.value.catalog
|
||||
.single()
|
||||
.name,
|
||||
)
|
||||
coEvery { repo.connect(listOf("drive"), false) } returns AccountConnectorResult.Success(snapshot())
|
||||
vm.connect("drive")
|
||||
vm.connect("drive")
|
||||
advanceUntilIdle()
|
||||
coVerify(exactly = 1) { repo.connect(listOf("drive"), false) }
|
||||
assertEquals(
|
||||
"https://example.com/auth",
|
||||
vm.connectionState.value.operation
|
||||
?.targets
|
||||
?.single()
|
||||
?.safeConnectUrl,
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `connect and remove errors remain visible rather than cleared by reload`() =
|
||||
runTest(dispatcher) {
|
||||
advanceUntilIdle()
|
||||
coEvery { repo.connect(any(), any()) } returns
|
||||
AccountConnectorResult.Failure(ConnectorError.RequestFailed("authorization failed"))
|
||||
vm.connect("drive")
|
||||
advanceUntilIdle()
|
||||
assertEquals("authorization failed", vm.state.value.error)
|
||||
coEvery { repo.remove("c1") } returns
|
||||
AccountConnectorResult.Failure(ConnectorError.RequestFailed("remove failed"))
|
||||
vm.remove("c1")
|
||||
advanceUntilIdle()
|
||||
assertEquals("remove failed", vm.state.value.error)
|
||||
assertFalse(vm.state.value.busy)
|
||||
}
|
||||
|
||||
@Test fun `profile switch clears operation and rejects prior account update`() =
|
||||
runTest(dispatcher) {
|
||||
advanceUntilIdle()
|
||||
coEvery { repo.connect(any(), any()) } returns AccountConnectorResult.Success(snapshot())
|
||||
vm.connect("drive")
|
||||
advanceUntilIdle()
|
||||
scopes.value = scopes.value?.copy(activeProfileId = "other")
|
||||
advanceUntilIdle()
|
||||
events.emit(WsEvent.ConnectionUpdate(snapshot().copy(seq = 2)))
|
||||
advanceUntilIdle()
|
||||
assertNull(vm.connectionState.value.operation)
|
||||
assertEquals(
|
||||
"other",
|
||||
vm.state.value.scope
|
||||
?.activeProfileId,
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `late tool response cannot populate a new account scope`() =
|
||||
runTest(dispatcher) {
|
||||
advanceUntilIdle()
|
||||
val pending =
|
||||
CompletableDeferred<AccountConnectorResult<List<com.m57.hermescontrol.data.model.ConnectorTool>>>()
|
||||
coEvery { repo.tools("drive", false) } coAnswers { pending.await() }
|
||||
vm.loadTools("drive")
|
||||
dispatcher.scheduler.runCurrent()
|
||||
scopes.value = scopes.value?.copy(inMemoryAuthGeneration = 2)
|
||||
advanceUntilIdle()
|
||||
pending.complete(AccountConnectorResult.Success(emptyList()))
|
||||
advanceUntilIdle()
|
||||
assertTrue(
|
||||
vm.state.value.tools
|
||||
.isEmpty(),
|
||||
)
|
||||
assertTrue(
|
||||
vm.state.value.toolsLoading
|
||||
.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `continue answers account operation and settlement closes it`() =
|
||||
runTest(dispatcher) {
|
||||
advanceUntilIdle()
|
||||
coEvery { repo.connect(any(), any()) } returns AccountConnectorResult.Success(snapshot())
|
||||
coEvery { repo.operationRequest(any(), any()) } returns mapOf("status" to "ok")
|
||||
coEvery { repo.operationStatus("op-a") } returns
|
||||
AccountConnectorResult.Success(snapshot().copy(seq = 2, settled = true))
|
||||
vm.connect("drive")
|
||||
advanceUntilIdle()
|
||||
vm.continueOperation()
|
||||
advanceUntilIdle()
|
||||
coVerify {
|
||||
repo.operationRequest(
|
||||
WsMethods.CONNECTION_RESPOND,
|
||||
mapOf(
|
||||
"owner" to mapOf("type" to "account"),
|
||||
"op_id" to "op-a",
|
||||
"result" to mapOf("settled_by" to "continue"),
|
||||
),
|
||||
)
|
||||
}
|
||||
assertNull(vm.connectionState.value.operation)
|
||||
}
|
||||
|
||||
@Test fun `session update never changes account operation`() =
|
||||
runTest(dispatcher) {
|
||||
advanceUntilIdle()
|
||||
coEvery { repo.connect(any(), any()) } returns AccountConnectorResult.Success(snapshot())
|
||||
vm.connect("drive")
|
||||
advanceUntilIdle()
|
||||
events.emit(
|
||||
WsEvent.ConnectionUpdate(
|
||||
snapshot().copy(seq = 3, settled = true, accountOwned = false, sessionId = "chat"),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
assertEquals(
|
||||
1L,
|
||||
vm.connectionState.value.operation
|
||||
?.seq,
|
||||
)
|
||||
}
|
||||
|
||||
private fun snapshot() =
|
||||
checkNotNull(
|
||||
ConnectionOperationParser.parse(
|
||||
mapOf(
|
||||
"owner" to mapOf("type" to "account"),
|
||||
"op_id" to "op-a",
|
||||
"seq" to 1,
|
||||
"deadline_at" to 2000000000.0,
|
||||
"settled" to false,
|
||||
"targets" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"name" to "drive",
|
||||
"kind" to "connector",
|
||||
"action" to "authorize",
|
||||
"state" to "initiated",
|
||||
"connect_url" to "https://example.com/auth",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package com.m57.hermescontrol.ui.mcp
|
||||
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.McpServer
|
||||
import com.m57.hermescontrol.data.model.McpServerTestResponse
|
||||
import com.m57.hermescontrol.data.model.McpServersResponse
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import retrofit2.Response
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class McpOwnershipTest {
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
private lateinit var api: HermesApiService
|
||||
private val owned = McpServer(name = "scanner", enabled = true, source = "plugin", plugin = "security")
|
||||
private val conflict = "Server 'scanner' is provided by plugin 'security' and cannot be modified"
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
api = mockk()
|
||||
mockkObject(ApiClient, AuthManager)
|
||||
every { ApiClient.hermesApi } returns api
|
||||
every { AuthManager.currentDataScope() } throws IllegalStateException("No test scope")
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ownership decodes and legacy servers remain editable`() {
|
||||
val json = OkHttpProvider.json
|
||||
val plugin =
|
||||
json.decodeFromString<McpServer>(
|
||||
"""{"name":"scanner","enabled":true,"source":"plugin","plugin":"security"}""",
|
||||
)
|
||||
assertEquals(owned, plugin)
|
||||
assertTrue(plugin.isPluginOwned)
|
||||
assertTrue(plugin.copy(plugin = null).isPluginOwned)
|
||||
val legacy = json.decodeFromString<McpServer>("""{"name":"local","enabled":true}""")
|
||||
assertFalse(legacy.isPluginOwned)
|
||||
assertNull(legacy.plugin)
|
||||
val config =
|
||||
json.decodeFromString<McpServer>(
|
||||
"""{"name":"local","enabled":true,"source":"config","plugin":null}""",
|
||||
)
|
||||
assertFalse(config.isPluginOwned)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plugin mutations are blocked even for stale callbacks but testing remains available`() {
|
||||
val vm = loaded(owned)
|
||||
val stale = owned.copy(source = "config", plugin = null)
|
||||
vm.toggleServer(stale)
|
||||
vm.deleteServer(owned.name)
|
||||
vm.startEditingEnv(stale)
|
||||
vm.updateEnvKey("KEY")
|
||||
vm.updateEnvValue("value")
|
||||
vm.addEnvVar(owned.name)
|
||||
vm.removeEnvVar(owned.name, "KEY")
|
||||
vm.startMcpOAuthFlow(stale) { error("Must not open browser") }
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
assertTrue(
|
||||
vm.uiState.value.servers
|
||||
.single()
|
||||
.enabled,
|
||||
)
|
||||
assertNull(vm.uiState.value.editingEnvFor)
|
||||
coVerify(exactly = 0) { api.toggleMcpServer(any(), any()) }
|
||||
coVerify(exactly = 0) { api.deleteMcpServer(any()) }
|
||||
coVerify(exactly = 0) { api.updateMcpServer(any(), any()) }
|
||||
coVerify(exactly = 0) { api.authMcpServer(any()) }
|
||||
coEvery { api.testMcpServer(owned.name) } returns Response.success(McpServerTestResponse(ok = true))
|
||||
vm.testServer(owned.name)
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
assertTrue(
|
||||
vm.uiState.value.serverTestResults
|
||||
.getValue(owned.name)
|
||||
.ok,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `config server toggle and environment editing remain available`() {
|
||||
val config = owned.copy(source = "config", plugin = null)
|
||||
val vm = loaded(config)
|
||||
coEvery { api.toggleMcpServer(config.name, any()) } returns Response.success(Unit)
|
||||
vm.toggleServer(config)
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
assertFalse(
|
||||
vm.uiState.value.servers
|
||||
.single()
|
||||
.enabled,
|
||||
)
|
||||
vm.startEditingEnv(config)
|
||||
assertEquals(config.name, vm.uiState.value.editingEnvFor)
|
||||
coEvery { api.updateMcpServer(config.name, any()) } returns Response.success(config)
|
||||
vm.updateEnvKey("KEY")
|
||||
vm.updateEnvValue("value")
|
||||
vm.addEnvVar(config.name)
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
coVerify { api.updateMcpServer(config.name, mapOf("env" to mapOf("KEY" to "value"))) }
|
||||
vm.removeEnvVar(config.name, "KEY")
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
coVerify { api.updateMcpServer(config.name, mapOf("env" to emptyMap<String, String>())) }
|
||||
coEvery { api.deleteMcpServer(config.name) } returns Response.success(Unit)
|
||||
vm.deleteServer(config.name)
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
coVerify(exactly = 1) { api.deleteMcpServer(config.name) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale ownership conflicts surface detail verbatim and revert toggle`() {
|
||||
val config = owned.copy(source = "config", plugin = null)
|
||||
val vm = loaded(config)
|
||||
coEvery { api.toggleMcpServer(any(), any()) } returns conflictResponse()
|
||||
vm.toggleServer(config)
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
assertTrue(
|
||||
vm.uiState.value.servers
|
||||
.single()
|
||||
.enabled,
|
||||
)
|
||||
assertEquals(conflict, vm.uiState.value.toastMessage)
|
||||
coEvery { api.deleteMcpServer(any()) } returns conflictResponse()
|
||||
vm.deleteServer(config.name)
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals(conflict, vm.uiState.value.toastMessage)
|
||||
coEvery { api.updateMcpServer(any(), any()) } coAnswers { conflictResponse() }
|
||||
vm.updateEnvKey("KEY")
|
||||
vm.addEnvVar(config.name)
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals(conflict, vm.uiState.value.toastMessage)
|
||||
vm.clearToast()
|
||||
vm.removeEnvVar(config.name, "KEY")
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals(conflict, vm.uiState.value.toastMessage)
|
||||
coEvery { api.authMcpServer(any()) } returns conflictResponse()
|
||||
vm.clearToast()
|
||||
vm.startMcpOAuthFlow(config) { error("Must not open browser") }
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals(conflict, vm.uiState.value.toastMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `add collision surfaces plugin conflict verbatim`() {
|
||||
coEvery { api.addMcpServer(any()) } returns conflictResponse()
|
||||
val vm = McpServersViewModel(ioDispatcher = dispatcher)
|
||||
vm.toggleAddForm()
|
||||
vm.updateAddServerName(owned.name)
|
||||
vm.updateAddServerUrl("https://example.com/mcp")
|
||||
vm.submitAddServer()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals(conflict, vm.uiState.value.toastMessage)
|
||||
assertFalse(vm.uiState.value.addingServer)
|
||||
assertTrue(vm.uiState.value.showAddForm)
|
||||
assertEquals(owned.name, vm.uiState.value.addServerName)
|
||||
}
|
||||
|
||||
private fun loaded(server: McpServer): McpServersViewModel {
|
||||
coEvery { api.getMcpServers() } returns Response.success(McpServersResponse(listOf(server)))
|
||||
return McpServersViewModel(ioDispatcher = dispatcher).also {
|
||||
it.loadServers()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals(listOf(server), it.uiState.value.servers)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> conflictResponse(): Response<T> = Response.error(409, """{"detail":"$conflict"}""".toResponseBody())
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import com.m57.hermescontrol.data.model.PluginCatalogCapabilities
|
||||
import com.m57.hermescontrol.data.model.PluginCatalogEntry
|
||||
import com.m57.hermescontrol.data.model.PluginCatalogResponse
|
||||
import com.m57.hermescontrol.data.model.PluginInfo
|
||||
import com.m57.hermescontrol.data.model.PluginUpdateRequest
|
||||
import com.m57.hermescontrol.data.model.PluginUpdateResult
|
||||
import com.m57.hermescontrol.data.model.PluginsHubResponse
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||
@@ -72,6 +74,107 @@ class PluginsViewModelTest {
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `update waits for consent and only accepts on explicit confirmation`() {
|
||||
coEvery { mockApi.updatePlugin("snyk", PluginUpdateRequest(false)) } returns
|
||||
Response.success(PluginUpdateResult(consentRequired = true, deltaLines = listOf("tools: scan")))
|
||||
coEvery { mockApi.updatePlugin("snyk", PluginUpdateRequest(true)) } returns
|
||||
Response.success(PluginUpdateResult(ok = true))
|
||||
coEvery { mockApi.getPlugins() } returns Response.success(PluginsHubResponse(emptyList()))
|
||||
val viewModel = PluginsViewModel()
|
||||
viewModel.updatePlugin("snyk")
|
||||
viewModel.updatePlugin("snyk")
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals(
|
||||
listOf("tools: scan"),
|
||||
viewModel.uiState.value.updateConsent
|
||||
?.result
|
||||
?.capabilityDelta,
|
||||
)
|
||||
assertNull(viewModel.uiState.value.toastMessage)
|
||||
assertNull(viewModel.uiState.value.rowBusy)
|
||||
coVerify(exactly = 0) { mockApi.updatePlugin(any(), PluginUpdateRequest(true)) }
|
||||
coVerify(exactly = 0) { mockApi.getPlugins() }
|
||||
viewModel.confirmPluginUpdate()
|
||||
viewModel.confirmPluginUpdate()
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
assertNull(viewModel.uiState.value.updateConsent)
|
||||
assertEquals("Plugin updated successfully", viewModel.uiState.value.toastMessage)
|
||||
coVerify(exactly = 1) { mockApi.updatePlugin("snyk", PluginUpdateRequest(false)) }
|
||||
coVerify(exactly = 1) { mockApi.updatePlugin("snyk", PluginUpdateRequest(true)) }
|
||||
coVerify(exactly = 1) { mockApi.getPlugins() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancel consent never retries the update`() {
|
||||
coEvery { mockApi.updatePlugin(any(), any()) } returns
|
||||
Response.success(PluginUpdateResult(consentRequired = true))
|
||||
val viewModel = PluginsViewModel()
|
||||
viewModel.updatePlugin("snyk")
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
viewModel.cancelPluginUpdate()
|
||||
viewModel.confirmPluginUpdate()
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
assertNull(viewModel.uiState.value.updateConsent)
|
||||
assertNull(viewModel.uiState.value.toastMessage)
|
||||
coVerify(exactly = 1) { mockApi.updatePlugin(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ok false is a failure even with HTTP 200 and unchanged true`() {
|
||||
coEvery { mockApi.updatePlugin(any(), any()) } returns
|
||||
Response.success(PluginUpdateResult(unchanged = true, error = "Update refused"))
|
||||
val viewModel = PluginsViewModel()
|
||||
viewModel.updatePlugin("snyk")
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals("Failed to update plugin: Update refused", viewModel.uiState.value.toastMessage)
|
||||
assertNull(viewModel.uiState.value.rowBusy)
|
||||
coVerify(exactly = 0) { mockApi.getPlugins() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unchanged success reports already up to date`() {
|
||||
coEvery { mockApi.updatePlugin(any(), any()) } returns
|
||||
Response.success(PluginUpdateResult(ok = true, unchanged = true))
|
||||
coEvery { mockApi.getPlugins() } returns Response.success(PluginsHubResponse(emptyList()))
|
||||
val viewModel = PluginsViewModel()
|
||||
viewModel.updatePlugin("snyk")
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals("Plugin is already up to date", viewModel.uiState.value.toastMessage)
|
||||
assertNull(viewModel.uiState.value.rowBusy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scope switch discards late update consent`() {
|
||||
val reply = CompletableDeferred<Response<PluginUpdateResult>>()
|
||||
coEvery { mockApi.updatePlugin(any(), any()) } coAnswers { reply.await() }
|
||||
val viewModel = PluginsViewModel()
|
||||
viewModel.updatePlugin("snyk")
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
viewModel.clearScopeOwnedState()
|
||||
reply.complete(Response.success(PluginUpdateResult(consentRequired = true)))
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
viewModel.confirmPluginUpdate()
|
||||
assertNull(viewModel.uiState.value.updateConsent)
|
||||
assertNull(viewModel.uiState.value.rowBusy)
|
||||
assertNull(viewModel.uiState.value.toastMessage)
|
||||
coVerify(exactly = 1) { mockApi.updatePlugin(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `HTTP update failure releases busy state`() {
|
||||
coEvery { mockApi.updatePlugin(any(), any()) } returns Response.error(503, "offline".toResponseBody())
|
||||
val viewModel = PluginsViewModel()
|
||||
viewModel.updatePlugin("snyk")
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
assertTrue(
|
||||
viewModel.uiState.value.toastMessage!!
|
||||
.contains("Failed to update"),
|
||||
)
|
||||
assertNull(viewModel.uiState.value.rowBusy)
|
||||
coVerify(exactly = 0) { mockApi.getPlugins() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `switch tab to CATALOG automatically triggers catalog load`() {
|
||||
coEvery { mockApi.getPluginCatalog() } returns
|
||||
|
||||
@@ -99,6 +99,72 @@ class SessionsViewModelTest {
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `corrupt refresh retains history and recovery replaces it`() {
|
||||
coEvery { mockApi.getSessions(any(), any(), any(), any(), any()) } returnsMany
|
||||
listOf(
|
||||
Response.success(SessionListResponse(listOf(SessionInfo("cached")), total = 1)),
|
||||
Response.success(
|
||||
SessionListResponse(emptyList(), storage = mapOf("default" to "corrupt")),
|
||||
),
|
||||
Response.success(SessionListResponse(emptyList())),
|
||||
)
|
||||
val vm = createViewModel()
|
||||
vm.loadSessions()
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
vm.loadSessions(forceRefresh = true)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals(
|
||||
listOf("cached"),
|
||||
vm.uiState.value.sessions
|
||||
.map { it.id },
|
||||
)
|
||||
assertEquals(setOf("default"), vm.uiState.value.corruptStorageProfiles)
|
||||
assertFalse(vm.uiState.value.isLoading)
|
||||
assertFalse(vm.uiState.value.hasMore)
|
||||
vm.loadSessions(forceRefresh = true)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
assertTrue(
|
||||
vm.uiState.value.sessions
|
||||
.isEmpty(),
|
||||
)
|
||||
assertTrue(
|
||||
vm.uiState.value.corruptStorageProfiles
|
||||
.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `corrupt first page retains partial rows and flags empty history`() {
|
||||
val response =
|
||||
kotlinx.serialization.json.Json.decodeFromString<SessionListResponse>(
|
||||
"""{"sessions":[],"storage":{"work":"corrupt"}}""",
|
||||
)
|
||||
coEvery { mockApi.getSessions(any(), any(), any(), any(), any()) } returns Response.success(response)
|
||||
val vm = createViewModel()
|
||||
vm.loadSessions()
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals(setOf("work"), vm.uiState.value.corruptStorageProfiles)
|
||||
assertFalse(vm.uiState.value.isLoading)
|
||||
coEvery { mockApi.getSessions(any(), any(), any(), any(), any()) } returns
|
||||
Response.success(response.copy(sessions = listOf(SessionInfo("partial"))))
|
||||
vm.loadSessions(forceRefresh = true)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals(
|
||||
listOf("partial"),
|
||||
vm.uiState.value.sessions
|
||||
.map { it.id },
|
||||
)
|
||||
assertEquals(setOf("work"), vm.uiState.value.corruptStorageProfiles)
|
||||
assertTrue(
|
||||
kotlinx.serialization.json.Json
|
||||
.decodeFromString<SessionListResponse>(
|
||||
"""{"sessions":[]}""",
|
||||
).storage
|
||||
.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wire paging metadata drives a second request without changing history paging`() {
|
||||
val first =
|
||||
|
||||
Reference in new issue
Block a user