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:
Trey T
2026-04-18 13:14:55 -05:00
parent 917c528f67
commit 704c59e5cb
7 changed files with 587 additions and 128 deletions
@@ -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)
}
}