Remove haze
This commit is contained in:
@@ -94,11 +94,6 @@ kotlin {
|
|||||||
implementation(libs.compose.unstyled)
|
implementation(libs.compose.unstyled)
|
||||||
implementation(libs.compose.icons.lucide)
|
implementation(libs.compose.icons.lucide)
|
||||||
implementation(libs.liquid)
|
implementation(libs.liquid)
|
||||||
implementation(libs.haze)
|
|
||||||
}
|
|
||||||
commonTest.dependencies {
|
|
||||||
implementation(libs.kotlin.test)
|
|
||||||
implementation(libs.kotlinx.coroutinesTest)
|
|
||||||
}
|
}
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
|
|||||||
@@ -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
|
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.PantrySearchViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
|
import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
|
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.dsl.module
|
||||||
import org.koin.plugin.module.dsl.viewModel
|
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 =
|
val shellModule =
|
||||||
module {
|
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.
|
// Shell-level state machine.
|
||||||
viewModel<ShellViewModel>()
|
viewModel<ShellViewModel>()
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ import recipe.composeapp.generated.resources.search_close_a11y
|
|||||||
* [animateContentSize] (size) + [AnimatedContent] (content swap) at 250ms with
|
* [animateContentSize] (size) + [AnimatedContent] (content swap) at 250ms with
|
||||||
* [FastOutSlowInEasing] per UI-SPEC line 198.
|
* [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.
|
* forbidden here per CLAUDE.md non-negotiable #10.
|
||||||
*
|
*
|
||||||
* Touch targets: each tab cell + collapsed toggle is ≥ 44dp (UI-SPEC line 52, 224).
|
* 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.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.Search
|
import com.composables.icons.lucide.Search
|
||||||
@@ -30,10 +31,9 @@ import recipe.composeapp.generated.resources.search_open_a11y
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun FloatingSearchButton(
|
fun FloatingSearchButton(
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onClick: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val a11y = stringResource(Res.string.search_open_a11y)
|
|
||||||
GlassSurface(
|
GlassSurface(
|
||||||
modifier = modifier.size(56.dp),
|
modifier = modifier.size(56.dp),
|
||||||
cornerRadius = 28.dp,
|
cornerRadius = 28.dp,
|
||||||
@@ -49,7 +49,7 @@ fun FloatingSearchButton(
|
|||||||
) {
|
) {
|
||||||
UnstyledIcon(
|
UnstyledIcon(
|
||||||
imageVector = Lucide.Search,
|
imageVector = Lucide.Search,
|
||||||
contentDescription = a11y,
|
contentDescription = stringResource(Res.string.search_open_a11y),
|
||||||
tint = RecipeTheme.colors.content,
|
tint = RecipeTheme.colors.content,
|
||||||
modifier = Modifier.size(24.dp),
|
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.
|
* Shared source/sampling state for glass chrome.
|
||||||
*
|
*
|
||||||
* AppShell wraps the screen body in [GlassBackdropSource]. GlassSurface backends
|
* 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.
|
* the dock/search chrome.
|
||||||
*/
|
*/
|
||||||
@Stable
|
@Stable
|
||||||
class GlassBackdropState internal constructor(
|
class GlassBackdropState internal constructor(
|
||||||
internal val liquidState: Any,
|
internal val liquidState: Any,
|
||||||
internal val hazeState: Any,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val LocalGlassBackdropState = compositionLocalOf<GlassBackdropState?> { null }
|
val LocalGlassBackdropState = compositionLocalOf<GlassBackdropState?> { null }
|
||||||
@@ -27,11 +26,9 @@ val LocalGlassBackdropState = compositionLocalOf<GlassBackdropState?> { null }
|
|||||||
@Composable
|
@Composable
|
||||||
fun rememberGlassBackdropState(): GlassBackdropState {
|
fun rememberGlassBackdropState(): GlassBackdropState {
|
||||||
val liquidState = rememberLiquidBackdropHandle()
|
val liquidState = rememberLiquidBackdropHandle()
|
||||||
val hazeState = rememberHazeBackdropHandle()
|
return remember(liquidState) {
|
||||||
return remember(liquidState, hazeState) {
|
|
||||||
GlassBackdropState(
|
GlassBackdropState(
|
||||||
liquidState = liquidState,
|
liquidState = liquidState
|
||||||
hazeState = hazeState,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,8 +43,7 @@ fun GlassBackdropSource(
|
|||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
.liquidBackdropSource(state)
|
.liquidBackdropSource(state),
|
||||||
.hazeBackdropSource(state),
|
|
||||||
content = content,
|
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 androidx.compose.ui.unit.dp
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
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
|
@Composable
|
||||||
fun GlassSurface(
|
fun GlassSurface(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -23,9 +18,5 @@ fun GlassSurface(
|
|||||||
content: @Composable BoxScope.() -> Unit,
|
content: @Composable BoxScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
val backdropState = LocalGlassBackdropState.current
|
val backdropState = LocalGlassBackdropState.current
|
||||||
when (LocalGlassBackend.current) {
|
LiquidGlassSurface(modifier, tint, cornerRadius, border, backdropState, content)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
@Composable
|
||||||
private fun PlaceholderText(
|
private fun PlaceholderText(
|
||||||
text: String,
|
text: String,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ import recipe.composeapp.generated.resources.search_dismiss_keyboard_a11y
|
|||||||
* Layout responsibilities:
|
* Layout responsibilities:
|
||||||
* - Background: full-screen [RecipeTheme.colors.background] under the safe area.
|
* - Background: full-screen [RecipeTheme.colors.background] under the safe area.
|
||||||
* - Body: [RootNavHost] consumes the full screen, wrapped in [GlassBackdropSource]
|
* - 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]
|
* - Bottom chrome (overlay): bottom-anchored Column containing optional [SearchPill]
|
||||||
* (when ui.searchOpen && active.hasSearch) and the [DockBar] (always visible).
|
* (when ui.searchOpen && active.hasSearch) and the [DockBar] (always visible).
|
||||||
* Chrome consumes [WindowInsets.navigationBars] + [imePadding] explicitly per
|
* Chrome consumes [WindowInsets.navigationBars] + [imePadding] explicitly per
|
||||||
@@ -137,7 +137,7 @@ fun AppShell(modifier: Modifier = Modifier) {
|
|||||||
.background(RecipeTheme.colors.background),
|
.background(RecipeTheme.colors.background),
|
||||||
) {
|
) {
|
||||||
// Body — RootNavHost fills the available space and is the shared source layer
|
// 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()) {
|
GlassBackdropSource(modifier = Modifier.fillMaxSize()) {
|
||||||
RootNavHost(
|
RootNavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.ProvidableCompositionLocal
|
import androidx.compose.runtime.ProvidableCompositionLocal
|
||||||
import androidx.compose.runtime.ReadOnlyComposable
|
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).
|
* Recipe theme entry point (CONTEXT D-14, D-15).
|
||||||
@@ -35,7 +32,6 @@ public val LocalRecipeGlass: ProvidableCompositionLocal<RecipeGlass> =
|
|||||||
public fun RecipeTheme(content: @Composable () -> Unit) {
|
public fun RecipeTheme(content: @Composable () -> Unit) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val recipeColors = if (dark) DarkRecipeColors else LightRecipeColors
|
val recipeColors = if (dark) DarkRecipeColors else LightRecipeColors
|
||||||
val glassBackend = koinInject<GlassBackend>()
|
|
||||||
|
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalRecipeColors provides recipeColors,
|
LocalRecipeColors provides recipeColors,
|
||||||
@@ -43,7 +39,6 @@ public fun RecipeTheme(content: @Composable () -> Unit) {
|
|||||||
LocalRecipeSpacing provides DefaultRecipeSpacing,
|
LocalRecipeSpacing provides DefaultRecipeSpacing,
|
||||||
LocalRecipeShapes provides DefaultRecipeShapes,
|
LocalRecipeShapes provides DefaultRecipeShapes,
|
||||||
LocalRecipeGlass provides DefaultRecipeGlass,
|
LocalRecipeGlass provides DefaultRecipeGlass,
|
||||||
LocalGlassBackend provides glassBackend,
|
|
||||||
content = content,
|
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
|
|
||||||
@@ -23,7 +23,6 @@ navigation-compose = "2.9.2"
|
|||||||
compose-unstyled = "1.49.9"
|
compose-unstyled = "1.49.9"
|
||||||
compose-icons = "2.2.1"
|
compose-icons = "2.2.1"
|
||||||
liquid = "1.1.1"
|
liquid = "1.1.1"
|
||||||
haze = "1.6.10"
|
|
||||||
postgresql = "42.7.10"
|
postgresql = "42.7.10"
|
||||||
spotless = "8.4.0"
|
spotless = "8.4.0"
|
||||||
testcontainers = "1.21.4"
|
testcontainers = "1.21.4"
|
||||||
@@ -94,7 +93,6 @@ navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-co
|
|||||||
compose-unstyled = { module = "com.composables:composeunstyled", version.ref = "compose-unstyled" }
|
compose-unstyled = { module = "com.composables:composeunstyled", version.ref = "compose-unstyled" }
|
||||||
compose-icons-lucide = { module = "com.composables:icons-lucide-cmp", version.ref = "compose-icons" }
|
compose-icons-lucide = { module = "com.composables:icons-lucide-cmp", version.ref = "compose-icons" }
|
||||||
liquid = { module = "io.github.fletchmckee.liquid:liquid", version.ref = "liquid" }
|
liquid = { module = "io.github.fletchmckee.liquid:liquid", version.ref = "liquid" }
|
||||||
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
|
|
||||||
|
|
||||||
# Phase 2 — Server: Exposed DSL + Hikari (D-26)
|
# Phase 2 — Server: Exposed DSL + Hikari (D-26)
|
||||||
exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" }
|
exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" }
|
||||||
|
|||||||
Reference in New Issue
Block a user