diff --git a/README.md b/README.md index c70f7c0f..5533df2c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ English | [简体中文](./README.zh-CN.md) [![NPM downloads](http://img.shields.io/npm/dm/@ice/store.svg?style=flat)](https://npmjs.org/package/@ice/store) [![Known Vulnerabilities](https://snyk.io/test/npm/@ice/store/badge.svg)](https://snyk.io/test/npm/@ice/store) [![David deps](https://img.shields.io/david/ice-lab/icestore.svg?style=flat-square)](https://david-dm.org/ice-lab/icestore) +[![codecov](https://codecov.io/gh/ice-lab/icestore/branch/master/graph/badge.svg)](https://codecov.io/gh/ice-lab/icestore) diff --git a/README.zh-CN.md b/README.zh-CN.md index ffbc66da..c94406aa 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -10,6 +10,7 @@ [![NPM downloads](http://img.shields.io/npm/dm/@ice/store.svg?style=flat)](https://npmjs.org/package/@ice/store) [![Known Vulnerabilities](https://snyk.io/test/npm/@ice/store/badge.svg)](https://snyk.io/test/npm/@ice/store) [![David deps](https://img.shields.io/david/ice-lab/icestore.svg?style=flat-square)](https://david-dm.org/ice-lab/icestore) +[![codecov](https://codecov.io/gh/ice-lab/icestore/branch/master/graph/badge.svg)](https://codecov.io/gh/ice-lab/icestore)
diff --git a/codecov.yml b/codecov.yml index 1be6c967..9255ed44 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,4 +1,12 @@ -comment: off +comment: + layout: "reach, diff, flags, files" + behavior: default + require_changes: false + require_base: no + require_head: yes + branches: + - "master" + coverage: status: project: diff --git a/examples/todos/src/components/Todos.tsx b/examples/todos/src/components/Todos.tsx index 56cdbd16..5cc54337 100644 --- a/examples/todos/src/components/Todos.tsx +++ b/examples/todos/src/components/Todos.tsx @@ -7,7 +7,7 @@ const { useModel, useModelEffectsLoading } = store; export default function Todos() { const todos = useModel('todos'); - const [ state, dispatchers ] = todos; + const [state, dispatchers] = todos; const effectsLoading = useModelEffectsLoading('todos'); const { dataSource } = state; @@ -15,8 +15,7 @@ export default function Todos() { useEffect(() => { refresh(); - - // eslint-disable-next-line + // eslint-disable-next-line }, []); const noTaskView =
no task
; diff --git a/examples/todos/src/components/User.tsx b/examples/todos/src/components/User.tsx index 57ea7ca9..2f0b4215 100644 --- a/examples/todos/src/components/User.tsx +++ b/examples/todos/src/components/User.tsx @@ -4,15 +4,14 @@ import store from '../store'; const { useModel } = store; export default function UserApp() { - const [ state, dispatchers ] = useModel('user'); + const [state, dispatchers] = useModel('user'); const { dataSource, auth, todos } = state; const { login } = dispatchers; const { name } = dataSource; useEffect(() => { login(); - - // eslint-disable-next-line + // eslint-disable-next-line }, []); console.debug('UserApp rending...'); diff --git a/package.json b/package.json index 6c1cd288..00482742 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "prepublishOnly": "npm run lint:nofix && npm run test && npm run build", "lint": "npm run lint:nofix -- --fix", "lint:nofix": "eslint --cache --ext .ts,.tsx ./", - "test": "NODE_ENV=unittest jest", + "test": "cross-env NODE_ENV=unittest jest", + "test:w": "jest --watch", "coverage": "codecov" }, "devDependencies": { @@ -38,15 +39,18 @@ "@commitlint/config-conventional": "^8.2.0", "@ice/spec": "^0.1.9", "@testing-library/react": "^9.0.0", - "@types/jest": "^24.0.12", + "@testing-library/react-hooks": "^3.2.1", + "@types/jest": "^25.2.1", "@types/node": "^12.0.0", "codecov": "^3.3.0", + "cross-env": "^7.0.2", "eslint": "^6.7.2", "husky": "^3.0.9", - "jest": "^24.7.1", + "jest": "^25.2.1", "react": "^16.8.0", "react-dom": "^16.8.0", - "ts-jest": "^24.0.2", + "react-test-renderer": "^16.13.0", + "ts-jest": "^25.2.1", "typescript": "^3.7.4" }, "peerDependencies": { @@ -55,6 +59,10 @@ "jest": { "coverageDirectory": "./coverage/", "collectCoverage": true, + "coveragePathIgnorePatterns": [ + "/tests/helpers/", + "/node_modules/" + ], "preset": "ts-jest" }, "dependencies": { @@ -64,4 +72,4 @@ "redux": "^4.0.5", "redux-thunk": "^2.3.0" } -} +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index aa7902f1..b0afacb5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -276,7 +276,8 @@ export interface ModelEffects { [key: string]: ( this: { [key: string]: (payload?: any, meta?: any) => Action }, payload: any, - rootState: S + rootState: S, + meta: any ) => void; } diff --git a/tests/helpers/CounterComponent.tsx b/tests/helpers/CounterComponent.tsx new file mode 100644 index 00000000..4329c9aa --- /dev/null +++ b/tests/helpers/CounterComponent.tsx @@ -0,0 +1,60 @@ +import React, { PureComponent } from 'react'; +import { + ExtractIModelFromModelConfig, + ExtractIModelDispatchersFromModelConfig, + ExtractIModelEffectsStateFromModelConfig, +} from '../../src'; +import counterModel from './counter'; + +interface CounterProps { + counter: ExtractIModelFromModelConfig; + children: React.ReactNode; +} + +export default class Counter extends PureComponent { + render() { + const { counter, children } = this.props; + const [state, dispatchers] = counter; + const { count } = state; + return ( + +
{count}
+
dispatchers.setState({ count: 1 })} /> +
+
+
+ {children} + + ); + } +} + +interface CounterUseDispathcersProps { + counterDispatchers: ExtractIModelDispatchersFromModelConfig; +}; +export class CounterUseDispathcers extends PureComponent { + render() { + const { counterDispatchers } = this.props; + return ( +
counterDispatchers.reset()} /> + ); + } +}; + +interface CounterUseEffectsStateProps { + counterEffectsState: ExtractIModelEffectsStateFromModelConfig; + children: React.ReactChild; +} +export class CounterUseEffectsState extends PureComponent { + render() { + const { counterEffectsState, children } = this.props; + return ( + + + {JSON.stringify(counterEffectsState.decrementAsync)} + + {children} + + ); + } +} \ No newline at end of file diff --git a/tests/helpers/counter.ts b/tests/helpers/counter.ts new file mode 100644 index 00000000..d9407dc1 --- /dev/null +++ b/tests/helpers/counter.ts @@ -0,0 +1,64 @@ +import { delay } from './utils'; + +export interface CounterState { + count: number; +} + +const counter = { + state: { + count: 0, + }, + reducers: { + increment: (prevState: CounterState) => prevState.count += 1, + decrement: (prevState: CounterState) => prevState.count -= 1, + reset: () => ({ count: 0 }), + }, + effects: (dispatch) => ({ + async decrementAsync(_, rootState) { + if (rootState.counter.count <= 0) { + throw new Error('count should be greater than or equal to 0'); + } + await delay(1000); + this.decrement(); + }, + }), +}; + +export const counterWithUnsupportEffects = { + state: { + a: 1, + }, + effects: { + incrementA: (state, value) => { + return { + ...state, + a: state.a + value, + }; + }, + }, +}; + +export const counterWithUnsupportActions = { + state: { + a: 1, + }, + actions: { + incrementA: (state, value) => { + return { + ...state, + a: state.a + value, + }; + }, + }, +}; + +export const counterWithNoImmer = { + state: { + count: 1, + }, + reducers: { + increment: (prevState) => { return prevState.count + 1; }, + }, +}; + +export default counter; \ No newline at end of file diff --git a/tests/helpers/models.ts b/tests/helpers/models.ts new file mode 100644 index 00000000..a9d65c80 --- /dev/null +++ b/tests/helpers/models.ts @@ -0,0 +1,2 @@ +export { default as todos } from './todos'; +export { default as user } from './user'; diff --git a/tests/helpers/todos.ts b/tests/helpers/todos.ts new file mode 100644 index 00000000..7a7f281a --- /dev/null +++ b/tests/helpers/todos.ts @@ -0,0 +1,45 @@ +import { delay } from './utils'; + +export interface Todo { + name: string; + done?: boolean; +} + +export interface TodosState { + dataSource: Todo[]; +} + +const todos = { + state: { + dataSource: [ + { + name: 'Init', + done: false, + }, + ], + }, + + reducers: { + addTodo(state: TodosState, todo: Todo) { + state.dataSource.push(todo); + }, + removeTodo(state: TodosState, index: number) { + state.dataSource.splice(index, 1); + }, + }, + + effects: (dispatch) => ({ + add(todo, rootState, { store }) { + this.addTodo(todo); + dispatch.user.setTodos(store.getModelState('todos').dataSource.length); + }, + + async delete(index, rootState, { store }) { + await delay(1000); + this.removeTodo(index); + dispatch.user.setTodos(store.getModelState('todos').dataSource.length); + }, + }), +}; + +export default todos; diff --git a/tests/helpers/user.ts b/tests/helpers/user.ts new file mode 100644 index 00000000..c348ab53 --- /dev/null +++ b/tests/helpers/user.ts @@ -0,0 +1,21 @@ +interface DataSourceState { + name: string; +} +class UserStateProps { + dataSource: DataSourceState = { name: 'testName' }; + + todos: number = 1; + + auth: boolean = false; +} + +const user = { + state: new UserStateProps, + reducers: { + setTodos(state: UserStateProps, todos: number) { + state.todos = todos; + }, + }, +}; + +export default user; diff --git a/tests/helpers/utils.ts b/tests/helpers/utils.ts new file mode 100644 index 00000000..d94746a6 --- /dev/null +++ b/tests/helpers/utils.ts @@ -0,0 +1 @@ +export const delay = (time) => new Promise((resolve) => setTimeout(() => resolve(), time)); diff --git a/tests/index.spec.tsx b/tests/index.spec.tsx index d0d1b424..51f40874 100644 --- a/tests/index.spec.tsx +++ b/tests/index.spec.tsx @@ -1,7 +1,393 @@ -import { createStore } from '../src/index'; +/* eslint-disable react/jsx-filename-extension */ +import React, { useCallback } from "react"; +import * as rhl from "@testing-library/react-hooks"; +import * as rtl from "@testing-library/react"; +import createStore from "../src/index"; +import * as models from "./helpers/models"; +import counterModel, { counterWithUnsupportEffects, counterWithNoImmer } from "./helpers/counter"; +import Counter, { CounterUseDispathcers, CounterUseEffectsState } from './helpers/CounterComponent'; +import * as warning from '../src/utils/warning'; -describe('#Test', () => { - test('should ok.', () => { +describe("createStore", () => { + test("creteStore should be defined", () => { expect(createStore).toBeDefined(); }); + + it("exposes the public API", () => { + const store = createStore(models); + const methods = Reflect.ownKeys(store); + + expect(methods).toContain("Provider"); + expect(methods).toContain("useModel"); + expect(methods).toContain("getModel"); + expect(methods).toContain("withModel"); + expect(methods).toContain("useModelDispatchers"); + expect(methods).toContain("withModelDispatchers"); + expect(methods).toContain("useModelEffectsState"); + expect(methods).toContain("withModelEffectsState"); + expect(methods).toContain("getModelState"); + expect(methods).toContain("getModelDispatchers"); + }); + + it("create unsupported effects should console error", () => { + const spy = jest.spyOn(warning, "default"); + createStore({ counterWithUnsupportEffects }); + expect(spy).toHaveBeenCalled(); + }); + + describe("Provider", () => { + afterEach(() => rtl.cleanup()); + const store = createStore(models); + const { Provider } = store; + + it("should not enforce one child", () => { + expect(() => + rtl.render( + +
+ , + ), + ).not.toThrow(); + + expect(() => + rtl.render( + +
+
+ , + ), + ).not.toThrow(); + }); + }); + + const renderHook = (callback, namespace, Provider, initialStates?: any) => { + return rhl.renderHook(() => callback(namespace), { + wrapper: (props) => ( + + {props.children} + + ), + }); + }; + + describe("function component model", () => { + afterEach(rhl.cleanup); + + it("throw error when trying to use the inexisted model", () => { + const store = createStore(models); + const { Provider, useModel } = store; + const namespace = "test"; + const { result } = renderHook(useModel, namespace, Provider); + expect(result.error).toEqual( + Error(`Not found model by namespace: ${namespace}.`), + ); + }); + + describe("passes the initial states", () => { + const store = createStore(models); + const { Provider, useModel } = store; + const initialStates = { + todos: { + dataSource: [{ name: 'test', done: true }], + }, + user: { + dataSource: [{ name: "test" }], + }, + }; + + it("the models states should equal to the initialStates ", () => { + const { result: todosResult } = renderHook(useModel, "todos", Provider, initialStates); + const { result: userResult } = renderHook(useModel, "user", Provider, initialStates); + const [todosState] = todosResult.current; + const [userState] = userResult.current; + expect(todosState).toEqual(initialStates.todos); + expect(userState).toEqual(initialStates.user); + }); + + it('applies the reducer to the initial states', async () => { + const { result } = renderHook(useModel, "todos", Provider); + + const [state, dispatchers] = result.current; + const todos = models.todos; + + expect(state).toEqual(initialStates.todos); + expect(Reflect.ownKeys(dispatchers)).toEqual([ + ...Reflect.ownKeys(todos.reducers), + ...Reflect.ownKeys(todos.effects(jest.fn)), + ]); + + rhl.act(() => { + dispatchers.addTodo({ name: 'testReducers', done: false }); + }); + expect(result.current[0].dataSource).toEqual( + [ + { name: 'test', done: true }, + { name: 'testReducers', done: false }, + ], + ); + }); + }); + + describe("not pass the initial states", () => { + const store = createStore(models); + const { Provider, useModel, useModelEffectsState } = store; + + it("not pass the initial states", () => { + const { result: todosResult } = renderHook(useModel, "todos", Provider); + const { result: userResult } = renderHook(useModel, "user", Provider); + const [todosState] = todosResult.current; + const [userState] = userResult.current; + expect(todosState).toEqual({ + dataSource: [ + { name: 'Init', done: false }, + ], + }); + expect(userState).toEqual({ + dataSource: { name: 'testName' }, + todos: 1, + auth: false, + }); + }); + + it('applies the reducer to the previous state', async () => { + const { result } = renderHook(useModel, "todos", Provider); + + const [state, dispatchers] = result.current; + const todos = models.todos; + + expect(state).toEqual(todos.state); + expect(Reflect.ownKeys(dispatchers)).toEqual([ + ...Reflect.ownKeys(todos.reducers), + ...Reflect.ownKeys(todos.effects(jest.fn)), + ]); + + rhl.act(() => { + dispatchers.addTodo({ name: 'testReducers', done: false }); + }); + + expect(result.current[0].dataSource).toEqual( + [ + { name: 'Init', done: false }, + { name: 'testReducers', done: false }, + ], + ); + rhl.act(() => { + dispatchers.removeTodo(1); + }); + expect(result.current[0].dataSource).toEqual([ + { name: 'Init', done: false }, + ]); + }); + + it('get model effects state', async () => { + // Define a new hooks for that renderHook api doesn't support render one more hooks + function useModelEffect(namespace) { + const [state, dispatchers] = useModel(namespace); + const effectsState = useModelEffectsState(namespace); + + return { state, dispatchers, effectsState }; + } + + const { result, waitForNextUpdate } = renderHook(useModelEffect, 'todos', Provider); + + expect(result.current.state.dataSource).toEqual(models.todos.state.dataSource); + rhl.act(() => { + result.current.dispatchers.delete(0, { store }); + }); + + expect(result.current.effectsState.delete).toEqual({ isLoading: true, error: null }); + + await waitForNextUpdate(); + + expect(result.current.state.dataSource).toEqual([]); + expect(result.current.effectsState.delete).toEqual({ isLoading: false, error: null }); + }); + }); + }); + + describe("class component model", () => { + afterEach(() => { + rtl.cleanup(); + }); + + describe("passes the initial states", () => { + const initialStates = { counter: { count: 5 } }; + const store = createStore({ counter: counterModel }); + const { Provider, withModel } = store; + + const WithModelCounter = withModel('counter')(Counter); + + it('the counter model state should equal to the initialStates ', () => { + const tester = rtl.render(); + const { getByTestId } = tester; + expect(getByTestId('count').innerHTML).toBe('5'); + }); + + it('applies the reducer to the initial states', () => { + const tester = rtl.render(); + const { getByTestId } = tester; + expect(getByTestId('count').innerHTML).toBe('5'); + + rtl.fireEvent.click(getByTestId('setState')); + expect(getByTestId('count').innerHTML).toBe('1'); + + rtl.fireEvent.click(getByTestId('decrement')); + expect(getByTestId('count').innerHTML).toBe('0'); + }); + }); + + describe("not passes the initial states", () => { + const store = createStore({ counter: counterModel }); + const { Provider, withModel, withModelDispatchers, withModelEffectsState } = store; + + const WithModelCounter = withModel('counter')(Counter); + const WithCounterUseDispathcers = withModelDispatchers('counter')(CounterUseDispathcers); + const WithCounterUseEffectsState = withModelEffectsState('counter')(CounterUseEffectsState); + + it('the counter model state should equal to the previous state', () => { + const tester = rtl.render(); + const { getByTestId } = tester; + expect(getByTestId('count').innerHTML).toBe('0'); + }); + + it('applies the reducer to the previous states', () => { + const tester = rtl.render(); + const { getByTestId } = tester; + expect(getByTestId('count').innerHTML).toBe('0'); + + rtl.fireEvent.click(getByTestId('setState')); + expect(getByTestId('count').innerHTML).toBe('1'); + + rtl.fireEvent.click(getByTestId('decrement')); + expect(getByTestId('count').innerHTML).toBe('0'); + }); + + it('withDispatchers', () => { + const tester = rtl.render( + + + + + , + ); + const { getByTestId } = tester; + expect(getByTestId('count').innerHTML).toBe('0'); + + rtl.fireEvent.click(getByTestId('increment')); + expect(getByTestId('count').innerHTML).toBe('1'); + + rtl.fireEvent.click(getByTestId('reset')); + expect(getByTestId('count').innerHTML).toBe('0'); + }); + + it('withModelEffectsState', async () => { + const container = ( + + + + + + ); + const tester = rtl.render(container); + const { getByTestId } = tester; + + expect(getByTestId('count').innerHTML).toBe('0'); + rtl.fireEvent.click(getByTestId('decrementAsync')); + await rtl.waitForDomChange(); + expect(JSON.parse(getByTestId('decrementAsyncEffectsState').innerHTML).error).not.toBeNull(); + + rtl.fireEvent.click(getByTestId('increment')); + expect(getByTestId('count').innerHTML).toBe('1'); + + rtl.fireEvent.click(getByTestId('decrementAsync')); + expect(getByTestId('decrementAsyncEffectsState').innerHTML).toBe('{"isLoading":true,"error":null}'); + + await rtl.waitForDomChange(); + expect(getByTestId('decrementAsyncEffectsState').innerHTML).toBe('{"isLoading":false,"error":null}'); + expect(getByTestId('count').innerHTML).toBe('0'); + }); + }); + }); + + describe("get model api", () => { + afterEach(rtl.cleanup); + + const store = createStore({ counter: counterModel }); + + function useCounter(initialValue = 0) { + const setCounter = useCallback(() => { + const [state, dispatchers] = store.getModel('counter'); + if (state.count >= 10) { + return; + } + dispatchers.setState({ count: initialValue }); + }, [initialValue]); + return { setCounter }; + } + it('should set counter to updated initial value', () => { + let initialValue = 0; + const { result, rerender } = rhl.renderHook(() => useCounter(initialValue)); + + initialValue = 10; + rerender(); + rhl.act(() => { + result.current.setCounter(); + }); + expect(store.getModelState('counter').count).toBe(10); + + initialValue = 20; + rerender(); + rhl.act(() => { + result.current.setCounter(); // fail to update the state + }); + expect(store.getModelState('counter').count).toBe(10); + }); + }); + + describe("createStore options", () => { + const mockFn = jest + .fn() + .mockReturnValueOnce(createStore(models, { + disableLoading: true, + })) + .mockReturnValueOnce(createStore(models, { + disableError: true, + })) + .mockReturnValueOnce(createStore({ counterWithNoImmer }, { + disableImmer: true, + })); + + afterEach(() => { + rhl.cleanup(); + }); + + it("disableLoading", () => { + const store = mockFn(); + const methods = Reflect.ownKeys(store); + + expect(methods).not.toContain("useModelEffectsLoading"); + expect(methods).not.toContain("withModelEffectsLoading"); + }); + + it("disableError", () => { + const store = mockFn(); + const methods = Reflect.ownKeys(store); + + expect(methods).not.toContain("useModelEffectsError"); + expect(methods).not.toContain("withModelEffectsError"); + }); + + it("disableImmer", () => { + const store = mockFn(); + const { Provider, useModel } = store; + const { result } = renderHook(useModel, "counterWithNoImmer", Provider); + + const [state, dispatchers] = result.current; + expect(state).toEqual(counterWithNoImmer.state); + rhl.act(() => { + dispatchers.increment(); + }); + expect(result.current[0]).toEqual(2); + }); + }); }); diff --git a/tests/utils/appendReducer.spec.ts b/tests/utils/appendReducer.spec.ts new file mode 100644 index 00000000..54a7fa22 --- /dev/null +++ b/tests/utils/appendReducer.spec.ts @@ -0,0 +1,21 @@ +import appendReducers from '../../src/utils/appendReducers'; +import { Models } from '../../src/types'; + +const originModels = { + counter: { + state: 0, + }, +}; + +describe('utils/appendReducers', () => { + it('apply no reducers', () => { + const models: Models = appendReducers(originModels); + expect(Reflect.ownKeys(models)).toEqual(['counter']); + + const { counter } = models; + expect(Reflect.ownKeys(counter)).toEqual(['state', 'reducers']); + + const { reducers } = counter; + expect(Reflect.ownKeys(reducers).length).toBe(2); + }); +}); diff --git a/tests/utils/converter.spec.ts b/tests/utils/converter.spec.ts new file mode 100644 index 00000000..cae7a2e8 --- /dev/null +++ b/tests/utils/converter.spec.ts @@ -0,0 +1,31 @@ +import { convertEffects, convertActions } from '../../src/utils/converter'; +import { Models, ModelEffects } from '../../src'; +import { counterWithUnsupportEffects, counterWithUnsupportActions } from '../helpers/counter'; +import * as warning from '../../src/utils/warning'; + +describe('utils/convert', () => { + it('withUnsupportEffects', () => { + const spy = jest.spyOn(warning, 'default'); + const models: Models = convertEffects({ counter: counterWithUnsupportEffects }); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + + const { counter } = models; + expect(Reflect.ownKeys(counter).includes('effects')).toBe(true); + const effects = counter.effects as (dispatch: any) => ModelEffects; + expect(Reflect.ownKeys(effects(jest.fn))).toEqual(['incrementA']); + }); + + it('withUnsupportActions', () => { + const spy = jest.spyOn(warning, 'default'); + const models: Models = convertActions({ counter: counterWithUnsupportActions }); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + + const { counter } = models; + expect(Reflect.ownKeys(counter).includes('effects')).toBe(true); + + const effects = counter.effects as (dispatch: any) => ModelEffects; + expect(Reflect.ownKeys(effects(jest.fn))).toEqual(['incrementA']); + }); +}); diff --git a/tests/utils/validate.spec.ts b/tests/utils/validate.spec.ts new file mode 100644 index 00000000..d7bdeec9 --- /dev/null +++ b/tests/utils/validate.spec.ts @@ -0,0 +1,17 @@ +import validate from '../../src/utils/validate'; + +describe('utils/validate', () => { + it('will throw Error', () => { + const model: any = {}; + expect(() => { + validate([[model.state === undefined, 'model state is required']]); + }).toThrowError(/^model state is required$/); + }); + + it('will throw Error', () => { + const model = { state: 0, name: 'test' }; + expect(() => { + validate([[model.state === undefined, 'model state is required']]); + }).not.toThrowError(); + }); +});