From 704c59e5cb79e1c01d6b291a3081186ef6ed03c7 Mon Sep 17 00:00:00 2001 From: Trey T Date: Sat, 18 Apr 2026 13:14:55 -0500 Subject: [PATCH] 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) --- .../network/CoilAuthInterceptorTest.kt | 13 +- .../commonMain/kotlin/com/tt/honeyDue/App.kt | 13 ++ .../ui/components/JoinResidenceDialog.kt | 126 ---------- .../com/tt/honeyDue/ui/screens/MainScreen.kt | 18 ++ .../screens/residence/JoinResidenceScreen.kt | 213 +++++++++++++++++ .../residence/JoinResidenceViewModel.kt | 113 +++++++++ .../residence/JoinResidenceViewModelTest.kt | 219 ++++++++++++++++++ 7 files changed, 587 insertions(+), 128 deletions(-) delete mode 100644 composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/JoinResidenceDialog.kt create mode 100644 composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/residence/JoinResidenceScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/residence/JoinResidenceViewModel.kt create mode 100644 composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/residence/JoinResidenceViewModelTest.kt diff --git a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/network/CoilAuthInterceptorTest.kt b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/network/CoilAuthInterceptorTest.kt index e6e1050..348fce7 100644 --- a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/network/CoilAuthInterceptorTest.kt +++ b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/network/CoilAuthInterceptorTest.kt @@ -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( diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/App.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/App.kt index 318d25b..abe344b 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/App.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/App.kt @@ -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 { + com.tt.honeyDue.ui.screens.residence.JoinResidenceScreen( + onNavigateBack = { navController.popBackStack() }, + onJoined = { residenceId -> + navController.popBackStack() + navController.navigate(ResidenceDetailRoute(residenceId)) + }, + ) + } + composable { AddResidenceScreen( onNavigateBack = { diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/JoinResidenceDialog.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/JoinResidenceDialog.kt deleted file mode 100644 index 93b2a1f..0000000 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/JoinResidenceDialog.kt +++ /dev/null @@ -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(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") - } - } - ) -} diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/MainScreen.kt index 078372a..58a34ac 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/MainScreen.kt @@ -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 { + 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 { Box(modifier = Modifier.fillMaxSize()) { AllTasksScreen( diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/residence/JoinResidenceScreen.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/residence/JoinResidenceScreen.kt new file mode 100644 index 0000000..a71dca9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/residence/JoinResidenceScreen.kt @@ -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) { + 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()) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/residence/JoinResidenceViewModel.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/residence/JoinResidenceViewModel.kt new file mode 100644 index 0000000..e95aa36 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/residence/JoinResidenceViewModel.kt @@ -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 = { code -> + APILayer.joinWithCode(code) + }, + private val analytics: (String, Map) -> Unit = { name, props -> + PostHogAnalytics.capture(name, props) + } +) : ViewModel() { + + private val _code = MutableStateFlow("") + val code: StateFlow = _code.asStateFlow() + + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage.asStateFlow() + + private val _submitState = MutableStateFlow>(ApiResult.Idle) + /** Success carries the id of the joined residence. */ + val submitState: StateFlow> = _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" + } +} diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/residence/JoinResidenceViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/residence/JoinResidenceViewModelTest.kt new file mode 100644 index 0000000..00b4c48 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/residence/JoinResidenceViewModelTest.kt @@ -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 = ApiResult.Success(fakeJoinResponse()), + onJoinCall: (String) -> Unit = {}, + onAnalytics: (String, Map) -> 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(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>(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(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>>() + 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>>() + 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) + } +}