feat(#365): add pin support for models with pinned section at top (#414)

* feat(#365): add pin support for models with pinned section at top

* fix(#365): stub Pinned Models methods in E2eIntegrationTest

* fix(#365): use state flow for pin/unpin, add max cap, add ViewModel tests

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-28 02:23:25 +03:00
1 parent 42a67bffa9
commit 4745728920
8 files changed
+630 -10

No files matched your search

@@ -0,0 +1,100 @@
package com.m57.hermescontrol.ui.model
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class ModelScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
private val mockViewModel = mockk<ModelViewModel>(relaxed = true)
private val uiStateFlow = MutableStateFlow(ModelUiState())
@Before
fun setUp() {
every { mockViewModel.uiState } returns uiStateFlow.asStateFlow()
}
@Test
fun modelScreen_pinnedSectionHidden_whenNoPinnedModels() {
uiStateFlow.value =
ModelUiState(
providers =
listOf(
ModelProvider(
slug = "openai",
name = "OpenAI",
is_current = true,
is_user_defined = false,
models = listOf("gpt-4"),
total_models = 1,
source = null,
authenticated = true,
auth_type = null,
warning = null,
),
),
pinnedModels = emptyList(),
)
composeTestRule.setContent {
ModelScreen(
onOpenDrawer = {},
viewModel = mockViewModel,
)
}
composeTestRule.onNodeWithText("Pinned Models").assertDoesNotExist()
}
@Test
fun modelScreen_rendersPinnedModels_whenPresent() {
uiStateFlow.value =
ModelUiState(
providers =
listOf(
ModelProvider(
slug = "openai",
name = "OpenAI",
is_current = true,
is_user_defined = false,
models = listOf("gpt-4"),
total_models = 1,
source = null,
authenticated = true,
auth_type = null,
warning = null,
),
),
pinnedModels =
listOf(
PinnedModel(providerSlug = "openai", modelName = "gpt-4"),
),
)
composeTestRule.setContent {
ModelScreen(
onOpenDrawer = {},
viewModel = mockViewModel,
)
}
composeTestRule.onNodeWithText("Pinned Models").assertIsDisplayed()
composeTestRule.onNodeWithText("gpt-4").assertIsDisplayed()
}
}
@@ -40,6 +40,7 @@ object AuthManager {
private const val KEY_USE_DYNAMIC_COLORS = "use_dynamic_colors" private const val KEY_USE_DYNAMIC_COLORS = "use_dynamic_colors"
private const val KEY_THEME_PRESET = "theme_preset" private const val KEY_THEME_PRESET = "theme_preset"
private const val KEY_BOTTOM_NAV_DISPLAY_MODE = "bottom_nav_display_mode" private const val KEY_BOTTOM_NAV_DISPLAY_MODE = "bottom_nav_display_mode"
private const val KEY_PINNED_MODELS = "pinned_models"
private const val DEFAULT_HOST = "127.0.0.1" private const val DEFAULT_HOST = "127.0.0.1"
private const val DEFAULT_PORT = 9119 private const val DEFAULT_PORT = 9119
@@ -180,6 +181,29 @@ object AuthManager {
requirePrefs().edit().putString(KEY_CONNECTION_PROFILES, json).apply() requirePrefs().edit().putString(KEY_CONNECTION_PROFILES, json).apply()
} }
// ── Pinned Models ────────────────────────────────────────────────────
fun getPinnedModels(): List<com.m57.hermescontrol.data.model.PinnedModel> {
val json = requirePrefs().getString(KEY_PINNED_MODELS, null) ?: return emptyList()
return try {
val type =
object : com.google.gson.reflect.TypeToken<
List<com.m57.hermescontrol.data.model.PinnedModel>,
>() {}.type
gson.fromJson(json, type) ?: emptyList()
} catch (e: Exception) {
if (com.m57.hermescontrol.BuildConfig.DEBUG) {
android.util.Log.w("AuthManager", "Failed to parse pinned models", e)
}
emptyList()
}
}
fun savePinnedModels(pinned: List<com.m57.hermescontrol.data.model.PinnedModel>) {
val json = gson.toJson(pinned)
requirePrefs().edit().putString(KEY_PINNED_MODELS, json).apply()
}
fun getProfileToken(profileId: String): String? = requirePrefs().getString("token_$profileId", null) fun getProfileToken(profileId: String): String? = requirePrefs().getString("token_$profileId", null)
fun setProfileToken( fun setProfileToken(
@@ -16,3 +16,8 @@ data class ModelProvider(
val auth_type: String?, val auth_type: String?,
val warning: String?, val warning: String?,
) )
data class PinnedModel(
val providerSlug: String,
val modelName: String,
)
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.ui.model package com.m57.hermescontrol.ui.model
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -11,14 +12,20 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -26,6 +33,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -116,6 +124,205 @@ fun ModelScreen(
placeholder = "Search models...", placeholder = "Search models...",
) )
} }
if (state.pinnedModels.isNotEmpty()) {
item {
var isPinnedSectionExpanded by rememberSaveable { mutableStateOf(true) }
val filteredPinned =
remember(query, state.pinnedModels) {
if (query.isBlank()) {
state.pinnedModels
} else {
state.pinnedModels.filter {
it.modelName.contains(query, ignoreCase = true) ||
it.providerSlug.contains(query, ignoreCase = true)
}
}
}
if (filteredPinned.isNotEmpty()) {
Card(
modifier =
Modifier
.fillMaxWidth()
.animateContentSize()
.padding(bottom = 8.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.5f,
),
),
onClick = {
isPinnedSectionExpanded = !isPinnedSectionExpanded
},
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Filled.PushPin,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(R.string.model_section_pinned),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
IconButton(
onClick = {
isPinnedSectionExpanded = !isPinnedSectionExpanded
},
) {
Icon(
imageVector =
if (isPinnedSectionExpanded) {
Icons.Filled.KeyboardArrowUp
} else {
Icons.Filled.KeyboardArrowDown
},
contentDescription =
stringResource(
R.string.content_desc_pinned_section_toggle,
),
)
}
}
AnimatedVisibility(visible = isPinnedSectionExpanded) {
Column(modifier = Modifier.padding(top = 8.dp)) {
filteredPinned.forEach { pinned ->
val isActive =
state.activeProfile?.provider == pinned.providerSlug &&
state.activeProfile?.model == pinned.modelName
Card(
onClick = {
viewModel.selectModel(
pinned.providerSlug,
pinned.modelName,
)
},
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
colors =
CardDefaults.cardColors(
containerColor =
if (isActive) {
MaterialTheme.colorScheme
.primaryContainer
.copy(alpha = 0.3f)
} else {
MaterialTheme.colorScheme.surface
},
),
border =
BorderStroke(
width = 1.dp,
color =
if (isActive) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline.copy(
alpha = 0.2f,
)
},
),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = pinned.modelName,
style = MaterialTheme.typography.bodyMedium,
fontWeight =
if (isActive) {
FontWeight.Bold
} else {
FontWeight.Normal
},
color =
if (isActive) {
MaterialTheme.colorScheme
.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurface
},
)
Text(
text = pinned.providerSlug,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (isActive) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription =
stringResource(
R.string.content_desc_active_model,
),
tint = MaterialTheme.colorScheme.primary,
)
}
IconButton(
onClick = {
viewModel.unpinModel(
pinned.providerSlug,
pinned.modelName,
)
},
modifier = Modifier.size(24.dp),
) {
Icon(
imageVector = Icons.Filled.PushPin,
contentDescription =
stringResource(
R.string.content_desc_unpin_model,
),
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
}
}
}
}
}
}
}
items(filteredProviders, key = { it.slug }) { provider -> items(filteredProviders, key = { it.slug }) { provider ->
val isExpanded = expandedProviderSlug == provider.slug val isExpanded = expandedProviderSlug == provider.slug
val isCurrent = provider.is_current == true val isCurrent = provider.is_current == true
@@ -268,15 +475,61 @@ fun ModelScreen(
MaterialTheme.colorScheme.onSurface MaterialTheme.colorScheme.onSurface
}, },
) )
if (isActive) { Row(
Icon( verticalAlignment = Alignment.CenterVertically,
imageVector = Icons.Filled.Check, horizontalArrangement = Arrangement.spacedBy(8.dp),
contentDescription = ) {
stringResource( if (isActive) {
R.string.content_desc_active_model, Icon(
), imageVector = Icons.Filled.Check,
tint = MaterialTheme.colorScheme.primary, contentDescription =
) stringResource(
R.string.content_desc_active_model,
),
tint = MaterialTheme.colorScheme.primary,
)
}
val isPinned =
state.pinnedModels.any {
it.providerSlug == provider.slug &&
it.modelName == model
}
IconButton(
onClick = {
if (isPinned) {
viewModel.unpinModel(provider.slug, model)
} else {
viewModel.pinModel(provider.slug, model)
}
},
modifier = Modifier.size(24.dp),
) {
Icon(
imageVector =
if (isPinned) {
Icons.Filled.PushPin
} else {
Icons.Outlined.PushPin
},
contentDescription =
stringResource(
if (isPinned) {
R.string.content_desc_unpin_model
} else {
R.string.content_desc_pin_model
},
),
tint =
if (isPinned) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme
.onSurfaceVariant
.copy(alpha = 0.6f)
},
)
}
} }
} }
} }
@@ -2,7 +2,9 @@ package com.m57.hermescontrol.ui.model
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.ProfileInfo import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.model.UpdateProfileModelRequest import com.m57.hermescontrol.data.model.UpdateProfileModelRequest
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
@@ -24,12 +26,19 @@ data class ModelUiState(
val activeProfile: ProfileInfo? = null, val activeProfile: ProfileInfo? = null,
val errorMessage: String? = null, val errorMessage: String? = null,
val toastMessage: String? = null, val toastMessage: String? = null,
val pinnedModels: List<PinnedModel> = emptyList(),
) )
class ModelViewModel : ViewModel(), ToastHost { class ModelViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(ModelUiState()) private val _uiState = MutableStateFlow(ModelUiState())
val uiState: StateFlow<ModelUiState> = _uiState.asStateFlow() val uiState: StateFlow<ModelUiState> = _uiState.asStateFlow()
init {
_uiState.update { it.copy(pinnedModels = AuthManager.getPinnedModels()) }
}
fun loadModelOptions() { fun loadModelOptions() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch { viewModelScope.launch {
@@ -70,6 +79,7 @@ class ModelViewModel : ViewModel(), ToastHost {
isLoading = false, isLoading = false,
providers = responseResult.data.providers.orEmpty(), providers = responseResult.data.providers.orEmpty(),
activeProfile = activeProfile, activeProfile = activeProfile,
pinnedModels = AuthManager.getPinnedModels(),
) )
} }
} }
@@ -139,4 +149,38 @@ class ModelViewModel : ViewModel(), ToastHost {
override fun clearToast() { override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) } _uiState.update { it.copy(toastMessage = null) }
} }
companion object {
private const val MAX_PINNED_MODELS = 15
}
fun pinModel(
providerSlug: String,
modelName: String,
) {
val currentPinned = _uiState.value.pinnedModels.toMutableList()
val newPin = PinnedModel(providerSlug, modelName)
if (currentPinned.contains(newPin)) return
if (currentPinned.size >= MAX_PINNED_MODELS) {
_uiState.update {
it.copy(toastMessage = "Maximum of $MAX_PINNED_MODELS pinned models reached")
}
return
}
currentPinned.add(newPin)
AuthManager.savePinnedModels(currentPinned)
_uiState.update { it.copy(pinnedModels = currentPinned) }
}
fun unpinModel(
providerSlug: String,
modelName: String,
) {
val currentPinned = _uiState.value.pinnedModels.toMutableList()
val pinToRemove = PinnedModel(providerSlug, modelName)
if (currentPinned.remove(pinToRemove)) {
AuthManager.savePinnedModels(currentPinned)
_uiState.update { it.copy(pinnedModels = currentPinned) }
}
}
} }
+4
View File
@@ -268,6 +268,10 @@
<string name="model_label_available">Available Models:</string> <string name="model_label_available">Available Models:</string>
<string name="model_no_models">No models reported or provider needs authentication.</string> <string name="model_no_models">No models reported or provider needs authentication.</string>
<string name="content_desc_active_model">Active Model</string> <string name="content_desc_active_model">Active Model</string>
<string name="model_section_pinned">Pinned Models</string>
<string name="content_desc_pin_model">Pin model</string>
<string name="content_desc_unpin_model">Unpin model</string>
<string name="content_desc_pinned_section_toggle">Toggle pinned section visibility</string>
<!-- Sessions (History) Screen --> <!-- Sessions (History) Screen -->
<string name="history_empty_title">No history</string> <string name="history_empty_title">No history</string>
@@ -88,6 +88,8 @@ class E2eIntegrationTest {
every { AuthManager.getProfileToken(any()) } returns null every { AuthManager.getProfileToken(any()) } returns null
every { AuthManager.setProfileToken(any(), any()) } returns Unit every { AuthManager.setProfileToken(any(), any()) } returns Unit
every { AuthManager.saveConnectionProfiles(any()) } returns Unit every { AuthManager.saveConnectionProfiles(any()) } returns Unit
every { AuthManager.getPinnedModels() } returns emptyList()
every { AuthManager.savePinnedModels(any()) } returns Unit
// Application stubs // Application stubs
every { mockApp.getString(R.string.connect_error_401) } returns "Invalid token (401 Unauthorized)" every { mockApp.getString(R.string.connect_error_401) } returns "Invalid token (401 Unauthorized)"
@@ -0,0 +1,188 @@
package com.m57.hermescontrol.ui.model
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.PinnedModel
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ModelViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private var storedPinnedModels: MutableList<PinnedModel> = mutableListOf()
private fun createViewModel(): ModelViewModel {
val vm = ModelViewModel()
testDispatcher.scheduler.advanceUntilIdle()
return vm
}
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
mockkObject(AuthManager)
storedPinnedModels = mutableListOf()
every { AuthManager.getPinnedModels() } answers { storedPinnedModels.toList() }
every { AuthManager.savePinnedModels(any()) } answers {
storedPinnedModels = firstArg<List<PinnedModel>>().toMutableList()
}
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
@Test
fun `init loads pinned models from AuthManager`() {
storedPinnedModels.add(PinnedModel("openai", "gpt-4"))
storedPinnedModels.add(PinnedModel("anthropic", "claude-3"))
val viewModel = createViewModel()
val state = viewModel.uiState.value
assertEquals(2, state.pinnedModels.size)
assertEquals("gpt-4", state.pinnedModels[0].modelName)
assertEquals("claude-3", state.pinnedModels[1].modelName)
}
@Test
fun `init with empty pinned models`() {
val viewModel = createViewModel()
assertTrue(viewModel.uiState.value.pinnedModels.isEmpty())
}
@Test
fun `pinModel adds model to pinned list`() {
val viewModel = createViewModel()
viewModel.pinModel("openai", "gpt-4")
testDispatcher.scheduler.advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals(1, state.pinnedModels.size)
assertEquals(PinnedModel("openai", "gpt-4"), state.pinnedModels[0])
}
@Test
fun `pinModel prevents duplicate pins`() {
val viewModel = createViewModel()
viewModel.pinModel("openai", "gpt-4")
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(1, viewModel.uiState.value.pinnedModels.size)
viewModel.pinModel("openai", "gpt-4")
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(1, viewModel.uiState.value.pinnedModels.size)
}
@Test
fun `pinModel persists to AuthManager`() {
val viewModel = createViewModel()
viewModel.pinModel("openai", "gpt-4")
testDispatcher.scheduler.advanceUntilIdle()
verify { AuthManager.savePinnedModels(listOf(PinnedModel("openai", "gpt-4"))) }
}
@Test
fun `unpinModel removes model from pinned list`() {
val viewModel = createViewModel()
viewModel.pinModel("openai", "gpt-4")
viewModel.pinModel("anthropic", "claude-3")
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(2, viewModel.uiState.value.pinnedModels.size)
viewModel.unpinModel("openai", "gpt-4")
testDispatcher.scheduler.advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals(1, state.pinnedModels.size)
assertEquals(PinnedModel("anthropic", "claude-3"), state.pinnedModels[0])
}
@Test
fun `unpinModel does nothing for non-pinned model`() {
val viewModel = createViewModel()
viewModel.pinModel("openai", "gpt-4")
testDispatcher.scheduler.advanceUntilIdle()
viewModel.unpinModel("openai", "nonexistent-model")
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(1, viewModel.uiState.value.pinnedModels.size)
}
@Test
fun `unpinModel persists removal to AuthManager`() {
val viewModel = createViewModel()
viewModel.pinModel("openai", "gpt-4")
viewModel.pinModel("anthropic", "claude-3")
testDispatcher.scheduler.advanceUntilIdle()
viewModel.unpinModel("openai", "gpt-4")
testDispatcher.scheduler.advanceUntilIdle()
verify { AuthManager.savePinnedModels(listOf(PinnedModel("anthropic", "claude-3"))) }
}
@Test
fun `pinModel shows toast when max cap reached`() {
val viewModel = createViewModel()
// Pin 15 models to reach cap
for (i in 1..15) {
viewModel.pinModel("provider-$i", "model-$i")
}
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(15, viewModel.uiState.value.pinnedModels.size)
// Try pinning one more
viewModel.pinModel("overflow", "too-many")
testDispatcher.scheduler.advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals(15, state.pinnedModels.size)
assertNull(state.pinnedModels.find { it.providerSlug == "overflow" })
// Toast should be set
val toastMessage = state.toastMessage
assertTrue(toastMessage?.contains("15") == true)
}
@Test
fun `pin and unpin cycle works`() {
val viewModel = createViewModel()
viewModel.pinModel("openai", "gpt-4")
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(1, viewModel.uiState.value.pinnedModels.size)
viewModel.unpinModel("openai", "gpt-4")
testDispatcher.scheduler.advanceUntilIdle()
assertTrue(viewModel.uiState.value.pinnedModels.isEmpty())
// Re-pin same model after unpin
viewModel.pinModel("openai", "gpt-4")
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(1, viewModel.uiState.value.pinnedModels.size)
}
}