Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Namespaces #17

Merged
merged 12 commits into from
Sep 27, 2019
6 changes: 3 additions & 3 deletions examples/simpleActions.tests.ts → examples/actions.tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { map } from 'rxjs/operators';
import { actionCreator, ExtractPayload } from 'rxbeach';
import { ofType, extractPayload } from 'rxbeach/operators';

describe('example', function() {
describe('simple actions', function() {
export default function actionExamples() {
describe('actions', function() {
const voidAction = actionCreator('[test] void action');
const primitiveAction = actionCreator<number>('[test] primitive action');
type Payload = { foo: number };
Expand Down Expand Up @@ -45,4 +45,4 @@ describe('example', function() {
const payload_assignable_to_extracted: Extracted = (null as any) as Payload;
const extracted_assignable_to_payload: Payload = (null as any) as Extracted;
});
});
}
7 changes: 7 additions & 0 deletions examples/index.tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import actionExamples from './actions.tests';
import namespaceExamples from './namespace.tests';

describe('examples', function() {
actionExamples();
namespaceExamples();
});
108 changes: 108 additions & 0 deletions examples/namespace.tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { equal, deepEqual } from 'assert';
import { of, Subject } from 'rxjs';
import { reduce } from 'rxjs/operators';
import {
actionCreator,
namespaceActionCreator,
namespaceActionDispatcher,
} from 'rxbeach';
import { withNamespace } from 'rxbeach/operators';
import { AnyAction, mockAction } from 'rxbeach/internal';

export default function namespaceExamples() {
describe('namespaces', function() {
const testAction = actionCreator<number>('[test] primitive action');
tlaundal marked this conversation as resolved.
Show resolved Hide resolved
const namespaceA = 'A';
const namespaceB = 'B';

describe('namespacing action creators', function() {
const testActionA = namespaceActionCreator(namespaceA, testAction);
const testActionB = namespaceActionCreator(namespaceB, testAction);
const actionObjectA = testActionA(1);
const actionObjectB = testActionB(2);

let lastActionNamespaceA: AnyAction | undefined;
let lastActionNamespaceB: AnyAction | undefined;
let sumAllNamespaces: number | undefined;
this.afterEach(async function() {
lastActionNamespaceA = undefined;
lastActionNamespaceB = undefined;
sumAllNamespaces = undefined;
});

this.beforeEach(async function() {
const action$ = of(actionObjectA, actionObjectB);
const actionA$ = action$.pipe(withNamespace(namespaceA));
const actionB$ = action$.pipe(withNamespace(namespaceB));
const sum$ = action$.pipe(reduce((a, b) => a + (b.payload || 0), 0));

lastActionNamespaceA = await actionA$.toPromise();
lastActionNamespaceB = await actionB$.toPromise();
sumAllNamespaces = await sum$.toPromise();
});

it('can filter namespace A', async function() {
equal(lastActionNamespaceA, actionObjectA);
});
it('can filter namespace B', async function() {
equal(lastActionNamespaceB, actionObjectB);
});
it('dispatches to main action$', async function() {
equal(sumAllNamespaces, 3);
});
});

describe('namespacing action dispatchers', function() {
let lastActionNamespaceA: AnyAction | undefined;
let lastActionNamespaceB: AnyAction | undefined;
let sumAllNamespaces: number | undefined;
this.afterEach(function() {
lastActionNamespaceA = undefined;
lastActionNamespaceB = undefined;
sumAllNamespaces = undefined;
});

this.beforeEach(async function() {
const action$ = new Subject<AnyAction>();
const dispatchAction = action$.next.bind(action$);

const dispatchA = namespaceActionDispatcher(namespaceA, dispatchAction);
const dispatchB = namespaceActionDispatcher(namespaceB, dispatchAction);

const a_p = action$.pipe(withNamespace(namespaceA)).toPromise();
const b_p = action$.pipe(withNamespace(namespaceB)).toPromise();
const sum_p = action$
.pipe(reduce((a: any, b: any) => a + (b.payload || 0), 0))
.toPromise();

dispatchA(testAction(1));
dispatchB(testAction(2));
action$.complete();

const [a, b, sum] = await Promise.all([a_p, b_p, sum_p]);

lastActionNamespaceA = a;
lastActionNamespaceB = b;
sumAllNamespaces = sum;
});

it('applies namespace A', function() {
deepEqual(
lastActionNamespaceA,
mockAction(testAction.type, namespaceA, 1)
);
});

it('applies namespace B', function() {
deepEqual(
lastActionNamespaceB,
mockAction(testAction.type, namespaceB, 2)
);
});

it('dispatches to root action$', function() {
equal(sumAllNamespaces, 3);
});
});
});
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"private": true,
"license": "MIT",
"scripts": {
"test": "ts-mocha --paths -p tsconfig.json src/**/*.tests.ts examples/**/*.tests.ts",
"test": "ts-mocha --paths -p tsconfig.json src/*.tests.ts src/**/*.tests.ts examples/**/*.tests.ts",
"build": "tsc -p tsconfig.json",
"lint": "eslint --ext .ts src examples"
},
Expand Down
4 changes: 1 addition & 3 deletions src/actionCreator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,7 @@ export function actionCreator(type: string): UnknownActionCreator {
const action = (payload?: any) => ({
type,
payload,
meta: {
qualifiers: [],
},
meta: {},
});
action.type = type;

Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@ export {
} from './types/helpers';

export { actionCreator } from './actionCreator';

export { namespaceActionCreator, namespaceActionDispatcher } from './namespace';
2 changes: 1 addition & 1 deletion src/internal/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { actionWithoutPayload, actionWithPayload } from './testUtils';
export { mockAction } from './testUtils';
export {
VoidPayload,
AnyAction,
Expand Down
27 changes: 11 additions & 16 deletions src/internal/testUtils.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,13 @@
import { ActionWithPayload, ActionWithoutPayload } from 'types/Action';
import { Action } from 'rxbeach';
import { VoidPayload } from 'rxbeach/internal';

export const actionWithoutPayload = (
export const mockAction = <P = VoidPayload>(
type: string,
qualifiers: string[] = []
): ActionWithoutPayload => ({
meta: { qualifiers },
type,
});

export const actionWithPayload = <P>(
type: string,
payload: P,
qualifiers: string[] = []
): ActionWithPayload<P> => ({
...actionWithoutPayload(type, qualifiers),
payload,
});
namespace?: string,
payload?: P
): Action<P> =>
({
meta: { namespace },
type,
payload,
} as Action<P>);
51 changes: 51 additions & 0 deletions src/namespace.tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { deepEqual } from 'assert';
import {
namespaceActionCreator,
ActionDispatcher,
namespaceActionDispatcher,
} from 'rxbeach';
import { mockAction, AnyAction } from 'rxbeach/internal';

describe('namespace', function() {
describe('namespaceActionCreator', function() {
const type = 'action type';
const namespace = 'new namespace';
const actionCreator = (payload: number) =>
mockAction(type, 'old namespace', payload);
actionCreator.type = type;

const namespacedActionCreator = namespaceActionCreator(
namespace,
actionCreator
);

const actionObject = namespacedActionCreator(12);

it('Should create actions with namespace', function() {
deepEqual(actionObject, mockAction(type, namespace, 12));
});
});

describe('namespaceActionDispatcher', function() {
let dispatchedAction: AnyAction | undefined;
const parentDispatcher: ActionDispatcher = action =>
(dispatchedAction = action);

const namespace = 'new namespace';
const childDispatcher = namespaceActionDispatcher(
namespace,
parentDispatcher
);

const actionObject = mockAction('action', 'old namespace');

childDispatcher(actionObject);

it('Should invoke the parent dispatcher with namespaced actions', function() {
deepEqual(dispatchedAction, {
payload: undefined,
...mockAction(actionObject.type, namespace),
});
});
});
});
60 changes: 60 additions & 0 deletions src/namespace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { ActionCreator, ActionDispatcher } from 'rxbeach';
import { UnknownAction } from 'rxbeach/internal';

const _namespaceAction = (namespace: string, action: UnknownAction) => ({
type: action.type,
payload: action.payload,
tlaundal marked this conversation as resolved.
Show resolved Hide resolved
meta: {
...action.meta,
namespace,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if meta contains a namespace key? Maybe namespace should be top level? Unless there is a reason for it to live under meta.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tooling controls meta. Our action creators doesn't give the call sites access to meta.
FSA defines type, payload, meta and error as the only allowed top level properties.

In short, I think this is a non-issue, and we have a test verifying that if it should happen, the new namespace takes precedence. This is also documented in namespaceActionCreator.

},
});

/**
* Decorate an action creator so the created actions have the given namespace
*
* The given namespace will replace existing namespaces on the action objects.
*
* In contrast to `namespaceActionDispatcher`, the function returned by this
* function creates an action object instead of dispatching it.
*
* @see namespaceActionDispatcher
* @param namespace The namespace to set for the created actions
* @param actionCreator The action creator to decorate
* @returns An action creator that creates actions using the passed action
* creator, and sets the given namespace
*/
export const namespaceActionCreator = <Payload>(
namespace: string,
actionCreator: ActionCreator<Payload>
): ActionCreator<Payload> => {
const creator = (payload?: any) =>
_namespaceAction(namespace, actionCreator(payload));
creator.type = actionCreator.type;
tlaundal marked this conversation as resolved.
Show resolved Hide resolved

return creator as ActionCreator<Payload>;
};

/**
* Decorate an action dispatcher so it dispatches namespaced actions
*
* The given namespace is set as the namespace for each action dispatched with
* this function, before the action is dispatched with the action dispatcher
* from the arguments.
*
* In contrast to `namespaceActionCreator`, the function returned by this
* function dispatches the action, instead of creating it.
*
* @see namespaceActionCreator
* @param parentDispatcher The dispatcher the returned action dispatcher will
* dispatch to
* @param namespace The namespace that will be set for each action before they
* are passed on to the parent dispatcher
* @returns An action dispatcher that sets the namespace before passing the
* actions to the parent dispatcher
*/
export const namespaceActionDispatcher = (
namespace: string,
parentDispatcher: ActionDispatcher
): ActionDispatcher => action =>
parentDispatcher(_namespaceAction(namespace, action));
2 changes: 1 addition & 1 deletion src/operators/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { ofType, extractPayload } from './operators';
export { ofType, extractPayload, withNamespace } from './operators';
45 changes: 31 additions & 14 deletions src/operators/operators.tests.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { equal, deepEqual } from 'assert';
import { of, OperatorFunction } from 'rxjs';
import { tap, reduce } from 'rxjs/operators';
import { reduce } from 'rxjs/operators';
import { ActionWithPayload, ActionWithoutPayload } from 'rxbeach';
import { extractPayload, ofType } from 'rxbeach/operators';
import { actionWithPayload, actionWithoutPayload } from 'rxbeach/internal';
import { mockAction } from 'rxbeach/internal';
import { withNamespace } from './operators';
tlaundal marked this conversation as resolved.
Show resolved Hide resolved

const pipeActionWithPayload = <P, R>(
payload: P,
pipe: OperatorFunction<ActionWithPayload<P>, R>
): Promise<R> =>
of(actionWithPayload('', payload))
of(mockAction('', '', payload) as ActionWithPayload<P>)
.pipe(pipe)
.toPromise();

Expand All @@ -35,16 +36,15 @@ describe('operators', function() {
const targetType = 'Correct type';
const otherType = 'Wrong type';

await of<ActionWithoutPayload>(
actionWithoutPayload(targetType),
actionWithoutPayload(otherType),
actionWithoutPayload(targetType)
const res = await of<ActionWithoutPayload>(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't actually wait for async things to finish in tests. In this case it might not matter much, but I'm sure there are facilities for testing asynchronous things synchronously in tests (i.e. by providing a mock that returns when triggered explicitly).

More importantly though, I don't think we should be testing observables by converting them to promises. I did a quick google search, and this came up (more info here).

I think it's worth investing some time into thinking how we're going to test observables.

Copy link
Contributor

@holographic-principle holographic-principle Sep 25, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AVA is a testing framework with inbuilt support for observables and running tests concurrently. It also works with the aforementioned rxjs-marbles lib.

Looks pretty cool.

I guess you're not too hot for tests and testing frameworks, but I'll just leave this comment here anyway as a note to self.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to learn more about testing frameworks :)
@anton said he would look into marble testing.

My experience with writing these tests are that the awaits we are using here will only wait for one node tick, and the tests using them does prove something worthwhile about the code. I'm all open for a better way to test them, though!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But I think testing strategy and frameworks is out of scope for this PR. Please mention AVA in #14

mockAction(targetType),
mockAction(otherType),
mockAction(targetType)
)
.pipe(
ofType(targetType),
tap(action => equal(action.type, targetType))
)
.pipe(ofType(targetType))
.toPromise();

equal(res.type, targetType);
});

it('Should filter multiple action types', async function() {
Expand All @@ -53,9 +53,9 @@ describe('operators', function() {
const otherType = 'Wrong type';

const collectedTypes = await of<ActionWithoutPayload>(
actionWithoutPayload(targetType1),
actionWithoutPayload(otherType),
actionWithoutPayload(targetType2)
mockAction(targetType1),
mockAction(otherType),
mockAction(targetType2)
)
.pipe(
ofType(targetType1, targetType2),
Expand All @@ -66,4 +66,21 @@ describe('operators', function() {
deepEqual(collectedTypes, [targetType1, targetType2]);
});
});

describe('withNamespace', function() {
it('Should filter actions by namespace', async function() {
const actionType = 'actionType';
const namespace = 'namespace';

const res = await of<ActionWithoutPayload>(
mockAction(actionType),
mockAction(actionType, namespace),
mockAction(actionType)
)
.pipe(withNamespace(namespace))
.toPromise();

deepEqual(res, mockAction(actionType, namespace));
});
});
});
Loading