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:
+219
@@ -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