Skip to content

Commit 0749c16

Browse files
authored
Merge pull request #49 from vankeisb/feature/program-testing
program testing
2 parents 4dff527 + 05fa3cb commit 0749c16

4 files changed

Lines changed: 205 additions & 79 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { mount, ReactWrapper } from 'enzyme';
2+
import { extendJest, Cmd, Sub, Task, Program, ProgramProps, updateUntilIdle } from "react-tea-cup";
3+
import React, { ReactNode } from 'react';
4+
import { Dispatcher } from 'tea-cup-core';
5+
6+
extendJest(expect);
7+
8+
const init1: () => [number, Cmd<string>] = () => {
9+
return [0, toCmd('go')];
10+
}
11+
12+
const view1: (dispatch: Dispatcher<string>, model: number) => React.ReactNode = (dispatch: Dispatcher<string>, model: number) => {
13+
return (<div className={'count'}>{model}</div>);
14+
}
15+
16+
const update1: (msg: string, model: number) => [number, Cmd<string>] = (msg: string, model: number) => {
17+
return [model + 1, model < 5 ? toCmd('go') : Cmd.none()];
18+
}
19+
20+
// const toCmd = (msg: string) => Task.perform(Time.in(0), () => msg);
21+
const toCmd = (msg: string) => Task.perform(Task.succeed(0), () => msg);
22+
23+
describe('Test Program', () => {
24+
25+
it('expect when program is idle', () => {
26+
const props: ProgramProps<number, string> = {
27+
init: init1,
28+
view: view1,
29+
update: update1,
30+
subscriptions: () => Sub.none<string>()
31+
}
32+
return updateUntilIdle(props, mount).then(([model, wrapper]) => {
33+
expect(model).toEqual(6)
34+
// expect(wrapper).toHaveHTML('')
35+
expect(wrapper.find('.count')).toHaveText('6')
36+
})
37+
})
38+
})

tea-cup/src/TeaCup/Program.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export class Program<Model, Msg> extends Component<ProgramProps<Model, Msg>, nev
7070
this.count++;
7171
const count = this.count;
7272
const currentModel = this.currentModel;
73-
if (currentModel) {
73+
if (currentModel !== undefined) {
7474
const updated = this.props.update(msg, currentModel);
7575
if (this.props.devTools) {
7676
this.fireEvent({

tea-cup/src/TeaCup/Testing.ts

Lines changed: 0 additions & 78 deletions
This file was deleted.

tea-cup/src/TeaCup/Testing.tsx

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/*
2+
* MIT License
3+
*
4+
* Copyright (c) 2019 Rémi Van Keisbelck
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
* SOFTWARE.
23+
*
24+
*/
25+
26+
import { Dispatcher, Cmd, Sub } from 'tea-cup-core';
27+
import { ReactElement } from 'react';
28+
import { ProgramProps, Program } from '.';
29+
import React from 'react';
30+
31+
export function extendJest<M>(expect: jest.Expect) {
32+
expect.extend({
33+
toHaveDispatchedMsg(received: Testing<M>, expected: M) {
34+
const pass = this.equals(received.dispatched, expected);
35+
36+
const message = () =>
37+
this.utils.matcherHint('toBe', undefined, undefined) +
38+
'\n\n' +
39+
`Expected: ${this.utils.printExpected(expected)}\n` +
40+
`Received: ${this.utils.printReceived(received.dispatched)}`;
41+
42+
return { actual: received, message, pass };
43+
},
44+
});
45+
}
46+
47+
declare global {
48+
namespace jest {
49+
interface Matchers<R> {
50+
toHaveDispatchedMsg<M>(value: M): CustomMatcherResult;
51+
}
52+
}
53+
}
54+
55+
export class Testing<M> {
56+
private _dispatched: M | undefined;
57+
58+
public Testing() { }
59+
60+
public readonly noop: Dispatcher<M> = () => { };
61+
62+
public get dispatcher(): Dispatcher<M> {
63+
this._dispatched = undefined;
64+
return (msg: M) => {
65+
this._dispatched = msg;
66+
};
67+
}
68+
69+
public get dispatched(): M | undefined {
70+
return this._dispatched;
71+
}
72+
73+
public dispatchFrom(cmd: Cmd<M>): Promise<M> {
74+
return new Promise<M>((resolve, reject) => {
75+
const dispatchedMsg = (msg: M) => {
76+
resolve(msg);
77+
};
78+
cmd.execute(dispatchedMsg);
79+
});
80+
}
81+
}
82+
83+
type Trigger<Model, Msg, T> = (node: ReactElement<ProgramProps<Model, Msg>>) => T
84+
type ResolveType<Model, T> = (idle: [Model, T]) => void;
85+
86+
export function updateUntilIdle<Model, Msg, T>(props: ProgramProps<Model, Msg>, fun: Trigger<Model, Msg, T>): Promise<[Model, T]> {
87+
return new Promise(resolve => {
88+
fun(<Program {...testableProps(resolve, props, fun)} />)
89+
})
90+
}
91+
92+
function testableProps<Model, Msg, T>(resolve: ResolveType<Model, T>, props: ProgramProps<Model, Msg>, fun: Trigger<Model, Msg, T>) {
93+
const tprops: ProgramProps<TestableModel<Model, Msg, T>, Msg> = {
94+
init: initTestable(resolve, props.init),
95+
view: viewTestable(props.view),
96+
update: updateTestable((props.update)),
97+
subscriptions: suscriptionsTestable(props, fun)
98+
}
99+
return tprops
100+
}
101+
102+
type TestableModel<Model, Msg, T> = {
103+
readonly resolve: ResolveType<Model, T>;
104+
readonly cmds: Cmd<Msg>[];
105+
readonly model: Model;
106+
}
107+
108+
function initTestable<Model, Msg, T>(resolve: ResolveType<Model, T>, init: ProgramProps<Model, Msg>['init']): ProgramProps<TestableModel<Model, Msg, T>, Msg>['init'] {
109+
const mac = init();
110+
return () => [{
111+
resolve,
112+
cmds: [mac[1]],
113+
model: mac[0]
114+
}, Cmd.none()];
115+
}
116+
117+
function viewTestable<Model, Msg, T>(view: ProgramProps<Model, Msg>['view']): ProgramProps<TestableModel<Model, Msg, T>, Msg>['view'] {
118+
return (dispatch: Dispatcher<Msg>, model: TestableModel<Model, Msg, T>) => view(dispatch, model.model);
119+
}
120+
121+
function updateTestable<Model, Msg, T>(update: ProgramProps<Model, Msg>['update']): ProgramProps<TestableModel<Model, Msg, T>, Msg>['update'] {
122+
return (msg: Msg, model: TestableModel<Model, Msg, T>) => {
123+
const [model1, cmd1] = update(msg, model.model);
124+
const cmds = [cmd1].filter(cmd => cmd.constructor.name !== 'CmdNone')
125+
return [{
126+
...model,
127+
cmds,
128+
model: model1,
129+
}, Cmd.none()];
130+
}
131+
}
132+
133+
function suscriptionsTestable<Model, Msg, T>(props: ProgramProps<Model, Msg>, fun: Trigger<Model, Msg, T>): ProgramProps<TestableModel<Model, Msg, T>, Msg>['subscriptions'] {
134+
return (model: TestableModel<Model, Msg, T>) => {
135+
const subs = props.subscriptions(model.model);
136+
if (model.cmds.length === 0) {
137+
const result = fun(<Program
138+
init={() => [model.model, Cmd.none()]
139+
}
140+
update={(msg, model) => [model, Cmd.none()]
141+
}
142+
view={(d, m) => props.view(d, m)
143+
}
144+
subscriptions={(d) => Sub.none()}
145+
/>)
146+
model.resolve([model.model, result]);
147+
return subs;
148+
}
149+
return Sub.batch([new TestableSub(model.cmds), subs]);
150+
}
151+
}
152+
153+
class TestableSub<Msg> extends Sub<Msg> {
154+
constructor(private readonly cmds: readonly Cmd<Msg>[]) {
155+
super();
156+
}
157+
158+
protected onInit(): void {
159+
setTimeout(() => {
160+
if (this.dispatcher !== undefined) {
161+
const d = this.dispatcher.bind(this);
162+
this.cmds.map(cmd => cmd.execute(d));
163+
}
164+
}, 0)
165+
}
166+
}

0 commit comments

Comments
 (0)