* 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:
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_THEME_PRESET = "theme_preset"
|
||||
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_PORT = 9119
|
||||
@@ -180,6 +181,29 @@ object AuthManager {
|
||||
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 setProfileToken(
|
||||
|
||||
@@ -16,3 +16,8 @@ data class ModelProvider(
|
||||
val auth_type: String?,
|
||||
val warning: String?,
|
||||
)
|
||||
|
||||
data class PinnedModel(
|
||||
val providerSlug: String,
|
||||
val modelName: String,
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.m57.hermescontrol.ui.model
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -26,6 +33,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -116,6 +124,205 @@ fun ModelScreen(
|
||||
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 ->
|
||||
val isExpanded = expandedProviderSlug == provider.slug
|
||||
val isCurrent = provider.is_current == true
|
||||
@@ -268,15 +475,61 @@ fun ModelScreen(
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
if (isActive) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription =
|
||||
stringResource(
|
||||
R.string.content_desc_active_model,
|
||||
),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
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.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
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.UpdateProfileModelRequest
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
@@ -24,12 +26,19 @@ data class ModelUiState(
|
||||
val activeProfile: ProfileInfo? = null,
|
||||
val errorMessage: String? = null,
|
||||
val toastMessage: String? = null,
|
||||
val pinnedModels: List<PinnedModel> = emptyList(),
|
||||
)
|
||||
|
||||
class ModelViewModel : ViewModel(), ToastHost {
|
||||
class ModelViewModel :
|
||||
ViewModel(),
|
||||
ToastHost {
|
||||
private val _uiState = MutableStateFlow(ModelUiState())
|
||||
val uiState: StateFlow<ModelUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
_uiState.update { it.copy(pinnedModels = AuthManager.getPinnedModels()) }
|
||||
}
|
||||
|
||||
fun loadModelOptions() {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
viewModelScope.launch {
|
||||
@@ -70,6 +79,7 @@ class ModelViewModel : ViewModel(), ToastHost {
|
||||
isLoading = false,
|
||||
providers = responseResult.data.providers.orEmpty(),
|
||||
activeProfile = activeProfile,
|
||||
pinnedModels = AuthManager.getPinnedModels(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -139,4 +149,38 @@ class ModelViewModel : ViewModel(), ToastHost {
|
||||
override fun clearToast() {
|
||||
_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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -268,6 +268,10 @@
|
||||
<string name="model_label_available">Available Models:</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="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 -->
|
||||
<string name="history_empty_title">No history</string>
|
||||
|
||||
@@ -88,6 +88,8 @@ class E2eIntegrationTest {
|
||||
every { AuthManager.getProfileToken(any()) } returns null
|
||||
every { AuthManager.setProfileToken(any(), any()) } returns Unit
|
||||
every { AuthManager.saveConnectionProfiles(any()) } returns Unit
|
||||
every { AuthManager.getPinnedModels() } returns emptyList()
|
||||
every { AuthManager.savePinnedModels(any()) } returns Unit
|
||||
|
||||
// Application stubs
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user