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
@@ -0,0 +1,94 @@
|
|||||||
|
package com.m57.hermescontrol.data.local
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.core.Serializer
|
||||||
|
import androidx.datastore.dataStore
|
||||||
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disk-backed per-command usage counts for the slash-autocomplete ranking
|
||||||
|
* (issue #865).
|
||||||
|
*
|
||||||
|
* The backend's `commands.catalog` carries no usage data — the usage-ranked
|
||||||
|
* ordering exists only in the desktop/TUI completion path. Mobile keeps its
|
||||||
|
* own local counts (keyed by command name, lowercase, WITH leading slash,
|
||||||
|
* e.g. "/model") and ranks the suggestion menu by them.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
private data class SlashUsageState(
|
||||||
|
// Command name (lowercase, WITH leading slash, e.g. "/model") → count.
|
||||||
|
val counts: Map<String, Int> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private object SlashUsageSerializer : Serializer<SlashUsageState> {
|
||||||
|
override val defaultValue: SlashUsageState = SlashUsageState()
|
||||||
|
|
||||||
|
override suspend fun readFrom(input: InputStream): SlashUsageState =
|
||||||
|
try {
|
||||||
|
// Use the app's wire Json (ignoreUnknownKeys=true) so a future
|
||||||
|
// schema addition can't break the on-disk decode.
|
||||||
|
OkHttpProvider.json.decodeFromString(
|
||||||
|
SlashUsageState.serializer(),
|
||||||
|
input.readBytes().decodeToString(),
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun writeTo(
|
||||||
|
t: SlashUsageState,
|
||||||
|
output: OutputStream,
|
||||||
|
) {
|
||||||
|
output.write(
|
||||||
|
OkHttpProvider.json
|
||||||
|
.encodeToString(SlashUsageState.serializer(), t)
|
||||||
|
.toByteArray(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val Context.slashUsageDataStore: DataStore<SlashUsageState> by dataStore(
|
||||||
|
fileName = "slash_usage.json",
|
||||||
|
serializer = SlashUsageSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores how often each slash command was dispatched so the autocomplete can
|
||||||
|
* surface most-used commands first. Best-effort: read/write failures degrade
|
||||||
|
* to the empty map / a dropped write — never a crash.
|
||||||
|
*/
|
||||||
|
open class SlashUsageStore(
|
||||||
|
private val context: Context,
|
||||||
|
) {
|
||||||
|
/** Current usage counts; empty map when nothing is recorded yet. */
|
||||||
|
open fun counts(): Flow<Map<String, Int>> =
|
||||||
|
context.slashUsageDataStore.data
|
||||||
|
.catch { emit(SlashUsageState()) }
|
||||||
|
.map { it.counts }
|
||||||
|
|
||||||
|
/** Bump the count for [command] (e.g. "/model"). Never throws. */
|
||||||
|
open suspend fun recordUse(command: String) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
context.slashUsageDataStore.updateData { state ->
|
||||||
|
state.copy(
|
||||||
|
counts =
|
||||||
|
state.counts +
|
||||||
|
(command to ((state.counts[command] ?: 0) + 1)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Best-effort: a failed write must never block the dispatch.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,4 +45,15 @@ object ChatInputPolicy {
|
|||||||
* selection.
|
* selection.
|
||||||
*/
|
*/
|
||||||
fun commandFieldValue(command: String): TextFieldValue = TextFieldValue(command, TextRange(command.length))
|
fun commandFieldValue(command: String): TextFieldValue = TextFieldValue(command, TextRange(command.length))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rank slash-command suggestions by how often the user has dispatched them
|
||||||
|
* (issue #865): most-used first, ties broken by the current (catalog)
|
||||||
|
* order via the stable sort. Commands with no recorded usage keep their
|
||||||
|
* catalog position, so a fresh install behaves exactly as before.
|
||||||
|
*/
|
||||||
|
fun sortSlashSuggestions(
|
||||||
|
commands: List<String>,
|
||||||
|
usageCounts: Map<String, Int>,
|
||||||
|
): List<String> = commands.sortedByDescending { usageCounts[it.lowercase()] ?: 0 }
|
||||||
}
|
}
|
||||||
@@ -681,6 +681,7 @@ fun ChatScreen(
|
|||||||
isAgentTyping = state.isAgentTyping,
|
isAgentTyping = state.isAgentTyping,
|
||||||
isConnected = state.isConnected,
|
isConnected = state.isConnected,
|
||||||
commandCatalog = state.commandCatalog,
|
commandCatalog = state.commandCatalog,
|
||||||
|
slashUsageCounts = state.slashUsageCounts,
|
||||||
pendingAttachments = state.pendingAttachments,
|
pendingAttachments = state.pendingAttachments,
|
||||||
onCameraTap = {
|
onCameraTap = {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.lifecycle.AndroidViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import com.m57.hermescontrol.data.local.HermesDatabase
|
import com.m57.hermescontrol.data.local.HermesDatabase
|
||||||
|
import com.m57.hermescontrol.data.local.SlashUsageStore
|
||||||
import com.m57.hermescontrol.data.model.Attachment
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||||
import com.m57.hermescontrol.data.model.ModelProvider
|
import com.m57.hermescontrol.data.model.ModelProvider
|
||||||
@@ -274,6 +275,10 @@ data class ChatUiState(
|
|||||||
val typingEffectDelayMs: Int = 30,
|
val typingEffectDelayMs: Int = 30,
|
||||||
// Commands catalog
|
// Commands catalog
|
||||||
val commandCatalog: CommandCatalog = CommandCatalog(),
|
val commandCatalog: CommandCatalog = CommandCatalog(),
|
||||||
|
// Per-command usage counts for the slash-autocomplete ranking (issue
|
||||||
|
// #865). Empty until the local store loads; commands without recorded
|
||||||
|
// usage keep their catalog order.
|
||||||
|
val slashUsageCounts: Map<String, Int> = emptyMap(),
|
||||||
// In-session model picker (issue #589) — surfaced when the user types /model
|
// In-session model picker (issue #589) — surfaced when the user types /model
|
||||||
// (or taps the top-bar model chip). Mirror of the global model screen's
|
// (or taps the top-bar model chip). Mirror of the global model screen's
|
||||||
// picker, but the selection hot-swaps the CURRENT session via the slash path.
|
// picker, but the selection hot-swaps the CURRENT session via the slash path.
|
||||||
@@ -378,6 +383,7 @@ class ChatViewModel(
|
|||||||
ChatPersistenceRepository(
|
ChatPersistenceRepository(
|
||||||
HermesDatabase.get(application).chatMessageDao(),
|
HermesDatabase.get(application).chatMessageDao(),
|
||||||
),
|
),
|
||||||
|
slashUsageStore: SlashUsageStore = SlashUsageStore(application.applicationContext),
|
||||||
searchDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default,
|
searchDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default,
|
||||||
private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
|
private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
|
||||||
) : AndroidViewModel(application) {
|
) : AndroidViewModel(application) {
|
||||||
@@ -456,6 +462,7 @@ class ChatViewModel(
|
|||||||
|
|
||||||
// ── Session persistence ──────────────────────────────────────────────
|
// ── Session persistence ──────────────────────────────────────────────
|
||||||
private val repo: ChatPersistenceRepository = repo
|
private val repo: ChatPersistenceRepository = repo
|
||||||
|
private val slashUsageStore: SlashUsageStore = slashUsageStore
|
||||||
private val slashDispatcher = SlashCommandDispatcher()
|
private val slashDispatcher = SlashCommandDispatcher()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -565,6 +572,14 @@ class ChatViewModel(
|
|||||||
resetSessionState(sessionId = null, title = "Hermes", isLoading = true)
|
resetSessionState(sessionId = null, title = "Hermes", isLoading = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Slash-command usage ranking (issue #865): mirror the local usage
|
||||||
|
// counts into state so the autocomplete can surface most-used
|
||||||
|
// commands first. Best-effort — the store never throws.
|
||||||
|
viewModelScope.launch {
|
||||||
|
slashUsageStore.counts().collect { counts ->
|
||||||
|
_uiState.update { it.copy(slashUsageCounts = counts) }
|
||||||
|
}
|
||||||
|
}
|
||||||
if (wsClient.connectionStatus.value == ConnectionStatus.CONNECTED) {
|
if (wsClient.connectionStatus.value == ConnectionStatus.CONNECTED) {
|
||||||
handleGatewayReady()
|
handleGatewayReady()
|
||||||
}
|
}
|
||||||
@@ -1390,6 +1405,15 @@ class ChatViewModel(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track per-command usage for the slash-autocomplete ranking (issue
|
||||||
|
// #865). Counted here, AFTER the blocklist guard, so commands that
|
||||||
|
// can never dispatch don't climb the ranking. Best-effort — a store
|
||||||
|
// failure must never block the dispatch itself.
|
||||||
|
val commandName = command.split(" ", limit = 2)[0].lowercase()
|
||||||
|
viewModelScope.launch {
|
||||||
|
slashUsageStore.recordUse(commandName)
|
||||||
|
}
|
||||||
|
|
||||||
when (val result = slashDispatcher.dispatch(command)) {
|
when (val result = slashDispatcher.dispatch(command)) {
|
||||||
is SlashResult.Interrupt -> {
|
is SlashResult.Interrupt -> {
|
||||||
interruptSession()
|
interruptSession()
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ fun ChatInputBar(
|
|||||||
isAgentTyping: Boolean,
|
isAgentTyping: Boolean,
|
||||||
isConnected: Boolean,
|
isConnected: Boolean,
|
||||||
commandCatalog: CommandCatalog,
|
commandCatalog: CommandCatalog,
|
||||||
|
slashUsageCounts: Map<String, Int> = emptyMap(),
|
||||||
pendingAttachments: List<Attachment> = emptyList(),
|
pendingAttachments: List<Attachment> = emptyList(),
|
||||||
onCameraTap: () -> Unit = {},
|
onCameraTap: () -> Unit = {},
|
||||||
onImageTap: () -> Unit = {},
|
onImageTap: () -> Unit = {},
|
||||||
@@ -136,7 +137,10 @@ fun ChatInputBar(
|
|||||||
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(),
|
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(),
|
||||||
) {
|
) {
|
||||||
val filteredCommands =
|
val filteredCommands =
|
||||||
commandNames.filter { it.startsWith(inputFieldValue.text, ignoreCase = true) }
|
ChatInputPolicy.sortSlashSuggestions(
|
||||||
|
commandNames.filter { it.startsWith(inputFieldValue.text, ignoreCase = true) },
|
||||||
|
slashUsageCounts,
|
||||||
|
)
|
||||||
if (filteredCommands.isNotEmpty()) {
|
if (filteredCommands.isNotEmpty()) {
|
||||||
androidx.compose.material3.Surface(
|
androidx.compose.material3.Surface(
|
||||||
modifier =
|
modifier =
|
||||||
|
|||||||
@@ -107,4 +107,40 @@ class ChatInputPolicyTest {
|
|||||||
val value = ChatInputPolicy.commandFieldValue("/help")
|
val value = ChatInputPolicy.commandFieldValue("/help")
|
||||||
assertTrue("cursor must be past the shared /h prefix", value.selection.start > 2)
|
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.WsEvent
|
||||||
import com.m57.hermescontrol.data.ws.WsMethods
|
import com.m57.hermescontrol.data.ws.WsMethods
|
||||||
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
|
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
|
||||||
|
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
|
||||||
import io.mockk.*
|
import io.mockk.*
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.coVerify
|
import io.mockk.coVerify
|
||||||
@@ -67,6 +68,7 @@ class ChatViewModelTest {
|
|||||||
private val mockSwitchFlow = MutableSharedFlow<String>(extraBufferCapacity = 8)
|
private val mockSwitchFlow = MutableSharedFlow<String>(extraBufferCapacity = 8)
|
||||||
private lateinit var app: Application
|
private lateinit var app: Application
|
||||||
private lateinit var fakeRepo: FakeChatPersistenceRepository
|
private lateinit var fakeRepo: FakeChatPersistenceRepository
|
||||||
|
private lateinit var fakeSlashUsageStore: FakeSlashUsageStore
|
||||||
|
|
||||||
/** Counter used to generate unique WS request IDs. */
|
/** Counter used to generate unique WS request IDs. */
|
||||||
private var reqCount = 0
|
private var reqCount = 0
|
||||||
@@ -114,6 +116,7 @@ class ChatViewModelTest {
|
|||||||
|
|
||||||
app = mockk(relaxed = true)
|
app = mockk(relaxed = true)
|
||||||
fakeRepo = FakeChatPersistenceRepository()
|
fakeRepo = FakeChatPersistenceRepository()
|
||||||
|
fakeSlashUsageStore = FakeSlashUsageStore()
|
||||||
ActiveSessionHolder.set(null)
|
ActiveSessionHolder.set(null)
|
||||||
|
|
||||||
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
|
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||||
@@ -184,7 +187,7 @@ class ChatViewModelTest {
|
|||||||
private fun createViewModel(startCleanup: Boolean = false): ChatViewModel =
|
private fun createViewModel(startCleanup: Boolean = false): ChatViewModel =
|
||||||
// Both dispatchers injected — a real ioDispatcher would race the
|
// Both dispatchers injected — a real ioDispatcher would race the
|
||||||
// test scheduler (repo writes hop it) and shuffle RPC ordering.
|
// 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,
|
* Create ViewModel, simulate GatewayReady, feed SESSION_CREATE result,
|
||||||
@@ -402,6 +405,47 @@ class ChatViewModelTest {
|
|||||||
verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
|
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
|
@Test
|
||||||
fun testSlashCommand_unknown_showsErrorMessage() =
|
fun testSlashCommand_unknown_showsErrorMessage() =
|
||||||
runTest {
|
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.WsEvent
|
||||||
import com.m57.hermescontrol.data.ws.WsMethods
|
import com.m57.hermescontrol.data.ws.WsMethods
|
||||||
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
|
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
|
||||||
|
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.mockkObject
|
import io.mockk.mockkObject
|
||||||
@@ -126,7 +127,7 @@ class SlashCommandDispatchRpcTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun TestScope.createViewModelWithSession(): Pair<ChatViewModel, String> {
|
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()
|
advanceUntilIdle()
|
||||||
mockConnectionStatus.value = ConnectionStatus.CONNECTED
|
mockConnectionStatus.value = ConnectionStatus.CONNECTED
|
||||||
mockEventsFlow.emit(WsEvent.GatewayReady(null))
|
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