diff --git a/build.gradle b/build.gradle index b1c4425..0da2ee0 100644 --- a/build.gradle +++ b/build.gradle @@ -12,7 +12,6 @@ buildscript { dependencies { classpath deps.plugins.kotlin classpath deps.plugins.dokka - classpath deps.plugins.node } } apply from: "$rootDir/gradle/dependencies.gradle" @@ -22,6 +21,7 @@ allprojects { google() jcenter() maven { url "https://oss.sonatype.org/content/repositories/snapshots" } + maven { url "https://dl.bintray.com/spekframework/spek-dev" } } diff --git a/gradle/dependencies.gradle b/gradle/dependencies.gradle index 499409b..c3d81f6 100644 --- a/gradle/dependencies.gradle +++ b/gradle/dependencies.gradle @@ -1,12 +1,13 @@ ext.versions = [ kotlin : '1.3.30', dokka : '0.9.17', + spek : '2.1.0-alpha.0.9+3d5d865', + atrium : '0.8.0' ] + ext.deps = [ plugins: [ - android: 'com.android.tools.build:gradle:3.3.0', kotlin : "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}", dokka : "org.jetbrains.dokka:dokka-gradle-plugin:${versions.dokka}", - node : "com.moowork.gradle:gradle-node-plugin:${versions.nodePlugin}" ] ] diff --git a/lib/build.gradle b/lib/build.gradle index fe31107..e13d63d 100644 --- a/lib/build.gradle +++ b/lib/build.gradle @@ -1,3 +1,6 @@ +repositories { + maven { url "https://dl.bintray.com/spekframework/spek-dev" } +} apply plugin: 'java' apply plugin: 'kotlin-multiplatform' @@ -35,10 +38,13 @@ kotlin { } } commonTest { - kotlin.srcDir('src/test') + kotlin.srcDir('src/test/kotlin') dependencies { implementation kotlin("test-common") implementation kotlin("test-annotations-common") + implementation "org.spekframework.spek2:spek-dsl-metadata:$project.versions.spek" + implementation "ch.tutteli.atrium:atrium-cc-en_GB-robstoll-common:$project.versions.atrium" + implementation "io.mockk:mockk-common:1.9.3" } } @@ -52,7 +58,12 @@ kotlin { dependencies { implementation kotlin("test") implementation kotlin("test-junit") - implementation 'junit:junit:4.12' + implementation "org.spekframework.spek2:spek-dsl-jvm:$project.versions.spek" + implementation "ch.tutteli.atrium:atrium-cc-en_GB-robstoll:$project.versions.atrium" + implementation "io.mockk:mockk:1.9.3" + + runtimeOnly "org.spekframework.spek2:spek-runner-junit5:$project.versions.spek" + runtimeOnly 'org.jetbrains.kotlin:kotlin-reflect' } } jsMain { @@ -118,3 +129,8 @@ publishing { } } +jvmTest { + useJUnitPlatform { + includeEngines 'spek2' + } +} diff --git a/lib/src/commonMain/kotlin/org/reduxkotlin/ApplyMiddleware.kt b/lib/src/commonMain/kotlin/org/reduxkotlin/ApplyMiddleware.kt index 7c1a170..432b4a9 100644 --- a/lib/src/commonMain/kotlin/org/reduxkotlin/ApplyMiddleware.kt +++ b/lib/src/commonMain/kotlin/org/reduxkotlin/ApplyMiddleware.kt @@ -16,23 +16,21 @@ package org.reduxkotlin * @param {vararg Middleware} [middleware] The middleware chain to be applied. * @returns {StoreEnhancer} A store enhancer applying the middleware. */ -fun applyMiddleware(vararg middlewares: Middleware): StoreEnhancer { +fun applyMiddleware(vararg middlewares: Middleware): StoreEnhancer { return { storeCreator -> - { reducer, initialState -> - val store = storeCreator(reducer, initialState) - //TODO determine if handling dispatching while constructing middleware is needed. - //reduxjs throws an exception if action is dispatched before applymiddleware is complete - /* + { reducer, initialState, en -> + val store = storeCreator(reducer, initialState, en) var dispatch: Dispatcher = { action: Any -> throw Exception( """Dispatching while constructing your middleware is not allowed. Other middleware would not be applied to this dispatch.""") } - */ + store.dispatch = dispatch + val chain = middlewares.map { middleware -> middleware(store) } + dispatch = compose(chain)(store.dispatch) - val combinedDispatch = middlewares.foldRight(store.dispatch) { middleware, next -> {action -> middleware(store.getState, next, action)}} Store(getState = store.getState, - dispatch = combinedDispatch, + dispatch = dispatch, subscribe = store.subscribe, replaceReducer = store.replaceReducer) } diff --git a/lib/src/commonMain/kotlin/org/reduxkotlin/CreateStore.kt b/lib/src/commonMain/kotlin/org/reduxkotlin/CreateStore.kt index b92550b..516e9f9 100644 --- a/lib/src/commonMain/kotlin/org/reduxkotlin/CreateStore.kt +++ b/lib/src/commonMain/kotlin/org/reduxkotlin/CreateStore.kt @@ -25,11 +25,11 @@ import org.reduxkotlin.utils.isPlainObject * @returns {Store} A Redux store that lets you read the state, dispatch actions * and subscribe to changes. */ -fun createStore(reducer: Reducer, preloadedState: S, enhancer: StoreEnhancer? = null): Store { +fun createStore(reducer: Reducer, preloadedState: Any, enhancer: StoreEnhancer? = null): Store { if (enhancer != null) { - return enhancer { reducer, initialState -> createStore(reducer, initialState) }(reducer, preloadedState) + return enhancer { reducer, initialState, en -> createStore(reducer, initialState) }(reducer, preloadedState, null) } @@ -57,7 +57,7 @@ fun createStore(reducer: Reducer, preloadedState: S, enhancer: StoreEnhan * * @returns {S} The current state tree of your application. */ - fun getState(): S { + fun getState(): Any { if (isDispatching) { throw Exception( """You may not call store.getState() while the reducer is executing. @@ -184,7 +184,7 @@ fun createStore(reducer: Reducer, preloadedState: S, enhancer: StoreEnhan * @param {function} nextReducer The reducer for the store to use instead. * @returns {void} */ - fun replaceReducer(nextReducer: Reducer) { + fun replaceReducer(nextReducer: Reducer) { currentReducer = nextReducer // This action has a similar effect to ActionTypes.INIT. diff --git a/lib/src/commonMain/kotlin/org/reduxkotlin/Definitions.kt b/lib/src/commonMain/kotlin/org/reduxkotlin/Definitions.kt index 0e4ce81..07dd6b6 100644 --- a/lib/src/commonMain/kotlin/org/reduxkotlin/Definitions.kt +++ b/lib/src/commonMain/kotlin/org/reduxkotlin/Definitions.kt @@ -1,30 +1,79 @@ package org.reduxkotlin +import kotlin.reflect.KClass + /** * see also https://github.com/reactjs/redux/blob/master/docs/Glossary.md#reducer */ -typealias Reducer = (state: ReducerStateType, action: Any) -> ReducerStateType -typealias GetState = () -> S -typealias StoreSubscriber = ()-> Unit +typealias Reducer = (state: Any, action: Any) -> Any + +typealias GetState = () -> Any +typealias StoreSubscriber = () -> Unit typealias StoreSubscription = () -> Unit -typealias Dispatcher = (Any)->Any -typealias StoreCreator = (reducer: Reducer, initialState: S) -> Store +typealias Dispatcher = (Any) -> Any +typealias StoreCreator = (reducer: Reducer, initialState: Any, s: StoreEnhancerWrapper?) -> Store /** * get a store creator and return a new enhanced one * see https://github.com/reactjs/redux/blob/master/docs/Glossary.md#store-enhancer */ -typealias StoreEnhancer = (next: StoreCreator) -> StoreCreator +typealias StoreEnhancer = (next: StoreCreator) -> StoreCreator + +/** + * wrapper class is needed here to avoid a recursive type declaration. + */ +class StoreEnhancerWrapper(val storeEnhancer2: StoreEnhancer) : StoreEnhancer { + override fun invoke(p1: StoreCreator): StoreCreator { + return storeEnhancer2(p1) + } +} /** * see also https://github.com/reactjs/redux/blob/master/docs/Glossary.md#middleware */ -typealias Middleware = (getState: GetState, nextDispatcher: Dispatcher, action: Any) -> Any +typealias Middleware = (store: Store) -> (next: Dispatcher) -> (action: Any) -> Any -data class Store(val getState: GetState, - var dispatch: Dispatcher, - val subscribe: (StoreSubscriber) -> StoreSubscription, - val replaceReducer: (Reducer) -> Unit) { - val state: S +data class Store( + val getState: GetState, + var dispatch: Dispatcher, + val subscribe: (StoreSubscriber) -> StoreSubscription, + val replaceReducer: (Reducer) -> Unit +) { + val state: Any get() = getState() } + +/** + * Convenience function for creating a middleware + * usage: + * val myMiddleware = middleware { store, dispatch, action -> doStuff() } + */ +fun middleware(dispatch: (Store, dispatch: Dispatcher, action: Any) -> Any): Middleware = + { store -> + { next -> + { action: Any -> + { + dispatch(store, next, action) + } + } + } + } + +/** + * Creates a function that returns reducers with state casted to given state. + * This is to assist in readability of creating reducers and remove the need to cast. + * usage: + * * create reducers with castingReducer: + * val reducer = castingReducer { state: MyState, action -> + * when (action) { + * is Todo -> state.copy(...) + * } + * } + */ +inline fun castingReducer(crossinline reducer: ((T, Any) -> Any)): Reducer = { state: Any, action: Any -> + if (T::class.isInstance(state)) { + reducer(state as T, action) + } else { + { state: Any, action: Any -> state } + } +} diff --git a/lib/src/commonMain/kotlin/org/reduxkotlin/combineEnhancers.kt b/lib/src/commonMain/kotlin/org/reduxkotlin/combineEnhancers.kt index 2d1049d..abf4b50 100644 --- a/lib/src/commonMain/kotlin/org/reduxkotlin/combineEnhancers.kt +++ b/lib/src/commonMain/kotlin/org/reduxkotlin/combineEnhancers.kt @@ -1,7 +1,7 @@ package org.reduxkotlin -fun combineEnhancers(vararg enhancers: StoreEnhancer): StoreEnhancer = +fun combineEnhancers(vararg enhancers: StoreEnhancer): StoreEnhancer = { storeCreator -> compose(enhancers.map { it })(storeCreator) } diff --git a/lib/src/commonMain/kotlin/org/reduxkotlin/combineReducers.kt b/lib/src/commonMain/kotlin/org/reduxkotlin/combineReducers.kt index ae2ab08..98e7da7 100644 --- a/lib/src/commonMain/kotlin/org/reduxkotlin/combineReducers.kt +++ b/lib/src/commonMain/kotlin/org/reduxkotlin/combineReducers.kt @@ -1,11 +1,11 @@ package org.reduxkotlin -fun combineReducers(vararg reducers: Reducer): Reducer = +fun combineReducers(vararg reducers: Reducer): Reducer = { state, action -> reducers.fold(state, { s, reducer -> reducer(s, action) }) } -fun Reducer.combinedWith(vararg reducers: Reducer): Reducer { +fun Reducer.combinedWith(vararg reducers: Reducer): Reducer { return { state, action -> val sAfterFirstReducer = this(state, action) reducers.fold(sAfterFirstReducer, { s, reducer -> reducer(s, action) }) @@ -15,6 +15,6 @@ fun Reducer.combinedWith(vararg reducers: Reducer): Reducer { /** * combine two reducer with + operator */ -operator fun Reducer.plus(other: Reducer): Reducer = { s, a -> +operator fun Reducer.plus(other: Reducer): Reducer = { s, a -> other(this(s, a), a) } \ No newline at end of file diff --git a/lib/src/test/kotlin/org/reduxkotlin/ApplyMiddlewareTest.kt b/lib/src/test/kotlin/org/reduxkotlin/ApplyMiddlewareTest.kt new file mode 100644 index 0000000..10636ca --- /dev/null +++ b/lib/src/test/kotlin/org/reduxkotlin/ApplyMiddlewareTest.kt @@ -0,0 +1,91 @@ +package org.reduxkotlin + +import ch.tutteli.atrium.api.cc.en_GB.toBe +import ch.tutteli.atrium.api.cc.en_GB.toThrow +import ch.tutteli.atrium.verbs.expect +import io.mockk.spyk +import org.spekframework.spek2.Spek +import org.spekframework.spek2.style.specification.describe +import kotlin.test.assertEquals + + +object ApplyMiddlewareSpec : Spek({ + describe("middleware") { + it("fails") { + spyk(Any()) + expect(1).toBe(1) + assertEquals(1, 1) + } + } + + describe("applyMiddleware") { + it("warns when dispatching during middleware setup") { + fun dispatchingMiddleware(store: Store): (next: Dispatcher) -> (action: Any) -> Any { + store.dispatch(AddTodo("1", "Dont dispatch in middleware setup")); + return { next -> + { action -> + { + next(action) + } + } + } + } + + expect { + val storeEnhancer = applyMiddleware(::dispatchingMiddleware) + storeEnhancer(::createStore)(todos, Any(), null) + }.toThrow {} + } + + /* + it("wraps dispatch method with middleware once") { + fun test(spyOnMethods) { + return methods => { + spyOnMethods(methods) + return next => action => next(action) + } + } + + val spy = jest.fn() + val store = applyMiddleware (test(spy), thunk)(createStore)(reducers.todos) + + store.dispatch(AddTodo("Use Redux")) + store.dispatch(AddTodo("Flux FTW!")) + + expect(spy.mock.calls.length).toBe(1) + + expect(spy.mock.calls[0][0]).toHaveProperty("getState") + expect(spy.mock.calls[0][0]).toHaveProperty("dispatch") + + expect(store.getState()).toEqual([ + { id: 1, text: "Use Redux" }, + { id: 2, text: "Flux FTW!" } + ]) + }) + + */ + + } +}) + +/************** Test Reducer & actions - tobe moved into example app *********/ + +data class AddTodo(val id: String, val text: String) +data class ToggleTodo(val id: String) +data class Todo(val id: String, val text: String, val completed: Boolean = false) + +data class TestState(val todos: List = listOf()) + +val todos = castingReducer { state: TestState, action -> + when (action) { + is AddTodo -> state.copy(todos = state.todos.plus(Todo(action.id, action.text, false))) + is ToggleTodo -> state.copy(todos = state.todos.map { + if (it.id == action.id) { + it.copy(completed = !it.completed) + } else { + it + } + }) + else -> state + } + } \ No newline at end of file diff --git a/lib/src/test/kotlin/org/reduxkotlin/CreateStoreSpec.kt b/lib/src/test/kotlin/org/reduxkotlin/CreateStoreSpec.kt new file mode 100644 index 0000000..98217d7 --- /dev/null +++ b/lib/src/test/kotlin/org/reduxkotlin/CreateStoreSpec.kt @@ -0,0 +1,122 @@ +package org.reduxkotlin + +import ch.tutteli.atrium.api.cc.en_GB.toBe +import ch.tutteli.atrium.verbs.expect +import org.spekframework.spek2.Spek +import org.spekframework.spek2.style.specification.describe + +object CreateStoreSpec : Spek({ + describe("createStore") { + it("passes the initial state") { + val store = createStore( + todos, TestState( + listOf( + Todo( + id = "1", + text = "Hello" + ) + ) + ) + ) + + expect(store.getState()).toBe( + TestState( + listOf( + Todo( + id = "1", + text = "Hello" + ) + ) + ) + ) + } + it("applies the reducer to the previous state") { + val store = createStore(todos, TestState()) + expect(store.getState()).toBe(TestState()) + + store.dispatch(Any()) + expect(store.getState()).toBe(TestState()) + + store.dispatch(AddTodo("1", "Hello")) + expect(store.getState()).toBe( + TestState( + listOf( + Todo( + id = "1", + text = "Hello" + ) + ) + ) + ) + + //TODO are ids autoincrement? + store.dispatch(AddTodo("2", "World")) + expect(store.getState()).toBe( + TestState( + listOf( + Todo( + id = "1", + text = "Hello" + ), + Todo( + id = "2", + text = "World" + ) + ) + ) + ) + } + + it("applies the reducer to the initial state") { + val store = createStore( + todos, TestState( + listOf( + Todo( + id = "1", + text = "Hello" + ) + ) + ) + ) + expect(store.getState()).toBe( + TestState( + listOf( + Todo( + id = "1", + text = "Hello" + ) + ) + ) + ) + + store.dispatch(Any()) + expect(store.getState()).toBe( + TestState( + listOf( + Todo( + id = "1", + text = "Hello" + ) + ) + ) + ) + + store.dispatch(AddTodo("2", "World")) + expect(store.getState()).toBe( + TestState( + listOf( + Todo( + id = "1", + text = "Hello" + ), + Todo( + id = "2", + text = "World" + ) + ) + ) + ) + } + + } +})