UNPKG

@oqton/redux-black-box

Version:

Declare side effects as black boxes in redux: an alternative for redux-thunk, redux-saga, redux-loop, ...

714 lines (642 loc) 22.9 kB
import { Promise } from 'bluebird'; import { createStore, applyMiddleware } from 'redux'; import { every } from 'lodash'; import { PromiseBlackBox, ReduxBlackBox, AbstractBlackBox, AsyncBlackBox, blackBoxMiddleware } from '../lib'; Promise.config({ // Enable cancellation cancellation: true, }); global.console.assert = (check, msg) => { if (!check) throw new Error(msg || 'AssertionError'); }; const delay = ms => new Promise(res => setTimeout(res, ms)); describe('base black boxes tests', () => { describe('promise black boxes', () => { it('can handle a promise black box', async () => { const reducer = (state = { state: 'STATE0' }, action) => { switch (action.type) { case 'TRANSITION1': return { state: 'STATE1', blackBox: new PromiseBlackBox(() => Promise.delay(100) .then(() => ({ type: 'TRANSITION2' }))) }; case 'TRANSITION2': return { state: 'STATE2', }; default: return state; } }; const store = createStore(reducer, undefined, applyMiddleware(blackBoxMiddleware)); expect(store.getState().state).toBe('STATE0'); store.dispatch({ type: 'TRANSITION1' }); expect(store.getState().state).toBe('STATE1'); await Promise.delay(200); expect(store.getState().state).toBe('STATE2'); }); it('can handle a promise black box that returns no promise', async () => { const reducer = (state = { state: 'STATE0' }, action) => { switch (action.type) { case 'TRANSITION1': return { state: 'STATE1', blackBox: new PromiseBlackBox(() => ({ type: 'TRANSITION2' })) }; case 'TRANSITION2': return { state: 'STATE2', }; default: return state; } }; const store = createStore(reducer, undefined, applyMiddleware(blackBoxMiddleware)); expect(store.getState().state).toBe('STATE0'); store.dispatch({ type: 'TRANSITION1' }); expect(store.getState().state).toBe('STATE1'); await Promise.delay(200); expect(store.getState().state).toBe('STATE2'); }); it('can handle cancellation', async () => { const reducer = (state = { state: 'STATE0' }, action) => { switch (action.type) { case 'TRANSITION1': return { state: 'STATE1', blackBox: new PromiseBlackBox(() => Promise.delay(100) .then(() => ({ type: 'TRANSITION2' }))) }; case 'TRANSITION2': return { state: 'STATE2', blackBox: null }; case 'TRANSITION3': return { state: 'STATE3', blackBox: null }; default: return state; } }; const store = createStore(reducer, undefined, applyMiddleware(blackBoxMiddleware)); expect(store.getState().state).toBe('STATE0'); store.dispatch({ type: 'TRANSITION1' }); expect(store.getState().state).toBe('STATE1'); store.dispatch({ type: 'TRANSITION3' }); await Promise.delay(200); expect(store.getState().state).toBe('STATE3'); }); it('can handle many black boxes', async () => { const reducer = (state = { state: 'STATE0', cnt: 0 }, action) => { switch (action.type) { case 'TRANSITION1': return { ...state, state: 'STATE1', subStates: action.urns.map((urn, i) => new PromiseBlackBox(() => Promise.delay(10) .then(() => ({ type: 'TRANSITION2' })))) }; case 'TRANSITION2': console.assert(state.state === 'STATE1', `Unexpected state: ${state.state}`); return { ...state, cnt: state.cnt + 1 }; default: return state; } }; const store = createStore(reducer, undefined, applyMiddleware(blackBoxMiddleware)); expect(store.getState().state).toBe('STATE0'); store.dispatch({ type: 'TRANSITION1', urns: ['abc', 'def', 'def', 'def', 'def'] }); await Promise.delay(11); expect(store.getState().state).toBe('STATE1'); expect(store.getState().subStates.length).toBe(5); expect(store.getState().cnt).toBe(5); }); }); describe('redux black box', () => { it('can handle a simple redux black box', async () => { const reducer = (state = { state: 'STATE0' }, action) => { switch (action.type) { case 'TRANSITION1': return { state: 'STATE1', blackBox: new ReduxBlackBox( { type: 'TRANSITION2' }, { type: 'TRANSITION3' } ) }; case 'TRANSITION2': console.assert(state.state === 'STATE1'); return { ...state, state: 'STATE2' }; case 'TRANSITION3': console.assert(state.state === 'STATE2'); return { ...state, state: 'STATE3' }; default: return state; } }; const store = createStore(reducer, undefined, applyMiddleware(blackBoxMiddleware)); expect(store.getState().state).toBe('STATE0'); store.dispatch({ type: 'TRANSITION1' }); await Promise.delay(1); expect(store.getState().state).toBe('STATE3'); }); it('can handle a simple redux black box without after action', async () => { const reducer = (state = { state: 'STATE0' }, action) => { switch (action.type) { case 'TRANSITION1': return { state: 'STATE1', blackBox: new ReduxBlackBox( { type: 'TRANSITION2' } ) }; case 'TRANSITION2': console.assert(state.state === 'STATE1'); return { ...state, state: 'STATE2' }; case 'TRANSITION3': console.assert(state.state === 'STATE2'); return { ...state, state: 'STATE3' }; default: return state; } }; const store = createStore(reducer, undefined, applyMiddleware(blackBoxMiddleware)); expect(store.getState().state).toBe('STATE0'); store.dispatch({ type: 'TRANSITION1' }); await Promise.delay(1); expect(store.getState().state).toBe('STATE2'); }); it('can handle many redux black boxes', async () => { const reducer = (state = { state: 'STATE0', cnt: 0 }, action) => { switch (action.type) { case 'TRANSITION1': return { ...state, state: 'STATE1', subStates: action.urns.map((urn, i) => new ReduxBlackBox({ type: 'DUMMY' }, { type: 'TRANSITION2' })) }; case 'TRANSITION2': console.assert(state.state === 'STATE1', `Unexpected state: ${state.state}`); return { ...state, cnt: state.cnt + 1 }; default: return state; } }; const store = createStore(reducer, undefined, applyMiddleware(blackBoxMiddleware)); expect(store.getState().state).toBe('STATE0'); store.dispatch({ type: 'TRANSITION1', urns: ['abc', 'def', 'def', 'def', 'def'] }); await Promise.delay(10); expect(store.getState().state).toBe('STATE1'); expect(store.getState().subStates.length).toBe(5); expect(store.getState().cnt).toBe(5); }); it('can handle a redux black box', async () => { const reducer = (state = { state: 'STATE0' }, action) => { switch (action.type) { case 'TRANSITION1': return { state: 'STATE1', blackBox: new ReduxBlackBox( { type: 'TRANSITION2' }, { type: 'TRANSITION4' }, act => act.type === 'TRANSITION3' ) }; case 'TRANSITION2': console.assert(state.state === 'STATE1'); return { ...state, state: 'STATE2', promiseBlackBox: new PromiseBlackBox(() => Promise.delay(100) .then(() => ({ type: 'TRANSITION3' }))) }; case 'TRANSITION4': console.assert(state.state === 'STATE2'); return { ...state, state: 'STATE3' }; default: return state; } }; const store = createStore(reducer, undefined, applyMiddleware(blackBoxMiddleware)); expect(store.getState().state).toBe('STATE0'); store.dispatch({ type: 'TRANSITION1' }); await Promise.delay(1); expect(store.getState().state).toBe('STATE2'); await Promise.delay(200); expect(store.getState().state).toBe('STATE3'); }); it('can implement parallel subsystems', async () => { const subReducer = (state = ({ state: 'SUBSTATE0', }), action, i) => { switch (action.type) { case undefined: return { ...state, blackBox: new ReduxBlackBox({ type: 'LOAD', i }, { type: 'SUBACTION1', i }) }; case 'SUBACTION1': return { ...state, state: 'SUBSTATE1', blackBox: null, }; default: return state; } }; const reducer = (state = { state: 'STATE0' }, action) => { switch (action.type) { case 'TRANSITION1': return { ...state, state: 'STATE1', subStates: action.urns.map((urn, i) => subReducer(undefined, {}, i)) }; case 'SUBACTION1': { console.assert(state.state === 'STATE1', `Unexpected state: ${state.state}`); const newSubStates = state.subStates.map( (subState, i) => (i === action.i ? subReducer(subState, action) : subState) ); return { ...state, state: every(newSubStates, subState => subState.state === 'SUBSTATE1') ? 'STATE2' : 'STATE1', subStates: newSubStates }; } default: return state; } }; const store = createStore(reducer, undefined, applyMiddleware(blackBoxMiddleware)); expect(store.getState().state).toBe('STATE0'); store.dispatch({ type: 'TRANSITION1', urns: ['abc', 'def'] }); await delay(1); expect(store.getState().subStates.length).toBe(2); expect(store.getState().state).toBe('STATE2'); }); }); describe('async black box', () => { it('can handle an async black box', async () => { const reducer = (state = { state: 'STATE0', urn: 'abc' }, action) => { switch (action.type) { case 'TRANSITION1': return { ...state, state: 'STATE1', blackBox: new AsyncBlackBox( async ({ dispatch, getState, take }) => { let urn; if (action.urn) { urn = action.urn; } else { urn = getState().urn; } dispatch({ type: 'LOAD', urn }); await take(act => act.type === 'LOADED'); await Promise.delay(100); const urn2 = await (async function subSaga() { return getState().urn; }()); expect(urn2).toEqual('abc'); dispatch({ type: 'UNLOAD', urn }); await Promise.all([1, 2, 3].map(async (id) => { await Promise.delay(1); dispatch({ type: 'PING', id }); })); dispatch({ type: 'TRANSITION2' }); } ) }; case 'LOAD': return { ...state, loadSideEffect: new PromiseBlackBox(() => Promise.resolve().then(() => ({ type: 'LOADED' }))) }; case 'TRANSITION2': console.assert(state.state === 'STATE1'); return { ...state, state: 'STATE2', }; default: return state; } }; const store = createStore(reducer, undefined, applyMiddleware(blackBoxMiddleware)); expect(store.getState().state).toBe('STATE0'); store.dispatch({ type: 'TRANSITION1' }); expect(store.getState().state).toBe('STATE1'); await Promise.delay(200); expect(store.getState().state).toBe('STATE2'); }); it('can handle cancellation', async () => { let cancelCalled = false; const promiseGenerator = ({ dispatch, getState, take }) => { const promise = (async () => { await null; // force returning a promise before dispatching dispatch({ type: 'LOAD' }); expect(() => dispatch({ type: 'ANYTHING' })).toThrow(); })(); promise.cancel = () => { cancelCalled = true; }; return promise; }; const reducer = (state = { state: 'STATE0' }, action) => { switch (action.type) { case 'TRANSITION1': return { ...state, state: 'STATE1', blackbox: new AsyncBlackBox(promiseGenerator) }; case 'LOAD': return { ...state, state: 'LOADING_HANGED', blackbox: null }; default: return state; } }; const store = createStore(reducer, undefined, applyMiddleware(blackBoxMiddleware)); expect(store.getState().state).toBe('STATE0'); store.dispatch({ type: 'TRANSITION1' }); expect(store.getState().state).toBe('STATE1'); await Promise.delay(200); expect(store.getState().state).toBe('LOADING_HANGED'); expect(cancelCalled).toBeTruthy(); }); it('handle cancellation even if it happens before promise returned', async () => { let cancelCalled = false; const promiseGenerator = ({ dispatch, getState, take }) => { const promise = (async () => { dispatch({ type: 'LOAD' }); // immediate dispatch before promise is returned expect(() => dispatch({ type: 'ANYTHING' })).toThrow(); })(); promise.cancel = () => { cancelCalled = true; }; return promise; }; const reducer = (state = { state: 'STATE0' }, action) => { switch (action.type) { case 'TRANSITION1': return { ...state, blackbox: new AsyncBlackBox(promiseGenerator) }; case 'LOAD': return { ...state, blackbox: null }; default: return state; } }; const store = createStore(reducer, undefined, applyMiddleware(blackBoxMiddleware)); store.dispatch({ type: 'TRANSITION1' }); await Promise.delay(200); expect(cancelCalled).toBeTruthy(); }); it('handles getState', async () => { const reducer = (state = { state: 'STATE0', urn: 'abc' }, action) => { switch (action.type) { case 'TRANSITION1': return { ...state, state: 'STATE1', blackBox: new AsyncBlackBox( async ({ dispatch, getState, take }) => { expect(getState().state).toBe('STATE1'); expect(getState().blackBox).toBeTruthy(); } ) }; default: return state; } }; const store = createStore(reducer, undefined, applyMiddleware(blackBoxMiddleware)); expect(store.getState().state).toBe('STATE0'); store.dispatch({ type: 'TRANSITION1' }); await Promise.delay(200); }); it('example of chained black boxes', async () => { const reducer = (state = { }, action) => { switch (action.type) { case 'START': return { ...state, blackBox: new AsyncBlackBox( async ({ dispatch, getState, take }) => { await null; // force returning a promise dispatch({ type: 'LOAD' }); expect(getState().loadSideEffect).toBeInstanceOf(PromiseBlackBox); await Promise.delay(1); expect(getState().loadSideEffect).toBeFalsy(); } ) }; case 'LOAD': return { ...state, loadSideEffect: new PromiseBlackBox(() => ({ type: 'LOADED' })) }; case 'LOADED': return { ...state, loadSideEffect: null }; default: return state; } }; const store = createStore(reducer, undefined, applyMiddleware(blackBoxMiddleware)); store.dispatch({ type: 'START' }); await Promise.delay(10); }); it('can handle take with string', async () => { const reducer = (state = { state: 'STATE0', urn: 'abc' }, action) => { switch (action.type) { case 'TRANSITION1': return { ...state, state: 'STATE1', blackBox: new AsyncBlackBox( async ({ dispatch, getState, take }) => { await null; // force returning a promise dispatch({ type: 'LOAD' }); await take('LOADED'); dispatch({ type: 'TRANSITION2' }); } ) }; case 'LOAD': return { ...state, loadSideEffect: new PromiseBlackBox(() => ({ type: 'LOADED' })) }; case 'TRANSITION2': console.assert(state.state === 'STATE1'); return { ...state, state: 'STATE2', }; default: return state; } }; const store = createStore(reducer, undefined, applyMiddleware(blackBoxMiddleware)); expect(store.getState().state).toBe('STATE0'); store.dispatch({ type: 'TRANSITION1' }); expect(store.getState().state).toBe('STATE1'); await Promise.resolve(); expect(store.getState().state).toBe('STATE2'); }); }); it('can handle take with no args or wildcard', async () => { const reducer = (state = { state: 'STATE0', urn: 'abc' }, action) => { switch (action.type) { case 'TRANSITION1': return { ...state, state: 'STATE1', blackBox: new AsyncBlackBox( async ({ dispatch, getState, take }) => { await null; // force returning a promise dispatch({ type: 'LOAD' }); await take('*'); dispatch({ type: 'LOAD' }); await take(); dispatch({ type: 'TRANSITION2' }); } ) }; case 'LOAD': return { ...state, loadSideEffect: new PromiseBlackBox(() => ({ type: 'LOADED' })) }; case 'TRANSITION2': console.assert(state.state === 'STATE1'); return { ...state, state: 'STATE2', }; default: return state; } }; const store = createStore(reducer, undefined, applyMiddleware(blackBoxMiddleware)); expect(store.getState().state).toBe('STATE0'); store.dispatch({ type: 'TRANSITION1' }); expect(store.getState().state).toBe('STATE1'); await Promise.resolve(); expect(store.getState().state).toBe('STATE2'); }); it('can handle take with array', async () => { const reducer = (state = { state: 'STATE0', urn: 'abc' }, action) => { switch (action.type) { case 'TRANSITION1': return { ...state, state: 'STATE1', blackBox: new AsyncBlackBox( async ({ dispatch, getState, take }) => { await null; // force returning a promise dispatch({ type: 'LOAD' }); await take(['ACTION1', act => act.type === 'ACTION2', 'LOADED']); dispatch({ type: 'TRANSITION2' }); } ) }; case 'LOAD': return { ...state, loadSideEffect: new PromiseBlackBox(() => ({ type: 'LOADED' })) }; case 'TRANSITION2': console.assert(state.state === 'STATE1'); return { ...state, state: 'STATE2', }; default: return state; } }; const store = createStore(reducer, undefined, applyMiddleware(blackBoxMiddleware)); expect(store.getState().state).toBe('STATE0'); store.dispatch({ type: 'TRANSITION1' }); expect(store.getState().state).toBe('STATE1'); await Promise.resolve(); expect(store.getState().state).toBe('STATE2'); }); it('can handle a custom black box', async () => { class WebsocketSubsystem extends AbstractBlackBox { constructor(url) { super(); this._url = url; } onLoad({ dispatch }) { this.websocket = setInterval(() => dispatch({ type: 'TRANSITION2' }), 50); } onUnload() { clearInterval(this.websocket); this.websocket = null; } } const reducer = (state = { state: 'STATE0', cnt: 0 }, action) => { switch (action.type) { case 'TRANSITION1': return { ...state, state: 'STATE1', blackBox: new WebsocketSubsystem('ws:server.org') }; case 'TRANSITION2': console.assert(state.state === 'STATE1' || state.state === 'STATE2'); return { ...state, state: 'STATE2', cnt: state.cnt + 1, blackBox: state.cnt + 1 === 6 ? null : state.blackBox }; default: return state; } }; const store = createStore(reducer, undefined, applyMiddleware(blackBoxMiddleware)); expect(store.getState().state).toBe('STATE0'); store.dispatch({ type: 'TRANSITION1' }); expect(store.getState().state).toBe('STATE1'); await Promise.delay(200); expect(store.getState().state).toBe('STATE2'); expect(store.getState().cnt).toBe(3); await Promise.delay(200); expect(store.getState().cnt).toBe(6); }); });