P2 Stream F + Stream U fix: JoinResidenceScreen + Coil test compile fix
Stream F: Convert JoinResidenceDialog -> dedicated screen matching iOS JoinResidenceView. Invite-code input + inline validation + API success navigates to residence detail. Stream U fix: coil3 3.0.4 doesn't ship ColorImage (added in 3.1.0). Use a minimal FakeImage test-double so CoilAuthInterceptorTest compiles. Also completes consolidation of wave-3 work: all 6 parallel streams (D/E/F/H/O/S/U) now landed. Full unit suite green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
package com.tt.honeyDue.network
|
||||
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import coil3.ColorImage
|
||||
import coil3.Image
|
||||
import coil3.PlatformContext
|
||||
import coil3.decode.DataSource
|
||||
import coil3.intercept.Interceptor
|
||||
@@ -48,11 +48,20 @@ class CoilAuthInterceptorTest {
|
||||
|
||||
private fun makeSuccess(request: ImageRequest): SuccessResult =
|
||||
SuccessResult(
|
||||
image = ColorImage(0xFF000000.toInt()),
|
||||
image = FakeImage(),
|
||||
request = request,
|
||||
dataSource = DataSource.NETWORK
|
||||
)
|
||||
|
||||
/** Minimal coil3.Image test-double — coil3 3.0.4 doesn't yet ship ColorImage. */
|
||||
private class FakeImage : Image {
|
||||
override val size: Long = 0L
|
||||
override val width: Int = 1
|
||||
override val height: Int = 1
|
||||
override val shareable: Boolean = true
|
||||
override fun draw(canvas: coil3.Canvas) {}
|
||||
}
|
||||
|
||||
private fun make401Error(request: ImageRequest): ErrorResult {
|
||||
val response = NetworkResponse(code = 401, headers = NetworkHeaders.EMPTY)
|
||||
return ErrorResult(
|
||||
|
||||
@@ -486,6 +486,9 @@ fun App(
|
||||
onAddResidence = {
|
||||
navController.navigate(AddResidenceRoute)
|
||||
},
|
||||
onJoinResidence = {
|
||||
navController.navigate(JoinResidenceRoute)
|
||||
},
|
||||
onNavigateToProfile = {
|
||||
navController.navigate(ProfileRoute)
|
||||
},
|
||||
@@ -502,6 +505,16 @@ fun App(
|
||||
)
|
||||
}
|
||||
|
||||
composable<JoinResidenceRoute> {
|
||||
com.tt.honeyDue.ui.screens.residence.JoinResidenceScreen(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onJoined = { residenceId ->
|
||||
navController.popBackStack()
|
||||
navController.navigate(ResidenceDetailRoute(residenceId))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable<AddResidenceRoute> {
|
||||
AddResidenceScreen(
|
||||
onNavigateBack = {
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
package com.tt.honeyDue.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import com.tt.honeyDue.network.APILayer
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun JoinResidenceDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onJoined: () -> Unit = {}
|
||||
) {
|
||||
var shareCode by remember { mutableStateOf(TextFieldValue("")) }
|
||||
var isJoining by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Join Residence")
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, "Close")
|
||||
}
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = "Enter the 6-character share code to join a residence",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = shareCode,
|
||||
onValueChange = {
|
||||
if (it.text.length <= 6) {
|
||||
shareCode = it.copy(text = it.text.uppercase())
|
||||
error = null
|
||||
}
|
||||
},
|
||||
label = { Text("Share Code") },
|
||||
placeholder = { Text("ABC123") },
|
||||
singleLine = true,
|
||||
enabled = !isJoining,
|
||||
isError = error != null,
|
||||
supportingText = {
|
||||
if (error != null) {
|
||||
Text(
|
||||
text = error ?: "",
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
if (isJoining) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (shareCode.text.length == 6) {
|
||||
scope.launch {
|
||||
isJoining = true
|
||||
error = null
|
||||
when (val result = APILayer.joinWithCode(shareCode.text)) {
|
||||
is ApiResult.Success -> {
|
||||
isJoining = false
|
||||
onJoined()
|
||||
onDismiss()
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
error = result.message
|
||||
isJoining = false
|
||||
}
|
||||
else -> {
|
||||
isJoining = false
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error = "Share code must be 6 characters"
|
||||
}
|
||||
},
|
||||
enabled = !isJoining && shareCode.text.length == 6
|
||||
) {
|
||||
Text("Join")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
enabled = !isJoining
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -140,6 +140,9 @@ fun MainScreen(
|
||||
ResidencesScreen(
|
||||
onResidenceClick = onResidenceClick,
|
||||
onAddResidence = onAddResidence,
|
||||
onJoinResidence = {
|
||||
navController.navigate(JoinResidenceRoute)
|
||||
},
|
||||
onLogout = onLogout,
|
||||
onNavigateToProfile = {
|
||||
// Don't change selectedTab since Profile isn't in the bottom nav
|
||||
@@ -149,6 +152,21 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
|
||||
composable<JoinResidenceRoute> {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
com.tt.honeyDue.ui.screens.residence.JoinResidenceScreen(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onJoined = { residenceId ->
|
||||
// Pop the join screen and hand off to the
|
||||
// parent nav graph (ResidenceDetailRoute lives
|
||||
// above MainScreen in App.kt).
|
||||
navController.popBackStack()
|
||||
onResidenceClick(residenceId)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composable<MainTabTasksRoute> {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AllTasksScreen(
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package com.tt.honeyDue.ui.screens.residence
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import com.tt.honeyDue.ui.components.common.StandardCard
|
||||
import com.tt.honeyDue.ui.components.forms.FormTextField
|
||||
import com.tt.honeyDue.ui.theme.AppRadius
|
||||
import com.tt.honeyDue.ui.theme.AppSpacing
|
||||
|
||||
/**
|
||||
* Full-screen residence-join UI matching iOS
|
||||
* `iosApp/iosApp/Residence/JoinResidenceView.swift`.
|
||||
*
|
||||
* Replaces the legacy `JoinResidenceDialog`. The user enters a 6-character
|
||||
* invite code; on success [onJoined] is invoked with the newly joined
|
||||
* residence id so the caller can navigate to its detail screen. Errors
|
||||
* surface inline via the ViewModel and the screen stays mounted so the user
|
||||
* can retry without re-opening a sheet.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun JoinResidenceScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onJoined: (Int) -> Unit,
|
||||
viewModel: JoinResidenceViewModel = viewModel { JoinResidenceViewModel() },
|
||||
) {
|
||||
val code by viewModel.code.collectAsState()
|
||||
val error by viewModel.errorMessage.collectAsState()
|
||||
val submitState by viewModel.submitState.collectAsState()
|
||||
|
||||
val isLoading = submitState is ApiResult.Loading
|
||||
|
||||
// Navigate to the joined residence when submit transitions to Success.
|
||||
LaunchedEffect(submitState) {
|
||||
val s = submitState
|
||||
if (s is ApiResult.Success<Int>) {
|
||||
onJoined(s.data)
|
||||
viewModel.resetSubmitState()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Join Property",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = onNavigateBack,
|
||||
enabled = !isLoading,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { paddingValues: PaddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(horizontal = AppSpacing.lg, vertical = AppSpacing.lg),
|
||||
verticalArrangement = Arrangement.spacedBy(AppSpacing.lg),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
// Hero
|
||||
Icon(
|
||||
imageVector = Icons.Default.PersonAdd,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(72.dp),
|
||||
)
|
||||
Text(
|
||||
text = "Join a Shared Property",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Text(
|
||||
text = "Enter the 6-character share code provided by the owner.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
StandardCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
FormTextField(
|
||||
value = code,
|
||||
onValueChange = { viewModel.updateCode(it) },
|
||||
label = "Share Code",
|
||||
placeholder = "ABC123",
|
||||
enabled = !isLoading,
|
||||
error = error,
|
||||
helperText = if (error == null) "Codes are 6 uppercase characters" else null,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Characters,
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
),
|
||||
)
|
||||
|
||||
if (error != null) {
|
||||
Spacer(modifier = Modifier.height(AppSpacing.md))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(AppSpacing.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Text(
|
||||
text = error ?: "",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(AppSpacing.md))
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.submit() },
|
||||
enabled = viewModel.canSubmit && !isLoading,
|
||||
shape = RoundedCornerShape(AppRadius.md),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(22.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(AppSpacing.sm))
|
||||
Text(
|
||||
text = "Joining…",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PersonAdd,
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(AppSpacing.sm))
|
||||
Text(
|
||||
text = "Join Property",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.tt.honeyDue.ui.screens.residence
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tt.honeyDue.analytics.AnalyticsEvents
|
||||
import com.tt.honeyDue.analytics.PostHogAnalytics
|
||||
import com.tt.honeyDue.models.JoinResidenceResponse
|
||||
import com.tt.honeyDue.network.APILayer
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* ViewModel for [JoinResidenceScreen] mirroring iOS JoinResidenceView.
|
||||
*
|
||||
* Holds the 6-character share code, validates it, drives the submit API
|
||||
* call through [joinWithCode], and surfaces inline error messages without
|
||||
* popping the screen. On success, [submitState] becomes
|
||||
* `ApiResult.Success(residenceId)` so the UI can navigate to the joined
|
||||
* residence's detail screen.
|
||||
*
|
||||
* [joinWithCode] and [analytics] are injected to keep the ViewModel unit
|
||||
* testable without hitting [APILayer] or PostHog singletons.
|
||||
*/
|
||||
class JoinResidenceViewModel(
|
||||
private val joinWithCode: suspend (String) -> ApiResult<JoinResidenceResponse> = { code ->
|
||||
APILayer.joinWithCode(code)
|
||||
},
|
||||
private val analytics: (String, Map<String, Any>) -> Unit = { name, props ->
|
||||
PostHogAnalytics.capture(name, props)
|
||||
}
|
||||
) : ViewModel() {
|
||||
|
||||
private val _code = MutableStateFlow("")
|
||||
val code: StateFlow<String> = _code.asStateFlow()
|
||||
|
||||
private val _errorMessage = MutableStateFlow<String?>(null)
|
||||
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
|
||||
|
||||
private val _submitState = MutableStateFlow<ApiResult<Int>>(ApiResult.Idle)
|
||||
/** Success carries the id of the joined residence. */
|
||||
val submitState: StateFlow<ApiResult<Int>> = _submitState.asStateFlow()
|
||||
|
||||
/** True when the current code is exactly 6 chars and not already submitting. */
|
||||
val canSubmit: Boolean
|
||||
get() = _code.value.length == REQUIRED_LENGTH &&
|
||||
_submitState.value !is ApiResult.Loading
|
||||
|
||||
/**
|
||||
* Uppercases input and truncates to [REQUIRED_LENGTH]. Matches the
|
||||
* iOS TextField onChange handler. Also clears any stale inline error so
|
||||
* the user can retry after a failure.
|
||||
*/
|
||||
fun updateCode(raw: String) {
|
||||
val next = raw.uppercase().take(REQUIRED_LENGTH)
|
||||
_code.value = next
|
||||
if (_errorMessage.value != null) {
|
||||
_errorMessage.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates length and dispatches the API call. On success fires
|
||||
* [AnalyticsEvents.RESIDENCE_JOINED] and sets [submitState] to
|
||||
* Success(residenceId). On error sets inline [errorMessage] and leaves
|
||||
* [submitState] as Error so the screen stays visible.
|
||||
*/
|
||||
fun submit() {
|
||||
if (_code.value.length != REQUIRED_LENGTH) {
|
||||
_errorMessage.value = ERROR_LENGTH
|
||||
return
|
||||
}
|
||||
|
||||
val code = _code.value
|
||||
viewModelScope.launch {
|
||||
_errorMessage.value = null
|
||||
_submitState.value = ApiResult.Loading
|
||||
|
||||
when (val result = joinWithCode(code)) {
|
||||
is ApiResult.Success -> {
|
||||
val residenceId = result.data.residence.id
|
||||
analytics(
|
||||
AnalyticsEvents.RESIDENCE_JOINED,
|
||||
mapOf("residence_id" to residenceId)
|
||||
)
|
||||
_submitState.value = ApiResult.Success(residenceId)
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
_errorMessage.value = result.message
|
||||
_submitState.value = ApiResult.Error(result.message, result.code)
|
||||
}
|
||||
ApiResult.Loading -> {
|
||||
_submitState.value = ApiResult.Loading
|
||||
}
|
||||
ApiResult.Idle -> {
|
||||
_submitState.value = ApiResult.Idle
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset the transient submit state (e.g. when re-entering the screen). */
|
||||
fun resetSubmitState() {
|
||||
_submitState.value = ApiResult.Idle
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val REQUIRED_LENGTH = 6
|
||||
const val ERROR_LENGTH = "Share code must be 6 characters"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package com.tt.honeyDue.ui.screens.residence
|
||||
|
||||
import com.tt.honeyDue.analytics.AnalyticsEvents
|
||||
import com.tt.honeyDue.models.JoinResidenceResponse
|
||||
import com.tt.honeyDue.models.ResidenceResponse
|
||||
import com.tt.honeyDue.models.TotalSummary
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* State-logic unit tests for [JoinResidenceViewModel] covering the full
|
||||
* iOS parity surface of JoinResidenceView.swift:
|
||||
*
|
||||
* 1. Empty code → canSubmit is false.
|
||||
* 2. Code shorter than 6 chars → canSubmit is false.
|
||||
* 3. submit() with invalid length sets inline error and does NOT call API.
|
||||
* 4. submit() with valid code calls joinWithCode(code) with uppercased value.
|
||||
* 5. Successful API result fires `residence_joined` analytics event and
|
||||
* publishes the joined residence id to the navigation callback.
|
||||
* 6. API error surfaces inline error message and does NOT trigger navigation.
|
||||
* 7. updateCode() coerces input to uppercase and caps at 6 chars, matching
|
||||
* the iOS onChange handler.
|
||||
* 8. updateCode() clears a previously set inline error so the user can retry.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class JoinResidenceViewModelTest {
|
||||
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
// ---------- Fixtures ----------
|
||||
|
||||
private fun fakeResidence(id: Int = 42) = ResidenceResponse(
|
||||
id = id,
|
||||
ownerId = 1,
|
||||
name = "Joined Home",
|
||||
createdAt = "2026-01-01T00:00:00Z",
|
||||
updatedAt = "2026-01-01T00:00:00Z"
|
||||
)
|
||||
|
||||
private fun fakeJoinResponse(id: Int = 42) = JoinResidenceResponse(
|
||||
message = "Joined",
|
||||
residence = fakeResidence(id),
|
||||
summary = TotalSummary(totalResidences = 1)
|
||||
)
|
||||
|
||||
private fun makeViewModel(
|
||||
joinResult: ApiResult<JoinResidenceResponse> = ApiResult.Success(fakeJoinResponse()),
|
||||
onJoinCall: (String) -> Unit = {},
|
||||
onAnalytics: (String, Map<String, Any>) -> Unit = { _, _ -> }
|
||||
) = JoinResidenceViewModel(
|
||||
joinWithCode = { code ->
|
||||
onJoinCall(code)
|
||||
joinResult
|
||||
},
|
||||
analytics = onAnalytics
|
||||
)
|
||||
|
||||
// ---------- Tests ----------
|
||||
|
||||
@Test
|
||||
fun emptyCodeCannotSubmit() {
|
||||
val vm = makeViewModel()
|
||||
assertEquals("", vm.code.value)
|
||||
assertFalse(vm.canSubmit, "Submit should be disabled for empty code")
|
||||
assertNull(vm.errorMessage.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shortCodeCannotSubmit() {
|
||||
val vm = makeViewModel()
|
||||
vm.updateCode("ABC")
|
||||
assertFalse(vm.canSubmit, "Submit should be disabled for 3-char code")
|
||||
vm.updateCode("ABCDE")
|
||||
assertFalse(vm.canSubmit, "Submit should be disabled for 5-char code")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sixCharCodeCanSubmit() {
|
||||
val vm = makeViewModel()
|
||||
vm.updateCode("ABC123")
|
||||
assertTrue(vm.canSubmit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun submitWithInvalidLengthSetsInlineErrorAndSkipsApi() = runTest(dispatcher) {
|
||||
var apiCalled = false
|
||||
val vm = makeViewModel(onJoinCall = { apiCalled = true })
|
||||
|
||||
vm.updateCode("ABC")
|
||||
vm.submit()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
assertFalse(apiCalled, "API must NOT be called for invalid length")
|
||||
val err = vm.errorMessage.value
|
||||
assertTrue(err != null && err.isNotBlank(), "Inline error should be set")
|
||||
assertIs<ApiResult.Idle>(vm.submitState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun submitWithValidCodeCallsJoinWithCodeUppercased() = runTest(dispatcher) {
|
||||
var capturedCode: String? = null
|
||||
val vm = makeViewModel(onJoinCall = { capturedCode = it })
|
||||
|
||||
// Simulate user typing lowercase — updateCode should uppercase it,
|
||||
// but also verify submit sends the canonical uppercase value.
|
||||
vm.updateCode("abc123")
|
||||
assertEquals("ABC123", vm.code.value)
|
||||
|
||||
vm.submit()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
assertEquals("ABC123", capturedCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun successResultTriggersNavigationWithResidenceId() = runTest(dispatcher) {
|
||||
val vm = makeViewModel(
|
||||
joinResult = ApiResult.Success(fakeJoinResponse(id = 77))
|
||||
)
|
||||
vm.updateCode("ABC123")
|
||||
vm.submit()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
val state = vm.submitState.value
|
||||
assertIs<ApiResult.Success<Int>>(state)
|
||||
assertEquals(77, state.data)
|
||||
assertNull(vm.errorMessage.value, "Error should be cleared on success")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun apiErrorShowsInlineMessageAndDoesNotNavigate() = runTest(dispatcher) {
|
||||
val vm = makeViewModel(
|
||||
joinResult = ApiResult.Error("Invalid code", 404)
|
||||
)
|
||||
vm.updateCode("BADBAD")
|
||||
vm.submit()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
assertIs<ApiResult.Error>(vm.submitState.value)
|
||||
assertEquals("Invalid code", vm.errorMessage.value)
|
||||
// submitState is Error, NOT Success — so the UI will NOT navigate.
|
||||
assertFalse(vm.submitState.value is ApiResult.Success<*>)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun analyticsEventFiredOnSuccessMatchingIosEventName() = runTest(dispatcher) {
|
||||
val events = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||
val vm = makeViewModel(
|
||||
joinResult = ApiResult.Success(fakeJoinResponse(id = 7)),
|
||||
onAnalytics = { name, props -> events += name to props }
|
||||
)
|
||||
|
||||
vm.updateCode("ABC123")
|
||||
vm.submit()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
val joined = events.firstOrNull { it.first == AnalyticsEvents.RESIDENCE_JOINED }
|
||||
assertTrue(joined != null, "Expected residence_joined analytics event")
|
||||
assertEquals("residence_joined", joined.first)
|
||||
assertEquals(7, joined.second["residence_id"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun analyticsNotFiredOnApiError() = runTest(dispatcher) {
|
||||
val events = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||
val vm = makeViewModel(
|
||||
joinResult = ApiResult.Error("Nope", 400),
|
||||
onAnalytics = { name, props -> events += name to props }
|
||||
)
|
||||
vm.updateCode("ABC123")
|
||||
vm.submit()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
assertTrue(events.none { it.first == AnalyticsEvents.RESIDENCE_JOINED })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateCodeUppercasesAndCapsAtSix() {
|
||||
val vm = makeViewModel()
|
||||
vm.updateCode("abcdefghij")
|
||||
assertEquals("ABCDEF", vm.code.value, "Should cap at 6 chars and uppercase")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateCodeClearsPreviousError() = runTest(dispatcher) {
|
||||
val vm = makeViewModel(joinResult = ApiResult.Error("Invalid", 400))
|
||||
vm.updateCode("BADBAD")
|
||||
vm.submit()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals("Invalid", vm.errorMessage.value)
|
||||
|
||||
// User edits code → error should clear.
|
||||
vm.updateCode("GOODCO")
|
||||
assertNull(vm.errorMessage.value)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user