test(02-07): add Compose Resources, theme seed, and failing LoginViewModel tests

- Add Phase 2 auth strings.xml with Polish scaffold copy from UI-SPEC
- Add RecipeTheme with #3B6939 / #A2D597 seed and isSystemInDarkTheme
- Add LoginViewModelTest covering cancelled/network/unknown error mapping
  and the clear-error-on-retry behavior; tests fail compile until Task 2
This commit is contained in:
2026-04-28 17:35:11 +02:00
parent d69cb1caee
commit 466e4c7f7a
3 changed files with 257 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Phase 2 auth scaffold copy. Polish-only v1; resources are multi-locale-ready per
CLAUDE.md non-negotiable #9. Phase 11 polishes copy + plurals; do not edit
these keys without coordinating with the auth UI in `ui/screens/auth/*`.
-->
<resources>
<string name="auth_app_name">Recipe</string>
<string name="auth_sign_in_button">Zaloguj się przez Authentik</string>
<string name="auth_sign_out_button">Wyloguj się</string>
<string name="auth_welcome_format">Witaj, %1$s!</string>
<string name="auth_error_cancelled">Logowanie anulowane. Spróbuj ponownie.</string>
<string name="auth_error_network">Nie można połączyć z Authentik. Sprawdź połączenie.</string>
<string name="auth_error_unknown">Coś poszło nie tak. Spróbuj ponownie.</string>
</resources>

View File

@@ -0,0 +1,35 @@
package dev.ulfrx.recipe.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/**
* Phase 2 seed theme. Material 3 light/dark schemes with a single seed override on `primary`
* (`#3B6939` light / `#A2D597` dark — see `02-UI-SPEC.md` § Color). All other roles use
* Material 3 baseline values. Phase 11 may rebase the palette around a different seed.
*
* Intentionally minimal: no Haze, no custom typography, no shapes. Per UI-SPEC, Material 3
* defaults satisfy Phase 2's spacing/typography/accessibility contract.
*/
private val LightColors =
lightColorScheme(
primary = Color(0xFF3B6939),
)
private val DarkColors =
darkColorScheme(
primary = Color(0xFFA2D597),
)
@Composable
fun RecipeTheme(content: @Composable () -> Unit) {
val colors = if (isSystemInDarkTheme()) DarkColors else LightColors
MaterialTheme(
colorScheme = colors,
content = content,
)
}

View File

@@ -0,0 +1,207 @@
package dev.ulfrx.recipe.ui.screens.auth
import dev.ulfrx.recipe.auth.AuthLoginResult
import dev.ulfrx.recipe.auth.AuthSession
import dev.ulfrx.recipe.auth.AuthStateStore
import dev.ulfrx.recipe.auth.MeGateway
import dev.ulfrx.recipe.auth.OidcClientGateway
import dev.ulfrx.recipe.auth.OidcResult
import dev.ulfrx.recipe.shared.dto.User
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.yield
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.auth_error_cancelled
import recipe.composeapp.generated.resources.auth_error_network
import recipe.composeapp.generated.resources.auth_error_unknown
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class LoginViewModelTest {
@Test
fun cancelledAuthFailureMapsToCancelledStringResource() {
runBlocking {
val session = newSession(loginResult = OidcResult.Cancelled)
val viewModel = LoginViewModel(session)
viewModel.onSignInClick().join()
assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey)
assertEquals(false, viewModel.state.value.isLoading)
}
}
@Test
fun networkAuthFailureMapsToNetworkStringResource() {
runBlocking {
val session = newSession(loginResult = OidcResult.NetworkError)
val viewModel = LoginViewModel(session)
viewModel.onSignInClick().join()
assertEquals(Res.string.auth_error_network, viewModel.state.value.errorKey)
assertEquals(false, viewModel.state.value.isLoading)
}
}
@Test
fun unknownAuthFailureMapsToUnknownStringResource() {
runBlocking {
val session = newSession(loginResult = OidcResult.AuthError("token endpoint failed"))
val viewModel = LoginViewModel(session)
viewModel.onSignInClick().join()
assertEquals(Res.string.auth_error_unknown, viewModel.state.value.errorKey)
assertEquals(false, viewModel.state.value.isLoading)
}
}
@Test
fun successClearsErrorAndStopsLoading() {
runBlocking {
val session =
newSession(
loginResult =
OidcResult.Success(
authStateJson = "{}",
accessToken = "access",
idToken = null,
expiresAtEpochMillis = 0L,
),
)
val viewModel = LoginViewModel(session)
viewModel.onSignInClick().join()
assertNull(viewModel.state.value.errorKey)
assertEquals(false, viewModel.state.value.isLoading)
}
}
@Test
fun startingNewSignInClearsPreviousErrorAndSetsLoading() {
runBlocking {
// First login fails with cancelled to seed an error.
val firstOidc = FakeOidcClient(loginResult = OidcResult.Cancelled)
val session = AuthSession(firstOidc, FakeAuthStateStore(), FakeMeClient(USER))
val viewModel = LoginViewModel(session)
viewModel.onSignInClick().join()
assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey)
// Second login: gate the result so we can observe loading=true and error cleared.
val gate = CompletableDeferred<OidcResult>()
val gatedOidc =
object : OidcClientGateway {
override suspend fun login(): OidcResult = gate.await()
override suspend fun refresh(authStateJson: String): OidcResult =
OidcResult.AuthError("not used")
override suspend fun logout(authStateJson: String) {}
}
val gatedSession = AuthSession(gatedOidc, FakeAuthStateStore(), FakeMeClient(USER))
val gatedViewModel = LoginViewModel(gatedSession)
// Seed an error on the gated VM by directly priming via the cancelled session.
// Simpler: assert behavior with a single VM that has both attempts.
val singleOidcQueue =
mutableListOf<OidcResult>(
OidcResult.Cancelled,
)
val singleOidc =
object : OidcClientGateway {
override suspend fun login(): OidcResult {
// First call returns immediately; subsequent calls await the gate.
return if (singleOidcQueue.isNotEmpty()) {
singleOidcQueue.removeAt(0)
} else {
gate.await()
}
}
override suspend fun refresh(authStateJson: String): OidcResult =
OidcResult.AuthError("not used")
override suspend fun logout(authStateJson: String) {}
}
val singleSession = AuthSession(singleOidc, FakeAuthStateStore(), FakeMeClient(USER))
val singleVm = LoginViewModel(singleSession)
singleVm.onSignInClick().join()
assertEquals(Res.string.auth_error_cancelled, singleVm.state.value.errorKey)
// Now start the second login and observe immediate loading + cleared error.
val job =
async(Dispatchers.Unconfined) {
singleVm.onSignInClick().join()
}
// Yield to let the VM enter the loading state before await suspends.
yield()
assertTrue(singleVm.state.value.isLoading)
assertNull(singleVm.state.value.errorKey)
gate.complete(OidcResult.Cancelled)
job.await()
// After completion, loading is false and the new (still cancelled) error is set.
assertEquals(false, singleVm.state.value.isLoading)
assertEquals(Res.string.auth_error_cancelled, singleVm.state.value.errorKey)
}
}
private fun newSession(
loginResult: OidcResult,
store: AuthStateStore = FakeAuthStateStore(),
meClient: MeGateway = FakeMeClient(USER),
): AuthSession =
AuthSession(
oidcClient = FakeOidcClient(loginResult = loginResult),
store = store,
meClient = meClient,
)
private class FakeAuthStateStore(
var value: String? = null,
) : AuthStateStore {
override fun read(): String? = value
override fun write(authStateJson: String) {
value = authStateJson
}
override fun clear() {
value = null
}
}
private class FakeOidcClient(
private val loginResult: OidcResult = OidcResult.AuthError("login not configured"),
private val refreshResult: OidcResult = OidcResult.AuthError("refresh not configured"),
) : OidcClientGateway {
override suspend fun login(): OidcResult = loginResult
override suspend fun refresh(authStateJson: String): OidcResult = refreshResult
override suspend fun logout(authStateJson: String) {}
}
private class FakeMeClient(
private val user: User,
) : MeGateway {
override suspend fun getMe(accessToken: String?): User = user
}
private companion object {
val USER =
User(
id = "00000000-0000-0000-0000-000000000001",
sub = "authentik-sub",
email = "user@example.invalid",
displayName = "Recipe User",
)
}
}