From 4ed92bcc323750c238a5ae1d3c68b9342fac66ef Mon Sep 17 00:00:00 2001 From: Jesse Wilson Date: Fri, 3 Nov 2023 10:37:21 -0400 Subject: [PATCH] Make a CodeHost state machine (#1667) * Make a CodeHost state machine This simplifies much of the internal complexity of TreehouseApp. We can also delete FakeCodeHost because the single implementation is suitable for unit testing. Closes: https://github.com/cashapp/redwood/issues/1660 * Add a test for CodeHost Also adopt CoroutineScope more aggressively in CodeSession for lifecycle management. * Cover more lifecycle events with tests --- .../app/cash/redwood/treehouse/CodeHost.kt | 182 ++++++++++- .../app/cash/redwood/treehouse/CodeSession.kt | 4 +- .../cash/redwood/treehouse/TreehouseApp.kt | 170 ++++------ .../redwood/treehouse/TreehouseAppContent.kt | 50 +-- .../redwood/treehouse/ZiplineCodeSession.kt | 26 +- .../cash/redwood/treehouse/CodeHostTest.kt | 299 ++++++++++++++++++ .../cash/redwood/treehouse/FakeCodeHost.kt | 77 ++--- .../cash/redwood/treehouse/FakeCodeSession.kt | 20 +- .../cash/redwood/treehouse/FakeFrameClock.kt | 13 +- .../treehouse/FakeOnBackPressedDispatcher.kt | 12 +- .../redwood/treehouse/FakeTreehouseView.kt | 6 +- .../treehouse/TreehouseAppContentTest.kt | 94 ++++-- 12 files changed, 712 insertions(+), 241 deletions(-) create mode 100644 redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/CodeHostTest.kt diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeHost.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeHost.kt index 4da11616cf..09c04af806 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeHost.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeHost.kt @@ -15,16 +15,184 @@ */ package app.cash.redwood.treehouse -/** Manages loading and hot-reloading a series of code sessions. */ -internal interface CodeHost { - val stateStore: StateStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.job +import kotlinx.coroutines.launch - /** Only accessed on [TreehouseDispatchers.ui]. */ - val session: CodeSession? +/** + * Manages loading and hot-reloading a series of code sessions. + * + * The code host has 4 states: + * + * * `Idle` + * * `Starting`: collect code updates, and wait for an `Zipline` to load. + * * `Running`: collect code updates, and a `Zipline` is running. + * * `Crashed`: collect code updates, but the most recent `Zipline` failed. + * + * Transitions between states always occur on the UI dispatcher. These functions initiate state + * transitions: + * + * * `start()` - transition to `Starting` unless it’s `Starting` or `Running`. + * * `stop()` - transition to `Idle` immediately + * * `restart()` - transition to `Starting` unless it’s already `Starting`. + * + * Other state transitions also occur: + * + * * From `Starting` to `Running` when a `Zipline` finishes loading. + * * From `Running` to `Crashed` when a `Zipline` fails. + * * From `Running` to `Running` when the `Zipline` is replaced by a hot-reload. + */ +internal abstract class CodeHost( + private val dispatchers: TreehouseDispatchers, + private val appScope: CoroutineScope, + private val frameClockFactory: FrameClock.Factory, + val stateStore: StateStore, +) { + /** Contents that this app is currently responsible for. */ + private val listeners = mutableListOf>() + + private var state: State = State.Idle() + + private val codeSessionListener = object : CodeSession.Listener { + override fun onUncaughtException(codeSession: CodeSession, exception: Throwable) { + } + + override fun onCancel(codeSession: CodeSession) { + dispatchers.checkUi() + + codeSession.removeListener(this) + + // If a code session is canceled while we're still listening to it, it must have crashed. + val previous = state + if (previous is State.Running) { + state = State.Crashed(previous.codeUpdatesScope) + } + } + } + + val codeSession: CodeSession? + get() = state.codeSession + + /** Returns a flow that emits a new [CodeSession] each time we should load fresh code. */ + abstract fun codeUpdatesFlow(): Flow> + + fun start() { + dispatchers.checkUi() + + val previous = state + + if (previous is State.Starting || previous is State.Running) return // Nothing to do. + + // Force a restart if we're crashed. + previous.codeUpdatesScope?.cancel() + + val codeUpdatesScope = codeUpdatesScope() + state = State.Starting(codeUpdatesScope) + codeUpdatesScope.collectCodeUpdates() + } + + /** This function may only be invoked on [TreehouseDispatchers.zipline]. */ + fun stop() { + dispatchers.checkUi() + + val previous = state + previous.codeUpdatesScope?.cancel() + previous.codeSession?.removeListener(codeSessionListener) + previous.codeSession?.cancel() + + state = State.Idle() + } + + fun restart() { + dispatchers.checkUi() + + val previous = state + if (previous is State.Starting) return // Nothing to restart. + + previous.codeUpdatesScope?.cancel() + previous.codeSession?.removeListener(codeSessionListener) + previous.codeSession?.cancel() + + val codeUpdatesScope = codeUpdatesScope() + state = State.Starting(codeUpdatesScope) + codeUpdatesScope.collectCodeUpdates() + } + + fun addListener(listener: Listener) { + dispatchers.checkUi() + listeners += listener + } + + fun removeListener(listener: Listener) { + dispatchers.checkUi() + listeners -= listener + } + + private fun codeUpdatesScope() = + CoroutineScope(SupervisorJob(appScope.coroutineContext.job)) + + private fun CoroutineScope.collectCodeUpdates() { + launch(dispatchers.zipline) { + codeUpdatesFlow().collect { + codeSessionLoaded(it) + } + } + } + + private fun codeSessionLoaded(next: CodeSession) { + dispatchers.checkZipline() + + next.scope.launch(dispatchers.ui) { + // Clean up the previous session. + val previous = state + previous.codeSession?.removeListener(codeSessionListener) + previous.codeSession?.cancel() + + // If the codeUpdatesScope is null, we're stopped. Discard the newly-loaded code. + val codeUpdatesScope = state.codeUpdatesScope + if (codeUpdatesScope == null) { + next.cancel() + return@launch + } + + // Boot up the new code. + state = State.Running(codeUpdatesScope, next) + next.addListener(codeSessionListener) + next.start() + + for (listener in listeners) { + listener.codeSessionChanged(next) + } + } + } + + private sealed class State { + /** Non-null if we're prepared for code updates and restarts. */ + open val codeUpdatesScope: CoroutineScope? + get() = null + + /** Non-null if we're running code. */ + open val codeSession: CodeSession? + get() = null - fun addListener(listener: Listener) + class Idle : State() - fun removeListener(listener: Listener) + class Running( + override val codeUpdatesScope: CoroutineScope, + override val codeSession: CodeSession, + ) : State() + + class Starting( + override val codeUpdatesScope: CoroutineScope, + ) : State() + + class Crashed( + override val codeUpdatesScope: CoroutineScope, + ) : State() + } interface Listener { fun codeSessionChanged(next: CodeSession) diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeSession.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeSession.kt index 1334353558..e3c78d8b55 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeSession.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeSession.kt @@ -22,13 +22,15 @@ import kotlinx.serialization.json.Json /** The host state for a single code load. We get a new session each time we get new code. */ internal interface CodeSession { + val scope: CoroutineScope + val eventPublisher: EventPublisher val appService: A val json: Json - fun start(sessionScope: CoroutineScope, frameClock: FrameClock) + fun start() fun addListener(listener: Listener) diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt index 2c635db643..4fb644ca1d 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt @@ -24,10 +24,8 @@ import app.cash.zipline.loader.ZiplineHttpClient import app.cash.zipline.loader.ZiplineLoader import kotlin.native.ObjCName import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.job -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.mapNotNull import kotlinx.serialization.modules.EmptySerializersModule import kotlinx.serialization.modules.SerializersModule import okio.Closeable @@ -36,8 +34,6 @@ import okio.Path /** * This class binds downloaded code to on-screen views. - * - * It updates the content when new code is available in [onCodeChanged]. */ @ObjCName("TreehouseApp", exact = true) public class TreehouseApp private constructor( @@ -45,14 +41,27 @@ public class TreehouseApp private constructor( private val appScope: CoroutineScope, public val spec: Spec, ) { - private val codeHost = ZiplineCodeHost() - public val dispatchers: TreehouseDispatchers = factory.dispatchers - private var started = false - - /** Only accessed on [TreehouseDispatchers.zipline]. */ - private var closed = false + private val codeHost = object : CodeHost( + dispatchers = dispatchers, + appScope = appScope, + frameClockFactory = factory.frameClockFactory, + stateStore = factory.stateStore, + ) { + override fun codeUpdatesFlow(): Flow> { + return ziplineFlow().mapNotNull { loadResult -> + when (loadResult) { + is LoadResult.Failure -> { + null // EventListener already notified. + } + is LoadResult.Success -> { + createCodeSession(loadResult.zipline) + } + } + } + } + } /** * Returns the current zipline attached to this host, or null if Zipline hasn't loaded yet. The @@ -62,7 +71,7 @@ public class TreehouseApp private constructor( * instance may be replaced if new code is loaded. */ public val zipline: Zipline? - get() = codeHost.session?.zipline + get() = (codeHost.codeSession as? ZiplineCodeSession)?.zipline /** * Create content for [source]. @@ -78,7 +87,6 @@ public class TreehouseApp private constructor( return TreehouseAppContent( codeHost = codeHost, dispatchers = dispatchers, - appScope = appScope, codeListener = codeListener, source = source, ) @@ -89,25 +97,29 @@ public class TreehouseApp private constructor( * this app. * * This function returns immediately if this app is already started. + * + * This function may only be invoked on [TreehouseDispatchers.ui]. */ public fun start() { - if (started) return - started = true + codeHost.start() + } - appScope.launch(dispatchers.zipline) { - val ziplineFileFlow = ziplineFlow() - ziplineFileFlow.collect { - when (it) { - is LoadResult.Success -> { - val app = spec.create(it.zipline) - onCodeChanged(it.zipline, app) - } - is LoadResult.Failure -> { - // EventListener already notified. - } - } - } - } + /** + * Stop any currently-running code and stop receiving new code. + * + * This function may only be invoked on [TreehouseDispatchers.ui]. + */ + public fun stop() { + codeHost.stop() + } + + /** + * Stop the currently-running application (if any) and start it again. + * + * This function may only be invoked on [TreehouseDispatchers.ui]. + */ + public fun restart() { + codeHost.restart() } /** @@ -153,95 +165,21 @@ public class TreehouseApp private constructor( } } - /** - * Refresh the code. Even if no views are currently showing we refresh the code, so we're ready - * when a view is added. - * - * This function may only be invoked on [TreehouseDispatchers.zipline]. - */ - private fun onCodeChanged(zipline: Zipline, appService: A) { - dispatchers.checkZipline() - check(!closed) - - codeHost.onCodeChanged(zipline, appService) - } - - /** This function may only be invoked on [TreehouseDispatchers.zipline]. */ - public fun cancel() { - dispatchers.checkZipline() - closed = true - appScope.launch(dispatchers.ui) { - val session = codeHost.session ?: return@launch - session.removeListener(codeHost) - session.cancel() - codeHost.session = null - } - } - - private inner class ZiplineCodeHost : CodeHost, CodeSession.Listener { - /** - * Contents that this app is currently responsible for. - * - * Only accessed on [TreehouseDispatchers.ui]. - */ - private val listeners = mutableListOf>() - - override val stateStore: StateStore = factory.stateStore - - override var session: ZiplineCodeSession? = null - - override fun addListener(listener: CodeHost.Listener) { - dispatchers.checkUi() - listeners += listener - } - - override fun removeListener(listener: CodeHost.Listener) { - dispatchers.checkUi() - listeners -= listener - } - - override fun onUncaughtException(codeSession: CodeSession, exception: Throwable) { - } - - override fun onCancel(codeSession: CodeSession) { - check(codeSession == this.session) - this.session = null - } - - fun onCodeChanged(zipline: Zipline, appService: A) { - // Extract the RealEventPublisher() created in ziplineFlow(). - val eventListener = zipline.eventListener as RealEventPublisher.ZiplineEventListener - val eventPublisher = eventListener.eventPublisher - - val next = ZiplineCodeSession( - dispatchers = dispatchers, - appScope = appScope, - eventPublisher = eventPublisher, - appService = appService, - zipline = zipline, - ) - - val sessionScope = CoroutineScope( - SupervisorJob(appScope.coroutineContext.job) + next.coroutineExceptionHandler, - ) + private fun createCodeSession(zipline: Zipline): ZiplineCodeSession { + val appService = spec.create(zipline) - sessionScope.launch(dispatchers.ui) { - val previous = session - previous?.removeListener(this@ZiplineCodeHost) - previous?.cancel() + // Extract the RealEventPublisher() created in ziplineFlow(). + val eventListener = zipline.eventListener as RealEventPublisher.ZiplineEventListener + val eventPublisher = eventListener.eventPublisher - session = next - next.addListener(this@ZiplineCodeHost) - next.start( - sessionScope = sessionScope, - frameClock = factory.frameClockFactory.create(sessionScope, dispatchers), - ) - - for (listener in listeners) { - listener.codeSessionChanged(next) - } - } - } + return ZiplineCodeSession( + dispatchers = dispatchers, + eventPublisher = eventPublisher, + frameClockFactory = factory.frameClockFactory, + appService = appService, + zipline = zipline, + appScope = appScope, + ) } /** diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt index 399fcacf02..99daa991fd 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt @@ -59,7 +59,9 @@ private sealed interface ViewState { } private sealed interface CodeState { - class Idle : CodeState + class Idle( + val isInitialLaunch: Boolean, + ) : CodeState class Running( val viewContentCodeBinding: ViewContentCodeBinding, @@ -70,11 +72,12 @@ private sealed interface CodeState { internal class TreehouseAppContent( private val codeHost: CodeHost, private val dispatchers: TreehouseDispatchers, - private val appScope: CoroutineScope, private val codeListener: CodeListener, private val source: TreehouseContentSource, ) : Content, CodeHost.Listener, CodeSession.Listener { - private val stateFlow = MutableStateFlow>(State(ViewState.None, CodeState.Idle())) + private val stateFlow = MutableStateFlow>( + State(ViewState.None, CodeState.Idle(isInitialLaunch = true)), + ) override fun preload( onBackPressedDispatcher: OnBackPressedDispatcher, @@ -88,7 +91,7 @@ internal class TreehouseAppContent( val nextViewState = ViewState.Preloading(onBackPressedDispatcher, uiConfiguration) // Start the code if necessary. - val codeSession = codeHost.session + val codeSession = codeHost.codeSession val nextCodeState = when { previousState.codeState is CodeState.Idle && codeSession != null -> { CodeState.Running( @@ -120,7 +123,7 @@ internal class TreehouseAppContent( val nextViewState = ViewState.Bound(view) // Start the code if necessary. - val codeSession = codeHost.session + val codeSession = codeHost.codeSession val nextCodeState = when { previousState.codeState is CodeState.Idle && codeSession != null -> { CodeState.Running( @@ -169,7 +172,7 @@ internal class TreehouseAppContent( if (previousViewState is ViewState.None) return // Idempotent. val nextViewState = ViewState.None - val nextCodeState = CodeState.Idle() + val nextCodeState = CodeState.Idle(isInitialLaunch = true) // Cancel the code if necessary. codeHost.removeListener(this) @@ -204,7 +207,7 @@ internal class TreehouseAppContent( val nextCodeState = CodeState.Running( startViewCodeContentBinding( codeSession = next, - isInitialLaunch = previousCodeState is CodeState.Idle, + isInitialLaunch = (previousCodeState as? CodeState.Idle)?.isInitialLaunch == true, onBackPressedDispatcher = onBackPressedDispatcher, firstUiConfiguration = uiConfiguration, ), @@ -225,11 +228,19 @@ internal class TreehouseAppContent( stateFlow.value = State(viewState, nextCodeState) } + override fun onUncaughtException(codeSession: CodeSession, exception: Throwable) { + codeSessionCanceled(exception = exception) + } + + override fun onCancel(codeSession: CodeSession) { + codeSessionCanceled(exception = null) + } + /** - * If the code crashes, show an error on the UI and cancel the UI binding. This sets the code - * state back to idle. + * If the code crashes or is unloaded, show an error on the UI and cancel the UI binding. This + * sets the code state back to idle. */ - override fun onUncaughtException(codeSession: CodeSession, exception: Throwable) { + private fun codeSessionCanceled(exception: Throwable?) { dispatchers.checkUi() val previousState = stateFlow.value @@ -239,24 +250,21 @@ internal class TreehouseAppContent( // This listener should only fire if we're actively running code. require(previousCodeState is CodeState.Running) - // Cancel the UI binding to the crashed code. + // Cancel the UI binding to the canceled code. val binding = previousCodeState.viewContentCodeBinding binding.cancel() binding.codeSession.removeListener(this) - // If there's a UI, give it the error to display. + // If there's an error and a UI, show it. val view = (viewState as? ViewState.Bound)?.view - if (view != null) { + if (exception != null && view != null) { codeListener.onUncaughtException(view, exception) } - val nextCodeState = CodeState.Idle() + val nextCodeState = CodeState.Idle(isInitialLaunch = false) stateFlow.value = State(viewState, nextCodeState) } - override fun onCancel(codeSession: CodeSession) { - } - /** This function may only be invoked on [TreehouseDispatchers.ui]. */ private fun startViewCodeContentBinding( codeSession: CodeSession, @@ -270,7 +278,6 @@ internal class TreehouseAppContent( return ViewContentCodeBinding( stateStore = codeHost.stateStore, dispatchers = dispatchers, - appScope = appScope, eventPublisher = codeSession.eventPublisher, contentSource = source, codeListener = codeListener, @@ -300,7 +307,6 @@ internal class TreehouseAppContent( private class ViewContentCodeBinding( val stateStore: StateStore, val dispatchers: TreehouseDispatchers, - val appScope: CoroutineScope, val eventPublisher: EventPublisher, val contentSource: TreehouseContentSource, val codeListener: CodeListener, @@ -313,7 +319,7 @@ private class ViewContentCodeBinding( private val uiConfigurationFlow = SequentialStateFlow(firstUiConfiguration) private val bindingScope = CoroutineScope( - SupervisorJob(appScope.coroutineContext.job) + codeSession.coroutineExceptionHandler, + SupervisorJob(codeSession.scope.coroutineContext.job), ) /** Only accessed on [TreehouseDispatchers.ui]. Null before [initView] and after [cancel]. */ @@ -486,10 +492,10 @@ private class ViewContentCodeBinding( viewOrNull?.saveCallback = null viewOrNull = null bridgeOrNull = null - appScope.launch(dispatchers.zipline) { + bindingScope.launch(dispatchers.zipline) { treehouseUiOrNull = null - bindingScope.cancel() serviceScope.close() + bindingScope.cancel() } } } diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ZiplineCodeSession.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ZiplineCodeSession.kt index 62637efb73..62316b35a4 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ZiplineCodeSession.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ZiplineCodeSession.kt @@ -23,37 +23,39 @@ import app.cash.zipline.Zipline import app.cash.zipline.ZiplineScope import app.cash.zipline.withScope import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.serialization.json.Json internal class ZiplineCodeSession( private val dispatchers: TreehouseDispatchers, - private val appScope: CoroutineScope, override val eventPublisher: EventPublisher, + frameClockFactory: FrameClock.Factory, override val appService: A, val zipline: Zipline, + val appScope: CoroutineScope, ) : CodeSession, AppLifecycle.Host { private val listeners = mutableListOf>() private val ziplineScope = ZiplineScope() + override val scope = CoroutineScope( + SupervisorJob(appScope.coroutineContext.job) + coroutineExceptionHandler, + ) + override val json: Json get() = zipline.json - // These vars only accessed on TreehouseDispatchers.zipline. - private lateinit var sessionScope: CoroutineScope - private lateinit var frameClock: FrameClock + private val frameClock = frameClockFactory.create(scope, dispatchers) private lateinit var appLifecycle: AppLifecycle private var canceled = false - override fun start(sessionScope: CoroutineScope, frameClock: FrameClock) { + override fun start() { dispatchers.checkUi() - sessionScope.launch(dispatchers.zipline) { - this@ZiplineCodeSession.sessionScope = sessionScope - this@ZiplineCodeSession.frameClock = frameClock - + scope.launch(dispatchers.zipline) { val service = appService.withScope(ziplineScope).appLifecycle appLifecycle = service service.start(this@ZiplineCodeSession) @@ -81,10 +83,10 @@ internal class ZiplineCodeSession( listener.onCancel(this) } - appScope.launch(dispatchers.zipline) { - sessionScope.cancel() + scope.launch(dispatchers.zipline) { ziplineScope.close() zipline.close() + scope.cancel() } } @@ -107,7 +109,7 @@ internal class ZiplineCodeSession( } override fun handleUncaughtException(exception: Throwable) { - appScope.launch(dispatchers.ui) { + scope.launch(dispatchers.ui) { val listenersArray = listeners.toTypedArray() // onUncaughtException mutates. for (listener in listenersArray) { listener.onUncaughtException(this@ZiplineCodeSession, exception) diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/CodeHostTest.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/CodeHostTest.kt new file mode 100644 index 0000000000..022f5bc250 --- /dev/null +++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/CodeHostTest.kt @@ -0,0 +1,299 @@ +/* + * Copyright (C) 2023 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package app.cash.redwood.treehouse + +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest + +/** This test focuses on how [CodeHost] and its state machine. */ +@OptIn(ExperimentalCoroutinesApi::class) +class CodeHostTest { + private val eventLog = EventLog() + private val appScope = CoroutineScope(EmptyCoroutineContext) + + private val dispatcher = UnconfinedTestDispatcher() + private val eventPublisher = FakeEventPublisher() + private val dispatchers = FakeDispatchers(dispatcher, dispatcher) + private val codeHost = FakeCodeHost( + eventLog = eventLog, + eventPublisher = eventPublisher, + dispatchers = dispatchers, + appScope = appScope, + frameClockFactory = FakeFrameClock.Factory, + ) + private val codeListener = FakeCodeListener(eventLog) + private val onBackPressedDispatcher = FakeOnBackPressedDispatcher(eventLog) + + @AfterTest + fun tearDown() { + eventLog.assertNoEvents() + appScope.cancel() + } + + /** Confirm that we can bind() before CodeHost.start(). */ + @Test + fun bind_start_session_stop() = runTest { + val content = treehouseAppContent() + val view1 = treehouseView("view1") + content.bind(view1) + eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") + + codeHost.start() + eventLog.takeEvent("codeHostUpdates1.collect()") + codeHost.startCodeSession("codeSessionA") + eventLog.takeEvent("codeSessionA.start()") + eventLog.takeEvent("codeSessionA.app.uis[0].start()") + + codeHost.stop() + eventLog.takeEvent("codeHostUpdates1.close()") + eventLog.takeEvent("codeSessionA.app.uis[0].close()") + eventLog.takeEvent("codeSessionA.cancel()") + + content.unbind() + } + + /** Calling CodeHost.restart() from idle starts it up. */ + @Test + fun bind_restart_session_stop() = runTest { + val content = treehouseAppContent() + val view1 = treehouseView("view1") + content.bind(view1) + eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") + + codeHost.restart() + eventLog.takeEvent("codeHostUpdates1.collect()") + codeHost.startCodeSession("codeSessionA") + eventLog.takeEvent("codeSessionA.start()") + eventLog.takeEvent("codeSessionA.app.uis[0].start()") + + codeHost.stop() + eventLog.takeEvent("codeHostUpdates1.close()") + eventLog.takeEvent("codeSessionA.app.uis[0].close()") + eventLog.takeEvent("codeSessionA.cancel()") + + content.unbind() + } + + /** CodeHost doesn't have to stay resident forever. */ + @Test + fun bind_start_session_stop_start_session_stop() = runTest { + val content = treehouseAppContent() + val view1 = treehouseView("view1") + content.bind(view1) + eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") + + codeHost.start() + eventLog.takeEvent("codeHostUpdates1.collect()") + codeHost.startCodeSession("codeSessionA") + eventLog.takeEvent("codeSessionA.start()") + eventLog.takeEvent("codeSessionA.app.uis[0].start()") + codeHost.stop() + eventLog.takeEvent("codeHostUpdates1.close()") + eventLog.takeEvent("codeSessionA.app.uis[0].close()") + eventLog.takeEvent("codeSessionA.cancel()") + + codeHost.start() + eventLog.takeEvent("codeHostUpdates2.collect()") + codeHost.startCodeSession("codeSessionB") + eventLog.takeEvent("codeSessionB.start()") + eventLog.takeEvent("codeSessionB.app.uis[0].start()") + codeHost.stop() + eventLog.takeEvent("codeHostUpdates2.close()") + eventLog.takeEvent("codeSessionB.app.uis[0].close()") + eventLog.takeEvent("codeSessionB.cancel()") + + content.unbind() + } + + /** CodeHost can restart() after a failure. */ + @Test + fun bind_start_session_crash_restart_stop() = runTest { + val content = treehouseAppContent() + val view1 = treehouseView("view1") + content.bind(view1) + eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") + + codeHost.start() + eventLog.takeEvent("codeHostUpdates1.collect()") + val codeSessionA = codeHost.startCodeSession("codeSessionA") + eventLog.takeEvent("codeSessionA.start()") + eventLog.takeEvent("codeSessionA.app.uis[0].start()") + codeSessionA.handleUncaughtException(Exception("boom!")) + eventLog.takeEvent("codeSessionA.app.uis[0].close()") + eventLog.takeEvent("codeListener.onUncaughtException(view1, kotlin.Exception: boom!)") + eventLog.takeEvent("codeSessionA.cancel()") + + codeHost.restart() + eventLog.takeEvent("codeHostUpdates1.close()") + eventLog.takeEvent("codeHostUpdates2.collect()") + codeHost.startCodeSession("codeSessionB") + eventLog.takeEvent("codeSessionB.start()") + eventLog.takeEvent("codeSessionB.app.uis[0].start()") + codeHost.stop() + eventLog.takeEvent("codeHostUpdates2.close()") + eventLog.takeEvent("codeSessionB.app.uis[0].close()") + eventLog.takeEvent("codeSessionB.cancel()") + + content.unbind() + } + + /** New code will also trigger a restart after a failure. */ + @Test + fun bind_start_session_crash_session_stop() = runTest { + val content = treehouseAppContent() + val view1 = treehouseView("view1") + content.bind(view1) + eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") + + codeHost.start() + eventLog.takeEvent("codeHostUpdates1.collect()") + val codeSessionA = codeHost.startCodeSession("codeSessionA") + eventLog.takeEvent("codeSessionA.start()") + eventLog.takeEvent("codeSessionA.app.uis[0].start()") + codeSessionA.handleUncaughtException(Exception("boom!")) + eventLog.takeEvent("codeSessionA.app.uis[0].close()") + eventLog.takeEvent("codeListener.onUncaughtException(view1, kotlin.Exception: boom!)") + eventLog.takeEvent("codeSessionA.cancel()") + + codeHost.startCodeSession("codeSessionB") + eventLog.takeEvent("codeSessionB.start()") + eventLog.takeEvent("codeSessionB.app.uis[0].start()") + codeHost.stop() + eventLog.takeEvent("codeHostUpdates1.close()") + eventLog.takeEvent("codeSessionB.app.uis[0].close()") + eventLog.takeEvent("codeSessionB.cancel()") + + content.unbind() + } + + /** We can stop after a failure. */ + @Test + fun bind_start_session_crash_stop() = runTest { + val content = treehouseAppContent() + val view1 = treehouseView("view1") + content.bind(view1) + eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") + + codeHost.start() + eventLog.takeEvent("codeHostUpdates1.collect()") + val codeSessionA = codeHost.startCodeSession("codeSessionA") + eventLog.takeEvent("codeSessionA.start()") + eventLog.takeEvent("codeSessionA.app.uis[0].start()") + codeSessionA.handleUncaughtException(Exception("boom!")) + eventLog.takeEvent("codeSessionA.app.uis[0].close()") + eventLog.takeEvent("codeListener.onUncaughtException(view1, kotlin.Exception: boom!)") + eventLog.takeEvent("codeSessionA.cancel()") + + codeHost.stop() + eventLog.takeEvent("codeHostUpdates1.close()") + + content.unbind() + } + + /** Calling start() while it's starting is a no-op. */ + @Test + fun bind_start_start_session() = runTest { + val content = treehouseAppContent() + val view1 = treehouseView("view1") + content.bind(view1) + eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") + + codeHost.start() + eventLog.takeEvent("codeHostUpdates1.collect()") + eventLog.assertNoEvents() + + codeHost.start() + eventLog.assertNoEvents() + + codeHost.startCodeSession("codeSessionA") + eventLog.takeEvent("codeSessionA.start()") + eventLog.takeEvent("codeSessionA.app.uis[0].start()") + codeHost.stop() + eventLog.takeEvent("codeHostUpdates1.close()") + eventLog.takeEvent("codeSessionA.app.uis[0].close()") + eventLog.takeEvent("codeSessionA.cancel()") + + content.unbind() + } + + /** Calling start() while it's running is a no-op. */ + @Test + fun bind_start_session_start_stop() = runTest { + val content = treehouseAppContent() + val view1 = treehouseView("view1") + content.bind(view1) + eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") + + codeHost.start() + eventLog.takeEvent("codeHostUpdates1.collect()") + codeHost.startCodeSession("codeSessionA") + eventLog.takeEvent("codeSessionA.start()") + eventLog.takeEvent("codeSessionA.app.uis[0].start()") + + codeHost.start() + eventLog.assertNoEvents() + + codeHost.stop() + eventLog.takeEvent("codeHostUpdates1.close()") + eventLog.takeEvent("codeSessionA.app.uis[0].close()") + eventLog.takeEvent("codeSessionA.cancel()") + + content.unbind() + } + + /** Calling stop() while it's idle is a no-op. */ + @Test + fun bind_start_session_stop_stop() = runTest { + val content = treehouseAppContent() + val view1 = treehouseView("view1") + content.bind(view1) + eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") + + codeHost.start() + eventLog.takeEvent("codeHostUpdates1.collect()") + codeHost.startCodeSession("codeSessionA") + eventLog.takeEvent("codeSessionA.start()") + eventLog.takeEvent("codeSessionA.app.uis[0].start()") + + codeHost.stop() + eventLog.takeEvent("codeHostUpdates1.close()") + eventLog.takeEvent("codeSessionA.app.uis[0].close()") + eventLog.takeEvent("codeSessionA.cancel()") + + codeHost.stop() + + content.unbind() + } + + private fun treehouseAppContent(): TreehouseAppContent { + return TreehouseAppContent( + codeHost = codeHost, + dispatchers = dispatchers, + codeListener = codeListener, + source = { app -> app.newUi() }, + ) + } + + private fun treehouseView(name: String): FakeTreehouseView { + return FakeTreehouseView(onBackPressedDispatcher, name) + } +} diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeHost.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeHost.kt index d769c4b6c1..d1633b83b7 100644 --- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeHost.kt +++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeHost.kt @@ -15,61 +15,44 @@ */ package app.cash.redwood.treehouse -import app.cash.redwood.treehouse.CodeHost.Listener -import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.consumeAsFlow internal class FakeCodeHost( private val eventLog: EventLog, private val eventPublisher: EventPublisher, -) : CodeHost { - override val stateStore = MemoryStateStore() - - private val codeSessionListener = object : CodeSession.Listener { - override fun onUncaughtException( - codeSession: CodeSession, - exception: Throwable, - ) { - } - - override fun onCancel( - codeSession: CodeSession, - ) { - check(codeSession == this@FakeCodeHost.session) - this@FakeCodeHost.session = null + private val dispatchers: TreehouseDispatchers, + private val appScope: CoroutineScope, + frameClockFactory: FrameClock.Factory, +) : CodeHost( + dispatchers = dispatchers, + appScope = appScope, + frameClockFactory = frameClockFactory, + stateStore = MemoryStateStore(), +) { + private var codeSessions: Channel>? = null + private var nextCollectId = 1 + + /** + * Create a new channel every time we subscribe to code updates. The channel will be closed when + * the superclass is done consuming the flow. + */ + override fun codeUpdatesFlow(): Flow> { + val collectId = nextCollectId++ + eventLog += "codeHostUpdates$collectId.collect()" + val channel = Channel>(Int.MAX_VALUE) + channel.invokeOnClose { + eventLog += "codeHostUpdates$collectId.close()" } + codeSessions = channel + return channel.consumeAsFlow() } - override var session: CodeSession? = null - set(value) { - val previous = field - previous?.removeListener(codeSessionListener) - previous?.cancel() - - if (value != null) { - value.start(CoroutineScope(EmptyCoroutineContext), FakeFrameClock()) - for (listener in listeners) { - listener.codeSessionChanged(value) - } - } - - value?.addListener(codeSessionListener) - field = value - } - - private val listeners = mutableListOf>() - - fun startCodeSession(name: String): CodeSession { - val result = FakeCodeSession(eventLog, name, eventPublisher) - session = result + suspend fun startCodeSession(name: String): CodeSession { + val result = FakeCodeSession(dispatchers, eventPublisher, eventLog, name, appScope) + codeSessions!!.send(result) return result } - - override fun addListener(listener: Listener) { - listeners += listener - } - - override fun removeListener(listener: Listener) { - listeners -= listener - } } diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeSession.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeSession.kt index 4658c43d6e..c835773489 100644 --- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeSession.kt +++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeSession.kt @@ -18,22 +18,32 @@ package app.cash.redwood.treehouse import app.cash.redwood.treehouse.CodeSession.Listener import app.cash.redwood.treehouse.CodeSession.ServiceScope import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.job +import kotlinx.coroutines.launch import kotlinx.serialization.json.Json internal class FakeCodeSession( + private val dispatchers: TreehouseDispatchers, + override val eventPublisher: EventPublisher, private val eventLog: EventLog, private val name: String, - override val eventPublisher: EventPublisher, + appScope: CoroutineScope, ) : CodeSession { private val listeners = mutableListOf>() + override val scope = CoroutineScope( + SupervisorJob(appScope.coroutineContext.job) + coroutineExceptionHandler, + ) + override val json = Json override val appService = FakeAppService("$name.app", eventLog) private var canceled = false - override fun start(sessionScope: CoroutineScope, frameClock: FrameClock) { + override fun start() { eventLog += "$name.start()" } @@ -83,5 +93,11 @@ internal class FakeCodeSession( } eventLog += "$name.cancel()" + + // Cancel the scope asynchronously for consistency with ZiplineCodeSession. This is important + // because Listener.onCancel() enqueues work on this scope, and we need that to run. + scope.launch(dispatchers.zipline) { + scope.cancel() + } } } diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeFrameClock.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeFrameClock.kt index d648cbcb5a..ed188d837c 100644 --- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeFrameClock.kt +++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeFrameClock.kt @@ -15,7 +15,18 @@ */ package app.cash.redwood.treehouse -class FakeFrameClock : FrameClock { +import kotlinx.coroutines.CoroutineScope + +internal class FakeFrameClock : FrameClock { override fun requestFrame(appLifecycle: AppLifecycle) { } + + object Factory : FrameClock.Factory { + override fun create( + scope: CoroutineScope, + dispatchers: TreehouseDispatchers, + ): FrameClock { + return FakeFrameClock() + } + } } diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeOnBackPressedDispatcher.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeOnBackPressedDispatcher.kt index f574e37ff9..9f77ba4587 100644 --- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeOnBackPressedDispatcher.kt +++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeOnBackPressedDispatcher.kt @@ -19,17 +19,27 @@ import app.cash.redwood.ui.Cancellable import app.cash.redwood.ui.OnBackPressedCallback import app.cash.redwood.ui.OnBackPressedDispatcher -internal class FakeOnBackPressedDispatcher : OnBackPressedDispatcher { +internal class FakeOnBackPressedDispatcher( + val eventLog: EventLog, +) : OnBackPressedDispatcher { private val mutableCallbacks = mutableListOf() + private var nextCallbackId = 0 val callbacks: List get() = mutableCallbacks.toList() override fun addCallback(onBackPressedCallback: OnBackPressedCallback): Cancellable { + val id = nextCallbackId++ mutableCallbacks += onBackPressedCallback return object : Cancellable { + var canceled = false + override fun cancel() { + if (canceled) return + canceled = true + + eventLog += "onBackPressedDispatcher.callbacks[$id].cancel()" mutableCallbacks -= onBackPressedCallback } } diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeTreehouseView.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeTreehouseView.kt index 288703cbaa..920aeaa6ff 100644 --- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeTreehouseView.kt +++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeTreehouseView.kt @@ -22,6 +22,7 @@ import app.cash.redwood.widget.SavedStateRegistry import kotlinx.coroutines.flow.MutableStateFlow internal class FakeTreehouseView( + override val onBackPressedDispatcher: FakeOnBackPressedDispatcher, private val name: String, ) : TreehouseView { override val widgetSystem = FakeWidgetSystem() @@ -36,13 +37,10 @@ internal class FakeTreehouseView( override var saveCallback: TreehouseView.SaveCallback? = null - override val stateSnapshotId: StateSnapshot.Id - get() = error("unexpected call") + override val stateSnapshotId: StateSnapshot.Id = StateSnapshot.Id(null) override val children = MutableListChildren() - override val onBackPressedDispatcher = FakeOnBackPressedDispatcher() - override val uiConfiguration = MutableStateFlow(UiConfiguration()) override val savedStateRegistry: SavedStateRegistry? = null diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/TreehouseAppContentTest.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/TreehouseAppContentTest.kt index bc7841bee1..30a3ca1cd2 100644 --- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/TreehouseAppContentTest.kt +++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/TreehouseAppContentTest.kt @@ -20,35 +20,59 @@ import assertk.assertThat import assertk.assertions.isEmpty import assertk.assertions.isEqualTo import assertk.assertions.isNotEmpty +import kotlin.coroutines.EmptyCoroutineContext import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +/** + * This test focuses on how [TreehouseAppContent] behaves in response to lifecycle events from the + * code ([CodeSession]), the UI ([TreehouseView]), and the content ([ZiplineTreehouseUi]). + */ @OptIn(ExperimentalCoroutinesApi::class) class TreehouseAppContentTest { private val eventLog = EventLog() + private val appScope = CoroutineScope(EmptyCoroutineContext) private val dispatcher = UnconfinedTestDispatcher() private val eventPublisher = FakeEventPublisher() - private val codeHost = FakeCodeHost(eventLog, eventPublisher) private val dispatchers = FakeDispatchers(dispatcher, dispatcher) + private val onBackPressedDispatcher = FakeOnBackPressedDispatcher(eventLog) + private val codeHost = FakeCodeHost( + eventLog = eventLog, + eventPublisher = eventPublisher, + dispatchers = dispatchers, + appScope = appScope, + frameClockFactory = FakeFrameClock.Factory, + ) private val codeListener = FakeCodeListener(eventLog) private val uiConfiguration = UiConfiguration() + @BeforeTest + fun setUp() { + runBlocking { + codeHost.start() + eventLog.takeEvent("codeHostUpdates1.collect()") + } + } + @AfterTest fun tearDown() { eventLog.assertNoEvents() + appScope.cancel() } @Test fun bind_session_addWidget_unbind() = runTest { val content = treehouseAppContent() - val view1 = FakeTreehouseView("view1") + val view1 = treehouseView("view1") content.bind(view1) eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") @@ -68,7 +92,7 @@ class TreehouseAppContentTest { fun preload_session_addWidget_bind_unbind() = runTest { val content = treehouseAppContent() - content.preload(FakeOnBackPressedDispatcher(), uiConfiguration) + content.preload(onBackPressedDispatcher, uiConfiguration) eventLog.assertNoEvents() val codeSessionA = codeHost.startCodeSession("codeSessionA") @@ -79,7 +103,7 @@ class TreehouseAppContentTest { codeSessionA.appService.uis.single().addWidget("hello") eventLog.assertNoEvents() - val view1 = FakeTreehouseView("view1") + val view1 = treehouseView("view1") content.bind(view1) eventLog.takeEvent("codeListener.onCodeLoaded(view1, initial = true)") @@ -94,10 +118,10 @@ class TreehouseAppContentTest { val codeSessionA = codeHost.startCodeSession("codeSessionA") eventLog.takeEvent("codeSessionA.start()") - content.preload(FakeOnBackPressedDispatcher(), uiConfiguration) + content.preload(onBackPressedDispatcher, uiConfiguration) eventLog.takeEvent("codeSessionA.app.uis[0].start()") - val view1 = FakeTreehouseView("view1") + val view1 = treehouseView("view1") content.bind(view1) eventLog.assertNoEvents() @@ -116,7 +140,7 @@ class TreehouseAppContentTest { val codeSessionA = codeHost.startCodeSession("codeSessionA") eventLog.takeEvent("codeSessionA.start()") - val view1 = FakeTreehouseView("view1") + val view1 = treehouseView("view1") content.bind(view1) eventLog.takeEvent("codeSessionA.app.uis[0].start()") @@ -133,7 +157,7 @@ class TreehouseAppContentTest { fun bind_sessionA_sessionB_unbind() = runTest { val content = treehouseAppContent() - val view1 = FakeTreehouseView("view1") + val view1 = treehouseView("view1") content.bind(view1) eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") @@ -144,15 +168,16 @@ class TreehouseAppContentTest { eventLog.takeEvent("codeListener.onCodeLoaded(view1, initial = true)") val codeSessionB = codeHost.startCodeSession("codeSessionB") - eventLog.takeEvent("codeSessionA.cancel()") - eventLog.takeEvent("codeSessionB.start()") - eventLog.takeEvent("codeSessionB.app.uis[0].start()") + eventLog.takeEventsInAnyOrder( + "codeSessionA.app.uis[0].close()", + "codeSessionA.cancel()", + "codeSessionB.start()", + "codeSessionB.app.uis[0].start()", + ) // This still shows UI from codeSessionA. There's no onCodeLoaded() and no reset() until the new // code's first widget is added! assertThat(view1.children.single().value.label).isEqualTo("helloA") - eventLog.takeEvent("codeSessionA.app.uis[0].close()") - codeSessionB.appService.uis.single().addWidget("helloB") eventLog.takeEvent("codeListener.onCodeLoaded(view1, initial = false)") assertThat(view1.children.single().value.label).isEqualTo("helloB") @@ -165,7 +190,7 @@ class TreehouseAppContentTest { fun preload_unbind_session() = runTest { val content = treehouseAppContent() - content.preload(FakeOnBackPressedDispatcher(), uiConfiguration) + content.preload(onBackPressedDispatcher, uiConfiguration) eventLog.assertNoEvents() content.unbind() @@ -180,7 +205,7 @@ class TreehouseAppContentTest { fun bind_unbind_session() = runTest { val content = treehouseAppContent() - val view1 = FakeTreehouseView("view1") + val view1 = treehouseView("view1") content.bind(view1) eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") @@ -203,7 +228,7 @@ class TreehouseAppContentTest { val codeSessionA = codeHost.startCodeSession("codeSessionA") eventLog.takeEvent("codeSessionA.start()") - val view1 = FakeTreehouseView("view1") + val view1 = treehouseView("view1") content.bind(view1) eventLog.takeEvent("codeSessionA.app.uis[0].start()") @@ -229,7 +254,7 @@ class TreehouseAppContentTest { fun addBackHandler_receives_back_presses_until_canceled() = runTest { val content = treehouseAppContent() - val view1 = FakeTreehouseView("view1") + val view1 = treehouseView("view1") content.bind(view1) val codeSessionA = codeHost.startCodeSession("codeSessionA") eventLog.clear() @@ -242,6 +267,8 @@ class TreehouseAppContentTest { eventLog.takeEvent("codeSessionA.app.uis[0].onBackPressed()") backCancelable.cancel() + eventLog.takeEvent("onBackPressedDispatcher.callbacks[0].cancel()") + view1.onBackPressedDispatcher.onBack() eventLog.assertNoEvents() @@ -253,7 +280,7 @@ class TreehouseAppContentTest { fun addBackHandler_receives_no_back_presses_if_disabled() = runTest { val content = treehouseAppContent() - val view1 = FakeTreehouseView("view1") + val view1 = treehouseView("view1") content.bind(view1) val codeSessionA = codeHost.startCodeSession("codeSessionA") eventLog.clear() @@ -265,6 +292,7 @@ class TreehouseAppContentTest { backCancelable.cancel() content.unbind() + eventLog.takeEvent("onBackPressedDispatcher.callbacks[0].cancel()") eventLog.takeEvent("codeSessionA.app.uis[0].close()") } @@ -272,18 +300,25 @@ class TreehouseAppContentTest { fun backHandlers_cleared_when_session_changes() = runTest { val content = treehouseAppContent() - val view1 = FakeTreehouseView("view1") + val view1 = treehouseView("view1") content.bind(view1) val codeSessionA = codeHost.startCodeSession("codeSessionA") codeSessionA.appService.uis.single().addBackHandler(true) assertThat(view1.onBackPressedDispatcher.callbacks).isNotEmpty() + eventLog.clear() codeHost.startCodeSession("codeSessionB") // When we close codeSessionA, its back handlers are released with it. + eventLog.takeEventsInAnyOrder( + "codeSessionA.app.uis[0].close()", + "onBackPressedDispatcher.callbacks[0].cancel()", + "codeSessionA.cancel()", + "codeSessionB.start()", + "codeSessionB.app.uis[0].start()", + ) assertThat(view1.onBackPressedDispatcher.callbacks).isEmpty() - eventLog.clear() content.unbind() eventLog.takeEvent("codeSessionB.app.uis[0].close()") @@ -296,7 +331,7 @@ class TreehouseAppContentTest { val codeSessionA = codeHost.startCodeSession("codeSessionA") eventLog.takeEvent("codeSessionA.start()") - val view1 = FakeTreehouseView("view1") + val view1 = treehouseView("view1") content.bind(view1) eventLog.takeEvent("codeSessionA.app.uis[0].start()") @@ -320,7 +355,7 @@ class TreehouseAppContentTest { codeSessionA.handleUncaughtException(Exception("boom!")) eventLog.takeEvent("codeSessionA.cancel()") - val view1 = FakeTreehouseView("view1") + val view1 = treehouseView("view1") content.bind(view1) eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") @@ -339,7 +374,7 @@ class TreehouseAppContentTest { val codeSessionA = codeHost.startCodeSession("codeSessionA") eventLog.takeEvent("codeSessionA.start()") - val view1 = FakeTreehouseView("view1") + val view1 = treehouseView("view1") content.bind(view1) eventLog.takeEvent("codeSessionA.app.uis[0].start()") @@ -369,27 +404,30 @@ class TreehouseAppContentTest { val codeSessionA = codeHost.startCodeSession("codeSessionA") eventLog.takeEvent("codeSessionA.start()") - content.preload(FakeOnBackPressedDispatcher(), uiConfiguration) + content.preload(onBackPressedDispatcher, uiConfiguration) eventLog.takeEvent("codeSessionA.app.uis[0].start()") codeSessionA.handleUncaughtException(Exception("boom!")) eventLog.takeEvent("codeSessionA.app.uis[0].close()") eventLog.takeEvent("codeSessionA.cancel()") - val view1 = FakeTreehouseView("view1") + val view1 = treehouseView("view1") content.bind(view1) eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") content.unbind() } - private fun TestScope.treehouseAppContent(): TreehouseAppContent { + private fun treehouseAppContent(): TreehouseAppContent { return TreehouseAppContent( codeHost = codeHost, dispatchers = dispatchers, - appScope = CoroutineScope(coroutineContext), codeListener = codeListener, source = { app -> app.newUi() }, ) } + + private fun treehouseView(name: String): FakeTreehouseView { + return FakeTreehouseView(onBackPressedDispatcher, name) + } }