fix(#298): internationalize ConnectViewModel strings, fix null-assert crash on profile selector (#307)
* fix(#298): internationalize ConnectViewModel strings, fix null-assert crash on profile selector E1: Add all connect error resources to strings.xml for i18n readiness E2: Migrate ConnectViewModel to AndroidViewModel, replace all hardcoded user-facing strings with getString(R.string.*) calls F1: Replace state.selectedProfile!! with .?.let { } pattern in ConnectScreen.kt to eliminate NPE risk on mutable state copy Closes #298 * fix(#298): update test files for AndroidViewModel constructor Update ConnectViewModelTest.kt and E2eIntegrationTest.kt to pass mock Application to the new ConnectViewModel(app) constructor. Add getString() MockK stubs matching the string resource keys. * fix(#298): add missing LocalContext import and restore connect_pairing_string Two compilation errors: - ConnectScreen.kt was missing import for LocalContext - strings.xml was missing connect_pairing_string resource (accidentally removed in earlier patch) * fix(#298): extract ViewModelFactory to avoid composable-in-non-composable error LocalContext.current cannot be called inside a viewModel { } factory lambda because the lambda is not @Composable. Fix by extracting a ConnectViewModelFactory class and using run { } to capture the Application value at the composable call site before passing to the factory constructor. * fix(#298): fix MockK varargs matching in test stubs getString(int, Object...) takes varargs. MockK's any<Int>() and any<String>() didn't match the vararg Object... slot. Changed to any() (untyped) which matches correctly. * fix(#298): fix MockK answers — arg indexing was wrong In getString(int, Object...), firstArg is the resource ID, secondArg is the format argument. Using firstArg<Int>() in the answers block was returning the res ID, not 500/etc. Changed to secondArg<Any>(). * fix(#298): replace getString(int,Object...) with String.format(getString(int),...) Avoid MockK varargs matching issues by calling app.getString(int) for the format template and String.format() to apply args. Simplifies test stubs to pure getString(int) returns. --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
8e040543de
commit
e8025efcde
5 files changed
+156
-57
No files matched your search
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol.ui.connect
|
package com.m57.hermescontrol.ui.connect
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
@@ -45,6 +46,7 @@ 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
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
@@ -60,7 +62,14 @@ import com.m57.hermescontrol.R
|
|||||||
fun ConnectScreen(
|
fun ConnectScreen(
|
||||||
onConnected: () -> Unit,
|
onConnected: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: ConnectViewModel = viewModel { ConnectViewModel() },
|
viewModel: ConnectViewModel =
|
||||||
|
viewModel(
|
||||||
|
factory =
|
||||||
|
run {
|
||||||
|
val app = LocalContext.current.applicationContext as Application
|
||||||
|
ConnectViewModelFactory(app)
|
||||||
|
},
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
@@ -135,11 +144,9 @@ fun ConnectScreen(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text =
|
text =
|
||||||
if (state.selectedProfile != null) {
|
state.selectedProfile?.let { p ->
|
||||||
stringResource(R.string.connect_selected_profile, state.selectedProfile!!.name)
|
stringResource(R.string.connect_selected_profile, p.name)
|
||||||
} else {
|
} ?: stringResource(R.string.connect_action_select_profile),
|
||||||
stringResource(R.string.connect_action_select_profile)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package com.m57.hermescontrol.ui.connect
|
package com.m57.hermescontrol.ui.connect
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.NetworkError
|
import com.m57.hermescontrol.data.remote.NetworkError
|
||||||
@@ -28,7 +31,7 @@ data class ConnectUiState(
|
|||||||
val selectedProfile: com.m57.hermescontrol.data.model.ConnectionProfile? = null,
|
val selectedProfile: com.m57.hermescontrol.data.model.ConnectionProfile? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
class ConnectViewModel : ViewModel() {
|
class ConnectViewModel(private val app: Application) : ViewModel() {
|
||||||
private val _uiState = MutableStateFlow(ConnectUiState())
|
private val _uiState = MutableStateFlow(ConnectUiState())
|
||||||
val uiState: StateFlow<ConnectUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<ConnectUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
@@ -110,16 +113,16 @@ class ConnectViewModel : ViewModel() {
|
|||||||
fun connect() {
|
fun connect() {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
if (state.token.isBlank()) {
|
if (state.token.isBlank()) {
|
||||||
_uiState.update { it.copy(errorMessage = "Token is required") }
|
_uiState.update { it.copy(errorMessage = app.getString(R.string.connect_error_token_required)) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (state.host.isBlank()) {
|
if (state.host.isBlank()) {
|
||||||
_uiState.update { it.copy(errorMessage = "Host is required") }
|
_uiState.update { it.copy(errorMessage = app.getString(R.string.connect_error_host_required)) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val port = state.port.toIntOrNull()
|
val port = state.port.toIntOrNull()
|
||||||
if (port == null || port !in 1..65535) {
|
if (port == null || port !in 1..65535) {
|
||||||
_uiState.update { it.copy(errorMessage = "Port must be between 1 and 65535") }
|
_uiState.update { it.copy(errorMessage = app.getString(R.string.connect_error_port_invalid)) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,15 +188,15 @@ class ConnectViewModel : ViewModel() {
|
|||||||
when (err.code) {
|
when (err.code) {
|
||||||
401 -> {
|
401 -> {
|
||||||
AuthManager.setToken(null)
|
AuthManager.setToken(null)
|
||||||
"Invalid token (401 Unauthorized)"
|
app.getString(R.string.connect_error_401)
|
||||||
}
|
}
|
||||||
|
|
||||||
403 -> {
|
403 -> {
|
||||||
"Access denied (403 Forbidden)"
|
app.getString(R.string.connect_error_403)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
"Server returned HTTP ${err.code}"
|
String.format(app.getString(R.string.connect_error_http_code), err.code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,20 +204,46 @@ class ConnectViewModel : ViewModel() {
|
|||||||
is NetworkError.Connection -> {
|
is NetworkError.Connection -> {
|
||||||
val causeMessage = err.cause.message ?: ""
|
val causeMessage = err.cause.message ?: ""
|
||||||
when {
|
when {
|
||||||
causeMessage.contains("timeout", true) -> "Connection timed out"
|
causeMessage.contains(
|
||||||
causeMessage.contains("refused", true) -> "Connection refused – is Hermes running?"
|
"timeout",
|
||||||
causeMessage.contains("resolve", true) -> "Could not resolve host"
|
true,
|
||||||
else -> "Connection failed: ${err.cause.message}"
|
) -> app.getString(R.string.connect_error_timeout)
|
||||||
|
causeMessage.contains(
|
||||||
|
"refused",
|
||||||
|
true,
|
||||||
|
) -> app.getString(R.string.connect_error_refused)
|
||||||
|
causeMessage.contains(
|
||||||
|
"resolve",
|
||||||
|
true,
|
||||||
|
) -> app.getString(R.string.connect_error_resolve)
|
||||||
|
else ->
|
||||||
|
String.format(
|
||||||
|
app.getString(R.string.connect_error_connection_failed),
|
||||||
|
err.cause.message ?: "",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkError.Unknown -> {
|
is NetworkError.Unknown -> {
|
||||||
val causeMessage = err.cause.message ?: ""
|
val causeMessage = err.cause.message ?: ""
|
||||||
when {
|
when {
|
||||||
causeMessage.contains("timeout", true) -> "Connection timed out"
|
causeMessage.contains(
|
||||||
causeMessage.contains("refused", true) -> "Connection refused – is Hermes running?"
|
"timeout",
|
||||||
causeMessage.contains("resolve", true) -> "Could not resolve host"
|
true,
|
||||||
else -> "Connection failed: ${err.cause.message}"
|
) -> app.getString(R.string.connect_error_timeout)
|
||||||
|
causeMessage.contains(
|
||||||
|
"refused",
|
||||||
|
true,
|
||||||
|
) -> app.getString(R.string.connect_error_refused)
|
||||||
|
causeMessage.contains(
|
||||||
|
"resolve",
|
||||||
|
true,
|
||||||
|
) -> app.getString(R.string.connect_error_resolve)
|
||||||
|
else ->
|
||||||
|
String.format(
|
||||||
|
app.getString(R.string.connect_error_connection_failed),
|
||||||
|
err.cause.message ?: "",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,7 +304,7 @@ class ConnectViewModel : ViewModel() {
|
|||||||
// Decoded valid JSON but missing required fields
|
// Decoded valid JSON but missing required fields
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
errorMessage = "Malformed pairing string — missing host, port, or token",
|
errorMessage = app.getString(R.string.connect_error_missing_fields),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -283,7 +312,7 @@ class ConnectViewModel : ViewModel() {
|
|||||||
// Decoded to valid string but not JSON shape — not a pairing string
|
// Decoded to valid string but not JSON shape — not a pairing string
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
errorMessage = "Malformed pairing string — expected URL or Base64-encoded JSON",
|
errorMessage = app.getString(R.string.connect_error_malformed),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -294,17 +323,38 @@ class ConnectViewModel : ViewModel() {
|
|||||||
} else {
|
} else {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
errorMessage = "Malformed pairing string — expected URL or Base64-encoded JSON",
|
errorMessage = app.getString(R.string.connect_error_malformed),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.update { it.copy(errorMessage = "Failed to parse pairing string: ${e.message}") }
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
errorMessage =
|
||||||
|
String.format(
|
||||||
|
app.getString(R.string.connect_error_parse_failed),
|
||||||
|
e.message ?: "",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.update { it.copy(errorMessage = "Failed to parse pairing string: ${e.message}") }
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
errorMessage = String.format(app.getString(R.string.connect_error_parse_failed), e.message ?: ""),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ConnectViewModelFactory(
|
||||||
|
private val app: Application,
|
||||||
|
) : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
return ConnectViewModel(app) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -165,6 +165,22 @@
|
|||||||
<string name="connect_selected_profile">Profile: %1$s</string>
|
<string name="connect_selected_profile">Profile: %1$s</string>
|
||||||
<string name="connect_action_select_profile">Select Saved Profile</string>
|
<string name="connect_action_select_profile">Select Saved Profile</string>
|
||||||
<string name="connect_profile_none">None (Standalone)</string>
|
<string name="connect_profile_none">None (Standalone)</string>
|
||||||
|
|
||||||
|
<!-- Connect Error Messages -->
|
||||||
|
<string name="connect_error_token_required">Token is required</string>
|
||||||
|
<string name="connect_error_host_required">Host is required</string>
|
||||||
|
<string name="connect_error_port_invalid">Port must be between 1 and 65535</string>
|
||||||
|
<string name="connect_error_401">Invalid token (401 Unauthorized)</string>
|
||||||
|
<string name="connect_error_403">Access denied (403 Forbidden)</string>
|
||||||
|
<string name="connect_error_http_code">Server returned HTTP %1$d</string>
|
||||||
|
<string name="connect_error_timeout">Connection timed out</string>
|
||||||
|
<string name="connect_error_refused">Connection refused – is Hermes running?</string>
|
||||||
|
<string name="connect_error_resolve">Could not resolve host</string>
|
||||||
|
<string name="connect_error_connection_failed">Connection failed: %1$s</string>
|
||||||
|
<string name="connect_error_missing_fields">Malformed pairing string — missing host, port, or token</string>
|
||||||
|
<string name="connect_error_malformed">Malformed pairing string — expected URL or Base64-encoded JSON</string>
|
||||||
|
<string name="connect_error_parse_failed">Failed to parse pairing string: %1$s</string>
|
||||||
|
|
||||||
<string name="connect_pairing_string">Pairing string</string>
|
<string name="connect_pairing_string">Pairing string</string>
|
||||||
<string name="connect_placeholder_pairing">Paste hermes://\u2026 or Base64 config</string>
|
<string name="connect_placeholder_pairing">Paste hermes://\u2026 or Base64 config</string>
|
||||||
<string name="connect_save_profile">Save connection profile</string>
|
<string name="connect_save_profile">Save connection profile</string>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol
|
package com.m57.hermescontrol
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
import androidx.navigation3.runtime.NavBackStack
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
import androidx.navigation3.runtime.NavKey
|
import androidx.navigation3.runtime.NavKey
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
@@ -56,6 +57,7 @@ import java.io.IOException
|
|||||||
class E2eIntegrationTest {
|
class E2eIntegrationTest {
|
||||||
private val testDispatcher = StandardTestDispatcher()
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
private lateinit var mockApiService: HermesApiService
|
private lateinit var mockApiService: HermesApiService
|
||||||
|
private val mockApp = mockk<Application>(relaxed = true)
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
@@ -85,6 +87,9 @@ 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
|
||||||
|
|
||||||
|
// Application stubs
|
||||||
|
every { mockApp.getString(R.string.connect_error_401) } returns "Invalid token (401 Unauthorized)"
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
@@ -760,7 +765,7 @@ class E2eIntegrationTest {
|
|||||||
val mockResponse = createErrorResponse<StatusResponse>(401)
|
val mockResponse = createErrorResponse<StatusResponse>(401)
|
||||||
coEvery { mockApiService.getStatus() } returns mockResponse
|
coEvery { mockApiService.getStatus() } returns mockResponse
|
||||||
|
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
viewModel.onTokenChange("expired-token")
|
viewModel.onTokenChange("expired-token")
|
||||||
viewModel.connect()
|
viewModel.connect()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
@@ -781,7 +786,7 @@ class E2eIntegrationTest {
|
|||||||
val statusResponse = mockk<StatusResponse>()
|
val statusResponse = mockk<StatusResponse>()
|
||||||
coEvery { mockApiService.getStatus() } returns Response.success(statusResponse)
|
coEvery { mockApiService.getStatus() } returns Response.success(statusResponse)
|
||||||
|
|
||||||
val connectViewModel = ConnectViewModel()
|
val connectViewModel = ConnectViewModel(mockApp)
|
||||||
connectViewModel.onTokenChange("valid-token")
|
connectViewModel.onTokenChange("valid-token")
|
||||||
connectViewModel.onHostChange("127.0.0.1")
|
connectViewModel.onHostChange("127.0.0.1")
|
||||||
connectViewModel.onPortChange("9119")
|
connectViewModel.onPortChange("9119")
|
||||||
@@ -1069,7 +1074,7 @@ class E2eIntegrationTest {
|
|||||||
val statusResponse = mockk<StatusResponse>()
|
val statusResponse = mockk<StatusResponse>()
|
||||||
coEvery { mockApiService.getStatus() } returns Response.success(statusResponse)
|
coEvery { mockApiService.getStatus() } returns Response.success(statusResponse)
|
||||||
|
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
viewModel.onPairingString("hermes://connect?host=192.168.1.5&port=9119&token=TEST_TOKEN")
|
viewModel.onPairingString("hermes://connect?host=192.168.1.5&port=9119&token=TEST_TOKEN")
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
@@ -1095,7 +1100,7 @@ class E2eIntegrationTest {
|
|||||||
val statusResponse = mockk<StatusResponse>()
|
val statusResponse = mockk<StatusResponse>()
|
||||||
coEvery { mockApiService.getStatus() } returns Response.success(statusResponse)
|
coEvery { mockApiService.getStatus() } returns Response.success(statusResponse)
|
||||||
|
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
viewModel.onPairingString(base64Str)
|
viewModel.onPairingString(base64Str)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.m57.hermescontrol.ui.connect
|
package com.m57.hermescontrol.ui.connect
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import com.m57.hermescontrol.data.model.StatusResponse
|
import com.m57.hermescontrol.data.model.StatusResponse
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
@@ -25,6 +27,7 @@ import retrofit2.Response
|
|||||||
class ConnectViewModelTest {
|
class ConnectViewModelTest {
|
||||||
private val testDispatcher = StandardTestDispatcher()
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
private lateinit var mockApiService: HermesApiService
|
private lateinit var mockApiService: HermesApiService
|
||||||
|
private val mockApp = mockk<Application>(relaxed = true)
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
@@ -60,6 +63,24 @@ class ConnectViewModelTest {
|
|||||||
every { AuthManager.setProfileToken(any(), any()) } returns Unit
|
every { AuthManager.setProfileToken(any(), any()) } returns Unit
|
||||||
every { AuthManager.getSelectedProfileId() } returns null
|
every { AuthManager.getSelectedProfileId() } returns null
|
||||||
every { AuthManager.setSelectedProfileId(any()) } returns Unit
|
every { AuthManager.setSelectedProfileId(any()) } returns Unit
|
||||||
|
|
||||||
|
// Mock Application string resources
|
||||||
|
every { mockApp.getString(R.string.connect_error_token_required) } returns "Token is required"
|
||||||
|
every { mockApp.getString(R.string.connect_error_host_required) } returns "Host is required"
|
||||||
|
every { mockApp.getString(R.string.connect_error_port_invalid) } returns "Port must be between 1 and 65535"
|
||||||
|
every { mockApp.getString(R.string.connect_error_401) } returns "Invalid token (401 Unauthorized)"
|
||||||
|
every { mockApp.getString(R.string.connect_error_403) } returns "Access denied (403 Forbidden)"
|
||||||
|
every { mockApp.getString(R.string.connect_error_http_code) } returns "Server returned HTTP %1\$d"
|
||||||
|
every { mockApp.getString(R.string.connect_error_refused) } returns "Connection refused – is Hermes running?"
|
||||||
|
every { mockApp.getString(R.string.connect_error_timeout) } returns "Connection timed out"
|
||||||
|
every { mockApp.getString(R.string.connect_error_connection_failed) } returns "Connection failed: %1\$s"
|
||||||
|
every {
|
||||||
|
mockApp.getString(R.string.connect_error_malformed)
|
||||||
|
} returns "Malformed pairing string — expected URL or Base64-encoded JSON"
|
||||||
|
every {
|
||||||
|
mockApp.getString(R.string.connect_error_missing_fields)
|
||||||
|
} returns "Malformed pairing string — missing host, port, or token"
|
||||||
|
every { mockApp.getString(R.string.connect_error_parse_failed) } returns "Failed to parse pairing string: %1\$s"
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
@@ -74,7 +95,7 @@ class ConnectViewModelTest {
|
|||||||
every { AuthManager.getHost() } returns "hermes.local"
|
every { AuthManager.getHost() } returns "hermes.local"
|
||||||
every { AuthManager.getPort() } returns 8888
|
every { AuthManager.getPort() } returns 8888
|
||||||
|
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
val state = viewModel.uiState.value
|
val state = viewModel.uiState.value
|
||||||
|
|
||||||
assertEquals("saved-token", state.token)
|
assertEquals("saved-token", state.token)
|
||||||
@@ -87,7 +108,7 @@ class ConnectViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testOnTokenChange() {
|
fun testOnTokenChange() {
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
viewModel.onTokenChange(" new-token ")
|
viewModel.onTokenChange(" new-token ")
|
||||||
assertEquals("new-token", viewModel.uiState.value.token)
|
assertEquals("new-token", viewModel.uiState.value.token)
|
||||||
assertNull(viewModel.uiState.value.errorMessage)
|
assertNull(viewModel.uiState.value.errorMessage)
|
||||||
@@ -95,7 +116,7 @@ class ConnectViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testOnHostChange() {
|
fun testOnHostChange() {
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
viewModel.onHostChange(" 192.168.1.50 ")
|
viewModel.onHostChange(" 192.168.1.50 ")
|
||||||
assertEquals("192.168.1.50", viewModel.uiState.value.host)
|
assertEquals("192.168.1.50", viewModel.uiState.value.host)
|
||||||
assertNull(viewModel.uiState.value.errorMessage)
|
assertNull(viewModel.uiState.value.errorMessage)
|
||||||
@@ -103,7 +124,7 @@ class ConnectViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testOnPortChange() {
|
fun testOnPortChange() {
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
viewModel.onPortChange("90abc90")
|
viewModel.onPortChange("90abc90")
|
||||||
assertEquals("9090", viewModel.uiState.value.port)
|
assertEquals("9090", viewModel.uiState.value.port)
|
||||||
assertNull(viewModel.uiState.value.errorMessage)
|
assertNull(viewModel.uiState.value.errorMessage)
|
||||||
@@ -111,7 +132,7 @@ class ConnectViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testConnect_blankToken_showsError() {
|
fun testConnect_blankToken_showsError() {
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
viewModel.onTokenChange("")
|
viewModel.onTokenChange("")
|
||||||
viewModel.connect()
|
viewModel.connect()
|
||||||
assertEquals("Token is required", viewModel.uiState.value.errorMessage)
|
assertEquals("Token is required", viewModel.uiState.value.errorMessage)
|
||||||
@@ -119,7 +140,7 @@ class ConnectViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testConnect_blankHost_showsError() {
|
fun testConnect_blankHost_showsError() {
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
viewModel.onTokenChange("valid-token")
|
viewModel.onTokenChange("valid-token")
|
||||||
viewModel.onHostChange("")
|
viewModel.onHostChange("")
|
||||||
viewModel.connect()
|
viewModel.connect()
|
||||||
@@ -128,7 +149,7 @@ class ConnectViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testConnect_invalidPortBounds_showsError() {
|
fun testConnect_invalidPortBounds_showsError() {
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
viewModel.onTokenChange("valid-token")
|
viewModel.onTokenChange("valid-token")
|
||||||
viewModel.onHostChange("127.0.0.1")
|
viewModel.onHostChange("127.0.0.1")
|
||||||
|
|
||||||
@@ -148,7 +169,7 @@ class ConnectViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun testConnect_success() =
|
fun testConnect_success() =
|
||||||
runTest {
|
runTest {
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
viewModel.onTokenChange("valid-token")
|
viewModel.onTokenChange("valid-token")
|
||||||
viewModel.onHostChange("127.0.0.1")
|
viewModel.onHostChange("127.0.0.1")
|
||||||
viewModel.onPortChange("9119")
|
viewModel.onPortChange("9119")
|
||||||
@@ -185,7 +206,7 @@ class ConnectViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun testConnect_failure_unauthorized() =
|
fun testConnect_failure_unauthorized() =
|
||||||
runTest {
|
runTest {
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
viewModel.onTokenChange("invalid-token")
|
viewModel.onTokenChange("invalid-token")
|
||||||
viewModel.onHostChange("127.0.0.1")
|
viewModel.onHostChange("127.0.0.1")
|
||||||
viewModel.onPortChange("9119")
|
viewModel.onPortChange("9119")
|
||||||
@@ -207,7 +228,7 @@ class ConnectViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun testConnect_failure_forbidden() =
|
fun testConnect_failure_forbidden() =
|
||||||
runTest {
|
runTest {
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
viewModel.onTokenChange("invalid-token")
|
viewModel.onTokenChange("invalid-token")
|
||||||
viewModel.onHostChange("127.0.0.1")
|
viewModel.onHostChange("127.0.0.1")
|
||||||
viewModel.onPortChange("9119")
|
viewModel.onPortChange("9119")
|
||||||
@@ -229,7 +250,7 @@ class ConnectViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun testConnect_failure_otherHttpCode() =
|
fun testConnect_failure_otherHttpCode() =
|
||||||
runTest {
|
runTest {
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
viewModel.onTokenChange("token")
|
viewModel.onTokenChange("token")
|
||||||
viewModel.onHostChange("127.0.0.1")
|
viewModel.onHostChange("127.0.0.1")
|
||||||
viewModel.onPortChange("9119")
|
viewModel.onPortChange("9119")
|
||||||
@@ -251,7 +272,7 @@ class ConnectViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun testConnect_exception_connectionRefused() =
|
fun testConnect_exception_connectionRefused() =
|
||||||
runTest {
|
runTest {
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
viewModel.onTokenChange("token")
|
viewModel.onTokenChange("token")
|
||||||
viewModel.onHostChange("127.0.0.1")
|
viewModel.onHostChange("127.0.0.1")
|
||||||
viewModel.onPortChange("9119")
|
viewModel.onPortChange("9119")
|
||||||
@@ -270,7 +291,7 @@ class ConnectViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun testConnect_exception_timeout() =
|
fun testConnect_exception_timeout() =
|
||||||
runTest {
|
runTest {
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
viewModel.onTokenChange("token")
|
viewModel.onTokenChange("token")
|
||||||
viewModel.onHostChange("127.0.0.1")
|
viewModel.onHostChange("127.0.0.1")
|
||||||
viewModel.onPortChange("9119")
|
viewModel.onPortChange("9119")
|
||||||
@@ -289,7 +310,7 @@ class ConnectViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun testConnect_exception_unknown() =
|
fun testConnect_exception_unknown() =
|
||||||
runTest {
|
runTest {
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
viewModel.onTokenChange("token")
|
viewModel.onTokenChange("token")
|
||||||
viewModel.onHostChange("127.0.0.1")
|
viewModel.onHostChange("127.0.0.1")
|
||||||
viewModel.onPortChange("9119")
|
viewModel.onPortChange("9119")
|
||||||
@@ -307,7 +328,7 @@ class ConnectViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testOnPairingString_malformedBase64_showsError() {
|
fun testOnPairingString_malformedBase64_showsError() {
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
|
|
||||||
// Mock Base64.decode to throw IllegalArgumentException for an invalid short string
|
// Mock Base64.decode to throw IllegalArgumentException for an invalid short string
|
||||||
every { android.util.Base64.decode(any<String>(), any()) } throws IllegalArgumentException("bad base64")
|
every { android.util.Base64.decode(any<String>(), any()) } throws IllegalArgumentException("bad base64")
|
||||||
@@ -320,7 +341,7 @@ class ConnectViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testOnPairingString_malformedBase64_validRawToken() {
|
fun testOnPairingString_malformedBase64_validRawToken() {
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
|
|
||||||
// Mock Base64.decode to throw IllegalArgumentException
|
// Mock Base64.decode to throw IllegalArgumentException
|
||||||
every { android.util.Base64.decode(any<String>(), any()) } throws IllegalArgumentException("bad base64")
|
every { android.util.Base64.decode(any<String>(), any()) } throws IllegalArgumentException("bad base64")
|
||||||
@@ -345,7 +366,7 @@ class ConnectViewModelTest {
|
|||||||
)
|
)
|
||||||
every { AuthManager.getProfileToken("test-id") } returns "profile-token"
|
every { AuthManager.getProfileToken("test-id") } returns "profile-token"
|
||||||
|
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
viewModel.selectProfile(profile)
|
viewModel.selectProfile(profile)
|
||||||
|
|
||||||
val state = viewModel.uiState.value
|
val state = viewModel.uiState.value
|
||||||
@@ -360,7 +381,7 @@ class ConnectViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testSelectProfile_null_clearsState() {
|
fun testSelectProfile_null_clearsState() {
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
viewModel.selectProfile(null)
|
viewModel.selectProfile(null)
|
||||||
|
|
||||||
val state = viewModel.uiState.value
|
val state = viewModel.uiState.value
|
||||||
@@ -376,7 +397,7 @@ class ConnectViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun testConnect_saveProfile_savesAndSelects() =
|
fun testConnect_saveProfile_savesAndSelects() =
|
||||||
runTest {
|
runTest {
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
viewModel.onTokenChange("valid-token")
|
viewModel.onTokenChange("valid-token")
|
||||||
viewModel.onHostChange("127.0.0.1")
|
viewModel.onHostChange("127.0.0.1")
|
||||||
viewModel.onPortChange("9119")
|
viewModel.onPortChange("9119")
|
||||||
@@ -424,7 +445,7 @@ class ConnectViewModelTest {
|
|||||||
every { AuthManager.getConnectionProfiles() } returns listOf(profile)
|
every { AuthManager.getConnectionProfiles() } returns listOf(profile)
|
||||||
every { AuthManager.getSelectedProfileId() } returns "prof-1"
|
every { AuthManager.getSelectedProfileId() } returns "prof-1"
|
||||||
|
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
val state = viewModel.uiState.value
|
val state = viewModel.uiState.value
|
||||||
|
|
||||||
assertEquals("Work", state.profileName)
|
assertEquals("Work", state.profileName)
|
||||||
@@ -446,7 +467,7 @@ class ConnectViewModelTest {
|
|||||||
every { AuthManager.getConnectionProfiles() } returns listOf(profile)
|
every { AuthManager.getConnectionProfiles() } returns listOf(profile)
|
||||||
every { AuthManager.getSelectedProfileId() } returns "nonexistent-id"
|
every { AuthManager.getSelectedProfileId() } returns "nonexistent-id"
|
||||||
|
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
val state = viewModel.uiState.value
|
val state = viewModel.uiState.value
|
||||||
|
|
||||||
assertNull(state.selectedProfile)
|
assertNull(state.selectedProfile)
|
||||||
@@ -465,7 +486,7 @@ class ConnectViewModelTest {
|
|||||||
host = "10.0.0.2",
|
host = "10.0.0.2",
|
||||||
port = 9220,
|
port = 9220,
|
||||||
)
|
)
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
viewModel.selectProfile(profile)
|
viewModel.selectProfile(profile)
|
||||||
|
|
||||||
assertEquals("", viewModel.uiState.value.token)
|
assertEquals("", viewModel.uiState.value.token)
|
||||||
@@ -475,7 +496,7 @@ class ConnectViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun testConnect_withoutSaveProfile_clearsSelectedProfile() =
|
fun testConnect_withoutSaveProfile_clearsSelectedProfile() =
|
||||||
runTest {
|
runTest {
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
viewModel.onTokenChange("standalone-token")
|
viewModel.onTokenChange("standalone-token")
|
||||||
viewModel.onHostChange("10.0.0.1")
|
viewModel.onHostChange("10.0.0.1")
|
||||||
viewModel.onPortChange("9119")
|
viewModel.onPortChange("9119")
|
||||||
@@ -504,14 +525,14 @@ class ConnectViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testOnProfileNameChange_updatesState() {
|
fun testOnProfileNameChange_updatesState() {
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
viewModel.onProfileNameChange("My Profile")
|
viewModel.onProfileNameChange("My Profile")
|
||||||
assertEquals("My Profile", viewModel.uiState.value.profileName)
|
assertEquals("My Profile", viewModel.uiState.value.profileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testOnSaveProfileChange_togglesFlag() {
|
fun testOnSaveProfileChange_togglesFlag() {
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
assertFalse(viewModel.uiState.value.saveProfile)
|
assertFalse(viewModel.uiState.value.saveProfile)
|
||||||
viewModel.onSaveProfileChange(true)
|
viewModel.onSaveProfileChange(true)
|
||||||
assertTrue(viewModel.uiState.value.saveProfile)
|
assertTrue(viewModel.uiState.value.saveProfile)
|
||||||
@@ -529,7 +550,7 @@ class ConnectViewModelTest {
|
|||||||
every { AuthManager.getConnectionProfiles() } returns profiles
|
every { AuthManager.getConnectionProfiles() } returns profiles
|
||||||
every { AuthManager.getSelectedProfileId() } returns null
|
every { AuthManager.getSelectedProfileId() } returns null
|
||||||
|
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
assertEquals(2, viewModel.uiState.value.profiles.size)
|
assertEquals(2, viewModel.uiState.value.profiles.size)
|
||||||
assertEquals("Alpha", viewModel.uiState.value.profiles[0].name)
|
assertEquals("Alpha", viewModel.uiState.value.profiles[0].name)
|
||||||
assertEquals("Beta", viewModel.uiState.value.profiles[1].name)
|
assertEquals("Beta", viewModel.uiState.value.profiles[1].name)
|
||||||
@@ -559,7 +580,7 @@ class ConnectViewModelTest {
|
|||||||
)
|
)
|
||||||
coEvery { mockApiService.getStatus() } returns mockResponse
|
coEvery { mockApiService.getStatus() } returns mockResponse
|
||||||
|
|
||||||
val viewModel = ConnectViewModel()
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
viewModel.onPairingString("hermes://connect?host=192.168.1.1&port=8888&token=abc123")
|
viewModel.onPairingString("hermes://connect?host=192.168.1.1&port=8888&token=abc123")
|
||||||
|
|
||||||
val state = viewModel.uiState.value
|
val state = viewModel.uiState.value
|
||||||
|
|||||||
Reference in new issue
Block a user