* 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
+621
-1
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,6 +475,10 @@ fun ModelScreen(
|
|||||||
MaterialTheme.colorScheme.onSurface
|
MaterialTheme.colorScheme.onSurface
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Check,
|
imageVector = Icons.Filled.Check,
|
||||||
@@ -278,6 +489,48 @@ fun ModelScreen(
|
|||||||
tint = MaterialTheme.colorScheme.primary,
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user