From 890086352a985b6f9e044564f420145ec4712ce8 Mon Sep 17 00:00:00 2001 From: Fahad Ibnay Heylaal Date: Mon, 2 Jan 2017 21:37:07 +0100 Subject: [PATCH] Store management with `createStore` (#71) * store: `createStore` function. * store (in progress) * tests for `createStore`. * full test coverage for `createStore`. * backwards compatibility for `createStore`. * Store should catch and print errors from reducers, if any. * tests for `createStore` with combined reducers. * fix for alex -_- * `combineReducers` with tests. * initialize Store by firing first init action. * implement `createStore` everywhere. * delete now-redundant code. * drop `redux`. * append `appName` to dispatched action payloads like before. * padded pretty date in logs. --- .alexignore | 2 + .alexrc | 5 + package.json | 1 - src/combineReducers.js | 41 ++- src/createApp.js | 53 +--- src/createStore.js | 135 ++++++++ src/index.js | 2 + src/middlewares/appendAction.js | 18 -- src/middlewares/async.js | 19 -- src/middlewares/logger.js | 44 --- test/combineReducers.spec.js | 121 ++++++++ test/components/Region.spec.js | 9 +- test/components/mapToProps.spec.js | 13 +- test/createApp.spec.js | 9 +- test/createStore.spec.js | 431 ++++++++++++++++++++++++++ test/index.spec.js | 2 +- test/middlewares/appendAction.spec.js | 37 --- test/middlewares/async.js | 58 ---- test/middlewares/logger.spec.js | 126 -------- test/server/renderToString.spec.js | 1 + 20 files changed, 770 insertions(+), 357 deletions(-) create mode 100644 .alexignore create mode 100644 .alexrc create mode 100644 src/createStore.js delete mode 100644 src/middlewares/appendAction.js delete mode 100644 src/middlewares/async.js delete mode 100644 src/middlewares/logger.js create mode 100644 test/combineReducers.spec.js create mode 100644 test/createStore.spec.js delete mode 100644 test/middlewares/appendAction.spec.js delete mode 100644 test/middlewares/async.js delete mode 100644 test/middlewares/logger.spec.js diff --git a/.alexignore b/.alexignore new file mode 100644 index 00000000..a76c37c3 --- /dev/null +++ b/.alexignore @@ -0,0 +1,2 @@ +node_modules +src/createStore.js diff --git a/.alexrc b/.alexrc new file mode 100644 index 00000000..909cc7d9 --- /dev/null +++ b/.alexrc @@ -0,0 +1,5 @@ +{ + "allow": [ + "destroy" + ] +} diff --git a/package.json b/package.json index 260dab94..368ab1d6 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,6 @@ "lodash": "^4.13.1", "react": "^0.14.8", "react-dom": "^0.14.8", - "redux": "^3.5.2", "rxjs": "^5.0.1" }, "devDependencies": { diff --git a/src/combineReducers.js b/src/combineReducers.js index 81045a78..03554650 100644 --- a/src/combineReducers.js +++ b/src/combineReducers.js @@ -1,3 +1,40 @@ -import { combineReducers } from 'redux'; +/* eslint-disable prefer-template */ +export default function combineReducers(reducers, options = {}) { + const keys = Object.keys(reducers); + const opts = { + console: console, + ...options, + }; -export default combineReducers; + return function rootReducer(state = {}, action) { + let changed = false; + + const fullStateTree = {}; + keys.forEach(function processReducer(key) { + const reducer = reducers[key]; + const previousState = state[key]; + let updatedState; + + try { + updatedState = reducer(previousState, action); + } catch (reducerError) { + opts.console.error('Reducer for key `' + key + '` threw an error:'); + throw reducerError; + } + + if (typeof updatedState === 'undefined') { + throw new Error('Reducer for key `' + key + '` returned `undefined`'); + } + + fullStateTree[key] = updatedState; + + if (changed === true || updatedState !== previousState) { + changed = true; + } + }); + + return changed + ? fullStateTree + : state; + }; +} diff --git a/src/createApp.js b/src/createApp.js index e45918d2..36c3a455 100644 --- a/src/createApp.js +++ b/src/createApp.js @@ -1,11 +1,9 @@ /* eslint-disable no-console, no-underscore-dangle */ /* globals window */ import { Subject } from 'rxjs'; -import { createStore, applyMiddleware, compose } from 'redux'; import _ from 'lodash'; -import createAppendActionMiddleware from './middlewares/appendAction'; -import createAsyncMiddleware from './middlewares/async'; +import createStore from './createStore'; import Provider from './components/Provider'; import h from './h'; @@ -70,17 +68,6 @@ class BaseApp { ); this.readableApps = []; - - // state$ - this._storeSubscription = null; - const store = this._getStore(); - const state$ = new Subject(); - - this._storeSubscription = store.subscribe(() => { - state$.next(store.getState()); - }); - - this.state$ = state$.startWith(store.getState()); } getRootApp() { @@ -131,29 +118,16 @@ class BaseApp { } _createStore(rootReducer, initialState = {}) { - const middlewares = [ - createAsyncMiddleware({ app: this }), - createAppendActionMiddleware({ - key: 'appName', - value: this.getOption('name') - }) - ]; - - if (process.env.NODE_ENV !== 'production') { - if (this.getOption('enableLogger') === true) { - const createLogger = require('./middlewares/logger'); // eslint-disable-line - - middlewares.push(createLogger()); - } - } - - this.options.store = createStore( - rootReducer, + const Store = createStore({ + reducer: rootReducer, initialState, - compose( - applyMiddleware(...middlewares) - ) - ); + enableLogger: this.options.enableLogger, + thunkArgument: { app: this }, + appendAction: { + appName: this.options.name, + }, + }); + this.options.store = new Store(); return this.options.store; } @@ -208,7 +182,7 @@ class BaseApp { return null; } - return app.state$; + return app.options.store.getState$(); } dispatch(action) { @@ -250,10 +224,7 @@ class BaseApp { beforeUnmount() { const output = this.options.beforeUnmount.bind(this)(); - - if (typeof this._storeSubscription === 'function') { - this._storeSubscription(); - } + this.options.store.destroy(); return output; } diff --git a/src/createStore.js b/src/createStore.js new file mode 100644 index 00000000..f78dd81e --- /dev/null +++ b/src/createStore.js @@ -0,0 +1,135 @@ +/* eslint-disable no-console */ +import _ from 'lodash'; +import { BehaviorSubject } from 'rxjs'; + +class BaseStore { + constructor(options = {}) { + this.options = { + initialState: null, + thunkArgument: null, + appendAction: false, + reducer: state => state, + enableLogger: true, + console: console, + ...options, + }; + + this.internalState$ = new BehaviorSubject(this.options.initialState) + .scan((previousState, action) => { + let updatedState; + const d = new Date(); + const prettyDate = [ + _.padStart(d.getHours(), 2, 0), + ':', + _.padStart(d.getMinutes(), 2, 0), + ':', + _.padStart(d.getSeconds(), 2, 0), + '.', + _.padStart(d.getMilliseconds(), 3, 0) + ].join(''); + + try { + updatedState = this.options.reducer(previousState, action); + } catch (error) { + if (action && action.type) { + this.options.console.error(`Error processing @ ${prettyDate} ${action.type}:`); + } + this.options.console.error(error); + + return previousState; + } + + // logger in non-production mode only + if (process.env.NODE_ENV !== 'production') { + if (this.options.enableLogger === true) { + const groupName = `action @ ${prettyDate} ${action.type}`; + + if (typeof this.options.console.group === 'function') { + this.options.console.group(groupName); + } + + this.options.console.log('%cprevious state', 'color: #9e9e9e; font-weight: bold;', previousState); + this.options.console.log('%caction', 'color: #33c3f0; font-weight: bold;', action); + this.options.console.log('%ccurrent state', 'color: #4cAf50; font-weight: bold;', updatedState); + + if (typeof this.options.console.groupEnd === 'function') { + this.options.console.groupEnd(); + } + } + } + + return updatedState; + }); + this.exposedState$ = new BehaviorSubject(); + + this.cachedState = Object.assign({}, this.options.initialState); + this.subscription = this.internalState$ + .subscribe((state) => { + this.cachedState = state; + this.exposedState$.next(state); + }); + + this.dispatch({ type: '__FRINT_INIT__' }); + } + + getState$() { + return this.exposedState$; + } + + getState = () => { + this.options.console.warn('[DEPRECATED] `Store.getState` has been deprecated, and kept for consistency purpose only with v0.x'); + + return this.cachedState; + } + + dispatch = (action) => { + if (typeof action === 'function') { + return action( + this.dispatch, + this.getState, + this.options.thunkArgument + ); + } + + const payload = ( + this.options.appendAction && + _.isPlainObject(this.options.appendAction) + ) + ? { ...this.options.appendAction, ...action } + : action; + + return this.internalState$.next(payload); + } + + subscribe(callback) { + this.options.console.warn('[DEPRECATED] `Store.subscribe` has been deprecated, and kept for consistency purpose only with v0.x'); + + const subscription = this.getState$() + .subscribe((state) => { + callback(state); + }); + + return function unsubscribe() { + subscription.unsubscribe(); + }; + } + + destroy() { + if (this.subscription) { + this.subscription.unsubscribe(); + } + } +} + +export default function createStore(options = {}) { + class Store extends BaseStore { + constructor(opts = {}) { + super(_.merge( + options, + opts + )); + } + } + + return Store; +} diff --git a/src/index.js b/src/index.js index 3bf53871..76010382 100644 --- a/src/index.js +++ b/src/index.js @@ -9,6 +9,7 @@ import Model from './Model'; import PropTypes from './PropTypes'; import Region from './components/Region'; import render from './render'; +import createStore from './createStore'; import isObservable from './utils/isObservable'; import h from './h'; @@ -19,6 +20,7 @@ export default { createFactory, createModel, createService, + createStore, mapToProps, Model, PropTypes, diff --git a/src/middlewares/appendAction.js b/src/middlewares/appendAction.js deleted file mode 100644 index 1fb7cd06..00000000 --- a/src/middlewares/appendAction.js +++ /dev/null @@ -1,18 +0,0 @@ -export default function (options = {}) { - const opts = { - key: null, - value: null, - ...options, - }; - - return store => next => action => { // eslint-disable-line - if (!opts.key) { - return next(action); - } - - return next({ - ...action, - [opts.key]: opts.value - }); - }; -} diff --git a/src/middlewares/async.js b/src/middlewares/async.js deleted file mode 100644 index e25c37fb..00000000 --- a/src/middlewares/async.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * ES6 version of: - * https://github.com/gaearon/redux-thunk - */ -export default function (...args) { - return (store) => { - const { dispatch, getState } = store; - - return (next) => { - return (action) => { - if (typeof action !== 'function') { - return next(action); - } - - return action(dispatch, getState, ...args); - }; - }; - }; -} diff --git a/src/middlewares/logger.js b/src/middlewares/logger.js deleted file mode 100644 index 9d85a134..00000000 --- a/src/middlewares/logger.js +++ /dev/null @@ -1,44 +0,0 @@ -/* eslint-disable no-console */ -export default function (options = {}) { - const opts = { - throwError: true, - console: console, - ...options, - }; - - return store => next => action => { // eslint-disable-line - let nextAction; - let error; - - const prevState = store.getState(); - - try { - nextAction = next({ - ...action, - }); - } catch (e) { - error = e; - } - - if (opts.throwError && error) { - throw error; - } - - const d = new Date(); - const groupName = `action @ ${d.getHours()}:${d.getMinutes()}:${d.getSeconds()}.${d.getMilliseconds()} ${action.type}`; - - if (typeof opts.console.group === 'function') { - opts.console.group(groupName); - } - - opts.console.log('%cprevious state', 'color: #9e9e9e; font-weight: bold;', prevState); - opts.console.log('%caction', 'color: #33c3f0; font-weight: bold;', action); - opts.console.log('%ccurrent state', 'color: #4cAf50; font-weight: bold;', store.getState()); - - if (typeof opts.console.group === 'function') { - opts.console.groupEnd(); - } - - return nextAction; - }; -} diff --git a/test/combineReducers.spec.js b/test/combineReducers.spec.js new file mode 100644 index 00000000..3b565adc --- /dev/null +++ b/test/combineReducers.spec.js @@ -0,0 +1,121 @@ +/* global describe, it */ +import { expect } from 'chai'; + +import { + combineReducers +} from '../src'; + +describe('combineReducers', function () { + function counterReducer(state = { value: 0 }, action) { + switch (action.type) { + case 'INCREMENT_COUNTER': + return Object.assign({}, { + value: state.value + 1 + }); + case 'DECREMENT_COUNTER': + return Object.assign({}, { + value: state.value - 1 + }); + default: + return state; + } + } + + function colorReducer(state = { value: 'blue' }, action) { + switch (action.type) { + case 'SET_COLOR': + return Object.assign({}, { + value: action.color + }); + default: + return state; + } + } + + function buggyReducer(state = {}, action) { + switch (action.type) { + case 'DO_ERROR': + throw new Error('I am an Error from buggy'); + case 'DO_UNDEFINED': + return undefined; + default: + return state; + } + } + + it('combines multiple reducers', function () { + const rootReducer = combineReducers({ + counter: counterReducer, + color: colorReducer + }); + + const initialState = { + counter: { value: 100 }, + color: { value: 'red' } + }; + + const states = []; + states.push(rootReducer(initialState, { type: 'DO_NOTHING' })); + + states.push(rootReducer(states[states.length - 1], { type: 'INCREMENT_COUNTER' })); + states.push(rootReducer(states[states.length - 1], { type: 'INCREMENT_COUNTER' })); + states.push(rootReducer(states[states.length - 1], { type: 'DECREMENT_COUNTER' })); + states.push(rootReducer(states[states.length - 1], { type: 'SET_COLOR', color: 'blue' })); + + expect(states).to.deep.equal([ + { counter: { value: 100 }, color: { value: 'red' } }, + { counter: { value: 101 }, color: { value: 'red' } }, + { counter: { value: 102 }, color: { value: 'red' } }, + { counter: { value: 101 }, color: { value: 'red' } }, + { counter: { value: 101 }, color: { value: 'blue' } } + ]); + }); + + it('throws error with reducer key name, when individual reducer errors', function () { + const consoleCalls = []; + const fakeConsole = { + error(...args) { + consoleCalls.push({ + method: 'error', + args + }); + } + }; + + const rootReducer = combineReducers({ + counter: counterReducer, + color: colorReducer, + buggy: buggyReducer, + }, { + console: fakeConsole + }); + + const initialState = { + counter: { value: 100 }, + color: { value: 'red' } + }; + + const states = []; + states.push(rootReducer(initialState, { type: '__INITIAL__' })); + + states.push(rootReducer(states[states.length - 1], { type: 'INCREMENT_COUNTER' })); + states.push(rootReducer(states[states.length - 1], { type: 'SET_COLOR', color: 'blue' })); + + expect(states).to.deep.equal([ + { counter: { value: 100 }, color: { value: 'red' }, buggy: {} }, + { counter: { value: 101 }, color: { value: 'red' }, buggy: {} }, + { counter: { value: 101 }, color: { value: 'blue' }, buggy: {} } + ]); + + expect(() => rootReducer(states[states.length - 1], { type: 'DO_ERROR' })) + .to.throw(/I am an Error from buggy/); + expect(consoleCalls.length).to.equal(1); + expect(consoleCalls[0]).to.deep.equal({ + method: 'error', + args: ['Reducer for key `buggy` threw an error:'] + }); + + expect(() => rootReducer(states[states.length - 1], { type: 'DO_UNDEFINED' })) + .to.throw(/Reducer for key `buggy` returned `undefined`/); + }); +}); diff --git a/test/components/Region.spec.js b/test/components/Region.spec.js index a64b111c..b2c2aabd 100644 --- a/test/components/Region.spec.js +++ b/test/components/Region.spec.js @@ -23,7 +23,8 @@ describe('components › Region', () => { const MyCoreApp = createApp({ name: 'myAppName', - component: MyCoreComponent + component: MyCoreComponent, + enableLogger: false, }); return new MyCoreApp(appOptions); @@ -37,7 +38,8 @@ describe('components › Region', () => { const MyWidgetApp = createApp({ appName: appName, name: widgetName, - component: MyWidgetComponent + component: MyWidgetComponent, + enableLogger: false, }); const myWidgetAppInstance = new MyWidgetApp(); @@ -236,7 +238,8 @@ describe('components › Region', () => { const BarApp = createApp({ name: 'testBar', - component: BarRootComponent + component: BarRootComponent, + enableLogger: false, }); it('unmounts widget if Region is removed', function () { diff --git a/test/components/mapToProps.spec.js b/test/components/mapToProps.spec.js index e4b18ed7..2a7f616f 100644 --- a/test/components/mapToProps.spec.js +++ b/test/components/mapToProps.spec.js @@ -113,7 +113,8 @@ describe('components › mapToProps', function () { baz: { name: 'TestBazModel' } - } + }, + enableLogger: false, }); it('creates container from component', function () { @@ -303,6 +304,7 @@ describe('components › mapToProps', function () { document.querySelector('#root .setByStepAsync').click(); // 15 document.querySelector('#root .setByStepAsync').click(); // 20 + expect(document.querySelector('#root .counter').innerHTML).to.equal('20'); }); }); @@ -322,7 +324,8 @@ describe('components › mapToProps', function () { const CoreApp = createApp({ name: 'TestCore', - component: CoreComponent + component: CoreComponent, + enableLogger: false, }); // Widget #1: Foo @@ -410,7 +413,8 @@ describe('components › mapToProps', function () { const BarApp = createApp({ name: 'testBar', - component: BarRootComponent + component: BarRootComponent, + enableLogger: false, }); it('renders Widget Bar, with Foo\'s initial state', function () { @@ -531,7 +535,8 @@ describe('components › mapToProps', function () { const TestApp = createApp({ name: 'TestCore', - component: TestRootComponent + component: TestRootComponent, + enableLogger: false, }); it('maps Observable values to props', function () { diff --git a/test/createApp.spec.js b/test/createApp.spec.js index c599b7dd..5b0f85b9 100644 --- a/test/createApp.spec.js +++ b/test/createApp.spec.js @@ -30,17 +30,20 @@ describe('createApp', function () { const CoreApp = createApp({ name: 'CoreAppName', - component: true + component: true, + enableLogger: false, }); const WidgetApp = createApp({ name: 'WidgetAppName', - component: true + component: true, + enableLogger: false, }); const SecondWidgetApp = createApp({ name: 'SecondWidgetAppName', - component: true + component: true, + enableLogger: false, }); beforeEach(function () { diff --git a/test/createStore.spec.js b/test/createStore.spec.js new file mode 100644 index 00000000..7e75df75 --- /dev/null +++ b/test/createStore.spec.js @@ -0,0 +1,431 @@ +/* global describe, it */ +import { expect } from 'chai'; + +import { + createStore, + combineReducers, +} from '../src'; + +describe('createStore', function () { + it('returns function', function () { + const Store = createStore(); + expect(Store).to.be.a('function'); + }); + + it('returns initial state upon subscription', function (done) { + const Store = createStore(); + const store = new Store({ + enableLogger: false, + initialState: { + ok: true, + } + }); + + const subscription = store.getState$() + .subscribe(function (state) { + expect(state).to.deep.equal({ + ok: true, + }); + + done(); + }); + + subscription.unsubscribe(); + }); + + it('dispatches actions, that update state', function () { + const Store = createStore({ + enableLogger: false, + initialState: { + counter: 0, + }, + reducer: function (state, action) { + switch (action.type) { + case 'INCREMENT_COUNTER': + return Object.assign({}, { + counter: state.counter + 1 + }); + case 'DECREMENT_COUNTER': + return Object.assign({}, { + counter: state.counter - 1 + }); + default: + return state; + } + } + }); + const store = new Store(); + + const states = []; + const subscription = store.getState$() + .subscribe(function (state) { + states.push(state); + }); + + store.dispatch({ type: 'INCREMENT_COUNTER' }); + store.dispatch({ type: 'INCREMENT_COUNTER' }); + store.dispatch({ type: 'DECREMENT_COUNTER' }); + + expect(states.length).to.equal(4); // 1 initial + 3 dispatches + + const lastState = states[states.length - 1]; + expect(lastState).to.deep.equal({ + counter: 1 + }); + + const synchronousState = store.getState(); + expect(synchronousState).to.deep.equal({ + counter: 1 + }); + + subscription.unsubscribe(); + }); + + it('appends to action payload', function () { + const actions = []; + const Store = createStore({ + enableLogger: false, + appendAction: { + appName: 'Blah', + }, + initialState: { + counter: 0, + }, + reducer: function (state, action) { + actions.push(action); + + return state; + } + }); + + const store = new Store(); + + const states = []; + const subscription = store.getState$() + .subscribe(function (state) { + states.push(state); + }); + + store.dispatch({ type: 'INCREMENT_COUNTER' }); + + expect(actions).to.deep.equal([ + { appName: 'Blah', type: '__FRINT_INIT__' }, + { appName: 'Blah', type: 'INCREMENT_COUNTER' }, + ]); + + subscription.unsubscribe(); + }); + + it('dispatches async actions, with thunk argument', function () { + const actions = []; + const Store = createStore({ + enableLogger: false, + thunkArgument: { foo: 'bar' }, + initialState: { + counter: 0, + }, + reducer: function (state, action) { + actions.push(action); + + switch (action.type) { + case 'INCREMENT_COUNTER': + return Object.assign({}, { + counter: state.counter + 1 + }); + case 'DECREMENT_COUNTER': + return Object.assign({}, { + counter: state.counter - 1 + }); + default: + return state; + } + } + }); + const store = new Store(); + + const states = []; + const subscription = store.getState$() + .subscribe(function (state) { + states.push(state); + }); + + store.dispatch({ type: 'INCREMENT_COUNTER' }); + store.dispatch(function (dispatch, getState, thunkArg) { + dispatch({ + type: 'INCREMENT_COUNTER', + thunkArg + }); + }); + store.dispatch({ type: 'DECREMENT_COUNTER' }); + + expect(actions).to.deep.equal([ + { type: '__FRINT_INIT__' }, + { type: 'INCREMENT_COUNTER' }, + { type: 'INCREMENT_COUNTER', thunkArg: { foo: 'bar' } }, + { type: 'DECREMENT_COUNTER' }, + ]); + + expect(states.length).to.equal(4); + expect(states).to.deep.equal([ + { counter: 0 }, + { counter: 1 }, + { counter: 2 }, + { counter: 1 }, + ]); + + subscription.unsubscribe(); + }); + + it('subscribes with callback function', function () { + const Store = createStore({ + enableLogger: false, + thunkArgument: { foo: 'bar' }, + initialState: { + counter: 0, + }, + reducer: function (state, action) { + switch (action.type) { + case 'INCREMENT_COUNTER': + return Object.assign({}, { + counter: state.counter + 1 + }); + case 'DECREMENT_COUNTER': + return Object.assign({}, { + counter: state.counter - 1 + }); + default: + return state; + } + } + }); + const store = new Store(); + + const states = []; + const unsubscribe = store.subscribe(function (state) { + states.push(state); + }); + + store.dispatch({ type: 'INCREMENT_COUNTER' }); + store.dispatch({ type: 'INCREMENT_COUNTER' }); + store.dispatch({ type: 'DECREMENT_COUNTER' }); + + expect(states.length).to.equal(4); // 1 initial + 3 dispatches + expect(states).to.deep.equal([ + { counter: 0 }, + { counter: 1 }, + { counter: 2 }, + { counter: 1 }, + ]); + + unsubscribe(); + + store.dispatch({ type: 'INCREMENT_COUNTER' }); + expect(states.length).to.equal(4); // no more triggers + }); + + it('destroys internal subscription', function () { + const Store = createStore({ + enableLogger: false, + initialState: { + counter: 0 + } + }); + const store = new Store(); + + let changesCount = 0; + const subscription = store.getState$() + .subscribe(function () { + changesCount += 1; + }); + + store.dispatch({ type: 'DO_SOMETHING' }); + expect(changesCount).to.equal(2); // 1 initial + 1 dispatch + + store.destroy(); + + store.dispatch({ type: 'DO_SOMETHING_IGNORED' }); + expect(changesCount).to.equal(2); // will stop at 2 + + subscription.unsubscribe(); + }); + + it('logs state changes', function () { + const consoleCalls = []; + const fakeConsole = { + group() { }, + groupEnd() { }, + log(...args) { + consoleCalls.push({ method: 'log', args }); + }, + error(...args) { + consoleCalls.push({ method: 'error', args }); + } + }; + + const Store = createStore({ + enableLogger: true, + console: fakeConsole, + initialState: { + counter: 0, + }, + reducer: function (state, action) { + switch (action.type) { + case 'INCREMENT_COUNTER': + return Object.assign({}, { + counter: state.counter + 1 + }); + case 'DECREMENT_COUNTER': + return Object.assign({}, { + counter: state.counter - 1 + }); + default: + return state; + } + } + }); + const store = new Store(); + + const states = []; + const subscription = store.getState$() + .subscribe(function (state) { + states.push(state); + }); + + store.dispatch({ type: 'INCREMENT_COUNTER' }); + store.dispatch({ type: 'INCREMENT_COUNTER' }); + store.dispatch({ type: 'DECREMENT_COUNTER' }); + + expect(states.length).to.equal(4); // 1 initial + 3 dispatches + expect(states).to.deep.equal([ + { counter: 0 }, + { counter: 1 }, + { counter: 2 }, + { counter: 1 }, + ]); + + expect(consoleCalls.length).to.equal(12); // (1 init + 3 actions) * 3 logs (prev + action + current) + expect(consoleCalls[3].args[2]).to.deep.equal({ counter: 0 }); // prev + expect(consoleCalls[4].args[2]).to.deep.equal({ type: 'INCREMENT_COUNTER' }); // action + expect(consoleCalls[5].args[2]).to.deep.equal({ counter: 1 }); // action + + subscription.unsubscribe(); + }); + + it('logs errors from reducers', function () { + const consoleCalls = []; + const fakeConsole = { + group() { }, + groupEnd() { }, + log(...args) { + consoleCalls.push({ method: 'log', args }); + }, + error(...args) { + consoleCalls.push({ method: 'error', args }); + } + }; + + const Store = createStore({ + enableLogger: true, + console: fakeConsole, + initialState: { + counter: 0, + }, + reducer: function (state, action) { + switch (action.type) { + case 'DO_SOMETHING': + throw new Error('Something went wrong...'); + default: + return state; + } + } + }); + const store = new Store(); + + const subscription = store.getState$() + .subscribe(() => {}); + + store.dispatch({ type: 'DO_SOMETHING' }); + + expect(consoleCalls.length).to.equal(5); // 3 init + 2 errors + + expect(consoleCalls[3].method).to.equal('error'); + expect(consoleCalls[3].args[0]).to.exist + .and.to.contain('Error processing @') + .and.to.contain('DO_SOMETHING'); + + expect(consoleCalls[4].method).to.equal('error'); + expect(consoleCalls[4].args[0]).to.exist + .and.be.instanceof(Error) + .and.have.property('message', 'Something went wrong...'); + + subscription.unsubscribe(); + }); + + it('handles combined reducers', function () { + function counterReducer(state = { value: 0 }, action) { + switch (action.type) { + case 'INCREMENT_COUNTER': + return Object.assign({}, { + value: state.value + 1 + }); + case 'DECREMENT_COUNTER': + return Object.assign({}, { + value: state.value - 1 + }); + default: + return state; + } + } + + function colorReducer(state = { value: 'blue' }, action) { + switch (action.type) { + case 'SET_COLOR': + return Object.assign({}, { + value: action.color + }); + default: + return state; + } + } + + const rootReducer = combineReducers({ + counter: counterReducer, + color: colorReducer, + }); + + const Store = createStore({ + enableLogger: false, + initialState: { + counter: { + value: 100, + }, + color: { + value: 'red' + } + }, + reducer: rootReducer, + }); + const store = new Store(); + + const states = []; + const subscription = store.getState$() + .subscribe((state) => { + states.push(state); + }); + + store.dispatch({ type: 'INCREMENT_COUNTER' }); + store.dispatch({ type: 'INCREMENT_COUNTER' }); + store.dispatch({ type: 'DECREMENT_COUNTER' }); + store.dispatch({ type: 'SET_COLOR', color: 'green' }); + + expect(states).to.deep.equal([ + { counter: { value: 100 }, color: { value: 'red' } }, // initial + { counter: { value: 101 }, color: { value: 'red' } }, // INCREMENT_COUNTER + { counter: { value: 102 }, color: { value: 'red' } }, // INCREMENT_COUNTER + { counter: { value: 101 }, color: { value: 'red' } }, // DECREMENT_COUNTER + { counter: { value: 101 }, color: { value: 'green' } } // SET_COLOR + ]); + + subscription.unsubscribe(); + }); +}); diff --git a/test/index.spec.js b/test/index.spec.js index 06e7b1da..0dbb8f7f 100644 --- a/test/index.spec.js +++ b/test/index.spec.js @@ -8,7 +8,7 @@ import frint from '../src'; describe('index', function () { const publicModules = { - combineReducers: require('redux').combineReducers, + combineReducers: require('../src/combineReducers'), createApp: require('../src/createApp'), createComponent: require('../src/createComponent'), createFactory: require('../src/createFactory'), diff --git a/test/middlewares/appendAction.spec.js b/test/middlewares/appendAction.spec.js deleted file mode 100644 index c081087d..00000000 --- a/test/middlewares/appendAction.spec.js +++ /dev/null @@ -1,37 +0,0 @@ -/* global describe, it */ -import { expect } from 'chai'; -import appendAction from '../../src/middlewares/appendAction'; - -describe('middlewares › appendAction', function () { - it('appends key/value pair to action payload', function (done) { - const middleware = appendAction({ - key: 'customKey', - value: 'appended value' - }); - - const action = { foo: 'bar' }; - - middleware()(function (updatedAction) { - expect(updatedAction).to.deep.equal({ - foo: 'bar', - customKey: 'appended value' - }); - - done(); - })(action); - }); - - it('does not do anything, if no key is provided', function (done) { - const middleware = appendAction(); - - const action = { foo: 'bar' }; - - middleware()(function (updatedAction) { - expect(updatedAction).to.deep.equal({ - foo: 'bar' - }); - - done(); - })(action); - }); -}); diff --git a/test/middlewares/async.js b/test/middlewares/async.js deleted file mode 100644 index 7119d960..00000000 --- a/test/middlewares/async.js +++ /dev/null @@ -1,58 +0,0 @@ -/* global describe, it */ -import { expect } from 'chai'; -import createAsyncMiddleware from '../../src/middlewares/async'; - -describe('middlewares › async', function () { - it('returns a function', function () { - expect(createAsyncMiddleware()).to.be.a('function'); - }); - - it('dispatches function when returned from an action creator', function (done) { - const fakeStore = { - dispatch(payload) { - if ( - payload.type === 'SECOND_ACTION' && - payload.value === 20 - ) { - done(); - } - }, - - getState() { - return { - counter: 10 - }; - } - }; - - const fakeAppOptions = { - name: 'FakeApp' - }; - - const fakeApp = { - getOption(key) { - return fakeAppOptions[key]; - } - }; - - function actualIncrementAction(step) { - expect(step).to.equal(5); - - done(); - } - - function incrementActionAsync(step) { - return (dispatch, getState, { app }) => { - if (app.getOption('name') === 'FakeApp') { - return dispatch(actualIncrementAction(step)); - } - - return null; - }; - } - - const middleware = createAsyncMiddleware({ app: fakeApp }); - - middleware(fakeStore)()(incrementActionAsync(5)); - }); -}); diff --git a/test/middlewares/logger.spec.js b/test/middlewares/logger.spec.js deleted file mode 100644 index 941d1323..00000000 --- a/test/middlewares/logger.spec.js +++ /dev/null @@ -1,126 +0,0 @@ -/* global describe, it */ -import { expect } from 'chai'; - -import createLoggerMiddleware from '../../src/middlewares/logger'; - -describe('middlewares › logger', function () { - it('returns a function', function () { - expect(createLoggerMiddleware()).to.be.a('function'); - }); - - it('logs dispatched actions', function () { - let consoleCallCount = 0; - const capturedLogs = []; - - const fakeConsole = { - group() { - consoleCallCount += 1; - }, - groupEnd() { - consoleCallCount += 1; - }, - log(...args) { - consoleCallCount += 1; - - capturedLogs.push(args.map(function (item) { - if (typeof item === 'string') { - return item; - } - - return Object.assign({}, item); - })); - } - }; - - const fakeState = { - counter: { - value: 0 - } - }; - - const fakeStore = { - dispatch(action) { - switch (action.type) { - case 'INCREMENT_COUNTER': - fakeState.counter.value += 1; - return fakeState; - - case 'DECREMENT_COUNTER': - fakeState.counter.value -= 1; - return fakeState; - - default: - return fakeState; - } - }, - - getState() { - return fakeState; - } - }; - - const fakeNext = function (action) { - fakeStore.dispatch(action); - }; - - const middleware = createLoggerMiddleware({ console: fakeConsole }); - const action = { type: 'INCREMENT_COUNTER' }; - - middleware(fakeStore)(fakeNext)(action); - expect(consoleCallCount).to.equal(5); - - // previous state - expect(capturedLogs[0][2], { - counter: { - value: 0 - } - }); - - // action - expect(capturedLogs[1][2], { - type: 'INCREMENT_COUNTER' - }); - - // next state - expect(capturedLogs[2][2], { - counter: { - value: 1 - } - }); - }); - - it('throws error if action throws error', function () { - const fakeConsole = { - log() { } - }; - - const fakeState = {}; - - const fakeStore = { - dispatch(action) { - switch (action.type) { - case 'INCREMENT_COUNTER': - throw new Error('I am buggy'); - - default: - return fakeState; - } - }, - - getState() { - return fakeState; - } - }; - - const fakeNext = function (action) { - fakeStore.dispatch(action); - }; - - const middleware = createLoggerMiddleware({ console: fakeConsole }); - const action = { type: 'INCREMENT_COUNTER' }; - - expect(function () { - middleware(fakeStore)(fakeNext)(action); - }).to.throw(/I am buggy/); - }); -}); diff --git a/test/server/renderToString.spec.js b/test/server/renderToString.spec.js index a141cab3..76d1fc7b 100644 --- a/test/server/renderToString.spec.js +++ b/test/server/renderToString.spec.js @@ -27,6 +27,7 @@ describe('server › renderToString', function () { const TestApp = createApp({ name: 'TestAppname', component: TestComponent, + enableLogger: false, }); const app = new TestApp();