All Downloads are FREE. Search and download functionalities are using the official Maven repository.

jvmTest.MobiusLoopTest.kt Maven / Gradle / Ivy

package kt.mobius

import com.google.common.util.concurrent.SettableFuture
import kt.mobius.Effects.effects
import kt.mobius.disposables.Disposable
import kt.mobius.functions.Consumer
import kt.mobius.functions.Producer
import kt.mobius.internal_util.Throwables
import kt.mobius.runners.ExecutorServiceWorkRunner
import kt.mobius.runners.ImmediateWorkRunner
import kt.mobius.runners.Runnable
import kt.mobius.runners.WorkRunner
import kt.mobius.test.TestWorkRunner
import org.awaitility.Awaitility.await
import org.awaitility.Duration
import org.junit.After
import org.junit.Before
import java.util.*
import java.util.concurrent.ExecutionException
import java.util.concurrent.Executors
import java.util.concurrent.Semaphore
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.test.*

class MobiusLoopTest {

    private lateinit var mobiusLoop: MobiusLoop
    private lateinit var mobiusStore: MobiusStore
    private lateinit var effectHandler: Connectable

    private val immediateRunner = ImmediateWorkRunner()
    private lateinit var backgroundRunner: WorkRunner

    private var eventSource =
        EventSource {
            Disposable {
            }
        }

    private lateinit var observer: RecordingModelObserver
    private var effectObserver: RecordingConsumer? = null
    private lateinit var update: Update

    @Before
    fun setUp() {
        backgroundRunner = ExecutorServiceWorkRunner(Executors.newSingleThreadExecutor())
        val init = Init { model ->
            First.first(model)
        }

        update = Update { model, mobiusEvent ->
            when (mobiusEvent) {
                is TestEvent.EventWithCrashingEffect ->
                    Next.next("will crash", effects(TestEffect.Crash))
                is TestEvent.EventWithSafeEffect -> Next.next(
                    "$model->$mobiusEvent", setOf(TestEffect.SafeEffect(mobiusEvent.toString()))
                )
                else -> Next.next("$model->$mobiusEvent")
            }
        }

        mobiusStore = MobiusStore.create(init, update, "init")

        effectHandler = Connectable {
            SimpleConnection { effect ->
                effectObserver?.accept(effect)
                if (effect is TestEffect.Crash) {
                    throw RuntimeException("Crashing!")
                }
            }
        }

        setupWithEffects(effectHandler, immediateRunner)
    }

    @After
    fun tearDown() {
        backgroundRunner.dispose()
    }

    @Test
    fun shouldTransitionToNextStateBasedOnInput() {
        mobiusLoop.dispatchEvent(TestEvent.Simple("first"))
        mobiusLoop.dispatchEvent(TestEvent.Simple("second"))

        observer.assertStates("init", "init->first", "init->first->second")
    }

    @Test
    fun shouldSurviveEffectPerformerThrowing() {
        mobiusLoop.dispatchEvent(TestEvent.EventWithCrashingEffect)
        mobiusLoop.dispatchEvent(TestEvent.Simple("should happen"))

        observer.assertStates("init", "will crash", "will crash->should happen")
    }

    @Test
    fun shouldSurviveEffectPerformerThrowingMultipleTimes() {
        mobiusLoop.dispatchEvent(TestEvent.EventWithCrashingEffect)
        mobiusLoop.dispatchEvent(TestEvent.Simple("should happen"))
        mobiusLoop.dispatchEvent(TestEvent.EventWithCrashingEffect)
        mobiusLoop.dispatchEvent(TestEvent.Simple("should happen, too"))

        observer.assertStates(
            "init",
            "will crash",
            "will crash->should happen",
            "will crash",
            "will crash->should happen, too"
        )
    }

    @Test
    fun shouldSupportEffectsThatGenerateEvents() {
        setupWithEffects(Connectable { eventConsumer ->
            SimpleConnection { effect ->
                eventConsumer.accept(TestEvent.Simple(effect.toString()))
            }
        }, immediateRunner)

        mobiusLoop.dispatchEvent(TestEvent.EventWithSafeEffect("hi"))

        observer.assertStates("init", "init->hi", "init->hi->effecthi")
    }

    @Test
    fun shouldOrderStateChangesCorrectlyWhenEffectsAreSlow() {
        val future = SettableFuture.create()

        setupWithEffects(Connectable { eventConsumer ->
            SimpleConnection {

                try {
                    eventConsumer.accept(future.get())
                } catch (e: InterruptedException) {
                    e.printStackTrace()
                } catch (e: ExecutionException) {
                    e.printStackTrace()
                }
            }
        }, backgroundRunner)

        mobiusLoop.dispatchEvent(TestEvent.EventWithSafeEffect("1"))
        mobiusLoop.dispatchEvent(TestEvent.Simple("2"))

        await().atMost(Duration.ONE_SECOND).until { observer.valueCount() >= 3 }

        future.set(TestEvent.Simple("3"))

        await().atMost(Duration.ONE_SECOND).until { observer.valueCount() >= 4 }
        observer.assertStates("init", "init->1", "init->1->2", "init->1->2->3")
    }

    @Test
    fun shouldSupportHandlingEffectsWhenOneEffectNeverCompletes() {
        setupWithEffects(Connectable { eventConsumer ->
            SimpleConnection { effect ->

                if (effect is TestEffect.SafeEffect) {
                    if (effect.id == "1") {
                        try {
                            // Rough approximation of waiting infinite amount of time.
                            Thread.sleep(2000)
                        } catch (e: InterruptedException) {
                            // ignored.
                        }

                        return@SimpleConnection
                    }
                }
                eventConsumer.accept(TestEvent.Simple(effect.toString()))
            }
        }, ExecutorServiceWorkRunner(Executors.newFixedThreadPool(2)))

        // the effectHandler associated with "1" should never happen
        mobiusLoop.dispatchEvent(TestEvent.EventWithSafeEffect("1"))
        mobiusLoop.dispatchEvent(TestEvent.Simple("2"))
        mobiusLoop.dispatchEvent(TestEvent.EventWithSafeEffect("3"))

        await().atMost(Duration.FIVE_SECONDS).until { observer.valueCount() >= 5 }

        observer.assertStates(
            "init", "init->1", "init->1->2", "init->1->2->3", "init->1->2->3->effect3"
        )
    }

    @Test
    fun shouldPerformEffectFromInit() {
        val init = Init { model ->
            First.first(model, setOf(TestEffect.SafeEffect("frominit")))
        }

        val update =
            Update { model, event ->
                Next.next("$model->$event")
            }


        mobiusStore = MobiusStore.create(init, update, "init")
        val testWorkRunner = TestWorkRunner()

        setupWithEffects(Connectable { eventConsumer ->
            SimpleConnection { effect ->
                eventConsumer.accept(TestEvent.Simple(effect.toString()))
            }
        }, testWorkRunner)

        //TODO: observer.waitForChange(100)
        testWorkRunner.runAll()

        observer.assertStates("init", "init->effectfrominit")
    }

    @Test
    fun dispatchingEventsAfterDisposalThrowsException() {
        mobiusLoop.dispose()

        assertFailsWith(IllegalStateException::class) {
            mobiusLoop.dispatchEvent(TestEvent.Simple("2"))
        }
    }

    @Test
    fun disposingTheLoopDisposesTheWorkRunners() {
        val eventRunner = TestWorkRunner()
        val effectRunner = TestWorkRunner()

        mobiusLoop =
            MobiusLoop.create(mobiusStore, effectHandler, eventSource, eventRunner, effectRunner)

        mobiusLoop.dispose()

        assertTrue(eventRunner.isDisposed, "expecting event WorkRunner to be disposed")
        assertTrue(effectRunner.isDisposed, "expecting effect WorkRunner to be disposed")
    }

    @Test
    fun shouldSupportUnregisteringObserver() {
        observer = RecordingModelObserver()

        mobiusLoop =
            MobiusLoop.create(
                mobiusStore, effectHandler, eventSource, immediateRunner, immediateRunner
            );

        val unregister = mobiusLoop.observe(observer)

        mobiusLoop.dispatchEvent(TestEvent.Simple("active observer"))
        unregister.dispose()
        mobiusLoop.dispatchEvent(TestEvent.Simple("shouldn't be seen"))

        observer.assertStates("init", "init->active observer")
    }

    @Test
    fun shouldThrowForEventSourceEventsAfterDispose() {
        val eventSource = FakeEventSource()

        mobiusLoop =
            MobiusLoop.create(
                mobiusStore, effectHandler, eventSource, immediateRunner, immediateRunner
            )

        observer = RecordingModelObserver() // to clear out the init from the previous setup
        mobiusLoop.observe(observer)

        eventSource.emit(TestEvent.EventWithSafeEffect("one"))
        mobiusLoop.dispose()


        assertFailsWith(IllegalStateException::class) {
            eventSource.emit(TestEvent.EventWithSafeEffect("two"))
        }

        observer.assertStates("init", "init->one")
    }

    @Test
    fun shouldThrowForEffectHandlerEventsAfterDispose() {
        val effectHandler = FakeEffectHandler()

        setupWithEffects(effectHandler, immediateRunner);

        effectHandler.emitEvent(TestEvent.EventWithSafeEffect("good one"))

        mobiusLoop.dispose()

        assertFailsWith(IllegalStateException::class) {
            effectHandler.emitEvent(TestEvent.EventWithSafeEffect("bad one"))
        }

        observer.assertStates("init", "init->good one")
    }

    @Test
    fun shouldProcessInitBeforeEventsFromEffectHandler() {
        mobiusStore = MobiusStore.create(Init { First.first("I$it") }, update, "init")

        // when an effect handler that emits events before returning the connection
        setupWithEffects(
            Connectable { output ->
                output.accept(TestEvent.Simple("1"))

                SimpleConnection {
                }
            },
            immediateRunner
        )

        // in this scenario, the init and the first event get processed before the observer
        // is connected, meaning the 'Iinit' state is never seen
        observer.assertStates("Iinit->1")
    }

    @Test
    fun shouldProcessInitBeforeEventsFromEventSource() {
        mobiusStore = MobiusStore.create(Init { First.first("First$it") }, update, "init")

        eventSource =
            EventSource { eventConsumer ->
                eventConsumer.accept(TestEvent.Simple("1"))
                Disposable {
                    // do nothing
                }
            }

        setupWithEffects(FakeEffectHandler(), immediateRunner)

        // in this scenario, the init and the first event get processed before the observer
        // is connected, meaning the 'Firstinit' state is never seen
        observer.assertStates("Firstinit->1")
    }

    @Test
    fun eventsFromEventSourceDuringDisposeAreIgnored() {
        val updateWasCalled = AtomicBoolean()

        val builder = Mobius.loop(Update { _: String, _: TestEvent ->
            updateWasCalled.set(true)
            Next.noChange()
        }, effectHandler)

        builder.eventSource(EmitDuringDisposeEventSource(TestEvent.Simple("bar")))
            .startFrom("foo")
            .dispose()

        assertFalse(updateWasCalled.get())
    }

    @Test
    fun eventsFromEffectHandlerDuringDisposeAreIgnored() {
        val updateWasCalled = AtomicBoolean()

        val builder = Mobius.loop(Update { _: String, _: TestEvent ->
            updateWasCalled.set(true)
            Next.noChange()
        }, EmitDuringDisposeEffectHandler())

        builder.startFrom("foo").dispose()

        assertFalse(updateWasCalled.get())
    }

    @Test
    fun disposingLoopWhileInitIsRunningDoesNotEmitNewState() {
        // Model changes emitted from the init function during dispose should be ignored.

        // This test will start a loop and wait until (using the initRequested semaphore) the runnable
        // that runs Init is posted to the event runner. The init function will then be blocked using
        // the initLock semaphore. At this point, we proceed to add the observer then dispose of the
        // loop. The loop is setup with an event source that returns a disposable that will unlock
        // init when it is disposed. So when we dispose of the loop, that will unblock init as part of
        // the disposal procedure. The test then waits until the init runnable has completed running.
        // Completion of the init runnable means:
        // a) init has returned a First
        // b) that first has been unpacked and the model has been set on the store
        // c) that model has been passed back to the loop to be emitted to any state observers
        // Since we're in the process of disposing of the loop, we should see no states in our observer
        observer = RecordingModelObserver()
        val initLock = Semaphore(0)
        val initRequested = Semaphore(0)
        val initFinished = Semaphore(0)

        val update = Update { _: String, _: TestEvent ->
            Next.noChange()
        }
        val builder =
            Mobius.loop(update, effectHandler)
                .init(
                    Init { m ->
                        initLock.acquireUninterruptibly()
                        First.first(m)
                    })
                .eventSource(EventSource { Disposable(initLock::release) })
                .eventRunner(Producer {
                    object : WorkRunner {
                        override fun post(runnable: Runnable) {
                            backgroundRunner.post(Runnable {
                                initRequested.release()
                                runnable.run()
                                initFinished.release()
                            })
                        }

                        override fun dispose() {
                            backgroundRunner.dispose()
                        }
                    }
                })
        mobiusLoop = builder.startFrom("foo")
        initRequested.acquireUninterruptibly()
        mobiusLoop.observe(observer)
        mobiusLoop.dispose()
        initFinished.acquireUninterruptibly(1)
        observer.assertStates()
    }

    @Test
    fun disposingLoopBeforeInitRunsIgnoresModelFromInit() {
        // Model changes emitted from the init function during dispose should be ignored.
        // This test sets up the following scenario:
        // 1. The loop is created and initialized on a separate thread
        // 2. The loop is configured with an event runner that will block before executing the init function
        // 3. The test will then dispose of the loop
        // 4. Once the loop is disposed, the test will proceed to unblock the initialization runnable
        // 5. Once the initialization is completed, the test will proceed to examine the observer

        observer = RecordingModelObserver()

        val awaitInitExecutionRequest = Semaphore(0)
        val blockInitExecution = Semaphore(0)
        val initExecutionCompleted = Semaphore(0)

        val update = Update { _: String, _: TestEvent ->
            Next.noChange()
        }
        val builder =
            Mobius.loop(update, effectHandler)
                .eventRunner(
                    Producer {
                        object : WorkRunner {
                            override fun post(runnable: Runnable) {
                                backgroundRunner.post(Runnable {
                                    awaitInitExecutionRequest.release()
                                    blockInitExecution.acquireUninterruptibly()
                                    runnable.run()
                                    initExecutionCompleted.release()
                                })
                            }

                            override fun dispose() {
                                backgroundRunner.dispose()
                            }
                        }
                    })

        Thread { mobiusLoop = builder.startFrom("foo") }.start()

        awaitInitExecutionRequest.acquireUninterruptibly()

        mobiusLoop.observe(observer)
        mobiusLoop.dispose()

        blockInitExecution.release()
        initExecutionCompleted.acquireUninterruptibly()

        observer.assertStates()
    }

    @Test
    fun modelsFromUpdateDuringDisposeAreIgnored() {
        // Model changes emitted from the update function during dispose should be ignored.

        observer = RecordingModelObserver()
        val lock = Semaphore(0)

        val update = Update { _: String, _: TestEvent ->
            lock.acquireUninterruptibly()
            Next.next("baz")
        }

        val builder = Mobius.loop(update, effectHandler)
            .eventRunner(Producer { InitImmediatelyThenUpdateConcurrentlyWorkRunner.create(backgroundRunner) })

        mobiusLoop = builder.startFrom("foo")
        mobiusLoop.observe(observer)

        mobiusLoop.dispatchEvent(TestEvent.Simple("bar"))
        releaseLockAfterDelay(lock, 30)
        mobiusLoop.dispose()

        observer.assertStates("foo")
    }

    @Test
    fun effectsFromUpdateDuringDisposeAreIgnored() {
        // Effects emitted from the update function during dispose should be ignored.

        effectObserver = RecordingConsumer()
        val lock = Semaphore(0)

        val builder = Mobius.loop(
            Update { _: String, _: TestEvent ->
                lock.acquireUninterruptibly()
                Next.dispatch(effects(TestEffect.SafeEffect("baz")))
            },
            effectHandler
        )

        mobiusLoop = builder.startFrom("foo")

        mobiusLoop.dispatchEvent(TestEvent.Simple("bar"))
        releaseLockAfterDelay(lock, 45)
        mobiusLoop.dispose()

        effectObserver!!.assertValues()
    }

    private fun setupWithEffects(
        effectHandler: Connectable, effectRunner: WorkRunner
    ) {
        observer = RecordingModelObserver()

        mobiusLoop =
            MobiusLoop.create(mobiusStore, effectHandler, eventSource, immediateRunner, effectRunner)

        mobiusLoop.observe(observer)
    }

    private fun releaseLockAfterDelay(lock: Semaphore, delay: Int) {
        Thread {
            try {
                Thread.sleep(delay.toLong())
            } catch (e: InterruptedException) {
                throw Throwables.propagate(e)
            }

            lock.release()
        }.start()
    }

    private sealed class TestEvent(
        private val name: String
    ) {

        class Simple(name: String) : TestEvent(name)

        object EventWithCrashingEffect : TestEvent("crash!")

        class EventWithSafeEffect(id: String) : TestEvent(id)

        override fun toString(): String {
            return name
        }
    }

    private sealed class TestEffect {
        object Crash : TestEffect()

        data class SafeEffect(
            val id: String
        ) : TestEffect() {

            override fun toString(): String {
                return "effect$id"
            }
        }
    }

    private class FakeEffectHandler : Connectable {

        @Volatile
        private var eventConsumer: Consumer? = null

        fun emitEvent(event: TestEvent) {
            // throws NPE if not connected; that's OK
            eventConsumer!!.accept(event)
        }

        override fun connect(output: Consumer): Connection {
            if (eventConsumer != null) {
                throw ConnectionLimitExceededException()
            }

            eventConsumer = output

            return object : Connection {
                override fun accept(value: TestEffect) {
                    // do nothing
                }

                override fun dispose() {
                    // do nothing
                }
            }
        }
    }

    private class EmitDuringDisposeEventSource(private val event: TestEvent) : EventSource {

        override fun subscribe(eventConsumer: Consumer): Disposable {
            return Disposable { eventConsumer.accept(event) }
        }
    }

    private class EmitDuringDisposeEffectHandler : Connectable {

        override fun connect(eventConsumer: Consumer): Connection {
            return object : Connection {
                override fun accept(value: TestEffect) {
                    // ignored
                }

                override fun dispose() {
                    eventConsumer.accept(TestEvent.Simple("bar"))
                }
            }
        }
    }

    @Test
    fun shouldDisposeMultiThreadedEventSourceSafely() {
        // event source that just pushes stuff every X ms on a thread.

        val source = RecurringEventSource()

        val builder = Mobius.loop(update, effectHandler).eventSource(source)

        val random = Random()

        for (i in 0..99) {
            mobiusLoop = builder.startFrom("foo")

            Thread.sleep(random.nextInt(30).toLong())

            mobiusLoop.dispose()
        }
    }

    private class RecurringEventSource : EventSource {

        internal val completion = SettableFuture.create()

        override fun subscribe(eventConsumer: Consumer): Disposable {
            if (completion.isDone) {
                try {
                    completion.get() // should throw since the only way it can complete is exceptionally
                } catch (e: InterruptedException) {
                    throw RuntimeException("handle this", e)
                } catch (e: ExecutionException) {
                    throw RuntimeException("handle this", e)
                }
            }

            val generator = Generator(eventConsumer)

            val t = Thread(generator)
            t.start()

            return Disposable {
                generator.generate = false
                try {
                    t.join()
                } catch (e: InterruptedException) {
                    throw Throwables.propagate(e)
                }
            }
        }

        private inner class Generator(private val consumer: Consumer) : Runnable {

            @Volatile
            var generate = true

            override fun run() {
                while (generate) {
                    try {
                        consumer.accept(TestEvent.Simple("hi"))
                        Thread.sleep(15)
                    } catch (e: Exception) {
                        completion.setException(e)
                    }
                }
            }
        }
    }

    private class InitImmediatelyThenUpdateConcurrentlyWorkRunner private constructor(
        private val delegate: WorkRunner
    ) : WorkRunner {

        private var ranOnce: Boolean = false

        @Synchronized
        override fun post(runnable: Runnable) {
            if (ranOnce) {
                delegate.post(runnable)
                return
            }

            ranOnce = true
            runnable.run()
        }

        override fun dispose() {
            delegate.dispose()
        }

        companion object {

            fun create(eventRunner: WorkRunner): WorkRunner {
                return InitImmediatelyThenUpdateConcurrentlyWorkRunner(eventRunner)
            }
        }
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy