Remove haze
This commit is contained in:
@@ -1,26 +0,0 @@
|
||||
package dev.ulfrx.recipe.ui.components.glass
|
||||
|
||||
import android.app.Application
|
||||
import android.content.pm.ApplicationInfo
|
||||
|
||||
/**
|
||||
* Android actual: this module does not expose an app `BuildConfig` class on the
|
||||
* Kotlin compile classpath, so read the runtime debuggable flag from the current
|
||||
* application instead. This keeps release builds on the production path without
|
||||
* requiring a build-file change outside this plan's ownership.
|
||||
*/
|
||||
actual val isDebugBuild: Boolean
|
||||
get() =
|
||||
currentApplication()
|
||||
?.applicationInfo
|
||||
?.flags
|
||||
?.and(ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||
|
||||
@Suppress("PrivateApi")
|
||||
private fun currentApplication(): Application? =
|
||||
runCatching {
|
||||
Class
|
||||
.forName("android.app.ActivityThread")
|
||||
.getMethod("currentApplication")
|
||||
.invoke(null) as? Application
|
||||
}.getOrNull()
|
||||
@@ -1,9 +1,5 @@
|
||||
package dev.ulfrx.recipe.di
|
||||
|
||||
import com.russhwolf.settings.Settings
|
||||
import dev.ulfrx.recipe.ui.components.glass.GlassBackend
|
||||
import dev.ulfrx.recipe.ui.components.glass.isDebugBuild
|
||||
import dev.ulfrx.recipe.ui.components.glass.resolveGlassBackend
|
||||
import dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModel
|
||||
import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
|
||||
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
|
||||
@@ -14,37 +10,8 @@ import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
|
||||
import org.koin.dsl.module
|
||||
import org.koin.plugin.module.dsl.viewModel
|
||||
|
||||
/**
|
||||
* Phase 2.1 (UI-03 / UI-04 / UI-09 / UI-10) — DI module for the app-shell layer.
|
||||
*
|
||||
* Registers:
|
||||
* - 4 tab ViewModels (Planner / Recipes / Pantry / Shopping) — pure StateFlow,
|
||||
* no dependencies this phase. Phase 5+ extends each to inject repositories.
|
||||
* - 2 Search ViewModels (Recipes + Pantry) — pure StateFlow with nullable
|
||||
* `searchSource: SearchSource? = null` per RESEARCH § Pattern 4.
|
||||
* - 1 ShellViewModel — active-tab + search-open state machine.
|
||||
* - 1 GlassBackend single — resolved at composition root from
|
||||
* [resolveGlassBackend] (CONTEXT D-16 / D-17). Default backend is
|
||||
* [GlassBackend.Liquid] — the iOS+Android primary path; debug builds may
|
||||
* pick up a runtime override stored in `multiplatform-settings`.
|
||||
*
|
||||
* Settings binding: registered in platform-specific Koin modules
|
||||
* (`auth/IosAuthModule.kt`, `auth/AndroidAuthModule.kt`) for use by
|
||||
* SecureAuthStateStore — the same single<Settings> binding is reused here.
|
||||
*/
|
||||
val shellModule =
|
||||
module {
|
||||
// Glass backend — resolved once at startup. Production builds short-circuit
|
||||
// [resolveGlassBackend] via [isDebugBuild] = false; debug builds may pick up
|
||||
// a runtime override stored in `multiplatform-settings`.
|
||||
single<GlassBackend> {
|
||||
resolveGlassBackend(
|
||||
settings = get<Settings>(),
|
||||
isDebug = isDebugBuild,
|
||||
default = GlassBackend.Liquid,
|
||||
)
|
||||
}
|
||||
|
||||
// Shell-level state machine.
|
||||
viewModel<ShellViewModel>()
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ import recipe.composeapp.generated.resources.search_close_a11y
|
||||
* [animateContentSize] (size) + [AnimatedContent] (content swap) at 250ms with
|
||||
* [FastOutSlowInEasing] per UI-SPEC line 198.
|
||||
*
|
||||
* Substrate: [GlassSurface] from plan 02.1-03 — direct Liquid/Haze API calls are
|
||||
* Substrate: [GlassSurface] from plan 02.1-03 — direct Liquid API calls are
|
||||
* forbidden here per CLAUDE.md non-negotiable #10.
|
||||
*
|
||||
* Touch targets: each tab cell + collapsed toggle is ≥ 44dp (UI-SPEC line 52, 224).
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Search
|
||||
@@ -30,10 +31,9 @@ import recipe.composeapp.generated.resources.search_open_a11y
|
||||
*/
|
||||
@Composable
|
||||
fun FloatingSearchButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
val a11y = stringResource(Res.string.search_open_a11y)
|
||||
GlassSurface(
|
||||
modifier = modifier.size(56.dp),
|
||||
cornerRadius = 28.dp,
|
||||
@@ -49,7 +49,7 @@ fun FloatingSearchButton(
|
||||
) {
|
||||
UnstyledIcon(
|
||||
imageVector = Lucide.Search,
|
||||
contentDescription = a11y,
|
||||
contentDescription = stringResource(Res.string.search_open_a11y),
|
||||
tint = RecipeTheme.colors.content,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
@@ -57,3 +57,4 @@ fun FloatingSearchButton(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
package dev.ulfrx.recipe.ui.components.glass
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
||||
/**
|
||||
* Flat translucent fallback with no blur. Geometry matches Liquid/Haze so
|
||||
* chrome call sites never branch on the active backend.
|
||||
*/
|
||||
@Composable
|
||||
internal fun FlatGlassSurface(
|
||||
modifier: Modifier,
|
||||
tint: Color,
|
||||
cornerRadius: Dp,
|
||||
border: BorderStroke?,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
val shape = RoundedCornerShape(cornerRadius)
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.clip(shape)
|
||||
.background(tint, shape)
|
||||
.let { if (border != null) it.border(border, shape) else it },
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -13,13 +13,12 @@ import androidx.compose.ui.Modifier
|
||||
* Shared source/sampling state for glass chrome.
|
||||
*
|
||||
* AppShell wraps the screen body in [GlassBackdropSource]. GlassSurface backends
|
||||
* consume [LocalGlassBackdropState] so Liquid/Haze sample the same layer behind
|
||||
* consume [LocalGlassBackdropState] so Liquid sample the same layer behind
|
||||
* the dock/search chrome.
|
||||
*/
|
||||
@Stable
|
||||
class GlassBackdropState internal constructor(
|
||||
internal val liquidState: Any,
|
||||
internal val hazeState: Any,
|
||||
)
|
||||
|
||||
val LocalGlassBackdropState = compositionLocalOf<GlassBackdropState?> { null }
|
||||
@@ -27,11 +26,9 @@ val LocalGlassBackdropState = compositionLocalOf<GlassBackdropState?> { null }
|
||||
@Composable
|
||||
fun rememberGlassBackdropState(): GlassBackdropState {
|
||||
val liquidState = rememberLiquidBackdropHandle()
|
||||
val hazeState = rememberHazeBackdropHandle()
|
||||
return remember(liquidState, hazeState) {
|
||||
return remember(liquidState) {
|
||||
GlassBackdropState(
|
||||
liquidState = liquidState,
|
||||
hazeState = hazeState,
|
||||
liquidState = liquidState
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -46,8 +43,7 @@ fun GlassBackdropSource(
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.liquidBackdropSource(state)
|
||||
.hazeBackdropSource(state),
|
||||
.liquidBackdropSource(state),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
package dev.ulfrx.recipe.ui.components.glass
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import com.russhwolf.settings.Settings
|
||||
|
||||
/**
|
||||
* Three glass-effect backends per CONTEXT D-16. All three consume the same
|
||||
* token API so chrome call sites never branch on the active backend.
|
||||
*/
|
||||
enum class GlassBackend {
|
||||
Liquid,
|
||||
Haze,
|
||||
Flat,
|
||||
}
|
||||
|
||||
/**
|
||||
* Composition root sets this to the resolved backend for the running build.
|
||||
* Consumers outside a provider fail safe to the simplest visible substrate.
|
||||
*/
|
||||
val LocalGlassBackend = compositionLocalOf { GlassBackend.Flat }
|
||||
|
||||
/**
|
||||
* Debug-only runtime override key (D-17). Values: "liquid", "haze", "flat".
|
||||
*/
|
||||
const val DEBUG_GLASS_BACKEND_KEY: String = "debug.glass_backend"
|
||||
|
||||
/**
|
||||
* Pure backend resolver used by production code and common tests.
|
||||
*
|
||||
* Release builds return [default] without consulting settings, so production
|
||||
* binaries do not carry a runtime backend switch.
|
||||
*/
|
||||
fun resolveGlassBackend(
|
||||
settings: Settings,
|
||||
isDebug: Boolean,
|
||||
default: GlassBackend,
|
||||
): GlassBackend {
|
||||
if (!isDebug) return default
|
||||
val raw = settings.getStringOrNull(DEBUG_GLASS_BACKEND_KEY) ?: return default
|
||||
return when (raw.lowercase()) {
|
||||
"liquid" -> GlassBackend.Liquid
|
||||
"haze" -> GlassBackend.Haze
|
||||
"flat" -> GlassBackend.Flat
|
||||
else -> default
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,6 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
|
||||
/**
|
||||
* Single public entry point for glass-effect chrome. Dispatches to one backend
|
||||
* through [LocalGlassBackend] and consumes the shared backdrop source when one
|
||||
* is present above it.
|
||||
*/
|
||||
@Composable
|
||||
fun GlassSurface(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -23,9 +18,5 @@ fun GlassSurface(
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
val backdropState = LocalGlassBackdropState.current
|
||||
when (LocalGlassBackend.current) {
|
||||
GlassBackend.Liquid -> LiquidGlassSurface(modifier, tint, cornerRadius, border, backdropState, content)
|
||||
GlassBackend.Haze -> HazeGlassSurface(modifier, tint, cornerRadius, border, backdropState, content)
|
||||
GlassBackend.Flat -> FlatGlassSurface(modifier, tint, cornerRadius, border, content)
|
||||
}
|
||||
LiquidGlassSurface(modifier, tint, cornerRadius, border, backdropState, content)
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
package dev.ulfrx.recipe.ui.components.glass
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.HazeStyle
|
||||
import dev.chrisbanes.haze.HazeTint
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.rememberHazeState
|
||||
|
||||
/**
|
||||
* Haze 1.x backend per CONTEXT D-16. The actual 1.6.10 API takes a
|
||||
* HazeStyle/block instead of a shape parameter, so shape is enforced by the
|
||||
* surrounding clip while the effect consumes the shared [HazeState].
|
||||
*/
|
||||
@Composable
|
||||
internal fun HazeGlassSurface(
|
||||
modifier: Modifier,
|
||||
tint: Color,
|
||||
cornerRadius: Dp,
|
||||
border: BorderStroke?,
|
||||
backdropState: GlassBackdropState?,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
val state = backdropState?.hazeState as? HazeState ?: rememberHazeState()
|
||||
val shape = RoundedCornerShape(cornerRadius)
|
||||
val style =
|
||||
HazeStyle(
|
||||
backgroundColor = tint.copy(alpha = 1f),
|
||||
tint = HazeTint(tint),
|
||||
blurRadius = 24.dp,
|
||||
)
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.clip(shape)
|
||||
.hazeEffect(state, style)
|
||||
.background(tint, shape)
|
||||
.let { if (border != null) it.border(border, shape) else it },
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun rememberHazeBackdropHandle(): Any = rememberHazeState()
|
||||
|
||||
internal fun Modifier.hazeBackdropSource(state: GlassBackdropState): Modifier = hazeSource(state.hazeState as HazeState)
|
||||
@@ -1,7 +0,0 @@
|
||||
package dev.ulfrx.recipe.ui.components.glass
|
||||
|
||||
/**
|
||||
* Compile-time gate for the [resolveGlassBackend] runtime override path
|
||||
* (CONTEXT D-17).
|
||||
*/
|
||||
expect val isDebugBuild: Boolean
|
||||
@@ -111,10 +111,6 @@ fun SearchPill(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper — placeholder text rendered when the BasicTextField is empty.
|
||||
* Plain text in [RecipeTheme.typography.body] tinted [RecipeTheme.colors.contentMuted].
|
||||
*/
|
||||
@Composable
|
||||
private fun PlaceholderText(
|
||||
text: String,
|
||||
|
||||
@@ -61,7 +61,7 @@ import recipe.composeapp.generated.resources.search_dismiss_keyboard_a11y
|
||||
* Layout responsibilities:
|
||||
* - Background: full-screen [RecipeTheme.colors.background] under the safe area.
|
||||
* - Body: [RootNavHost] consumes the full screen, wrapped in [GlassBackdropSource]
|
||||
* so Liquid/Haze chrome sample the screen body through [LocalGlassBackdropState].
|
||||
* so Liquid chrome samples the screen body through [LocalGlassBackdropState].
|
||||
* - Bottom chrome (overlay): bottom-anchored Column containing optional [SearchPill]
|
||||
* (when ui.searchOpen && active.hasSearch) and the [DockBar] (always visible).
|
||||
* Chrome consumes [WindowInsets.navigationBars] + [imePadding] explicitly per
|
||||
@@ -137,7 +137,7 @@ fun AppShell(modifier: Modifier = Modifier) {
|
||||
.background(RecipeTheme.colors.background),
|
||||
) {
|
||||
// Body — RootNavHost fills the available space and is the shared source layer
|
||||
// for Liquid/Haze chrome sampling via GlassBackdropSource (plan 02.1-03).
|
||||
// for Liquid chrome sampling via GlassBackdropSource (plan 02.1-03).
|
||||
GlassBackdropSource(modifier = Modifier.fillMaxSize()) {
|
||||
RootNavHost(
|
||||
navController = navController,
|
||||
|
||||
@@ -5,9 +5,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.ProvidableCompositionLocal
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import dev.ulfrx.recipe.ui.components.glass.GlassBackend
|
||||
import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackend
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
/**
|
||||
* Recipe theme entry point (CONTEXT D-14, D-15).
|
||||
@@ -35,7 +32,6 @@ public val LocalRecipeGlass: ProvidableCompositionLocal<RecipeGlass> =
|
||||
public fun RecipeTheme(content: @Composable () -> Unit) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val recipeColors = if (dark) DarkRecipeColors else LightRecipeColors
|
||||
val glassBackend = koinInject<GlassBackend>()
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalRecipeColors provides recipeColors,
|
||||
@@ -43,7 +39,6 @@ public fun RecipeTheme(content: @Composable () -> Unit) {
|
||||
LocalRecipeSpacing provides DefaultRecipeSpacing,
|
||||
LocalRecipeShapes provides DefaultRecipeShapes,
|
||||
LocalRecipeGlass provides DefaultRecipeGlass,
|
||||
LocalGlassBackend provides glassBackend,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package dev.ulfrx.recipe
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class ComposeAppCommonTest {
|
||||
@Test
|
||||
fun example() {
|
||||
assertEquals(3, 1 + 2)
|
||||
}
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
package dev.ulfrx.recipe.auth
|
||||
|
||||
import dev.lokksmith.client.request.flow.AuthFlow
|
||||
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class AuthSessionTest {
|
||||
@Test
|
||||
fun emptyStoreInitializesLoadingToUnauthenticated() {
|
||||
runTest {
|
||||
val session = newSession(store = FakeAuthStateStore())
|
||||
|
||||
assertIs<AuthState.Loading>(session.state.value)
|
||||
|
||||
session.initialize()
|
||||
|
||||
assertIs<AuthState.Unauthenticated>(session.state.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun successfulLoginPersistsAuthStateAndEmitsAuthenticated() {
|
||||
runTest {
|
||||
val store = FakeAuthStateStore()
|
||||
val oidcClient =
|
||||
FakeOidcClient(
|
||||
loginResult =
|
||||
OidcResult.Success(
|
||||
authStateJson = AUTH_STATE_JSON,
|
||||
accessToken = ACCESS_TOKEN,
|
||||
idToken = "id-token",
|
||||
expiresAtEpochMillis = 123_456L,
|
||||
),
|
||||
)
|
||||
val session = newSession(store = store, oidcClient = oidcClient)
|
||||
|
||||
val result = session.login(NoopBrowser)
|
||||
|
||||
assertEquals(AuthLoginResult.Success, result)
|
||||
assertEquals(AUTH_STATE_JSON, store.value)
|
||||
assertIs<AuthState.Authenticated>(session.state.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun existingStoreRefreshesAndEmitsAuthenticatedWithoutLogin() {
|
||||
runTest {
|
||||
val store = FakeAuthStateStore(value = "stored-auth-state-json")
|
||||
val oidcClient =
|
||||
FakeOidcClient(
|
||||
refreshResult =
|
||||
OidcResult.Success(
|
||||
authStateJson = REFRESHED_AUTH_STATE_JSON,
|
||||
accessToken = REFRESHED_ACCESS_TOKEN,
|
||||
idToken = null,
|
||||
expiresAtEpochMillis = 789_000L,
|
||||
),
|
||||
)
|
||||
val session = newSession(store = store, oidcClient = oidcClient)
|
||||
|
||||
session.initialize()
|
||||
|
||||
assertEquals(emptyList(), oidcClient.loginCalls)
|
||||
assertEquals(listOf("stored-auth-state-json"), oidcClient.refreshCalls)
|
||||
assertEquals(REFRESHED_AUTH_STATE_JSON, store.value)
|
||||
assertIs<AuthState.Authenticated>(session.state.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshInvalidGrantClearsStoreAndEmitsUnauthenticatedWithoutUiError() {
|
||||
runTest {
|
||||
val store = FakeAuthStateStore(value = "stored-auth-state-json")
|
||||
val oidcClient =
|
||||
FakeOidcClient(
|
||||
refreshResult = OidcResult.AuthError("invalid_grant"),
|
||||
)
|
||||
val session = newSession(store = store, oidcClient = oidcClient)
|
||||
|
||||
session.initialize()
|
||||
|
||||
assertNull(store.value)
|
||||
assertIs<AuthState.Unauthenticated>(session.state.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshAuthErrorClearsStoreAndEmitsUnauthenticatedWithoutUiError() {
|
||||
runTest {
|
||||
val store = FakeAuthStateStore(value = "stored-auth-state-json")
|
||||
val oidcClient =
|
||||
FakeOidcClient(
|
||||
refreshResult = OidcResult.AuthError("token endpoint rejected refresh"),
|
||||
)
|
||||
val session = newSession(store = store, oidcClient = oidcClient)
|
||||
|
||||
session.initialize()
|
||||
|
||||
assertNull(store.value)
|
||||
assertIs<AuthState.Unauthenticated>(session.state.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun logoutCallsEndSessionThenClearsStoreAndEmitsUnauthenticatedWhenLogoutSucceeds() {
|
||||
runTest {
|
||||
val store = FakeAuthStateStore(value = AUTH_STATE_JSON)
|
||||
val oidcClient = FakeOidcClient()
|
||||
val session = newSession(store = store, oidcClient = oidcClient)
|
||||
|
||||
session.logout(NoopBrowser)
|
||||
|
||||
assertEquals(listOf(AUTH_STATE_JSON), oidcClient.logoutCalls)
|
||||
assertNull(store.value)
|
||||
assertIs<AuthState.Unauthenticated>(session.state.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun logoutClearsStoreAndEmitsUnauthenticatedEvenWhenEndSessionThrows() {
|
||||
runTest {
|
||||
val store = FakeAuthStateStore(value = AUTH_STATE_JSON)
|
||||
val oidcClient = FakeOidcClient(logoutThrows = true)
|
||||
val session = newSession(store = store, oidcClient = oidcClient)
|
||||
|
||||
session.logout(NoopBrowser)
|
||||
|
||||
assertEquals(listOf(AUTH_STATE_JSON), oidcClient.logoutCalls)
|
||||
assertNull(store.value)
|
||||
assertIs<AuthState.Unauthenticated>(session.state.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loginCancelledMapsToUiRenderableCancelledResult() {
|
||||
runTest {
|
||||
val store = FakeAuthStateStore()
|
||||
val session =
|
||||
newSession(
|
||||
store = store,
|
||||
oidcClient = FakeOidcClient(loginResult = OidcResult.Cancelled),
|
||||
)
|
||||
|
||||
val result = session.login(NoopBrowser)
|
||||
|
||||
assertEquals(AuthLoginResult.Cancelled, result)
|
||||
assertNull(store.value)
|
||||
assertIs<AuthState.Unauthenticated>(session.state.value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun newSession(
|
||||
store: AuthStateStore = FakeAuthStateStore(),
|
||||
oidcClient: OidcClientGateway = FakeOidcClient(),
|
||||
): AuthSession =
|
||||
AuthSession(
|
||||
oidcClient = oidcClient,
|
||||
store = store,
|
||||
)
|
||||
|
||||
private object NoopBrowser : AuthBrowser {
|
||||
override suspend fun launchAndAwait(initiation: AuthFlow.Initiation): AuthFlowResultProvider.Result =
|
||||
AuthFlowResultProvider.Result.Undefined
|
||||
}
|
||||
|
||||
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"),
|
||||
private val logoutThrows: Boolean = false,
|
||||
) : OidcClientGateway {
|
||||
val loginCalls = mutableListOf<Unit>()
|
||||
val refreshCalls = mutableListOf<String>()
|
||||
val logoutCalls = mutableListOf<String>()
|
||||
|
||||
override suspend fun login(browser: AuthBrowser): OidcResult {
|
||||
loginCalls += Unit
|
||||
return loginResult
|
||||
}
|
||||
|
||||
override suspend fun refresh(authStateJson: String): OidcResult {
|
||||
refreshCalls += authStateJson
|
||||
return refreshResult
|
||||
}
|
||||
|
||||
override suspend fun logout(
|
||||
authStateJson: String,
|
||||
browser: AuthBrowser,
|
||||
) {
|
||||
logoutCalls += authStateJson
|
||||
if (logoutThrows) {
|
||||
error("end-session failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val AUTH_STATE_JSON = """{"refresh_token":"initial"}"""
|
||||
const val REFRESHED_AUTH_STATE_JSON = """{"refresh_token":"refreshed"}"""
|
||||
const val ACCESS_TOKEN = "access-token"
|
||||
const val REFRESHED_ACCESS_TOKEN = "refreshed-access-token"
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package dev.ulfrx.recipe.auth
|
||||
|
||||
import com.russhwolf.settings.Settings
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
private class InMemorySettings : Settings {
|
||||
private val map = mutableMapOf<String, Any>()
|
||||
|
||||
override val keys: Set<String> get() = map.keys
|
||||
override val size: Int get() = map.size
|
||||
|
||||
override fun clear() = map.clear()
|
||||
|
||||
override fun remove(key: String) {
|
||||
map.remove(key)
|
||||
}
|
||||
|
||||
override fun hasKey(key: String): Boolean = map.containsKey(key)
|
||||
|
||||
override fun putInt(
|
||||
key: String,
|
||||
value: Int,
|
||||
) {
|
||||
map[key] = value
|
||||
}
|
||||
|
||||
override fun getInt(
|
||||
key: String,
|
||||
defaultValue: Int,
|
||||
): Int = (map[key] as? Int) ?: defaultValue
|
||||
|
||||
override fun getIntOrNull(key: String): Int? = map[key] as? Int
|
||||
|
||||
override fun putLong(
|
||||
key: String,
|
||||
value: Long,
|
||||
) {
|
||||
map[key] = value
|
||||
}
|
||||
|
||||
override fun getLong(
|
||||
key: String,
|
||||
defaultValue: Long,
|
||||
): Long = (map[key] as? Long) ?: defaultValue
|
||||
|
||||
override fun getLongOrNull(key: String): Long? = map[key] as? Long
|
||||
|
||||
override fun putString(
|
||||
key: String,
|
||||
value: String,
|
||||
) {
|
||||
map[key] = value
|
||||
}
|
||||
|
||||
override fun getString(
|
||||
key: String,
|
||||
defaultValue: String,
|
||||
): String = (map[key] as? String) ?: defaultValue
|
||||
|
||||
override fun getStringOrNull(key: String): String? = map[key] as? String
|
||||
|
||||
override fun putFloat(
|
||||
key: String,
|
||||
value: Float,
|
||||
) {
|
||||
map[key] = value
|
||||
}
|
||||
|
||||
override fun getFloat(
|
||||
key: String,
|
||||
defaultValue: Float,
|
||||
): Float = (map[key] as? Float) ?: defaultValue
|
||||
|
||||
override fun getFloatOrNull(key: String): Float? = map[key] as? Float
|
||||
|
||||
override fun putDouble(
|
||||
key: String,
|
||||
value: Double,
|
||||
) {
|
||||
map[key] = value
|
||||
}
|
||||
|
||||
override fun getDouble(
|
||||
key: String,
|
||||
defaultValue: Double,
|
||||
): Double = (map[key] as? Double) ?: defaultValue
|
||||
|
||||
override fun getDoubleOrNull(key: String): Double? = map[key] as? Double
|
||||
|
||||
override fun putBoolean(
|
||||
key: String,
|
||||
value: Boolean,
|
||||
) {
|
||||
map[key] = value
|
||||
}
|
||||
|
||||
override fun getBoolean(
|
||||
key: String,
|
||||
defaultValue: Boolean,
|
||||
): Boolean = (map[key] as? Boolean) ?: defaultValue
|
||||
|
||||
override fun getBooleanOrNull(key: String): Boolean? = map[key] as? Boolean
|
||||
}
|
||||
|
||||
class SecureAuthStateStoreContractTest {
|
||||
@Test
|
||||
fun writeOverwritesPreviousValueAndReadReturnsLatest() {
|
||||
val store = SecureAuthStateStore(InMemorySettings())
|
||||
|
||||
store.write("""{"refresh_token":"first"}""")
|
||||
store.write("""{"refresh_token":"second"}""")
|
||||
|
||||
assertEquals("""{"refresh_token":"second"}""", store.read())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearRemovesStoredValue() {
|
||||
val store = SecureAuthStateStore(InMemorySettings())
|
||||
|
||||
store.write("""{"refresh_token":"stored"}""")
|
||||
store.clear()
|
||||
|
||||
assertNull(store.read())
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package dev.ulfrx.recipe.navigation
|
||||
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.navOptions
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* V-01 — UI-03 — `navigateToTab()` extension applies the four-flag multi-back-stack
|
||||
* incantation:
|
||||
* popUpTo(graph.findStartDestination().id) { saveState = true }
|
||||
* launchSingleTop = true
|
||||
* restoreState = true
|
||||
*
|
||||
* Strategy: the public NavHostController.navigateToTab call cannot be unit-tested
|
||||
* without a live NavHostController (which requires a Compose composition runtime
|
||||
* not available in pure commonTest). So we test the LAMBDA SHAPE that
|
||||
* navigateToTab passes to navigate(...): we replicate the production lambda body
|
||||
* against the official `navOptions { ... }` builder and assert the resulting
|
||||
* NavOptions properties via the public `shouldXxx()` accessors.
|
||||
*/
|
||||
class NavigationTest {
|
||||
@Test
|
||||
fun navigateToTab_lambda_setsLaunchSingleTopAndRestoreState() {
|
||||
val opts =
|
||||
navOptions {
|
||||
popUpTo(0) { saveState = true }
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
|
||||
assertTrue(opts.shouldLaunchSingleTop(), "launchSingleTop must be true")
|
||||
assertTrue(opts.shouldRestoreState(), "restoreState must be true")
|
||||
assertTrue(opts.shouldPopUpToSaveState(), "popUpTo { saveState = true } must be set")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun navigateToTab_extension_isPublicAndDefinedOnNavHostController() {
|
||||
// Compile-time + reflection-light assertion: the function exists with the
|
||||
// expected signature. If it disappears or its signature drifts, the test
|
||||
// file no longer compiles, which itself is a failed test.
|
||||
val fn: (NavHostController, Any) -> Unit = { c, route -> c.navigateToTab(route) }
|
||||
assertNotNull(fn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun navigateToTab_lambda_setsAllFourFlagsTogether() {
|
||||
// Belt-and-suspenders: a single test that the four flags fire together,
|
||||
// not individually — UI-03 hard-coded contract.
|
||||
val opts =
|
||||
navOptions {
|
||||
popUpTo(42) { saveState = true }
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
assertEquals(true, opts.shouldLaunchSingleTop())
|
||||
assertEquals(true, opts.shouldRestoreState())
|
||||
assertEquals(true, opts.shouldPopUpToSaveState())
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
package dev.ulfrx.recipe.ui.components.glass
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* V-03 - UI-04 - debug-build runtime override via multiplatform-settings
|
||||
* honors "debug.glass_backend" values. Production builds ignore overrides.
|
||||
*/
|
||||
class GlassBackendOverrideTest {
|
||||
@Test
|
||||
fun resolveGlassBackend_debugBuildHonorsHazeOverride() {
|
||||
val settings = MapSettings()
|
||||
settings.putString(DEBUG_GLASS_BACKEND_KEY, "haze")
|
||||
|
||||
val result =
|
||||
resolveGlassBackend(
|
||||
settings = settings,
|
||||
isDebug = true,
|
||||
default = GlassBackend.Liquid,
|
||||
)
|
||||
|
||||
assertEquals(GlassBackend.Haze, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveGlassBackend_debugBuildHonorsFlatOverride() {
|
||||
val settings = MapSettings()
|
||||
settings.putString(DEBUG_GLASS_BACKEND_KEY, "flat")
|
||||
|
||||
val result =
|
||||
resolveGlassBackend(
|
||||
settings = settings,
|
||||
isDebug = true,
|
||||
default = GlassBackend.Liquid,
|
||||
)
|
||||
|
||||
assertEquals(GlassBackend.Flat, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveGlassBackend_debugBuildHonorsLiquidOverride() {
|
||||
val settings = MapSettings()
|
||||
settings.putString(DEBUG_GLASS_BACKEND_KEY, "liquid")
|
||||
|
||||
val result =
|
||||
resolveGlassBackend(
|
||||
settings = settings,
|
||||
isDebug = true,
|
||||
default = GlassBackend.Haze,
|
||||
)
|
||||
|
||||
assertEquals(GlassBackend.Liquid, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveGlassBackend_caseInsensitive() {
|
||||
val settings = MapSettings()
|
||||
settings.putString(DEBUG_GLASS_BACKEND_KEY, "HAZE")
|
||||
|
||||
val result =
|
||||
resolveGlassBackend(
|
||||
settings = settings,
|
||||
isDebug = true,
|
||||
default = GlassBackend.Liquid,
|
||||
)
|
||||
|
||||
assertEquals(GlassBackend.Haze, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveGlassBackend_productionBuildIgnoresOverride() {
|
||||
val settings = MapSettings()
|
||||
settings.putString(DEBUG_GLASS_BACKEND_KEY, "haze")
|
||||
|
||||
val result =
|
||||
resolveGlassBackend(
|
||||
settings = settings,
|
||||
isDebug = false,
|
||||
default = GlassBackend.Liquid,
|
||||
)
|
||||
|
||||
assertEquals(GlassBackend.Liquid, result)
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package dev.ulfrx.recipe.ui.components.glass
|
||||
|
||||
import com.russhwolf.settings.Settings
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* V-02 - UI-04 - resolveGlassBackend(...) returns the compile-time default
|
||||
* when no debug override is present.
|
||||
*/
|
||||
class GlassBackendTest {
|
||||
@Test
|
||||
fun resolveGlassBackend_iosDefault_returnsLiquid() {
|
||||
val result =
|
||||
resolveGlassBackend(
|
||||
settings = MapSettings(),
|
||||
isDebug = false,
|
||||
default = GlassBackend.Liquid,
|
||||
)
|
||||
assertEquals(GlassBackend.Liquid, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveGlassBackend_emptySettings_returnsDefault() {
|
||||
val result =
|
||||
resolveGlassBackend(
|
||||
settings = MapSettings(),
|
||||
isDebug = true,
|
||||
default = GlassBackend.Liquid,
|
||||
)
|
||||
assertEquals(GlassBackend.Liquid, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveGlassBackend_unknownOverride_returnsDefault() {
|
||||
val settings = MapSettings()
|
||||
settings.putString(DEBUG_GLASS_BACKEND_KEY, "neon-wave")
|
||||
|
||||
val result =
|
||||
resolveGlassBackend(
|
||||
settings = settings,
|
||||
isDebug = true,
|
||||
default = GlassBackend.Liquid,
|
||||
)
|
||||
|
||||
assertEquals(GlassBackend.Liquid, result)
|
||||
}
|
||||
}
|
||||
|
||||
internal class MapSettings : Settings {
|
||||
private val values = mutableMapOf<String, Any>()
|
||||
|
||||
override val keys: Set<String>
|
||||
get() = values.keys
|
||||
|
||||
override val size: Int
|
||||
get() = values.size
|
||||
|
||||
override fun clear() {
|
||||
values.clear()
|
||||
}
|
||||
|
||||
override fun remove(key: String) {
|
||||
values.remove(key)
|
||||
}
|
||||
|
||||
override fun hasKey(key: String): Boolean = key in values
|
||||
|
||||
override fun putInt(
|
||||
key: String,
|
||||
value: Int,
|
||||
) {
|
||||
values[key] = value
|
||||
}
|
||||
|
||||
override fun getInt(
|
||||
key: String,
|
||||
defaultValue: Int,
|
||||
): Int = getIntOrNull(key) ?: defaultValue
|
||||
|
||||
override fun getIntOrNull(key: String): Int? = values[key] as? Int
|
||||
|
||||
override fun putLong(
|
||||
key: String,
|
||||
value: Long,
|
||||
) {
|
||||
values[key] = value
|
||||
}
|
||||
|
||||
override fun getLong(
|
||||
key: String,
|
||||
defaultValue: Long,
|
||||
): Long = getLongOrNull(key) ?: defaultValue
|
||||
|
||||
override fun getLongOrNull(key: String): Long? = values[key] as? Long
|
||||
|
||||
override fun putString(
|
||||
key: String,
|
||||
value: String,
|
||||
) {
|
||||
values[key] = value
|
||||
}
|
||||
|
||||
override fun getString(
|
||||
key: String,
|
||||
defaultValue: String,
|
||||
): String = getStringOrNull(key) ?: defaultValue
|
||||
|
||||
override fun getStringOrNull(key: String): String? = values[key] as? String
|
||||
|
||||
override fun putFloat(
|
||||
key: String,
|
||||
value: Float,
|
||||
) {
|
||||
values[key] = value
|
||||
}
|
||||
|
||||
override fun getFloat(
|
||||
key: String,
|
||||
defaultValue: Float,
|
||||
): Float = getFloatOrNull(key) ?: defaultValue
|
||||
|
||||
override fun getFloatOrNull(key: String): Float? = values[key] as? Float
|
||||
|
||||
override fun putDouble(
|
||||
key: String,
|
||||
value: Double,
|
||||
) {
|
||||
values[key] = value
|
||||
}
|
||||
|
||||
override fun getDouble(
|
||||
key: String,
|
||||
defaultValue: Double,
|
||||
): Double = getDoubleOrNull(key) ?: defaultValue
|
||||
|
||||
override fun getDoubleOrNull(key: String): Double? = values[key] as? Double
|
||||
|
||||
override fun putBoolean(
|
||||
key: String,
|
||||
value: Boolean,
|
||||
) {
|
||||
values[key] = value
|
||||
}
|
||||
|
||||
override fun getBoolean(
|
||||
key: String,
|
||||
defaultValue: Boolean,
|
||||
): Boolean = getBooleanOrNull(key) ?: defaultValue
|
||||
|
||||
override fun getBooleanOrNull(key: String): Boolean? = values[key] as? Boolean
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
package dev.ulfrx.recipe.ui.screens.auth
|
||||
|
||||
import dev.lokksmith.client.request.flow.AuthFlow
|
||||
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
|
||||
import dev.ulfrx.recipe.auth.AuthBrowser
|
||||
import dev.ulfrx.recipe.auth.AuthSession
|
||||
import dev.ulfrx.recipe.auth.AuthStateStore
|
||||
import dev.ulfrx.recipe.auth.OidcClientGateway
|
||||
import dev.ulfrx.recipe.auth.OidcResult
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.test.runTest
|
||||
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() =
|
||||
runTest {
|
||||
val session = newSession(loginResult = OidcResult.Cancelled)
|
||||
val viewModel = LoginViewModel(session)
|
||||
|
||||
viewModel.onSignInClick(NoopBrowser).join()
|
||||
|
||||
assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey)
|
||||
assertEquals(false, viewModel.state.value.isLoading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun networkAuthFailureMapsToNetworkStringResource() =
|
||||
runTest {
|
||||
val session = newSession(loginResult = OidcResult.NetworkError)
|
||||
val viewModel = LoginViewModel(session)
|
||||
|
||||
viewModel.onSignInClick(NoopBrowser).join()
|
||||
|
||||
assertEquals(Res.string.auth_error_network, viewModel.state.value.errorKey)
|
||||
assertEquals(false, viewModel.state.value.isLoading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownAuthFailureMapsToUnknownStringResource() =
|
||||
runTest {
|
||||
val session = newSession(loginResult = OidcResult.AuthError("token endpoint failed"))
|
||||
val viewModel = LoginViewModel(session)
|
||||
|
||||
viewModel.onSignInClick(NoopBrowser).join()
|
||||
|
||||
assertEquals(Res.string.auth_error_unknown, viewModel.state.value.errorKey)
|
||||
assertEquals(false, viewModel.state.value.isLoading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun successClearsErrorAndStopsLoading() =
|
||||
runTest {
|
||||
val session =
|
||||
newSession(
|
||||
loginResult =
|
||||
OidcResult.Success(
|
||||
authStateJson = "{}",
|
||||
accessToken = "access",
|
||||
idToken = null,
|
||||
expiresAtEpochMillis = 0L,
|
||||
),
|
||||
)
|
||||
val viewModel = LoginViewModel(session)
|
||||
|
||||
viewModel.onSignInClick(NoopBrowser).join()
|
||||
|
||||
assertNull(viewModel.state.value.errorKey)
|
||||
assertEquals(false, viewModel.state.value.isLoading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun startingNewSignInClearsPreviousErrorAndSetsLoading() =
|
||||
runTest {
|
||||
// Queue: first login resolves Cancelled to seed an inline error.
|
||||
// Second login awaits a gate so we can synchronously observe the
|
||||
// "loading=true, error=null" intermediate state contract from UI-SPEC.
|
||||
val gate = CompletableDeferred<OidcResult>()
|
||||
val queue = mutableListOf<OidcResult>(OidcResult.Cancelled)
|
||||
val oidc =
|
||||
object : OidcClientGateway {
|
||||
override suspend fun login(browser: AuthBrowser): OidcResult =
|
||||
if (queue.isNotEmpty()) queue.removeAt(0) else gate.await()
|
||||
|
||||
override suspend fun refresh(authStateJson: String): OidcResult = OidcResult.AuthError("not used")
|
||||
|
||||
override suspend fun logout(
|
||||
authStateJson: String,
|
||||
browser: AuthBrowser,
|
||||
) {}
|
||||
}
|
||||
val session = AuthSession(oidc, FakeAuthStateStore())
|
||||
val viewModel = LoginViewModel(session)
|
||||
|
||||
// First attempt: error seeded.
|
||||
viewModel.onSignInClick(NoopBrowser).join()
|
||||
assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey)
|
||||
|
||||
// Second attempt: launching the job sets loading=true + clears error
|
||||
// BEFORE suspending. onSignInClick() does that synchronously before
|
||||
// returning the launched Job, so we can assert immediately.
|
||||
val job = viewModel.onSignInClick(NoopBrowser)
|
||||
assertTrue(viewModel.state.value.isLoading)
|
||||
assertNull(viewModel.state.value.errorKey)
|
||||
|
||||
// Release the gate; the second login also returns Cancelled.
|
||||
gate.complete(OidcResult.Cancelled)
|
||||
job.join()
|
||||
|
||||
assertEquals(false, viewModel.state.value.isLoading)
|
||||
assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey)
|
||||
}
|
||||
|
||||
private fun newSession(
|
||||
loginResult: OidcResult,
|
||||
store: AuthStateStore = FakeAuthStateStore(),
|
||||
): AuthSession =
|
||||
AuthSession(
|
||||
oidcClient = FakeOidcClient(loginResult = loginResult),
|
||||
store = store,
|
||||
)
|
||||
|
||||
private object NoopBrowser : AuthBrowser {
|
||||
override suspend fun launchAndAwait(initiation: AuthFlow.Initiation): AuthFlowResultProvider.Result =
|
||||
AuthFlowResultProvider.Result.Undefined
|
||||
}
|
||||
|
||||
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(browser: AuthBrowser): OidcResult = loginResult
|
||||
|
||||
override suspend fun refresh(authStateJson: String): OidcResult = refreshResult
|
||||
|
||||
override suspend fun logout(
|
||||
authStateJson: String,
|
||||
browser: AuthBrowser,
|
||||
) {}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package dev.ulfrx.recipe.ui.screens.pantry
|
||||
|
||||
import dev.ulfrx.recipe.ui.screens.recipes.SearchState
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* V-07 — UI-10 — PantrySearchViewModel parity with RecipesSearchViewModel
|
||||
* (open / close / clear semantics — CONTEXT D-07 / D-08).
|
||||
*/
|
||||
class PantrySearchViewModelTest {
|
||||
@Test
|
||||
fun openThenQueryChangeThenClose_clearsQueryAndResetsIsOpen() =
|
||||
runTest {
|
||||
val vm = PantrySearchViewModel()
|
||||
vm.open()
|
||||
vm.onQueryChange("mleko")
|
||||
assertEquals(SearchState(isOpen = true, query = "mleko"), vm.state.value)
|
||||
vm.close()
|
||||
assertEquals(SearchState(isOpen = false, query = ""), vm.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clear_resetsQueryButKeepsIsOpenTrue() =
|
||||
runTest {
|
||||
val vm = PantrySearchViewModel()
|
||||
vm.open()
|
||||
vm.onQueryChange("mleko")
|
||||
vm.clear()
|
||||
assertEquals(SearchState(isOpen = true, query = ""), vm.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun open_setsIsOpenTrueWithoutTouchingQuery() =
|
||||
runTest {
|
||||
val vm = PantrySearchViewModel()
|
||||
assertEquals(SearchState(isOpen = false, query = ""), vm.state.value)
|
||||
vm.open()
|
||||
assertEquals(SearchState(isOpen = true, query = ""), vm.state.value)
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package dev.ulfrx.recipe.ui.screens.recipes
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* V-05 + V-06 — UI-10 — RecipesSearchViewModel state-machine semantics
|
||||
* (RESEARCH § Pattern 4 + CONTEXT D-07 / D-08).
|
||||
*
|
||||
* V-05: open() → onQueryChange("foo") → close() leaves SearchState(isOpen=false, query="").
|
||||
* V-06: clear() resets only query, keeps isOpen=true.
|
||||
*/
|
||||
class RecipesSearchViewModelTest {
|
||||
@Test
|
||||
fun openThenQueryChangeThenClose_clearsQueryAndResetsIsOpen() =
|
||||
runTest {
|
||||
val vm = RecipesSearchViewModel()
|
||||
vm.open()
|
||||
vm.onQueryChange("foo")
|
||||
assertEquals(SearchState(isOpen = true, query = "foo"), vm.state.value)
|
||||
vm.close()
|
||||
assertEquals(SearchState(isOpen = false, query = ""), vm.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clear_resetsQueryButKeepsIsOpenTrue() =
|
||||
runTest {
|
||||
val vm = RecipesSearchViewModel()
|
||||
vm.open()
|
||||
vm.onQueryChange("foo")
|
||||
vm.clear()
|
||||
assertEquals(SearchState(isOpen = true, query = ""), vm.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun open_setsIsOpenTrueWithoutTouchingQuery() =
|
||||
runTest {
|
||||
val vm = RecipesSearchViewModel()
|
||||
assertEquals(SearchState(isOpen = false, query = ""), vm.state.value)
|
||||
vm.open()
|
||||
assertEquals(SearchState(isOpen = true, query = ""), vm.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onQueryChange_doesNotAffectIsOpen() =
|
||||
runTest {
|
||||
val vm = RecipesSearchViewModel()
|
||||
vm.onQueryChange("foo")
|
||||
assertEquals(SearchState(isOpen = false, query = "foo"), vm.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun closeFromAlreadyClosed_isIdempotent() =
|
||||
runTest {
|
||||
val vm = RecipesSearchViewModel()
|
||||
vm.close()
|
||||
assertEquals(SearchState(isOpen = false, query = ""), vm.state.value)
|
||||
vm.close()
|
||||
assertEquals(SearchState(isOpen = false, query = ""), vm.state.value)
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package dev.ulfrx.recipe.ui.screens.shell
|
||||
|
||||
import dev.ulfrx.recipe.RootRoute
|
||||
import dev.ulfrx.recipe.auth.AuthState
|
||||
import dev.ulfrx.recipe.resolveRootRoute
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* V-04 — UI-09 — App.kt's `Authenticated + currentUser != null` branch resolves
|
||||
* to the AppShell route, not PostLoginPlaceholderScreen.
|
||||
*
|
||||
* Tested via the pure [resolveRootRoute] helper extracted in plan 02.1-08, so
|
||||
* the routing semantics are deterministic without instrumenting a real Compose
|
||||
* composition. (The CMP iOS Compose UI testing surface is too immature this
|
||||
* phase for snapshot/UI tests on the actual `App()` composable —
|
||||
* VALIDATION.md line 27.)
|
||||
*/
|
||||
class AppShellGateTest {
|
||||
@Test
|
||||
fun authenticatedWithUser_routesToShell_notPlaceholder() {
|
||||
val route =
|
||||
resolveRootRoute(
|
||||
authState = AuthState.Authenticated,
|
||||
hasCurrentUser = true,
|
||||
)
|
||||
assertEquals(RootRoute.Shell, route)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authenticatedWithoutUserYet_routesToSplash() {
|
||||
// Two-layer gate per App.kt docstring: tokens present but /me has not
|
||||
// returned yet → hold on splash, never show empty post-login.
|
||||
val route =
|
||||
resolveRootRoute(
|
||||
authState = AuthState.Authenticated,
|
||||
hasCurrentUser = false,
|
||||
)
|
||||
assertEquals(RootRoute.Splash, route)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unauthenticated_routesToLogin() {
|
||||
val route =
|
||||
resolveRootRoute(
|
||||
authState = AuthState.Unauthenticated,
|
||||
hasCurrentUser = false,
|
||||
)
|
||||
assertEquals(RootRoute.Login, route)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadingAuth_routesToSplash() {
|
||||
val route =
|
||||
resolveRootRoute(
|
||||
authState = AuthState.Loading,
|
||||
hasCurrentUser = false,
|
||||
)
|
||||
assertEquals(RootRoute.Splash, route)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadingAuthIgnoresHasCurrentUser() {
|
||||
// Defensive: while Loading, we should always splash regardless of
|
||||
// whether a stale currentUser is observable from a previous session.
|
||||
val route =
|
||||
resolveRootRoute(
|
||||
authState = AuthState.Loading,
|
||||
hasCurrentUser = true,
|
||||
)
|
||||
assertEquals(RootRoute.Splash, route)
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package dev.ulfrx.recipe.user
|
||||
|
||||
import dev.lokksmith.client.request.flow.AuthFlow
|
||||
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
|
||||
import dev.ulfrx.recipe.auth.AuthBrowser
|
||||
import dev.ulfrx.recipe.auth.AuthSession
|
||||
import dev.ulfrx.recipe.auth.AuthStateStore
|
||||
import dev.ulfrx.recipe.auth.OidcClientGateway
|
||||
import dev.ulfrx.recipe.auth.OidcResult
|
||||
import dev.ulfrx.recipe.shared.dto.User
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class UserRepositoryTest {
|
||||
@Test
|
||||
fun fetchesUserWhenAuthFlipsToAuthenticated() =
|
||||
runTest {
|
||||
val session = newSession()
|
||||
var fetchCount = 0
|
||||
val repository =
|
||||
UserRepository(
|
||||
authSession = session,
|
||||
fetchUser = {
|
||||
fetchCount++
|
||||
USER
|
||||
},
|
||||
scope = TestScope(testScheduler),
|
||||
)
|
||||
|
||||
session.login(NoopBrowser)
|
||||
|
||||
val user = repository.currentUser.first { it != null }
|
||||
assertEquals(USER, user)
|
||||
assertEquals(1, fetchCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearsUserOnLogout() =
|
||||
runTest {
|
||||
val session = newSession()
|
||||
val repository =
|
||||
UserRepository(
|
||||
authSession = session,
|
||||
fetchUser = { USER },
|
||||
scope = TestScope(testScheduler),
|
||||
)
|
||||
|
||||
session.login(NoopBrowser)
|
||||
repository.currentUser.first { it != null }
|
||||
|
||||
session.logout(NoopBrowser)
|
||||
|
||||
val cleared = repository.currentUser.firstOrNull { it == null }
|
||||
assertNull(cleared)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun networkFailureLeavesCurrentUserNullWithoutCrashing() =
|
||||
runTest {
|
||||
val session = newSession()
|
||||
val repository =
|
||||
UserRepository(
|
||||
authSession = session,
|
||||
fetchUser = { error("network down") },
|
||||
scope = TestScope(testScheduler),
|
||||
)
|
||||
|
||||
session.login(NoopBrowser)
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
assertNull(repository.currentUser.value)
|
||||
}
|
||||
|
||||
private fun newSession(): AuthSession =
|
||||
AuthSession(
|
||||
oidcClient = FakeOidcClient(loginResult = SUCCESS),
|
||||
store = FakeAuthStateStore(),
|
||||
)
|
||||
|
||||
private object NoopBrowser : AuthBrowser {
|
||||
override suspend fun launchAndAwait(initiation: AuthFlow.Initiation): AuthFlowResultProvider.Result =
|
||||
AuthFlowResultProvider.Result.Undefined
|
||||
}
|
||||
|
||||
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,
|
||||
) : OidcClientGateway {
|
||||
override suspend fun login(browser: AuthBrowser): OidcResult = loginResult
|
||||
|
||||
override suspend fun refresh(authStateJson: String): OidcResult = OidcResult.AuthError("not used")
|
||||
|
||||
override suspend fun logout(
|
||||
authStateJson: String,
|
||||
browser: AuthBrowser,
|
||||
) {}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val SUCCESS =
|
||||
OidcResult.Success(
|
||||
authStateJson = "{}",
|
||||
accessToken = "access",
|
||||
idToken = null,
|
||||
expiresAtEpochMillis = 0L,
|
||||
)
|
||||
|
||||
val USER =
|
||||
User(
|
||||
id = "00000000-0000-0000-0000-000000000001",
|
||||
sub = "authentik-sub",
|
||||
email = "user@example.invalid",
|
||||
displayName = "Recipe User",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package dev.ulfrx.recipe.ui.components.glass
|
||||
|
||||
/**
|
||||
* iOS actual: Kotlin/Native exposes whether the current binary was compiled
|
||||
* for a debug configuration.
|
||||
*/
|
||||
@OptIn(kotlin.experimental.ExperimentalNativeApi::class)
|
||||
actual val isDebugBuild: Boolean = kotlin.native.Platform.isDebugBinary
|
||||
Reference in New Issue
Block a user