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

@@ -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)
}
}