porting profile to reducer

This commit is contained in:
Adam Brown 2022-11-07 20:53:35 +00:00
parent 5c4ff454ea
commit 84bb15e5db
15 changed files with 307 additions and 182 deletions

View File

@ -12,13 +12,9 @@ import java.io.InputStream
class FakeChatEngine : ChatEngine by mockk() {
fun givenMessages(roomId: RoomId, disableReadReceipts: Boolean) = every { messages(roomId, disableReadReceipts) }.delegateReturn()
fun givenDirectory() = every { directory() }.delegateReturn()
fun givenImportKeys(inputStream: InputStream, passphrase: String) = coEvery { inputStream.importRoomKeys(passphrase) }.delegateReturn()
fun givenNotificationsInvites() = every { notificationsInvites() }.delegateEmit()
fun givenNotificationsMessages() = every { notificationsMessages() }.delegateEmit()
fun givenInvites() = every { invites() }.delegateEmit()
}

View File

@ -25,4 +25,8 @@ class JobBag {
jobs.remove(key.java.canonicalName)?.cancel()
}
fun cancelAll() {
jobs.values.forEach { it.cancel() }
}
}

View File

@ -3,7 +3,6 @@ applyAndroidComposeLibraryModule(project)
dependencies {
implementation project(":chat-engine")
implementation project(":domains:android:compose-core")
implementation project(":domains:android:viewmodel")
implementation project(":domains:state")
implementation project(":features:messenger")
implementation project(":core")
@ -16,7 +15,6 @@ dependencies {
androidImportFixturesWorkaround(project, project(":core"))
androidImportFixturesWorkaround(project, project(":domains:state"))
androidImportFixturesWorkaround(project, project(":domains:store"))
androidImportFixturesWorkaround(project, project(":domains:android:viewmodel"))
androidImportFixturesWorkaround(project, project(":domains:android:stub"))
androidImportFixturesWorkaround(project, project(":chat-engine"))
}

View File

@ -5,7 +5,7 @@ import app.dapk.st.directory.state.DirectoryState
import app.dapk.st.domain.StoreModule
import app.dapk.st.engine.ChatEngine
import app.dapk.st.login.LoginViewModel
import app.dapk.st.profile.ProfileViewModel
import app.dapk.st.profile.state.ProfileState
class HomeModule(
private val chatEngine: ChatEngine,
@ -13,13 +13,13 @@ class HomeModule(
val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase,
) : ProvidableModule {
internal fun homeViewModel(directory: DirectoryState, login: LoginViewModel, profileViewModel: ProfileViewModel): HomeViewModel {
internal fun homeViewModel(directory: DirectoryState, login: LoginViewModel, profile: ProfileState): HomeViewModel {
return HomeViewModel(
chatEngine,
storeModule.credentialsStore(),
directory,
login,
profileViewModel,
profile,
storeModule.cacheCleaner(),
betaVersionUpgradeUseCase,
)

View File

@ -10,7 +10,8 @@ import app.dapk.st.home.HomeScreenState.*
import app.dapk.st.login.LoginViewModel
import app.dapk.st.matrix.common.CredentialsStore
import app.dapk.st.matrix.common.isSignedIn
import app.dapk.st.profile.ProfileViewModel
import app.dapk.st.profile.state.ProfileAction
import app.dapk.st.profile.state.ProfileState
import app.dapk.st.viewmodel.DapkViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
@ -24,7 +25,7 @@ internal class HomeViewModel(
private val credentialsProvider: CredentialsStore,
private val directoryState: DirectoryState,
private val loginViewModel: LoginViewModel,
private val profileViewModel: ProfileViewModel,
private val profileState: ProfileState,
private val cacheCleaner: StoreCleaner,
private val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase,
) : DapkViewModel<HomeScreenState, HomeEvent>(
@ -35,7 +36,7 @@ internal class HomeViewModel(
fun directory() = directoryState
fun login() = loginViewModel
fun profile() = profileViewModel
fun profile() = profileState
fun start() {
viewModelScope.launch {
@ -125,7 +126,7 @@ internal class HomeViewModel(
Page.Profile -> {
directoryState.dispatch(ComponentLifecycle.OnGone)
profileViewModel.reset()
profileState.dispatch(ProfileAction.Reset)
}
}
}

View File

@ -25,7 +25,7 @@ class MainActivity : DapkActivity() {
private val directoryViewModel by state { module<DirectoryModule>().directoryState() }
private val loginViewModel by viewModel { module<LoginModule>().loginViewModel() }
private val profileViewModel by viewModel { module<ProfileModule>().profileViewModel() }
private val profileViewModel by state { module<ProfileModule>().profileState() }
private val homeViewModel by viewModel { module<HomeModule>().homeViewModel(directoryViewModel, loginViewModel, profileViewModel) }
override fun onCreate(savedInstanceState: Bundle?) {

View File

@ -4,8 +4,17 @@ dependencies {
implementation project(":chat-engine")
implementation project(":features:settings")
implementation project(':domains:store')
implementation project(':domains:state')
implementation project(":domains:android:compose-core")
implementation project(":domains:android:viewmodel")
implementation project(":design-library")
implementation project(":core")
kotlinTest(it)
androidImportFixturesWorkaround(project, project(":matrix:common"))
androidImportFixturesWorkaround(project, project(":core"))
androidImportFixturesWorkaround(project, project(":domains:state"))
androidImportFixturesWorkaround(project, project(":domains:store"))
androidImportFixturesWorkaround(project, project(":domains:android:stub"))
androidImportFixturesWorkaround(project, project(":chat-engine"))
}

View File

@ -1,16 +1,21 @@
package app.dapk.st.profile
import app.dapk.st.core.JobBag
import app.dapk.st.core.ProvidableModule
import app.dapk.st.core.createStateViewModel
import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.engine.ChatEngine
import app.dapk.st.profile.state.ProfileState
import app.dapk.st.profile.state.ProfileUseCase
import app.dapk.st.profile.state.profileReducer
class ProfileModule(
private val chatEngine: ChatEngine,
private val errorTracker: ErrorTracker,
) : ProvidableModule {
fun profileViewModel(): ProfileViewModel {
return ProfileViewModel(chatEngine, errorTracker)
fun profileState(): ProfileState {
return createStateViewModel { profileReducer(chatEngine, errorTracker, ProfileUseCase(chatEngine, errorTracker), JobBag()) }
}
}

View File

@ -20,18 +20,22 @@ import androidx.compose.ui.unit.dp
import app.dapk.st.core.Lce
import app.dapk.st.core.LifecycleEffect
import app.dapk.st.core.components.CenteredLoading
import app.dapk.st.core.page.PageAction
import app.dapk.st.design.components.*
import app.dapk.st.engine.RoomInvite
import app.dapk.st.engine.RoomInvite.InviteMeta
import app.dapk.st.profile.state.Page
import app.dapk.st.profile.state.ProfileAction
import app.dapk.st.profile.state.ProfileState
import app.dapk.st.settings.SettingsActivity
@Composable
fun ProfileScreen(viewModel: ProfileViewModel, onTopLevelBack: () -> Unit) {
fun ProfileScreen(viewModel: ProfileState, onTopLevelBack: () -> Unit) {
viewModel.ObserveEvents()
LifecycleEffect(
onStart = { viewModel.start() },
onStop = { viewModel.stop() }
onStart = { viewModel.dispatch(ProfileAction.ComponentLifecycle.Visible) },
onStop = { viewModel.dispatch(ProfileAction.ComponentLifecycle.Gone) }
)
val context = LocalContext.current
@ -39,11 +43,11 @@ fun ProfileScreen(viewModel: ProfileViewModel, onTopLevelBack: () -> Unit) {
val onNavigate: (SpiderPage<out Page>?) -> Unit = {
when (it) {
null -> onTopLevelBack()
else -> viewModel.goTo(it)
else -> viewModel.dispatch(PageAction.GoTo(it))
}
}
Spider(currentPage = viewModel.state.page, onNavigate = onNavigate) {
Spider(currentPage = viewModel.current.state1.page, onNavigate = onNavigate) {
item(Page.Routes.profile) {
ProfilePage(context, viewModel, it)
}
@ -54,7 +58,7 @@ fun ProfileScreen(viewModel: ProfileViewModel, onTopLevelBack: () -> Unit) {
}
@Composable
private fun ProfilePage(context: Context, viewModel: ProfileViewModel, profile: Page.Profile) {
private fun ProfilePage(context: Context, viewModel: ProfileState, profile: Page.Profile) {
Box(
modifier = Modifier
.fillMaxWidth()
@ -67,7 +71,7 @@ private fun ProfilePage(context: Context, viewModel: ProfileViewModel, profile:
when (val state = profile.content) {
is Lce.Loading -> CenteredLoading()
is Lce.Error -> GenericError { viewModel.start() }
is Lce.Error -> GenericError { viewModel.dispatch(ProfileAction.ComponentLifecycle.Visible) }
is Lce.Content -> {
val configuration = LocalConfiguration.current
val content = state.value
@ -111,7 +115,7 @@ private fun ProfilePage(context: Context, viewModel: ProfileViewModel, profile:
TextRow(
title = "Invitations",
content = "${content.invitationsCount} pending",
onClick = { viewModel.goToInvitations() }
onClick = { viewModel.dispatch(ProfileAction.GoToInvitations) }
)
}
}
@ -119,7 +123,7 @@ private fun ProfilePage(context: Context, viewModel: ProfileViewModel, profile:
}
@Composable
private fun SpiderItemScope.Invitations(viewModel: ProfileViewModel, invitations: Page.Invitations) {
private fun SpiderItemScope.Invitations(viewModel: ProfileState, invitations: Page.Invitations) {
when (val state = invitations.content) {
is Lce.Loading -> CenteredLoading()
is Lce.Content -> {
@ -133,11 +137,11 @@ private fun SpiderItemScope.Invitations(viewModel: ProfileViewModel, invitations
TextRow(title = text, includeDivider = false) {
Spacer(modifier = Modifier.height(4.dp))
Row {
Button(modifier = Modifier.weight(1f), onClick = { viewModel.rejectRoomInvite(it.roomId) }) {
Button(modifier = Modifier.weight(1f), onClick = { viewModel.dispatch(ProfileAction.RejectRoomInvite(it.roomId)) }) {
Text("Reject".uppercase())
}
Spacer(modifier = Modifier.fillMaxWidth(0.1f))
Button(modifier = Modifier.weight(1f), onClick = { viewModel.acceptRoomInvite(it.roomId) }) {
Button(modifier = Modifier.weight(1f), onClick = { viewModel.dispatch(ProfileAction.AcceptRoomInvite(it.roomId)) }) {
Text("Accept".uppercase())
}
}
@ -154,7 +158,7 @@ private fun SpiderItemScope.Invitations(viewModel: ProfileViewModel, invitations
private fun RoomInvite.inviterName() = this.from.displayName?.let { "$it (${this.from.id.value})" } ?: this.from.id.value
@Composable
private fun ProfileViewModel.ObserveEvents() {
private fun ProfileState.ObserveEvents() {
// StartObserving {
// this@ObserveEvents.events.launch {
// when (it) {

View File

@ -1,142 +0,0 @@
package app.dapk.st.profile
import android.util.Log
import androidx.lifecycle.viewModelScope
import app.dapk.st.core.Lce
import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.design.components.SpiderPage
import app.dapk.st.engine.ChatEngine
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.viewmodel.DapkViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
class ProfileViewModel(
private val chatEngine: ChatEngine,
private val errorTracker: ErrorTracker,
) : DapkViewModel<ProfileScreenState, ProfileEvent>(
ProfileScreenState(SpiderPage(Page.Routes.profile, "Profile", null, Page.Profile(Lce.Loading()), hasToolbar = false))
) {
private var currentPageJob: Job? = null
fun start() {
goToProfile()
}
private fun goToProfile() {
combine(
flow {
val result = runCatching { chatEngine.me(forceRefresh = true) }
.onFailure { errorTracker.track(it, "Loading profile") }
emit(result)
},
chatEngine.invites(),
transform = { me, invites -> me to invites }
)
.onEach { (me, invites) ->
updatePageState<Page.Profile> {
when (me.isSuccess) {
true -> copy(content = Lce.Content(Page.Profile.Content(me.getOrThrow(), invites.size)))
false -> copy(content = Lce.Error(me.exceptionOrNull()!!))
}
}
}
.launchPageJob()
}
fun goToInvitations() {
updateState { copy(page = SpiderPage(Page.Routes.invitation, "Invitations", Page.Routes.profile, Page.Invitations(Lce.Loading()))) }
chatEngine.invites()
.onEach {
updatePageState<Page.Invitations> {
copy(content = Lce.Content(it))
}
}
.launchPageJob()
}
fun goTo(page: SpiderPage<out Page>) {
currentPageJob?.cancel()
updateState { copy(page = page) }
when (page.state) {
is Page.Invitations -> goToInvitations()
is Page.Profile -> goToProfile()
}
}
private fun <T> Flow<T>.launchPageJob() {
currentPageJob?.cancel()
currentPageJob = this.launchIn(viewModelScope)
}
fun updateDisplayName() {
// TODO
}
fun updateAvatar() {
// TODO
}
fun acceptRoomInvite(roomId: RoomId) {
launchCatching { chatEngine.joinRoom(roomId) }.fold(
onError = {}
)
}
fun rejectRoomInvite(roomId: RoomId) {
launchCatching { chatEngine.rejectJoinRoom(roomId) }.fold(
onError = {
Log.e("!!!", it.message, it)
}
)
}
@Suppress("UNCHECKED_CAST")
private inline fun <reified S : Page> updatePageState(crossinline block: S.() -> S) {
val page = state.page
val currentState = page.state
require(currentState is S)
updateState { copy(page = (page as SpiderPage<S>).copy(state = block(page.state))) }
}
fun reset() {
when (state.page.state) {
is Page.Invitations -> updateState {
ProfileScreenState(
SpiderPage(
Page.Routes.profile,
"Profile",
null,
Page.Profile(Lce.Loading()),
hasToolbar = false
)
)
}
is Page.Profile -> {
// do nothing
}
}
}
fun stop() {
currentPageJob?.cancel()
}
}
fun <S, VE, T> DapkViewModel<S, VE>.launchCatching(block: suspend () -> T): LaunchCatching<T> {
return object : LaunchCatching<T> {
override fun fold(onSuccess: (T) -> Unit, onError: (Throwable) -> Unit) {
viewModelScope.launch { runCatching { block() }.fold(onSuccess, onError) }
}
}
}
interface LaunchCatching<T> {
fun fold(onSuccess: (T) -> Unit = {}, onError: (Throwable) -> Unit = {})
}

View File

@ -0,0 +1,17 @@
package app.dapk.st.profile.state
import app.dapk.st.matrix.common.RoomId
import app.dapk.state.Action
sealed interface ProfileAction : Action {
sealed interface ComponentLifecycle : ProfileAction {
object Visible : ComponentLifecycle
object Gone : ComponentLifecycle
}
object GoToInvitations : ProfileAction
data class AcceptRoomInvite(val roomId: RoomId) : ProfileAction
data class RejectRoomInvite(val roomId: RoomId) : ProfileAction
object Reset : ProfileAction
}

View File

@ -0,0 +1,87 @@
package app.dapk.st.profile.state
import app.dapk.st.core.JobBag
import app.dapk.st.core.Lce
import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.core.page.PageAction
import app.dapk.st.core.page.PageStateChange
import app.dapk.st.core.page.createPageReducer
import app.dapk.st.core.page.withPageContext
import app.dapk.st.design.components.SpiderPage
import app.dapk.st.engine.ChatEngine
import app.dapk.state.async
import app.dapk.state.createReducer
import app.dapk.state.sideEffect
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
fun profileReducer(
chatEngine: ChatEngine,
errorTracker: ErrorTracker,
profileUseCase: ProfileUseCase,
jobBag: JobBag,
) = createPageReducer(
initialPage = SpiderPage<Page>(Page.Routes.profile, "Profile", null, Page.Profile(Lce.Loading()), hasToolbar = false),
factory = {
createReducer(
initialState = Unit,
async(ProfileAction.ComponentLifecycle::class) {
when (it) {
ProfileAction.ComponentLifecycle.Visible -> {
jobBag.replace(Page.Profile::class, profileUseCase.content().onEach { content ->
withPageContext<Page.Profile> {
pageDispatch(PageStateChange.UpdatePage(it.copy(content = content)))
}
}.launchIn(coroutineScope))
}
ProfileAction.ComponentLifecycle.Gone -> jobBag.cancelAll()
}
},
async(ProfileAction.GoToInvitations::class) {
dispatch(PageAction.GoTo(SpiderPage(Page.Routes.invitation, "Invitations", Page.Routes.profile, Page.Invitations(Lce.Loading()))))
jobBag.replace(Page.Invitations::class, chatEngine.invites()
.onEach { invitations ->
withPageContext<Page.Invitations> {
pageDispatch(PageStateChange.UpdatePage(it.copy(content = Lce.Content(invitations))))
}
}
.launchIn(coroutineScope))
},
async(ProfileAction.Reset::class) {
when (rawPage().state) {
is Page.Invitations -> {
dispatch(PageAction.GoTo(SpiderPage<Page>(Page.Routes.profile, "Profile", null, Page.Profile(Lce.Loading()), hasToolbar = false)))
}
is Page.Profile -> {
// do nothing
}
}
},
sideEffect(ProfileAction.AcceptRoomInvite::class) { action, _ ->
kotlin.runCatching { chatEngine.joinRoom(action.roomId) }.fold(
onFailure = { errorTracker.track(it) },
onSuccess = {}
)
},
sideEffect(ProfileAction.RejectRoomInvite::class) { action, _ ->
kotlin.runCatching { chatEngine.rejectJoinRoom(action.roomId) }.fold(
onFailure = { errorTracker.track(it) },
onSuccess = {}
)
},
sideEffect(PageStateChange.ChangePage::class) { action, _ ->
jobBag.cancel(action.previous.state::class)
},
)
}
)

View File

@ -1,14 +1,14 @@
package app.dapk.st.profile
package app.dapk.st.profile.state
import app.dapk.st.core.Lce
import app.dapk.st.core.State
import app.dapk.st.core.page.PageContainer
import app.dapk.st.design.components.Route
import app.dapk.st.design.components.SpiderPage
import app.dapk.st.engine.Me
import app.dapk.st.engine.RoomInvite
import app.dapk.state.Combined2
data class ProfileScreenState(
val page: SpiderPage<out Page>,
)
typealias ProfileState = State<Combined2<PageContainer<Page>, Unit>, Unit>
sealed interface Page {
data class Profile(val content: Lce<Content>) : Page {
@ -25,8 +25,3 @@ sealed interface Page {
val invitation = Route<Invitations>("Invitations")
}
}
sealed interface ProfileEvent {
}

View File

@ -0,0 +1,30 @@
package app.dapk.st.profile.state
import app.dapk.st.core.Lce
import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.engine.ChatEngine
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
class ProfileUseCase(
private val chatEngine: ChatEngine,
private val errorTracker: ErrorTracker,
) {
fun content(): Flow<Lce<Page.Profile.Content>> {
val flow = flow {
val result = runCatching { chatEngine.me(forceRefresh = true) }
.onFailure { errorTracker.track(it, "Loading profile") }
emit(result)
}
val combine = combine(flow, chatEngine.invites(), transform = { me, invites -> me to invites })
return combine.map { (me, invites) ->
when (me.isSuccess) {
true -> Lce.Content(Page.Profile.Content(me.getOrThrow(), invites.size))
false -> Lce.Error(me.exceptionOrNull()!!)
}
}
}
}

View File

@ -0,0 +1,121 @@
package app.dapk.st.profile.state
import app.dapk.st.core.Lce
import app.dapk.st.core.page.PageAction
import app.dapk.st.core.page.PageContainer
import app.dapk.st.core.page.PageStateChange
import app.dapk.st.design.components.SpiderPage
import app.dapk.st.engine.Me
import app.dapk.st.matrix.common.HomeServerUrl
import app.dapk.state.Combined2
import fake.FakeChatEngine
import fake.FakeErrorTracker
import fake.FakeJobBag
import fixture.aRoomId
import fixture.aRoomInvite
import fixture.aUserId
import io.mockk.every
import io.mockk.mockk
import org.junit.Test
import test.assertOnlyDispatches
import test.delegateEmit
import test.testReducer
private val INITIAL_PROFILE_PAGE = SpiderPage(Page.Routes.profile, "Profile", null, Page.Profile(Lce.Loading()), hasToolbar = false)
private val INITIAL_INVITATION_PAGE = SpiderPage(Page.Routes.invitation, "Invitations", Page.Routes.profile, Page.Invitations(Lce.Loading()), hasToolbar = true)
private val A_ROOM_ID = aRoomId()
private val A_PROFILE_CONTENT = Page.Profile.Content(Me(aUserId(), null, null, HomeServerUrl("ignored")), invitationsCount = 4)
class ProfileReducerTest {
private val fakeChatEngine = FakeChatEngine()
private val fakeErrorTracker = FakeErrorTracker()
private val fakeProfileUseCase = FakeProfileUseCase()
private val fakeJobBag = FakeJobBag()
private val runReducerTest = testReducer { _: (Unit) -> Unit ->
profileReducer(
fakeChatEngine,
fakeErrorTracker,
fakeProfileUseCase.instance,
fakeJobBag.instance,
)
}
@Test
fun `initial state is empty loading`() = runReducerTest {
assertInitialState(pageState(SpiderPage(Page.Routes.profile, "Profile", null, Page.Profile(Lce.Loading()), hasToolbar = false)))
}
@Test
fun `given on Profile page, when Reset, then does nothing`() = runReducerTest {
reduce(ProfileAction.Reset)
assertNoChanges()
}
@Test
fun `when Visible, then updates Profile page content`() = runReducerTest {
fakeJobBag.instance.expect { it.replace(Page.Profile::class, any()) }
fakeProfileUseCase.givenContent().emits(Lce.Content(A_PROFILE_CONTENT))
reduce(ProfileAction.ComponentLifecycle.Visible)
assertOnlyDispatches(
PageStateChange.UpdatePage(INITIAL_PROFILE_PAGE.state.copy(Lce.Content(A_PROFILE_CONTENT))),
)
}
@Test
fun `when GoToInvitations, then goes to Invitations page and updates content`() = runReducerTest {
fakeJobBag.instance.expect { it.replace(Page.Invitations::class, any()) }
val goToInvitations = PageAction.GoTo(INITIAL_INVITATION_PAGE)
actionSideEffect(goToInvitations) { pageState(goToInvitations.page) }
val content = listOf(aRoomInvite())
fakeChatEngine.givenInvites().emits(content)
reduce(ProfileAction.GoToInvitations)
assertOnlyDispatches(
PageAction.GoTo(INITIAL_INVITATION_PAGE),
PageStateChange.UpdatePage(INITIAL_INVITATION_PAGE.state.copy(Lce.Content(content))),
)
}
@Test
fun `given on Invitation page, when Reset, then goes to Profile page`() = runReducerTest {
setState(pageState(INITIAL_INVITATION_PAGE))
reduce(ProfileAction.Reset)
assertOnlyDispatches(PageAction.GoTo(INITIAL_PROFILE_PAGE))
}
@Test
fun `when RejectRoomInvite, then rejects room`() = runReducerTest {
fakeChatEngine.expect { it.rejectJoinRoom(A_ROOM_ID) }
reduce(ProfileAction.RejectRoomInvite(A_ROOM_ID))
}
@Test
fun `when AcceptRoomInvite, then joins room`() = runReducerTest {
fakeChatEngine.expect { it.joinRoom(A_ROOM_ID) }
reduce(ProfileAction.AcceptRoomInvite(A_ROOM_ID))
}
@Test
fun `when ChangePage, then cancels any previous page jobs`() = runReducerTest {
fakeJobBag.instance.expect { it.cancel(Page.Invitations::class) }
reduce(PageStateChange.ChangePage(INITIAL_INVITATION_PAGE, INITIAL_PROFILE_PAGE))
}
}
private fun <P> pageState(page: SpiderPage<out P>) = Combined2(PageContainer(page), Unit)
class FakeProfileUseCase {
val instance = mockk<ProfileUseCase>()
fun givenContent() = every { instance.content() }.delegateEmit()
}