|
| 1 | +package app.cash.kfsm.exemplar |
| 2 | + |
| 3 | +import app.cash.kfsm.StateMachine |
| 4 | +import app.cash.kfsm.Transitioner |
| 5 | +import app.cash.kfsm.exemplar.Hamster.Asleep |
| 6 | +import app.cash.kfsm.exemplar.Hamster.Awake |
| 7 | +import app.cash.kfsm.exemplar.Hamster.Eating |
| 8 | +import app.cash.kfsm.exemplar.Hamster.RunningOnWheel |
| 9 | +import app.cash.kfsm.exemplar.Hamster.State |
| 10 | +import app.cash.quiver.extensions.ErrorOr |
| 11 | +import arrow.core.Either |
| 12 | +import arrow.core.flatMap |
| 13 | +import arrow.core.right |
| 14 | +import io.kotest.assertions.arrow.core.shouldBeLeft |
| 15 | +import io.kotest.assertions.arrow.core.shouldBeRight |
| 16 | +import io.kotest.core.spec.style.StringSpec |
| 17 | +import io.kotest.matchers.collections.shouldBeEmpty |
| 18 | +import io.kotest.matchers.shouldBe |
| 19 | + |
| 20 | +class PenelopePerfectDayTest : StringSpec({ |
| 21 | + |
| 22 | + val hamster = Hamster(name = "Penelope", state = Awake) |
| 23 | + |
| 24 | + val saves = mutableListOf<Hamster>() |
| 25 | + val locks = mutableListOf<Hamster>() |
| 26 | + val unlocks = mutableListOf<Hamster>() |
| 27 | + val notifications = mutableListOf<String>() |
| 28 | + |
| 29 | + // If you do not require pre and post hooks, you can simply instantiate a transitioner & provide the persistence |
| 30 | + // function as a constructor argument. |
| 31 | + // In this example, we extend the transitioner in order to define hooks that will be executed before each transition |
| 32 | + // and after each successful transition. |
| 33 | + val transitioner = object : Transitioner<HamsterTransition, Hamster, State>( |
| 34 | + // This is where you define how to save your updated value to a data store |
| 35 | + persist = { it.also(saves::add).right() } |
| 36 | + ) { |
| 37 | + |
| 38 | + // Any action you might wish to take prior to transitioning, such as pessimistic locking |
| 39 | + override suspend fun preHook(value: Hamster, via: HamsterTransition): ErrorOr<Unit> = Either.catch { |
| 40 | + locks.add(value) |
| 41 | + } |
| 42 | + |
| 43 | + // Any action you might wish to take after transitioning successfully, such as sending events or notifications |
| 44 | + override suspend fun postHook(from: State, value: Hamster, via: HamsterTransition): ErrorOr<Unit> = Either.catch { |
| 45 | + notifications.add("${value.name} was $from, then began ${via.description} and is now ${via.to}") |
| 46 | + } |
| 47 | + } |
| 48 | + |
| 49 | + beforeTest { |
| 50 | + setOf(locks, unlocks, saves, notifications).forEach { it.clear() } |
| 51 | + } |
| 52 | + |
| 53 | + "a newly woken hamster eats broccoli" { |
| 54 | + val result = transitioner.transition(hamster, EatBreakfast("broccoli")).shouldBeRight() |
| 55 | + result.state shouldBe Eating |
| 56 | + locks shouldBe listOf(hamster) |
| 57 | + saves shouldBe listOf(result) |
| 58 | + notifications shouldBe listOf("Penelope was Awake, then began eating broccoli for breakfast and is now Eating") |
| 59 | + } |
| 60 | + |
| 61 | + "the hamster has trouble eating cheese" { |
| 62 | + transitioner.transition(hamster, EatBreakfast("cheese")) shouldBeLeft |
| 63 | + LactoseIntoleranceTroubles("cheese") |
| 64 | + locks shouldBe listOf(hamster) |
| 65 | + saves.shouldBeEmpty() |
| 66 | + notifications.shouldBeEmpty() |
| 67 | + } |
| 68 | + |
| 69 | + "a sleeping hamster can awaken yet again" { |
| 70 | + transitioner.transition(hamster, EatBreakfast("broccoli")) |
| 71 | + .flatMap { transitioner.transition(it, RunOnWheel) } |
| 72 | + .flatMap { transitioner.transition(it, GoToBed) } |
| 73 | + .flatMap { transitioner.transition(it, WakeUp) } |
| 74 | + .flatMap { transitioner.transition(it, EatBreakfast("broccoli")) } |
| 75 | + .shouldBeRight().state shouldBe Eating |
| 76 | + locks shouldBe listOf( |
| 77 | + hamster, |
| 78 | + hamster.copy(state = Eating), |
| 79 | + hamster.copy(state = RunningOnWheel), |
| 80 | + hamster.copy(state = Asleep), |
| 81 | + hamster.copy(state = Awake), |
| 82 | + ) |
| 83 | + saves shouldBe listOf( |
| 84 | + hamster.copy(state = Eating), |
| 85 | + hamster.copy(state = RunningOnWheel), |
| 86 | + hamster.copy(state = Asleep), |
| 87 | + hamster.copy(state = Awake), |
| 88 | + hamster.copy(state = Eating), |
| 89 | + ) |
| 90 | + notifications shouldBe listOf( |
| 91 | + "Penelope was Awake, then began eating broccoli for breakfast and is now Eating", |
| 92 | + "Penelope was Eating, then began running on the wheel and is now RunningOnWheel", |
| 93 | + "Penelope was RunningOnWheel, then began going to bed and is now Asleep", |
| 94 | + "Penelope was Asleep, then began waking up and is now Awake", |
| 95 | + "Penelope was Awake, then began eating broccoli for breakfast and is now Eating" |
| 96 | + ) |
| 97 | + } |
| 98 | + |
| 99 | + "a sleeping hamster cannot immediately start running on the wheel" { |
| 100 | + transitioner.transition(hamster.copy(state = Asleep), RunOnWheel).shouldBeLeft() |
| 101 | + locks.shouldBeEmpty() |
| 102 | + saves.shouldBeEmpty() |
| 103 | + notifications.shouldBeEmpty() |
| 104 | + } |
| 105 | + |
| 106 | + "an eating hamster who wants to eat twice as hard will just keep eating" { |
| 107 | + val eatingHamster = hamster.copy(state = Eating) |
| 108 | + transitioner.transition(eatingHamster, EatBreakfast("broccoli")) |
| 109 | + .shouldBeRight(eatingHamster) |
| 110 | + locks.shouldBeEmpty() |
| 111 | + saves.shouldBeEmpty() |
| 112 | + notifications.shouldBeEmpty() |
| 113 | + } |
| 114 | + |
| 115 | + // Add a test like this to ensure you don't have states that cannot be reached |
| 116 | + "the state machine is hunky dory" { |
| 117 | + StateMachine.verify(Awake).shouldBeRight() |
| 118 | + } |
| 119 | + |
| 120 | + // Use this method to create mermaid diagrams in your markdown. |
| 121 | + // TODO(jem) - add a custom kotest matcher for ensuring the markdown is in a specific project file. |
| 122 | + "the mermaid diagram should be correct" { |
| 123 | + StateMachine.mermaid(Awake).shouldBeRight( |
| 124 | + """stateDiagram-v2 |
| 125 | + [*] --> Awake |
| 126 | + Asleep --> Awake |
| 127 | + Awake --> Eating |
| 128 | + Eating --> Asleep |
| 129 | + Eating --> Resting |
| 130 | + Eating --> RunningOnWheel |
| 131 | + Resting --> Asleep |
| 132 | + RunningOnWheel --> Asleep |
| 133 | + RunningOnWheel --> Resting |
| 134 | + """.trimIndent() |
| 135 | + ) |
| 136 | + } |
| 137 | +}) |
0 commit comments