Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
5ab7ad16df
commit
a9c3cd0691
9 files changed
+244
-3
No files matched your search
@@ -107,4 +107,40 @@ class ChatInputPolicyTest {
|
||||
val value = ChatInputPolicy.commandFieldValue("/help")
|
||||
assertTrue("cursor must be past the shared /h prefix", value.selection.start > 2)
|
||||
}
|
||||
|
||||
// ── Slash suggestion ranking (issue #865) ───────────────────────────────
|
||||
|
||||
@Test
|
||||
fun sortSlashSuggestions_mostUsedFirst_tiesKeepCatalogOrder() {
|
||||
val commands = listOf("/help", "/model", "/new", "/stop")
|
||||
val usage = mapOf("/stop" to 5, "/model" to 2)
|
||||
|
||||
assertEquals(
|
||||
"most-used must surface first; equal counts keep catalog order",
|
||||
listOf("/stop", "/model", "/help", "/new"),
|
||||
ChatInputPolicy.sortSlashSuggestions(commands, usage),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sortSlashSuggestions_noUsage_keepsCatalogOrder() {
|
||||
val commands = listOf("/help", "/model", "/new")
|
||||
assertEquals(
|
||||
"no history must fall back to catalog order unchanged",
|
||||
commands,
|
||||
ChatInputPolicy.sortSlashSuggestions(commands, emptyMap()),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sortSlashSuggestions_lookupIsCaseInsensitive() {
|
||||
// Stored keys are always lowercase (normalized at dispatch time), but
|
||||
// the lookup lowercases the catalog name so a mismatched case can
|
||||
// never break the ranking.
|
||||
val commands = listOf("/model", "/new")
|
||||
assertEquals(
|
||||
listOf("/model", "/new"),
|
||||
ChatInputPolicy.sortSlashSuggestions(commands, mapOf("/MODEL" to 3)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import com.m57.hermescontrol.data.ws.JsonRpcError
|
||||
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.*
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
@@ -67,6 +68,7 @@ class ChatViewModelTest {
|
||||
private val mockSwitchFlow = MutableSharedFlow<String>(extraBufferCapacity = 8)
|
||||
private lateinit var app: Application
|
||||
private lateinit var fakeRepo: FakeChatPersistenceRepository
|
||||
private lateinit var fakeSlashUsageStore: FakeSlashUsageStore
|
||||
|
||||
/** Counter used to generate unique WS request IDs. */
|
||||
private var reqCount = 0
|
||||
@@ -114,6 +116,7 @@ class ChatViewModelTest {
|
||||
|
||||
app = mockk(relaxed = true)
|
||||
fakeRepo = FakeChatPersistenceRepository()
|
||||
fakeSlashUsageStore = FakeSlashUsageStore()
|
||||
ActiveSessionHolder.set(null)
|
||||
|
||||
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||
@@ -184,7 +187,7 @@ class ChatViewModelTest {
|
||||
private fun createViewModel(startCleanup: Boolean = false): ChatViewModel =
|
||||
// Both dispatchers injected — a real ioDispatcher would race the
|
||||
// test scheduler (repo writes hop it) and shuffle RPC ordering.
|
||||
ChatViewModel(app, startCleanup, fakeRepo, testDispatcher, testDispatcher)
|
||||
ChatViewModel(app, startCleanup, fakeRepo, fakeSlashUsageStore, testDispatcher, testDispatcher)
|
||||
|
||||
/**
|
||||
* Create ViewModel, simulate GatewayReady, feed SESSION_CREATE result,
|
||||
@@ -402,6 +405,47 @@ class ChatViewModelTest {
|
||||
verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
|
||||
}
|
||||
|
||||
// ── Slash usage ranking (issue #865) ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun slashUsage_dispatchIncrementsCountPerCommand() =
|
||||
runTest {
|
||||
val (viewModel, sessionId) = createViewModelWithSession()
|
||||
|
||||
every {
|
||||
HermesWsClient.request(WsMethods.COMMAND_DISPATCH, any(), any())
|
||||
} returns
|
||||
CompletableDeferred(
|
||||
mapOf("type" to "exec", "output" to "ok"),
|
||||
)
|
||||
|
||||
viewModel.sendMessage("/help")
|
||||
advanceUntilIdle()
|
||||
viewModel.sendMessage("/help")
|
||||
advanceUntilIdle()
|
||||
viewModel.sendMessage("/new")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(2, viewModel.uiState.value.slashUsageCounts["/help"])
|
||||
assertEquals(1, viewModel.uiState.value.slashUsageCounts["/new"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun slashUsage_blockedCommand_notCounted() =
|
||||
runTest {
|
||||
val (viewModel, sessionId) = createViewModelWithSession()
|
||||
|
||||
viewModel.sendMessage("/clear")
|
||||
advanceUntilIdle()
|
||||
|
||||
// A blocklisted command can never dispatch — it must not climb
|
||||
// the autocomplete ranking either.
|
||||
assertTrue(viewModel.uiState.value.slashUsageCounts.isEmpty())
|
||||
verify(exactly = 0) {
|
||||
HermesWsClient.request(WsMethods.COMMAND_DISPATCH, any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSlashCommand_unknown_showsErrorMessage() =
|
||||
runTest {
|
||||
|
||||
@@ -9,6 +9,7 @@ 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.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
@@ -126,7 +127,7 @@ class SlashCommandDispatchRpcTest {
|
||||
}
|
||||
|
||||
private suspend fun TestScope.createViewModelWithSession(): Pair<ChatViewModel, String> {
|
||||
val vm = ChatViewModel(app, false, fakeRepo, ioDispatcher = testDispatcher)
|
||||
val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
|
||||
advanceUntilIdle()
|
||||
mockConnectionStatus.value = ConnectionStatus.CONNECTED
|
||||
mockEventsFlow.emit(WsEvent.GatewayReady(null))
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.m57.hermescontrol.ui.chat.fakes
|
||||
|
||||
import com.m57.hermescontrol.data.local.SlashUsageStore
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/**
|
||||
* In-memory [SlashUsageStore] for ChatViewModel tests (issue #865).
|
||||
*
|
||||
* Mirrors the real store's semantics: [recordUse] bumps the count and
|
||||
* [counts] emits the current map. The real store needs an Android Context
|
||||
* (DataStore), which unit tests don't have — the relaxed mock is a dummy
|
||||
* the overrides never touch (same pattern as the mocked Application in
|
||||
* ChatViewModelTest).
|
||||
*/
|
||||
class FakeSlashUsageStore : SlashUsageStore(mockk(relaxed = true)) {
|
||||
private val countsFlow = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||
|
||||
override fun counts(): Flow<Map<String, Int>> = countsFlow
|
||||
|
||||
override suspend fun recordUse(command: String) {
|
||||
countsFlow.update { it + (command to ((it[command] ?: 0) + 1)) }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user