diff --git a/libs/horizon/index.ts b/libs/horizon/index.ts index 320ac08f..9e29e71d 100644 --- a/libs/horizon/index.ts +++ b/libs/horizon/index.ts @@ -27,10 +27,12 @@ import { useState, useDebugValue } from './src/renderer/hooks/HookExternal'; -import { launchUpdateFromVNode as _launchUpdateFromVNode, asyncUpdates } from './src/renderer/TreeBuilder'; +import { asyncUpdates } from './src/renderer/TreeBuilder'; import { callRenderQueueImmediate } from './src/renderer/taskExecutor/RenderQueue'; import { runAsyncEffects } from './src/renderer/submit/HookEffectHandler'; -import { getProcessingVNode as _getProcessingVNode } from './src/renderer/GlobalVar'; + +import { createStore, useStore, clearStore } from './src/horizonx/store/StoreHandler'; +import * as reduxAdapter from './src/horizonx/adapters/redux'; // act用于测试,作用是:如果fun触发了刷新(包含了异步刷新),可以保证在act后面的代码是在刷新完成后才执行。 const act = fun => { @@ -79,8 +81,10 @@ const Horizon = { findDOMNode, unmountComponentAtNode, act, - _launchUpdateFromVNode, - _getProcessingVNode, + createStore, + useStore, + clearStore, + reduxAdapter, }; export const version = __VERSION__; @@ -116,9 +120,11 @@ export { findDOMNode, unmountComponentAtNode, act, - // 暂时给HorizonX使用 - _launchUpdateFromVNode, - _getProcessingVNode, + // 状态管理器HorizonX接口 + createStore, + useStore, + clearStore, + reduxAdapter, }; export default Horizon; diff --git a/libs/horizon/src/horizonx/CommonUtils.js b/libs/horizon/src/horizonx/CommonUtils.js new file mode 100644 index 00000000..85a328b4 --- /dev/null +++ b/libs/horizon/src/horizonx/CommonUtils.js @@ -0,0 +1,52 @@ +export function isObject(obj) { + const type = typeof obj; + return obj != null && (type === 'object' || type === 'function'); +} + +export function isSet(obj) { + return obj != null && (Object.prototype.toString.call(obj) === '[object Set]' || obj.constructor === Set); +} + +export function isWeakSet(obj) { + return obj != null && (Object.prototype.toString.call(obj) === '[object WeakSet]' || obj.constructor === WeakSet); +} + +export function isMap(obj) { + return obj != null && (Object.prototype.toString.call(obj) === '[object Map]' || obj.constructor === Map); +} + +export function isWeakMap(obj) { + return obj != null && (Object.prototype.toString.call(obj) === '[object WeakMap]' || obj.constructor === WeakMap); +} + +export function isArray(obj) { + return Object.prototype.toString.call(obj) === '[object Array]'; +} + +export function isCollection(obj) { + return isSet(obj) || isWeakSet(obj) || isMap(obj) || isWeakMap(obj); +} + +export function isString(obj) { + return typeof obj === 'string'; +} + +export function isValidIntegerKey(key) { + return isString(key) && key !== 'NaN' && key[0] !== '-' && String(parseInt(key, 10)) === key; +} + +export const noop = () => {}; + +export function isSame(x, y) { + if (!(typeof Object.is === 'function')) { + if (x === y) { + // +0 != -0 + return x !== 0 || 1 / x === 1 / y; + } else { + // NaN == NaN + return x !== x && y !== y; + } + } else { + return Object.is(x, y); + } +} diff --git a/libs/horizon/src/horizonx/Constants.ts b/libs/horizon/src/horizonx/Constants.ts new file mode 100644 index 00000000..5967d853 --- /dev/null +++ b/libs/horizon/src/horizonx/Constants.ts @@ -0,0 +1,3 @@ +// The two constants must be the same as those in horizon. +export const FunctionComponent = 'FunctionComponent'; +export const ClassComponent = 'ClassComponent'; diff --git a/libs/horizon/src/horizonx/adapters/redux.ts b/libs/horizon/src/horizonx/adapters/redux.ts new file mode 100644 index 00000000..293700b6 --- /dev/null +++ b/libs/horizon/src/horizonx/adapters/redux.ts @@ -0,0 +1,124 @@ +import { createStore as createStoreX } from '../store/StoreHandler'; + +import { ReduxStoreHandler, ReduxAction, ReduxMiddleware } from '../types'; + +export { thunk } from './reduxThunk'; + +export { Provider, useSelector, useStore, useDispatch, connect, createSelectorHook, createDispatchHook } from './reduxReact'; + +type Reducer = (state: any, action: ReduxAction) => any; + +export function createStore(reducer: Reducer, preloadedState: any, enhancers): ReduxStoreHandler { + const store = createStoreX({ + id: 'defaultStore', + state: { stateWrapper: preloadedState }, + actions: { + dispatch: (state: { stateWrapper?: any }, action) => { + let result; + if (state.stateWrapper !== undefined && state.stateWrapper !== null) { + result = reducer(state.stateWrapper, action); + } else { + result = reducer(undefined, action); + } + + if (result === undefined) { + return; + } // NOTE: reducer should never return undefined, in this case, do not change state + state.stateWrapper = result; + }, + }, + options: { + suppressHooks: true, + }, + })(); + + const result = { + reducer, + getState: function() { + return store.$state.stateWrapper; + }, + subscribe: listener => { + store.$subscribe(listener); + + return () => { + store.$unsubscribe(listener); + }; + }, + replaceReducer: newReducer => { + reducer = newReducer; + }, + _horizonXstore: store, + dispatch: store.$actions.dispatch, + }; + + enhancers && enhancers(result); + + result.dispatch({ type: 'HorizonX' }); + + store.reduxHandler = result; + + return result; +} + +export function combineReducers(reducers: { [key: string]: Reducer }): Reducer { + return (state = {}, action) => { + const newState = {}; + Object.entries(reducers).forEach(([key, reducer]) => { + newState[key] = reducer(state[key], action); + }); + return newState; + }; +} + +export function applyMiddleware(...middlewares: ReduxMiddleware[]): (store: ReduxStoreHandler) => void { + return store => { + return applyMiddlewares(store, middlewares); + }; +} + +function applyMiddlewares(store: ReduxStoreHandler, middlewares: ReduxMiddleware[]): void { + middlewares = middlewares.slice(); + middlewares.reverse(); + let dispatch = store.dispatch; + middlewares.forEach(middleware => { + dispatch = middleware(store)(dispatch); + }); + store.dispatch = dispatch; +} + +type ActionCreator = (...params: any[]) => ReduxAction; +type ActionCreators = { [key: string]: ActionCreator }; +export type BoundActionCreator = (...params: any[]) => void; +type BoundActionCreators = { [key: string]: BoundActionCreator }; +type Dispatch = (action) => any; + +export function bindActionCreators(actionCreators: ActionCreators, dispatch: Dispatch): BoundActionCreators { + const boundActionCreators = {}; + Object.entries(actionCreators).forEach(([key, value]) => { + boundActionCreators[key] = (...args) => { + dispatch(value(...args)); + }; + }); + + return boundActionCreators; +} + +export function compose(middlewares: ReduxMiddleware[]) { + return (store: ReduxStoreHandler, extraArgument: any) => { + let val; + middlewares.reverse().forEach((middleware: ReduxMiddleware, index) => { + if (!index) { + val = middleware(store, extraArgument); + return; + } + val = middleware(val); + }); + return val; + }; +} + + +// HorizonX batches updates by default, this function is only for backwards compatibility +export function batch(fn: () => void) { + fn(); +} diff --git a/libs/horizon/src/horizonx/adapters/reduxPromiseMiddleware.ts b/libs/horizon/src/horizonx/adapters/reduxPromiseMiddleware.ts new file mode 100644 index 00000000..e69de29b diff --git a/libs/horizon/src/horizonx/adapters/reduxReact.ts b/libs/horizon/src/horizonx/adapters/reduxReact.ts new file mode 100644 index 00000000..da99ab52 --- /dev/null +++ b/libs/horizon/src/horizonx/adapters/reduxReact.ts @@ -0,0 +1,166 @@ +// @ts-ignore +import { useState, useContext, useEffect, useRef } from '../../renderer/hooks/HookExternal'; +import { createContext } from '../../renderer/components/context/CreateContext'; +import { createElement } from '../../external/JSXElement'; +import { BoundActionCreator } from './redux'; +import { ReduxAction, ReduxStoreHandler } from '../types'; + +const DefaultContext = createContext(); +type Context = typeof DefaultContext; + +export function Provider({ + store, + context = DefaultContext, + children, +}: { + store: ReduxStoreHandler; + context: Context; + children?: any[]; +}) { + const Context = context; // NOTE: bind redux API to horizon API requires this renaming; + return createElement(Context.Provider, { value: store }, children); +} + +export function createStoreHook(context: Context) { + return () => { + return useContext(context); + }; +} + +export function createSelectorHook(context: Context): (selector: (any) => any) => any { + const store = createStoreHook(context)(); + return function(selector = state => state) { + const [b, fr] = useState(false); + + const listener = () => { + fr(!b); + }; + + useEffect(() => { + const unsubscribe = store.subscribe(listener); + + return () => { + unsubscribe(listener); + }; + }); + + return selector(store.getState()); + }; +} + +export function createDispatchHook(context: Context): BoundActionCreator { + const store = createStoreHook(context)(); + return function() { + return action => { + this.dispatch(action); + }; + }.bind(store); +} + +export const useSelector = selector => { + return createSelectorHook(DefaultContext)(selector); +}; + +export const useDispatch = () => { + return createDispatchHook(DefaultContext)(); +}; + +export const useStore = () => { + return createStoreHook(DefaultContext)(); +}; + +// function shallowCompare(a,b){ +// return Object.keys(a).length === Object.keys(b).length && +// Object.keys(a).every(key => a[key] === b[key]); +// } + +//TODO: implement options +// context?: Object, +// areStatesEqual?: Function, :) +// areOwnPropsEqual?: Function, +// areStatePropsEqual?: Function, +// areMergedPropsEqual?: Function, +// forwardRef?: boolean, +// const defaultOptions = { +// areStatesEqual: shallowCompare, +// areOwnPropsEqual: shallowCompare, +// areStatePropsEqual: shallowCompare, +// areMergedPropsEqual: shallowCompare +// }; + +export function connect( + mapStateToProps?: (state: any, ownProps: { [key: string]: any }) => Object, + mapDispatchToProps?: + | { [key: string]: (...args: any[]) => ReduxAction } + | ((dispatch: (action: ReduxAction) => any, ownProps?: Object) => Object), + mergeProps?: (stateProps: Object, dispatchProps: Object, ownProps: Object) => Object, + options?: { + areStatesEqual?: (oldState: any, newState: any) => boolean; + context?: any; // TODO: type this + } +) { + if (!options) { + options = {}; + } + + return Component => { + const useStore = createStoreHook(options.context || DefaultContext); + + function Wrapper(props) { + const [f, forceReload] = useState(true); + + const store = useStore(); + + useEffect(() => { + const unsubscribe = store.subscribe(() => forceReload(!f)); + () => { + unsubscribe(() => forceReload(!f)); + }; + }); + + const previous = useRef({ + state: {}, + }); + + let mappedState; + if (options.areStatesEqual) { + if (options.areStatesEqual(previous.current.state, store.getState())) { + mappedState = previous.current.mappedState; + } else { + mappedState = mapStateToProps ? mapStateToProps(store.getState(), props) : {}; + previous.current.mappedState = mappedState; + } + } else { + mappedState = mapStateToProps ? mapStateToProps(store.getState(), props) : {}; + previous.current.mappedState = mappedState; + } + let mappedDispatch: { dispatch?: (action) => void } = {}; + if (mapDispatchToProps) { + if (typeof mapDispatchToProps === 'object') { + Object.entries(mapDispatchToProps).forEach(([key, value]) => { + mappedDispatch[key] = (...args) => { + store.dispatch(value(...args)); + }; + }); + } else { + mappedDispatch = mapDispatchToProps(store.dispatch, props); + } + } else { + mappedDispatch.dispatch = store.dispatch; + } + const mergedProps = ( + mergeProps || + ((state, dispatch, originalProps) => { + return { ...state, ...dispatch, ...originalProps }; + }) + )(mappedState, mappedDispatch, props); + + previous.current.state = store.getState(); + + const node = createElement(Component, mergedProps); + return node; + } + + return Wrapper; + }; +} diff --git a/libs/horizon/src/horizonx/adapters/reduxThunk.ts b/libs/horizon/src/horizonx/adapters/reduxThunk.ts new file mode 100644 index 00000000..850f1b1c --- /dev/null +++ b/libs/horizon/src/horizonx/adapters/reduxThunk.ts @@ -0,0 +1,22 @@ +import { ReduxStoreHandler, ReduxAction, ReduxMiddleware } from '../types'; + +function createThunkMiddleware(extraArgument?: any): ReduxMiddleware { + return (store: ReduxStoreHandler) => (next: (action: ReduxAction) => any) => ( + action: + | ReduxAction + | ((dispatch: (action: ReduxAction) => void, store: ReduxStoreHandler, extraArgument?: any) => any) + ) => { + // This gets called for every action you dispatch. + // If it's a function, call it. + if (typeof action === 'function') { + return action(store.dispatch, store.getState.bind(store), extraArgument); + } + + // Otherwise, just continue processing this action as usual + return next(action); + }; +} + +export const thunk = createThunkMiddleware(); +// @ts-ignore +thunk.withExtraArgument = createThunkMiddleware; diff --git a/libs/horizon/src/horizonx/proxy/HooklessObserver.ts b/libs/horizon/src/horizonx/proxy/HooklessObserver.ts new file mode 100644 index 00000000..4192774d --- /dev/null +++ b/libs/horizon/src/horizonx/proxy/HooklessObserver.ts @@ -0,0 +1,47 @@ +// TODO: implement vNode type + +import {IObserver} from '../types'; + +/** + * 一个对象(对象、数组、集合)对应一个Observer + * + */ +export class HooklessObserver implements IObserver { + + listeners = []; + vNodeKeys: null; + keyVNodes: null; + + useProp(key: string): void { + } + + addListener(listener: () => void) { + this.listeners.push(listener); + } + + removeListener(listener: () => void) { + this.listeners = this.listeners.filter(item => item != listener); + } + + setProp(key: string): void { + this.triggerChangeListeners(); + } + + triggerChangeListeners(): void { + this.listeners.forEach(listener => { + if (!listener) { + return; + } + listener(); + }); + } + + triggerUpdate(vNode): void { + } + + allChange(): void { + } + + clearByVNode(vNode): void { + } +} diff --git a/libs/horizon/src/horizonx/proxy/Observer.ts b/libs/horizon/src/horizonx/proxy/Observer.ts new file mode 100644 index 00000000..588f3d17 --- /dev/null +++ b/libs/horizon/src/horizonx/proxy/Observer.ts @@ -0,0 +1,101 @@ +/** + * 一个对象(对象、数组、集合)对应一个Observer + */ + +//@ts-ignore +import { launchUpdateFromVNode } from '../../renderer/TreeBuilder'; +import { getProcessingVNode } from '../../renderer/GlobalVar'; +import { VNode } from '../../renderer/vnode/VNode'; +import { IObserver } from '../types'; + +export class Observer implements IObserver { + vNodeKeys = new WeakMap(); + + keyVNodes = new Map(); + + listeners = []; + + useProp(key: string): void { + const processingVNode = getProcessingVNode(); + if (processingVNode === null || !processingVNode.observers) { + return; + } + + // vNode -> Observers + processingVNode.observers.add(this); + + // key -> vNodes + let vNodes = this.keyVNodes.get(key); + if (!vNodes) { + vNodes = new Set(); + this.keyVNodes.set(key, vNodes); + } + vNodes.add(processingVNode); + + // vNode -> keys + let keys = this.vNodeKeys.get(processingVNode); + if (!keys) { + keys = new Set(); + this.vNodeKeys.set(processingVNode, keys); + } + keys.add(key); + } + + addListener(listener: () => void): void { + this.listeners.push(listener); + } + + removeListener(listener: () => void): void { + this.listeners = this.listeners.filter(item => item != listener); + } + + setProp(key: string): void { + const vNodes = this.keyVNodes.get(key); + vNodes?.forEach((vNode: VNode) => { + if (vNode.isStoreChange) { + // update already triggered + return; + } + vNode.isStoreChange = true; + + // 触发vNode更新 + this.triggerUpdate(vNode); + }); + this.triggerChangeListeners(); + } + + triggerChangeListeners(): void { + this.listeners.forEach(listener => listener()); + } + + triggerUpdate(vNode: VNode): void { + if (!vNode) { + return; + } + launchUpdateFromVNode(vNode); + } + + allChange(): void { + let keyIt = this.keyVNodes.keys(); + let keyItem = keyIt.next(); + while (!keyItem.done) { + this.setProp(keyItem.value); + keyItem = keyIt.next(); + } + } + + clearByVNode(vNode: VNode): void { + const keys = this.vNodeKeys.get(vNode); + if (keys) { + keys.forEach((key: any) => { + const vNodes = this.keyVNodes.get(key); + vNodes.delete(vNode); + if (vNodes.size === 0) { + this.keyVNodes.delete(key); + } + }); + } + + this.vNodeKeys.delete(vNode); + } +} diff --git a/libs/horizon/src/horizonx/proxy/ProxyHandler.ts b/libs/horizon/src/horizonx/proxy/ProxyHandler.ts new file mode 100644 index 00000000..b74fd965 --- /dev/null +++ b/libs/horizon/src/horizonx/proxy/ProxyHandler.ts @@ -0,0 +1,61 @@ +import { createObjectProxy } from './handlers/ObjectProxyHandler'; +import { Observer } from './Observer'; +import { HooklessObserver } from './HooklessObserver'; +import { isArray, isCollection, isObject } from '../CommonUtils'; +import { createArrayProxy } from './handlers/ArrayProxyHandler'; +import { createCollectionProxy } from './handlers/CollectionProxyHandler'; + +const OBSERVER_KEY = Symbol('_horizonObserver'); + +const proxyMap = new WeakMap(); + +export const hookObserverMap = new WeakMap(); + +export function createProxy(rawObj: any, hookObserver = true): any { + // 不是对象(是原始数据类型)不用代理 + if (!isObject(rawObj)) { + return rawObj; + } + + const existProxy = proxyMap.get(rawObj); + if (existProxy) { + return existProxy; + } + + // Observer不需要代理 + if (rawObj instanceof Observer) { + return rawObj; + } + + // 创建Observer + let observer = getObserver(rawObj); + if (!observer) { + observer = hookObserver ? new Observer() : new HooklessObserver(); + rawObj[OBSERVER_KEY] = observer; + } + + hookObserverMap.set(rawObj, hookObserver); + + // 创建Proxy + let proxyObj; + if (isArray(rawObj)) { + // 数组 + proxyObj = createArrayProxy(rawObj as []); + } else if (isCollection(rawObj)) { + // 集合 + proxyObj = createCollectionProxy(rawObj); + } else { + // 原生对象 或 函数 + proxyObj = createObjectProxy(rawObj); + } + + proxyMap.set(rawObj, proxyObj); + proxyMap.set(proxyObj, proxyObj); + + return proxyObj; +} + +export function getObserver(rawObj: any): Observer { + return rawObj[OBSERVER_KEY]; +} + diff --git a/libs/horizon/src/horizonx/proxy/handlers/ArrayProxyHandler.ts b/libs/horizon/src/horizonx/proxy/handlers/ArrayProxyHandler.ts new file mode 100644 index 00000000..879dc088 --- /dev/null +++ b/libs/horizon/src/horizonx/proxy/handlers/ArrayProxyHandler.ts @@ -0,0 +1,40 @@ +import { getObserver } from '../ProxyHandler'; +import { isSame, isValidIntegerKey } from '../../CommonUtils'; +import { get as objectGet } from './ObjectProxyHandler'; + +export function createArrayProxy(rawObj: any[]): any[] { + const handle = { + get, + set, + }; + + return new Proxy(rawObj, handle); +} + +function get(rawObj: any[], key: string, receiver: any) { + if (isValidIntegerKey(key) || key === 'length') { + return objectGet(rawObj, key, receiver); + } + return Reflect.get(rawObj, key, receiver); +} + +function set(rawObj: any[], key: string, value: any, receiver: any) { + const oldValue = rawObj[key]; + const oldLength = rawObj.length; + const newValue = value; + + const ret = Reflect.set(rawObj, key, newValue, receiver); + + const newLength = rawObj.length; + const tracker = getObserver(rawObj); + + if (!isSame(newValue, oldValue)) { + tracker.setProp(key); + } + + if (oldLength !== newLength) { + tracker.setProp('length'); + } + + return ret; +} diff --git a/libs/horizon/src/horizonx/proxy/handlers/CollectionProxyHandler.ts b/libs/horizon/src/horizonx/proxy/handlers/CollectionProxyHandler.ts new file mode 100644 index 00000000..ec8214b3 --- /dev/null +++ b/libs/horizon/src/horizonx/proxy/handlers/CollectionProxyHandler.ts @@ -0,0 +1,188 @@ +import { createProxy, getObserver, hookObserverMap } from '../ProxyHandler'; +import { isMap, isWeakMap, isSame } from '../../CommonUtils'; + +const COLLECTION_CHANGE = '_collectionChange'; +const handler = { + get, + set, + add, + delete: deleteFun, + clear, + has, + entries, + forEach, + keys, + values, + [Symbol.iterator]: forOf, +}; + +export function createCollectionProxy(rawObj: Object, hookObserver = true): Object { + const boundHandler = {}; + Object.entries(handler).forEach(([id, val]) => { + boundHandler[id] = (...args: any[]) => { + return (val as any)(...args, hookObserver); + }; + }); + return new Proxy(rawObj, { ...boundHandler }); +} + +function get(rawObj: { size: number }, key: any, receiver: any): any { + if (key === 'size') { + return size(rawObj); + } else if (key === 'get') { + return getFun.bind(null, rawObj); + } else if (Object.prototype.hasOwnProperty.call(handler, key)) { + const value = Reflect.get(handler, key, receiver); + return value.bind(null, rawObj); + } + + return Reflect.get(rawObj, key, receiver); +} + +function getFun(rawObj: { get: (key: any) => any }, key: any) { + const tracker = getObserver(rawObj); + tracker.useProp(key); + + const value = rawObj.get(key); + // 对于value也需要进一步代理 + const valProxy = createProxy(value, hookObserverMap.get(rawObj)); + + return valProxy; +} + +// Map的set方法 +function set( + rawObj: { get: (key: any) => any; set: (key: any, value: any) => any; has: (key: any) => boolean }, + key: any, + value: any +) { + const oldValue = rawObj.get(key); + const newValue = value; + rawObj.set(key, newValue); + const valChange = !isSame(newValue, oldValue); + const tracker = getObserver(rawObj); + + if (valChange || !rawObj.has(key)) { + tracker.setProp(COLLECTION_CHANGE); + } + + if (valChange) { + tracker.setProp(key); + } + + return rawObj; +} + +// Set的add方法 +function add(rawObj: { add: (any) => void; set: (string, any) => any; has: (any) => boolean }, value: any): Object { + if (!rawObj.has(value)) { + rawObj.add(value); + + const tracker = getObserver(rawObj); + tracker.setProp(value); + tracker.setProp(COLLECTION_CHANGE); + } + + return rawObj; +} + +function has(rawObj: { has: (string) => boolean }, key: any): boolean { + const tracker = getObserver(rawObj); + tracker.useProp(key); + + return rawObj.has(key); +} + +function clear(rawObj: { size: number; clear: () => void }) { + const oldSize = rawObj.size; + rawObj.clear(); + + if (oldSize > 0) { + const tracker = getObserver(rawObj); + tracker.allChange(); + } +} + +function deleteFun(rawObj: { has: (key: any) => boolean; delete: (key: any) => void }, key: any) { + if (rawObj.has(key)) { + rawObj.delete(key); + + const tracker = getObserver(rawObj); + tracker.setProp(key); + tracker.setProp(COLLECTION_CHANGE); + + return true; + } + + return false; +} + +function size(rawObj: { size: number }) { + const tracker = getObserver(rawObj); + tracker.useProp(COLLECTION_CHANGE); + return rawObj.size; +} + +function keys(rawObj: { keys: () => { next: () => { value: any; done: boolean } } }) { + return wrapIterator(rawObj, rawObj.keys()); +} + +function values(rawObj: { values: () => { next: () => { value: any; done: boolean } } }) { + return wrapIterator(rawObj, rawObj.values()); +} + +function entries(rawObj: { entries: () => { next: () => { value: any; done: boolean } } }) { + return wrapIterator(rawObj, rawObj.entries(), true); +} + +function forOf(rawObj: { + entries: () => { next: () => { value: any; done: boolean } }; + values: () => { next: () => { value: any; done: boolean } }; +}) { + const isMapType = isMap(rawObj) || isWeakMap(rawObj); + const iterator = isMapType ? rawObj.entries() : rawObj.values(); + return wrapIterator(rawObj, iterator, isMapType); +} + +function forEach( + rawObj: { forEach: (callback: (value: any, key: any) => void) => void }, + callback: (valProxy: any, keyProxy: any, rawObj: any) => void +) { + const tracker = getObserver(rawObj); + tracker.useProp(COLLECTION_CHANGE); + rawObj.forEach((value, key) => { + const valProxy = createProxy(value, hookObserverMap.get(rawObj)); + const keyProxy = createProxy(key, hookObserverMap.get(rawObj)); + // 最后一个参数要返回代理对象 + return callback(valProxy, keyProxy, rawObj); + }); +} + +function wrapIterator(rawObj: Object, rawIt: { next: () => { value: any; done: boolean } }, isPair = false) { + const tracker = getObserver(rawObj); + const hookObserver = hookObserverMap.get(rawObj); + tracker.useProp(COLLECTION_CHANGE); + + return { + next() { + const { value, done } = rawIt.next(); + if (done) { + return { value: createProxy(value, hookObserver), done }; + } + + tracker.useProp(COLLECTION_CHANGE); + + let newVal; + if (isPair) { + newVal = [createProxy(value[0], hookObserver), createProxy(value[1], hookObserver)]; + } else { + newVal = createProxy(value, hookObserver); + } + + return { value: newVal, done }; + }, + [Symbol.iterator]() { + return this; + }, + }; +} diff --git a/libs/horizon/src/horizonx/proxy/handlers/ObjectProxyHandler.ts b/libs/horizon/src/horizonx/proxy/handlers/ObjectProxyHandler.ts new file mode 100644 index 00000000..709c68a9 --- /dev/null +++ b/libs/horizon/src/horizonx/proxy/handlers/ObjectProxyHandler.ts @@ -0,0 +1,50 @@ +import { isSame } from '../../CommonUtils'; +import { createProxy, getObserver, hookObserverMap } from '../ProxyHandler'; + +export function createObjectProxy(rawObj: T): ProxyHandler { + const proxy = new Proxy(rawObj, { + get, + set, + }); + + return proxy; +} + +export function get(rawObj: object, key: string, receiver: any): any { + const observer = getObserver(rawObj); + + if (key === 'addListener') { + return observer.addListener.bind(observer); + } + + if (key === 'removeListener') { + return observer.removeListener.bind(observer); + } + + observer.useProp(key); + + const value = Reflect.get(rawObj, key, receiver); + + // 对于value也需要进一步代理 + const valProxy = createProxy(value, hookObserverMap.get(rawObj)); + + return valProxy; +} + +export function set(rawObj: object, key: string, value: any, receiver: any): boolean { + const observer = getObserver(rawObj); + + if (value && key == 'removeListener') { + observer.removeListener(value); + } + const oldValue = rawObj[key]; + const newValue = value; + + const ret = Reflect.set(rawObj, key, newValue, receiver); + + if (!isSame(newValue, oldValue)) { + observer.setProp(key); + } + + return ret; +} diff --git a/libs/horizon/src/horizonx/proxy/readonlyProxy.ts b/libs/horizon/src/horizonx/proxy/readonlyProxy.ts new file mode 100644 index 00000000..65361c6a --- /dev/null +++ b/libs/horizon/src/horizonx/proxy/readonlyProxy.ts @@ -0,0 +1,25 @@ +import { isObject } from '../CommonUtils'; + +export function readonlyProxy(target: T): ProxyHandler { + return new Proxy(target, { + get(target, property, receiver) { + const result = Reflect.get(target, property, receiver); + try { + if (isObject(result)) { + return readonlyProxy(result); + } + } catch {} + return result; + }, + + set() { + throw Error('Trying to change readonly variable'); + }, + + deleteProperty() { + throw Error('Trying to change readonly variable'); + }, + }); +} + +export default readonlyProxy; diff --git a/libs/horizon/src/horizonx/store/StoreHandler.ts b/libs/horizon/src/horizonx/store/StoreHandler.ts new file mode 100644 index 00000000..5c9c48f7 --- /dev/null +++ b/libs/horizon/src/horizonx/store/StoreHandler.ts @@ -0,0 +1,225 @@ +//@ts-ignore +import { useEffect, useRef } from '../../renderer/hooks/HookExternal'; +import { getProcessingVNode } from '../../renderer/GlobalVar'; +import { createProxy } from '../proxy/ProxyHandler'; +import readonlyProxy from '../proxy/readonlyProxy'; +import { StoreHandler, StoreConfig, UserActions, UserComputedValues } from '../types'; +import { Observer } from '../proxy/Observer'; +import { FunctionComponent, ClassComponent } from '../Constants'; + +const storeMap = new Map(); + +function isPromise(obj: any): boolean { + return !!obj && (typeof obj === 'object' || typeof obj === 'function') && typeof obj.then === 'function'; +} + +export function createStore, C extends UserComputedValues>( + config: StoreConfig +): () => StoreHandler { + let handler: any = { + $subscribe: null, + $unsubscribe: null, + $state: null, + $config: config, + $queue: null, + $actions: {}, + $computed: {}, + }; + + const obj = { + ...config, + config, + plannedActions: [], + rawState: config.state, + rawActions: { ...config.actions }, + }; + + // 校验 + if (Object.prototype.toString.call(obj) !== '[object Object]') { + throw new Error('store obj must be pure object'); + } + + const proxyObj = createProxy(obj.state, !obj.options?.suppressHooks); + proxyObj.$pending = false; + handler.$subscribe = listener => { + proxyObj.addListener(listener); + }; + handler.$unsubscribe = listener => { + proxyObj.removeListener(listener); + }; + obj.rawState = obj.state; + obj.state = proxyObj; + + handler.$state = obj.state; + + handler.$config = obj.config; + + // handles.$reset = ()=>{ + // const keys = Object.keys(obj.state); + // Object.entries(obj.defaultState).forEach(([key,value])=>{ + // obj.state[key]=value; + // }); + // keys.forEach(key => { + // if(!obj.defaultState[key]){ + // delete obj.state[key]; + // } + // }); + // }; + + function tryNextAction() { + if (!obj.plannedActions.length) { + proxyObj.$pending = false; + return; + } + + const nextAction = obj.plannedActions.shift(); + const result = obj.rawActions[nextAction.action].bind(self, obj.state)(...nextAction.payload); + + if (isPromise(result)) { + result.then(value => { + nextAction.resolve(value); + tryNextAction(); + }); + } else { + nextAction.resolve(result); + tryNextAction(); + } + } + + // 包装actions + Object.keys(obj.actions).forEach(key => { + (obj.actions as any)[key] = handler[key] = function Wrapped(...payload) { + return obj.rawActions[key].bind(self, obj.state)(...payload); + }; + }); + + handler.$queue = {}; + Object.keys(obj.rawActions).forEach(action => { + handler.$queue[action] = (...payload) => { + return new Promise(resolve => { + if (!proxyObj.$pending) { + proxyObj.$pending = true; + const result = obj.rawActions[action].bind(self, obj.state)(...payload); + + if (isPromise(result)) { + result.then(value => { + resolve(value); + tryNextAction(); + }); + } else { + resolve(result); + tryNextAction(); + } + } else { + obj.plannedActions.push({ + action, + payload, + resolve, + }); + } + }); + }; + }); + + handler.$actions = obj.actions; + + // native getters + Object.keys(obj.state).forEach(key => { + Object.defineProperty(handler, key, { + get: () => obj.state[key], + }); + }); + + // computed + if (obj.computed) { + Object.keys(obj.computed).forEach(key => { + // supports access through attributes + Object.defineProperty(handler, key, { + get: obj.computed[key].bind(handler, readonlyProxy(obj.state)), + }); + + // supports access through function + (obj.computed as any)[key] = obj.computed[key].bind(handler, readonlyProxy(obj.state)); + }); + } + handler.$computed = obj.computed || {}; + + if (config.id) { + storeMap.set(config.id, handler); + } + + return createStoreHook(handler); +} + +function clearVNodeObservers(vNode) { + vNode.observers.forEach(observer => { + observer.clearByVNode(vNode); + }); + + vNode.observers.clear(); +} + +function hookStore() { + const processingVNode = getProcessingVNode(); + + // did not execute in a component + if (!processingVNode) { + return; + } + + if (processingVNode.observers) { + // 清除上一次缓存的Observer依赖 + clearVNodeObservers(processingVNode); + } else { + processingVNode.observers = new Set(); + } + + if (processingVNode.tag === FunctionComponent) { + // from FunctionComponent + const vNodeRef = useRef(null); + vNodeRef.current = processingVNode; + + useEffect(() => { + return () => { + clearVNodeObservers(vNodeRef.current); + vNodeRef.current.observers = null; + }; + }, []); + } else if (processingVNode.tag === ClassComponent) { + // from ClassComponent + if (!processingVNode.classComponentWillUnmount) { + processingVNode.classComponentWillUnmount = function(vNode) { + clearVNodeObservers(vNode); + vNode.observers = null; + }; + } + } +} + +function createStoreHook, C extends UserComputedValues>( + storeHandler: StoreHandler +): () => StoreHandler { + return () => { + if (!storeHandler.$config.options?.suppressHooks) { + hookStore(); + } + + return storeHandler; + }; +} + +export function useStore, C extends UserComputedValues>( + id: string +): StoreHandler { + const storeObj = storeMap.get(id); + + if (!storeObj.$config.options?.suppressHooks) { + hookStore(); + } + + return storeObj; +} + +export function clearStore(id: string): void { + storeMap.delete(id); +} diff --git a/libs/horizon/src/horizonx/types.d.ts b/libs/horizon/src/horizonx/types.d.ts new file mode 100644 index 00000000..e0816e89 --- /dev/null +++ b/libs/horizon/src/horizonx/types.d.ts @@ -0,0 +1,75 @@ +export interface IObserver { + vNodeKeys: WeakMap; + + keyVNodes: Map; + + listeners: (() => void)[]; + + useProp: (key: string) => void; + + addListener: (listener: () => void) => void; + + removeListener: (listener: () => void) => void; + + setProp: (key: string) => void; + + triggerChangeListeners: () => void; + + triggerUpdate: (vNode: any) => void; + + allChange: () => void; + + clearByVNode: (vNode: any) => void; +} + +type UserActions = { [K: string]: StateFunction }; +type UserComputedValues = { [K: string]: StateFunction }; + +type StateFunction = (state: S, ...args: any[]) => any; +type StoreActions> = { [K in keyof A]: A[K] }; +type ComputedValues> = { [K in keyof C]: C[K] }; +type PostponedAction = (state: object, ...args: any[]) => Promise; +type PostponedActions = { [key: string]: PostponedAction }; + +export type StoreHandler, C extends UserComputedValues> = { + $subscribe: (listener: () => void) => void; + $unsubscribe: (listener: () => void) => void; + $state: S; + $config: StoreConfig; + $queue: StoreActions; + $actions: StoreActions; + $computed: StoreActions; + reduxHandler?: ReduxStoreHandler; +} & { [K in keyof S]: S[K] } & + { [K in keyof A]: A[K] } & + { [K in keyof C]: C[K] }; + +export type StoreConfig, C extends UserComputedValues> = { + state?: S; + options?: { suppressHooks?: boolean }; + actions?: A; + id?: string; + computed?: C; +}; + +type ReduxStoreHandler = { + reducer: (state: any, action: { type: string }) => any; + dispatch: (action: { type: string }) => void; + getState: () => any; + subscribe: (listener: () => void) => (listener: () => void) => void; +}; + +type ReduxAction = { + type: string; +}; + +type ReduxMiddleware = ( + store: ReduxStoreHandler, + extraArgument?: any +) => ( + next: (action: ReduxAction) => any +) => ( + action: + | ReduxAction + | ((dispatch: (action: ReduxAction) => void, store: ReduxStoreHandler, extraArgument?: any) => any) +) => ReduxStoreHandler; diff --git a/scripts/__tests__/HorizonXText/StateManager/StateArray.test.js b/scripts/__tests__/HorizonXText/StateManager/StateArray.test.js new file mode 100644 index 00000000..826d6dda --- /dev/null +++ b/scripts/__tests__/HorizonXText/StateManager/StateArray.test.js @@ -0,0 +1,201 @@ +import * as Horizon from '@cloudsop/horizon/index.ts'; +import { clearStore, createStore, useStore } from '../../../../libs/horizon/src/horizonx/store/StoreHandler'; +import { App, Text, triggerClickEvent } from '../../jest/commonComponents'; + +describe('测试store中的Array', () => { + const { unmountComponentAtNode } = Horizon; + let container = null; + beforeEach(() => { + // 创建一个 DOM 元素作为渲染目标 + container = document.createElement('div'); + document.body.appendChild(container); + + const persons = [ + { name: 'p1', age: 1 }, + { name: 'p2', age: 2 }, + ]; + + createStore({ + id: 'user', + state: { + type: 'bing dun dun', + persons: persons, + }, + actions: { + addOnePerson: (state, person) => { + state.persons.push(person); + }, + delOnePerson: state => { + state.persons.pop(); + }, + clearPersons: state => { + state.persons = null; + }, + }, + }); + }); + + afterEach(() => { + // 退出时进行清理 + unmountComponentAtNode(container); + container.remove(); + container = null; + + clearStore('user'); + }); + + const newPerson = { name: 'p3', age: 3 }; + + function Parent(props) { + const userStore = useStore('user'); + const addOnePerson = function() { + userStore.addOnePerson(newPerson); + }; + const delOnePerson = function() { + userStore.delOnePerson(); + }; + return ( +
+ + +
{props.children}
+
+ ); + } + + it('测试Array方法: push()、pop()', () => { + function Child(props) { + const userStore = useStore('user'); + + return ( +
+ +
+ ); + } + + Horizon.render(, container); + + expect(container.querySelector('#hasPerson').innerHTML).toBe('has new person: 2'); + // 在Array中增加一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container.querySelector('#hasPerson').innerHTML).toBe('has new person: 3'); + + // 在Array中删除一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'delBtn'); + }); + expect(container.querySelector('#hasPerson').innerHTML).toBe('has new person: 2'); + }); + + it('测试Array方法: entries()、push()、shift()、unshift、直接赋值', () => { + let globalStore = null; + + function Child(props) { + const userStore = useStore('user'); + globalStore = userStore; + + const nameList = []; + const entries = userStore.$state.persons?.entries(); + if (entries) { + for (const entry of entries) { + nameList.push(entry[1].name); + } + } + + return ( +
+ +
+ ); + } + + Horizon.render(, container); + + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + // push + globalStore.$state.persons.push(newPerson); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2 p3'); + + // shift + globalStore.$state.persons.shift({ name: 'p0', age: 0 }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p2 p3'); + + // 赋值[2] + globalStore.$state.persons[2] = { name: 'p4', age: 4 }; + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p2 p3 p4'); + + // 重新赋值[2] + globalStore.$state.persons[2] = { name: 'p5', age: 5 }; + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p2 p3 p5'); + + // unshift + globalStore.$state.persons.unshift({ name: 'p1', age: 1 }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2 p3 p5'); + + // 重新赋值 null + globalStore.$state.persons = null; + expect(container.querySelector('#nameList').innerHTML).toBe('name list: '); + + // 重新赋值 [{ name: 'p1', age: 1 }] + globalStore.$state.persons = [{ name: 'p1', age: 1 }]; + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1'); + }); + + it('测试Array方法: forEach()', () => { + let globalStore = null; + + function Child(props) { + const userStore = useStore('user'); + globalStore = userStore; + + const nameList = []; + userStore.$state.persons?.forEach(per => { + nameList.push(per.name); + }); + + return ( +
+ +
+ ); + } + + Horizon.render(, container); + + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + // push + globalStore.$state.persons.push(newPerson); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2 p3'); + + // shift + globalStore.$state.persons.shift({ name: 'p0', age: 0 }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p2 p3'); + + // 赋值[2] + globalStore.$state.persons[2] = { name: 'p4', age: 4 }; + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p2 p3 p4'); + + // 重新赋值[2] + globalStore.$state.persons[2] = { name: 'p5', age: 5 }; + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p2 p3 p5'); + + // unshift + globalStore.$state.persons.unshift({ name: 'p1', age: 1 }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2 p3 p5'); + + // 重新赋值 null + globalStore.$state.persons = null; + expect(container.querySelector('#nameList').innerHTML).toBe('name list: '); + + // 重新赋值 [{ name: 'p1', age: 1 }] + globalStore.$state.persons = [{ name: 'p1', age: 1 }]; + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1'); + }); +}); diff --git a/scripts/__tests__/HorizonXText/StateManager/StateMap.test.js b/scripts/__tests__/HorizonXText/StateManager/StateMap.test.js new file mode 100644 index 00000000..f7eaed9f --- /dev/null +++ b/scripts/__tests__/HorizonXText/StateManager/StateMap.test.js @@ -0,0 +1,323 @@ +import * as Horizon from '@cloudsop/horizon/index.ts'; +import { clearStore, createStore, useStore } from '../../../../libs/horizon/src/horizonx/store/StoreHandler'; +import { App, Text, triggerClickEvent } from '../../jest/commonComponents'; + +describe('测试store中的Map', () => { + const { unmountComponentAtNode } = Horizon; + let container = null; + beforeEach(() => { + // 创建一个 DOM 元素作为渲染目标 + container = document.createElement('div'); + document.body.appendChild(container); + + const persons = new Map([ + ['p1', 1], + ['p2', 2], + ]); + + createStore({ + id: 'user', + state: { + type: 'bing dun dun', + persons: persons, + }, + actions: { + addOnePerson: (state, person) => { + state.persons.set(person.name, person.age); + }, + delOnePerson: (state, person) => { + state.persons.delete(person.name); + }, + clearPersons: state => { + state.persons.clear(); + }, + }, + }); + }); + + afterEach(() => { + // 退出时进行清理 + unmountComponentAtNode(container); + container.remove(); + container = null; + + clearStore('user'); + }); + + const newPerson = { name: 'p3', age: 3 }; + + function Parent(props) { + const userStore = useStore('user'); + const addOnePerson = function() { + userStore.addOnePerson(newPerson); + }; + const delOnePerson = function() { + userStore.delOnePerson(newPerson); + }; + const clearPersons = function() { + userStore.clearPersons(); + }; + + return ( +
+ + + +
{props.children}
+
+ ); + } + + it('测试Map方法: set()、delete()、clear()', () => { + function Child(props) { + const userStore = useStore('user'); + + return ( +
+ +
+ ); + } + + Horizon.render(, container); + + expect(container.querySelector('#size').innerHTML).toBe('persons number: 2'); + // 在Map中增加一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container.querySelector('#size').innerHTML).toBe('persons number: 3'); + + // 在Map中删除一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'delBtn'); + }); + expect(container.querySelector('#size').innerHTML).toBe('persons number: 2'); + + // clear Map + Horizon.act(() => { + triggerClickEvent(container, 'clearBtn'); + }); + expect(container.querySelector('#size').innerHTML).toBe('persons number: 0'); + }); + + it('测试Map方法: keys()', () => { + function Child(props) { + const userStore = useStore('user'); + + const nameList = []; + const keys = userStore.$state.persons.keys(); + for (const key of keys) { + nameList.push(key); + } + + return ( +
+ +
+ ); + } + + Horizon.render(, container); + + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + // 在Map中增加一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2 p3'); + + // 在Map中删除一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'delBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + + // clear Map + Horizon.act(() => { + triggerClickEvent(container, 'clearBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: '); + }); + + it('测试Map方法: values()', () => { + function Child(props) { + const userStore = useStore('user'); + + const ageList = []; + const values = userStore.$state.persons.values(); + for (const val of values) { + ageList.push(val); + } + + return ( +
+ +
+ ); + } + + Horizon.render(, container); + + expect(container.querySelector('#ageList').innerHTML).toBe('age list: 1 2'); + // 在Map中增加一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container.querySelector('#ageList').innerHTML).toBe('age list: 1 2 3'); + + // 在Map中删除一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'delBtn'); + }); + expect(container.querySelector('#ageList').innerHTML).toBe('age list: 1 2'); + + // clear Map + Horizon.act(() => { + triggerClickEvent(container, 'clearBtn'); + }); + expect(container.querySelector('#ageList').innerHTML).toBe('age list: '); + }); + + it('测试Map方法: entries()', () => { + function Child(props) { + const userStore = useStore('user'); + + const nameList = []; + const entries = userStore.$state.persons.entries(); + for (const entry of entries) { + nameList.push(entry[0]); + } + + return ( +
+ +
+ ); + } + + Horizon.render(, container); + + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + // 在Map中增加一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2 p3'); + + // 在Map中删除一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'delBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + + // clear Map + Horizon.act(() => { + triggerClickEvent(container, 'clearBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: '); + }); + + it('测试Map方法: forEach()', () => { + function Child(props) { + const userStore = useStore('user'); + + const nameList = []; + userStore.$state.persons.forEach((val, key) => { + nameList.push(key); + }); + + return ( +
+ +
+ ); + } + + Horizon.render(, container); + + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + // 在Map中增加一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2 p3'); + + // 在Map中删除一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'delBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + + // clear Map + Horizon.act(() => { + triggerClickEvent(container, 'clearBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: '); + }); + + it('测试Map方法: has()', () => { + function Child(props) { + const userStore = useStore('user'); + + return ( +
+ +
+ ); + } + + Horizon.render(, container); + + expect(container.querySelector('#hasPerson').innerHTML).toBe('has new person: false'); + // 在Map中增加一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container.querySelector('#hasPerson').innerHTML).toBe('has new person: true'); + }); + + it('测试Map方法: for of()', () => { + function Child(props) { + const userStore = useStore('user'); + + const nameList = []; + for (const per of userStore.$state.persons) { + nameList.push(per[0]); + } + + return ( +
+ +
+ ); + } + + Horizon.render(, container); + + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + // 在Map中增加一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2 p3'); + + // 在Map中删除一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'delBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + + // clear Map + Horizon.act(() => { + triggerClickEvent(container, 'clearBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: '); + }); +}); diff --git a/scripts/__tests__/HorizonXText/StateManager/StateMixType.test.js b/scripts/__tests__/HorizonXText/StateManager/StateMixType.test.js new file mode 100644 index 00000000..870d7d26 --- /dev/null +++ b/scripts/__tests__/HorizonXText/StateManager/StateMixType.test.js @@ -0,0 +1,164 @@ +import * as Horizon from '@cloudsop/horizon/index.ts'; +import { clearStore, createStore, useStore } from '../../../../libs/horizon/src/horizonx/store/StoreHandler'; +import { App, Text, triggerClickEvent } from '../../jest/commonComponents'; + +describe('测试store中的混合类型变化', () => { + const { unmountComponentAtNode } = Horizon; + let container = null; + beforeEach(() => { + // 创建一个 DOM 元素作为渲染目标 + container = document.createElement('div'); + document.body.appendChild(container); + + const persons = new Set([{ name: 'p1', age: 1, love: new Map() }]); + persons.add({ + name: 'p2', + age: 2, + love: new Map(), + }); + persons + .values() + .next() + .value.love.set('lanqiu', { moneny: 100, days: [1, 3, 5] }); + + createStore({ + id: 'user', + state: { + type: 'bing dun dun', + persons: persons, + }, + actions: { + addDay: (state, day) => { + state.persons + .values() + .next() + .value.love.get('lanqiu') + .days.push(day); + }, + }, + }); + }); + + afterEach(() => { + // 退出时进行清理 + unmountComponentAtNode(container); + container.remove(); + container = null; + + clearStore('user'); + }); + + function Parent(props) { + const userStore = useStore('user'); + const addDay = function() { + userStore.addDay(7); + }; + + return ( +
+ +
{props.children}
+
+ ); + } + + it('测试state -> set -> map -> array的数据变化', () => { + function Child(props) { + const userStore = useStore('user'); + + const days = userStore.$state.persons + .values() + .next() + .value.love.get('lanqiu').days; + + return ( +
+ +
+ ); + } + + Horizon.render(, container); + + expect(container.querySelector('#dayList').innerHTML).toBe('love: 1 3 5'); + Horizon.act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container.querySelector('#dayList').innerHTML).toBe('love: 1 3 5 7'); + }); + + it('属性是个class实例', () => { + class Person { + name; + age; + loves = new Set(); + + constructor(name, age) { + this.name = name; + this.age = age; + } + + setName(name) { + this.name = name; + } + + getName() { + return this.name; + } + + setAge(age) { + this.age = age; + } + + getAge() { + return this.age; + } + + addLove(lv) { + this.loves.add(lv); + } + + getLoves() { + return this.loves; + } + } + + let globalPerson; + let globalStore; + + function Child(props) { + const userStore = useStore('user'); + globalStore = userStore; + + const nameList = []; + const valIterator = userStore.$state.persons.values(); + let per = valIterator.next(); + while (!per.done) { + nameList.push(per.value.name ?? per.value.getName()); + globalPerson = per.value; + per = valIterator.next(); + } + + return ( +
+ +
+ ); + } + + Horizon.render(, container); + + expect(container.querySelector('#nameList').innerHTML).toBe('p1 p2'); + + // 动态增加一个Person实例 + globalStore.$state.persons.add(new Person('ClassPerson', 5)); + + expect(container.querySelector('#nameList').innerHTML).toBe('p1 p2 ClassPerson'); + + globalPerson.setName('ClassPerson1'); + + expect(container.querySelector('#nameList').innerHTML).toBe('p1 p2 ClassPerson1'); + }); +}); diff --git a/scripts/__tests__/HorizonXText/StateManager/StateSet.test.js b/scripts/__tests__/HorizonXText/StateManager/StateSet.test.js new file mode 100644 index 00000000..4b6bdc7b --- /dev/null +++ b/scripts/__tests__/HorizonXText/StateManager/StateSet.test.js @@ -0,0 +1,294 @@ +import * as Horizon from '@cloudsop/horizon/index.ts'; +import { clearStore, createStore, useStore } from '../../../../libs/horizon/src/horizonx/store/StoreHandler'; +import { App, Text, triggerClickEvent } from '../../jest/commonComponents'; + +describe('测试store中的Set', () => { + const { unmountComponentAtNode } = Horizon; + let container = null; + beforeEach(() => { + // 创建一个 DOM 元素作为渲染目标 + container = document.createElement('div'); + document.body.appendChild(container); + + const persons = new Set([ + { name: 'p1', age: 1 }, + { name: 'p2', age: 2 }, + ]); + + createStore({ + id: 'user', + state: { + type: 'bing dun dun', + persons: persons, + }, + actions: { + addOnePerson: (state, person) => { + state.persons.add(person); + }, + delOnePerson: (state, person) => { + state.persons.delete(person); + }, + clearPersons: state => { + state.persons.clear(); + }, + }, + }); + }); + + afterEach(() => { + // 退出时进行清理 + unmountComponentAtNode(container); + container.remove(); + container = null; + + clearStore('user'); + }); + + const newPerson = { name: 'p3', age: 3 }; + + function Parent(props) { + const userStore = useStore('user'); + const addOnePerson = function() { + userStore.addOnePerson(newPerson); + }; + const delOnePerson = function() { + userStore.delOnePerson(newPerson); + }; + const clearPersons = function() { + userStore.clearPersons(); + }; + + return ( +
+ + + +
{props.children}
+
+ ); + } + + it('测试Set方法: add()、delete()、clear()', () => { + function Child(props) { + const userStore = useStore('user'); + const personArr = Array.from(userStore.$state.persons); + const nameList = []; + const keys = userStore.$state.persons.keys(); + for (const key of keys) { + nameList.push(key.name); + } + + return ( +
+ + +
+ ); + } + + Horizon.render(, container); + + expect(container.querySelector('#size').innerHTML).toBe('persons number: 2'); + expect(container.querySelector('#lastAge').innerHTML).toBe('last person age: 2'); + // 在set中增加一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container.querySelector('#size').innerHTML).toBe('persons number: 3'); + + // 在set中删除一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'delBtn'); + }); + expect(container.querySelector('#size').innerHTML).toBe('persons number: 2'); + + // clear set + Horizon.act(() => { + triggerClickEvent(container, 'clearBtn'); + }); + expect(container.querySelector('#size').innerHTML).toBe('persons number: 0'); + expect(container.querySelector('#lastAge').innerHTML).toBe('last person age: 0'); + }); + + it('测试Set方法: keys()、values()', () => { + function Child(props) { + const userStore = useStore('user'); + + const nameList = []; + const keys = userStore.$state.persons.keys(); + // const keys = userStore.$state.persons.values(); + for (const key of keys) { + nameList.push(key.name); + } + + return ( +
+ +
+ ); + } + + Horizon.render(, container); + + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + // 在set中增加一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2 p3'); + + // 在set中删除一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'delBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + + // clear set + Horizon.act(() => { + triggerClickEvent(container, 'clearBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: '); + }); + + it('测试Set方法: entries()', () => { + function Child(props) { + const userStore = useStore('user'); + + const nameList = []; + const entries = userStore.$state.persons.entries(); + for (const entry of entries) { + nameList.push(entry[0].name); + } + + return ( +
+ +
+ ); + } + + Horizon.render(, container); + + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + // 在set中增加一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2 p3'); + + // 在set中删除一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'delBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + + // clear set + Horizon.act(() => { + triggerClickEvent(container, 'clearBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: '); + }); + + it('测试Set方法: forEach()', () => { + function Child(props) { + const userStore = useStore('user'); + + const nameList = []; + userStore.$state.persons.forEach(per => { + nameList.push(per.name); + }); + + return ( +
+ +
+ ); + } + + Horizon.render(, container); + + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + // 在set中增加一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2 p3'); + + // 在set中删除一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'delBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + + // clear set + Horizon.act(() => { + triggerClickEvent(container, 'clearBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: '); + }); + + it('测试Set方法: has()', () => { + function Child(props) { + const userStore = useStore('user'); + + return ( +
+ +
+ ); + } + + Horizon.render(, container); + + expect(container.querySelector('#hasPerson').innerHTML).toBe('has new person: false'); + // 在set中增加一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container.querySelector('#hasPerson').innerHTML).toBe('has new person: true'); + }); + + it('测试Set方法: for of()', () => { + function Child(props) { + const userStore = useStore('user'); + + const nameList = []; + for (const per of userStore.$state.persons) { + nameList.push(per.name); + } + + return ( +
+ +
+ ); + } + + Horizon.render(, container); + + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + // 在set中增加一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2 p3'); + + // 在set中删除一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'delBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + + // clear set + Horizon.act(() => { + triggerClickEvent(container, 'clearBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: '); + }); +}); diff --git a/scripts/__tests__/HorizonXText/StateManager/StateWeakMap.test.js b/scripts/__tests__/HorizonXText/StateManager/StateWeakMap.test.js new file mode 100644 index 00000000..28d4ff98 --- /dev/null +++ b/scripts/__tests__/HorizonXText/StateManager/StateWeakMap.test.js @@ -0,0 +1,124 @@ +import * as Horizon from '@cloudsop/horizon/index.ts'; +import { clearStore, createStore, useStore } from '../../../../libs/horizon/src/horizonx/store/StoreHandler'; +import { App, Text, triggerClickEvent } from '../../jest/commonComponents'; + +describe('测试store中的WeakMap', () => { + const { unmountComponentAtNode } = Horizon; + let container = null; + beforeEach(() => { + // 创建一个 DOM 元素作为渲染目标 + container = document.createElement('div'); + document.body.appendChild(container); + + const persons = new WeakMap([ + [{ name: 'p1' }, 1], + [{ name: 'p2' }, 2], + ]); + + createStore({ + id: 'user', + state: { + type: 'bing dun dun', + persons: persons, + }, + actions: { + addOnePerson: (state, person) => { + state.persons.set(person, 3); + }, + delOnePerson: (state, person) => { + state.persons.delete(person); + }, + clearPersons: state => { + state.persons.clear(); + }, + }, + }); + }); + + afterEach(() => { + // 退出时进行清理 + unmountComponentAtNode(container); + container.remove(); + container = null; + + clearStore('user'); + }); + + const newPerson = { name: 'p3' }; + + function Parent(props) { + const userStore = useStore('user'); + const addOnePerson = function() { + userStore.addOnePerson(newPerson); + }; + const delOnePerson = function() { + userStore.delOnePerson(newPerson); + }; + const clearPersons = function() { + userStore.clearPersons(); + }; + + return ( +
+ + + +
{props.children}
+
+ ); + } + + it('测试WeakMap方法: set()、delete()、has()', () => { + function Child(props) { + const userStore = useStore('user'); + + return ( +
+ +
+ ); + } + + Horizon.render(, container); + + expect(container.querySelector('#hasPerson').innerHTML).toBe('has new person: false'); + // 在WeakMap中增加一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container.querySelector('#hasPerson').innerHTML).toBe('has new person: true'); + + // 在WeakMap中删除一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'delBtn'); + }); + expect(container.querySelector('#hasPerson').innerHTML).toBe('has new person: false'); + }); + + it('测试WeakMap方法: get()', () => { + function Child(props) { + const userStore = useStore('user'); + + return ( +
+ +
+ ); + } + + Horizon.render(, container); + + expect(container.querySelector('#hasPerson').innerHTML).toBe('has new person: undefined'); + // 在WeakMap中增加一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container.querySelector('#hasPerson').innerHTML).toBe('has new person: 3'); + }); +}); diff --git a/scripts/__tests__/HorizonXText/StateManager/StateWeakSet.test.js b/scripts/__tests__/HorizonXText/StateManager/StateWeakSet.test.js new file mode 100644 index 00000000..4c6a8fca --- /dev/null +++ b/scripts/__tests__/HorizonXText/StateManager/StateWeakSet.test.js @@ -0,0 +1,96 @@ +import * as Horizon from '@cloudsop/horizon/index.ts'; +import { clearStore, createStore, useStore } from '../../../../libs/horizon/src/horizonx/store/StoreHandler'; +import { App, Text, triggerClickEvent } from '../../jest/commonComponents'; + +describe('测试store中的WeakSet', () => { + const { unmountComponentAtNode } = Horizon; + let container = null; + beforeEach(() => { + // 创建一个 DOM 元素作为渲染目标 + container = document.createElement('div'); + document.body.appendChild(container); + + const persons = new WeakSet([ + { name: 'p1', age: 1 }, + { name: 'p2', age: 2 }, + ]); + + createStore({ + id: 'user', + state: { + type: 'bing dun dun', + persons: persons, + }, + actions: { + addOnePerson: (state, person) => { + state.persons.add(person); + }, + delOnePerson: (state, person) => { + state.persons.delete(person); + }, + clearPersons: state => { + state.persons.clear(); + }, + }, + }); + }); + + afterEach(() => { + // 退出时进行清理 + unmountComponentAtNode(container); + container.remove(); + container = null; + + clearStore('user'); + }); + + const newPerson = { name: 'p3', age: 3 }; + + function Parent(props) { + const userStore = useStore('user'); + const addOnePerson = function() { + userStore.addOnePerson(newPerson); + }; + const delOnePerson = function() { + userStore.delOnePerson(newPerson); + }; + return ( +
+ + +
{props.children}
+
+ ); + } + + it('测试WeakSet方法: add()、delete()、has()', () => { + function Child(props) { + const userStore = useStore('user'); + + return ( +
+ +
+ ); + } + + Horizon.render(, container); + + expect(container.querySelector('#hasPerson').innerHTML).toBe('has new person: false'); + // 在WeakSet中增加一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container.querySelector('#hasPerson').innerHTML).toBe('has new person: true'); + + // 在WeakSet中删除一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'delBtn'); + }); + expect(container.querySelector('#hasPerson').innerHTML).toBe('has new person: false'); + }); +}); diff --git a/scripts/__tests__/HorizonXText/StoreFunctionality/async.test.js b/scripts/__tests__/HorizonXText/StoreFunctionality/async.test.js new file mode 100644 index 00000000..f45599d2 --- /dev/null +++ b/scripts/__tests__/HorizonXText/StoreFunctionality/async.test.js @@ -0,0 +1,161 @@ +import * as Horizon from '@cloudsop/horizon/index.ts'; +import { createStore } from '../../../../libs/horizon/src/horizonx/store/StoreHandler'; +import { triggerClickEvent } from '../../jest/commonComponents'; + +const { unmountComponentAtNode } = Horizon; + +function postpone(timer, func) { + return new Promise(resolve => { + setTimeout(function() { + resolve(func()); + }, timer); + }); +} + +describe('Asynchronous functions', () => { + let container = null; + + const COUNTER_ID = 'counter'; + const TOGGLE_ID = 'toggle'; + const TOGGLE_FAST_ID = 'toggleFast'; + const RESULT_ID = 'result'; + + let useAsyncCounter; + + beforeEach(() => { + useAsyncCounter = createStore({ + state: { + counter: 0, + check: false, + }, + actions: { + increment: function(state) { + return new Promise(resolve => { + setTimeout(() => { + state.counter++; + resolve(); + }, 100); + }); + }, + toggle: function(state) { + state.check = !state.check; + }, + }, + computed: { + value: state => { + return (state.check ? 'true' : 'false') + state.counter; + }, + }, + }); + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + unmountComponentAtNode(container); + container.remove(); + container = null; + }); + + it('Should wait for async actions', async () => { + jest.useRealTimers(); + let globalStore; + + function App() { + const store = useAsyncCounter(); + globalStore = store; + + return ( +
+

{store.value}

+ + + +
+ ); + } + + Horizon.render(, container); + + // initial state + expect(document.getElementById(RESULT_ID).innerHTML).toBe('false0'); + + // slow toggle has nothing to wait for, it is resolved immediately + Horizon.act(() => { + triggerClickEvent(container, TOGGLE_ID); + }); + + expect(document.getElementById(RESULT_ID).innerHTML).toBe('true0'); + + // counter increment is slow. slow toggle waits for result + Horizon.act(() => { + triggerClickEvent(container, COUNTER_ID); + }); + Horizon.act(() => { + triggerClickEvent(container, TOGGLE_ID); + }); + + expect(document.getElementById(RESULT_ID).innerHTML).toBe('true0'); + + // fast toggle does not wait for counter and it is resolved immediately + Horizon.act(() => { + triggerClickEvent(container, TOGGLE_FAST_ID); + }); + + expect(document.getElementById(RESULT_ID).innerHTML).toBe('false0'); + + // at 150ms counter increment will be resolved and slow toggle immediately after + const t150 = postpone(150, () => { + expect(document.getElementById(RESULT_ID).innerHTML).toBe('true1'); + }); + + // before that, two more actions are added to queue - another counter and slow toggle + Horizon.act(() => { + triggerClickEvent(container, COUNTER_ID); + }); + Horizon.act(() => { + triggerClickEvent(container, TOGGLE_ID); + }); + + // at 250ms they should be already resolved + const t250 = postpone(250, () => { + expect(document.getElementById(RESULT_ID).innerHTML).toBe('false2'); + }); + + await Promise.all([t150, t250]); + }); + + it('call async action by then', async () => { + jest.useFakeTimers(); + let globalStore; + + function App() { + const store = useAsyncCounter(); + globalStore = store; + + return ( +
+

{store.value}

+
+ ); + } + + Horizon.render(, container); + + // call async action by then + globalStore.$queue.increment().then(() => { + expect(document.getElementById(RESULT_ID).innerHTML).toBe('false1'); + }); + + expect(document.getElementById(RESULT_ID).innerHTML).toBe('false0'); + + // past 150 ms + jest.advanceTimersByTime(150); + }); +}); diff --git a/scripts/__tests__/HorizonXText/StoreFunctionality/basicAccess.test.js b/scripts/__tests__/HorizonXText/StoreFunctionality/basicAccess.test.js new file mode 100644 index 00000000..e42feeda --- /dev/null +++ b/scripts/__tests__/HorizonXText/StoreFunctionality/basicAccess.test.js @@ -0,0 +1,63 @@ +import * as Horizon from '@cloudsop/horizon/index.ts'; +import { triggerClickEvent } from '../../jest/commonComponents'; +import { useLogStore } from './store'; + +const { unmountComponentAtNode } = Horizon; + +describe('Basic store manipulation', () => { + let container = null; + + const BUTTON_ID = 'btn'; + const RESULT_ID = 'result'; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + unmountComponentAtNode(container); + container.remove(); + container = null; + }); + + it('Should use getters', () => { + function App() { + const logStore = useLogStore(); + + return
{logStore.length}
; + } + + Horizon.render(, container); + + expect(document.getElementById(RESULT_ID).innerHTML).toBe('1'); + }); + + it('Should use actions and update components', () => { + function App() { + const logStore = useLogStore(); + + return ( +
+ +

{logStore.length}

+
+ ); + } + + Horizon.render(, container); + + Horizon.act(() => { + triggerClickEvent(container, BUTTON_ID); + }); + + expect(document.getElementById(RESULT_ID).innerHTML).toBe('2'); + }); +}); diff --git a/scripts/__tests__/HorizonXText/StoreFunctionality/dollarAccess.test.js b/scripts/__tests__/HorizonXText/StoreFunctionality/dollarAccess.test.js new file mode 100644 index 00000000..032234d0 --- /dev/null +++ b/scripts/__tests__/HorizonXText/StoreFunctionality/dollarAccess.test.js @@ -0,0 +1,63 @@ +import * as Horizon from '@cloudsop/horizon/index.ts'; +import { triggerClickEvent } from '../../jest/commonComponents'; +import { useLogStore } from './store'; + +const { unmountComponentAtNode } = Horizon; + +describe('Dollar store access', () => { + let container = null; + + const BUTTON_ID = 'btn'; + const RESULT_ID = 'result'; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + unmountComponentAtNode(container); + container.remove(); + container = null; + }); + + it('Should use $state and $computed', () => { + function App() { + const logStore = useLogStore(); + + return
{logStore.$computed.length()}
; + } + + Horizon.render(, container); + + expect(document.getElementById(RESULT_ID).innerHTML).toBe('1'); + }); + + it('Should use $actions and update components', () => { + function App() { + const logStore = useLogStore(); + + return ( +
+ +

{logStore.$computed.length()}

+
+ ); + } + + Horizon.render(, container); + + Horizon.act(() => { + triggerClickEvent(container, BUTTON_ID); + }); + + expect(document.getElementById(RESULT_ID).innerHTML).toBe('2'); + }); +}); diff --git a/scripts/__tests__/HorizonXText/StoreFunctionality/otherCases.test.js b/scripts/__tests__/HorizonXText/StoreFunctionality/otherCases.test.js new file mode 100644 index 00000000..115164c0 --- /dev/null +++ b/scripts/__tests__/HorizonXText/StoreFunctionality/otherCases.test.js @@ -0,0 +1,148 @@ +import * as Horizon from '@cloudsop/horizon/index.ts'; +import { createStore } from '../../../../libs/horizon/src/horizonx/store/StoreHandler'; +import { triggerClickEvent } from '../../jest/commonComponents'; + +const { unmountComponentAtNode } = Horizon; + +describe('Self referencing', () => { + let container = null; + + const BUTTON_ID = 'btn'; + const RESULT_ID = 'result'; + + const useSelfRefStore = createStore({ + state: { + val: 2, + }, + actions: { + magic: function(state) { + state.val = state.val * 2 - 1; + }, + }, + computed: { + value: state => state.val, + double: function() { + return this.value * 2; + }, + }, + }); + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + unmountComponentAtNode(container); + container.remove(); + container = null; + }); + + it('Should use own getters', () => { + function App() { + const store = useSelfRefStore(); + + return ( +
+

{store.double}

+ +
+ ); + } + + Horizon.render(, container); + + expect(document.getElementById(RESULT_ID).innerHTML).toBe('4'); + + Horizon.act(() => { + triggerClickEvent(container, BUTTON_ID); + }); + + expect(document.getElementById(RESULT_ID).innerHTML).toBe('6'); + + Horizon.act(() => { + triggerClickEvent(container, BUTTON_ID); + }); + + expect(document.getElementById(RESULT_ID).innerHTML).toBe('10'); + }); + + it('should access other stores', () => { + const useOtherStore = createStore({ + state: {}, + actions: { + doMagic: () => useSelfRefStore().magic(), + }, + computed: { + magicConstant: () => useSelfRefStore().value, + }, + }); + + function App() { + const store = useOtherStore(); + + return ( +
+

{store.magicConstant}

+ +
+ ); + } + + Horizon.render(, container); + + expect(document.getElementById(RESULT_ID).innerHTML).toBe('5'); + + Horizon.act(() => { + triggerClickEvent(container, BUTTON_ID); + }); + + expect(document.getElementById(RESULT_ID).innerHTML).toBe('9'); + }); + + it('should use parametric getters', () => { + const useArrayStore = createStore({ + state: { + items: ['a', 'b', 'c'], + }, + actions: { + setItem: (state, index, value) => (state.items[index] = value), + }, + computed: { + getItem: state => index => state.items[index], + }, + }); + + function App() { + const store = useArrayStore(); + + return ( +
+

{store.getItem(0) + store.getItem(1) + store.getItem(2)}

+ +
+ ); + } + + Horizon.render(, container); + expect(document.getElementById(RESULT_ID).innerHTML).toBe('abc'); + + Horizon.act(() => { + triggerClickEvent(container, BUTTON_ID); + }); + expect(document.getElementById(RESULT_ID).innerHTML).toBe('def'); + }); +}); diff --git a/scripts/__tests__/HorizonXText/StoreFunctionality/reset.test.js b/scripts/__tests__/HorizonXText/StoreFunctionality/reset.test.js new file mode 100644 index 00000000..f0b349da --- /dev/null +++ b/scripts/__tests__/HorizonXText/StoreFunctionality/reset.test.js @@ -0,0 +1,89 @@ +import * as Horizon from '@cloudsop/horizon/index.ts'; +import { createStore } from '../../../../libs/horizon/src/horizonx/store/StoreHandler'; +import { triggerClickEvent } from '../../jest/commonComponents'; + +const { unmountComponentAtNode } = Horizon; + +describe('Reset', () => { + it('RESET NOT IMPLEMENTED', async () => { + // console.log('reset functionality is not yet implemented') + expect(true).toBe(true); + }); + return; + + let container = null; + + const BUTTON_ID = 'btn'; + const RESET_ID = 'reset'; + const RESULT_ID = 'result'; + + const useCounter = createStore({ + state: { + counter: 0, + }, + actions: { + increment: function(state) { + state.counter++; + }, + }, + computed: {}, + }); + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + unmountComponentAtNode(container); + container.remove(); + container = null; + }); + + it('Should reset to default state', async () => { + function App() { + const store = useCounter(); + + return ( +
+

{store.$state.counter}

+ + +
+ ); + } + + Horizon.render(, container); + + Horizon.act(() => { + triggerClickEvent(container, BUTTON_ID); + }); + + Horizon.act(() => { + triggerClickEvent(container, BUTTON_ID); + }); + + expect(document.getElementById(RESULT_ID).innerHTML).toBe('2'); + + Horizon.act(() => { + triggerClickEvent(container, RESET_ID); + }); + + expect(document.getElementById(RESULT_ID).innerHTML).toBe('0'); + + Horizon.act(() => { + triggerClickEvent(container, BUTTON_ID); + }); + + expect(document.getElementById(RESULT_ID).innerHTML).toBe('1'); + }); +}); diff --git a/scripts/__tests__/HorizonXText/StoreFunctionality/store.js b/scripts/__tests__/HorizonXText/StoreFunctionality/store.js new file mode 100644 index 00000000..ccdbc6a6 --- /dev/null +++ b/scripts/__tests__/HorizonXText/StoreFunctionality/store.js @@ -0,0 +1,25 @@ +import { createStore } from '../../../../libs/horizon/src/horizonx/store/StoreHandler'; + +export const useLogStore = createStore({ + id: 'logStore', // you do not need to specify ID for local store + state: { + logs: ['log'], + }, + actions: { + addLog: (state, data) => { + state.logs.push(data); + }, + removeLog: (state, index) => { + state.logs.splice(index, 1); + }, + cleanLog: state => { + state.logs.length = 0; + }, + }, + computed: { + length: state => { + return state.logs.length; + }, + log: state => index => state.logs[index], + }, +}); diff --git a/scripts/__tests__/HorizonXText/adapters/ReduxAdapter.test.js b/scripts/__tests__/HorizonXText/adapters/ReduxAdapter.test.js new file mode 100644 index 00000000..2fa93598 --- /dev/null +++ b/scripts/__tests__/HorizonXText/adapters/ReduxAdapter.test.js @@ -0,0 +1,208 @@ +import { + createStore, + applyMiddleware, + combineReducers, + bindActionCreators +} from '../../../../libs/horizon/src/horizonx/adapters/redux'; + +describe('Redux adapter', () => { + it('should use getState()', async () => { + const reduxStore = createStore((state, action) => { + return state; + }, 0); + + expect(reduxStore.getState()).toBe(0); + }) + + it('Should use default state, dispatch action and update state', async () => { + const reduxStore = createStore((state, action) => { + switch (action.type) { + case('ADD'): + return {counter: state.counter + 1} + default: + return {counter: 0}; + } + }); + + expect(reduxStore.getState().counter).toBe(0); + + reduxStore.dispatch({type: 'ADD'}); + + expect(reduxStore.getState().counter).toBe(1); + }); + + it('Should attach and detach listeners', async () => { + let counter = 0; + const reduxStore = createStore((state = 0, action) => { + switch (action.type) { + case('ADD'): + return state + 1 + default: + return state; + } + }); + + reduxStore.dispatch({type: 'ADD'}); + expect(counter).toBe(0); + expect(reduxStore.getState()).toBe(1); + const unsubscribe = reduxStore.subscribe(() => { + counter++; + }); + reduxStore.dispatch({type: 'ADD'}); + reduxStore.dispatch({type: 'ADD'}); + expect(counter).toBe(2); + expect(reduxStore.getState()).toBe(3); + unsubscribe(); + reduxStore.dispatch({type: 'ADD'}); + reduxStore.dispatch({type: 'ADD'}); + expect(counter).toBe(2); + expect(reduxStore.getState()).toBe(5); + }); + + it('Should bind action creators', async () => { + const addTodo = (text) => { + return { + type: 'ADD_TODO', + text + } + } + + const reduxStore = createStore((state = [], action) => { + if (action.type === 'ADD_TODO') { + return [...state, action.text]; + } + return state; + }); + + const actions = bindActionCreators({addTodo}, reduxStore.dispatch); + + actions.addTodo('todo'); + + expect(reduxStore.getState()[0]).toBe('todo'); + }); + + it('Should replace reducer', async () => { + const reduxStore = createStore((state, action) => { + switch (action.type) { + case('ADD'): + return {counter: state.counter + 1} + default: + return {counter: 0}; + } + }); + + reduxStore.dispatch({type: 'ADD'}); + + expect(reduxStore.getState().counter).toBe(1); + + reduxStore.replaceReducer((state, action) => { + switch (action.type) { + case('SUB'): + return {counter: state.counter - 1} + default: + return {counter: 0}; + } + }); + + reduxStore.dispatch({type: 'SUB'}); + + expect(reduxStore.getState().counter).toBe(0); + }) + + it('Should combine reducers', async () => { + const booleanReducer = (state = false, action) => { + switch (action.type) { + case('TOGGLE'): + return !state + default: + return state; + } + } + + const addReducer = (state = 0, action) => { + switch (action.type) { + case('ADD'): + return state + 1 + default: + return state; + } + }; + + const reduxStore = createStore(combineReducers({check: booleanReducer, counter: addReducer})); + + expect(reduxStore.getState().counter).toBe(0); + expect(reduxStore.getState().check).toBe(false); + + reduxStore.dispatch({type: 'ADD'}); + reduxStore.dispatch({type: 'TOGGLE'}); + + expect(reduxStore.getState().counter).toBe(1); + expect(reduxStore.getState().check).toBe(true); + }); + + it('Should apply enhancers', async () => { + let counter = 0; + let middlewareCallList = []; + + const callCounter = store => next => action => { + middlewareCallList.push('callCounter'); + counter++; + let result = next(action); + return result; + } + + const reduxStore = createStore((state, action) => { + switch (action.type) { + case('toggle'): + return { + check: !state.check + } + default: + return state; + } + }, {check: false}, applyMiddleware(callCounter)); + + reduxStore.dispatch({type: 'toggle'}); + reduxStore.dispatch({type: 'toggle'}); + + expect(counter).toBe(3); // NOTE: first action is always store initialization + }); + + it('Should apply multiple enhancers', async () => { + let counter = 0; + let lastAction = ''; + let middlewareCallList = []; + + const callCounter = store => next => action => { + middlewareCallList.push('callCounter'); + counter++; + let result = next(action); + return result; + } + + const lastFunctionStorage = store => next => action => { + middlewareCallList.push('lastFunctionStorage'); + lastAction = action.type; + let result = next(action); + return result; + } + + const reduxStore = createStore((state, action) => { + switch (action.type) { + case('toggle'): + return { + check: !state.check + } + default: + return state; + } + }, {check: false}, applyMiddleware(callCounter, lastFunctionStorage)); + + reduxStore.dispatch({type: 'toggle'}); + + expect(counter).toBe(2); // NOTE: first action is always store initialization + expect(lastAction).toBe('toggle'); + expect(middlewareCallList[0]).toBe("callCounter"); + expect(middlewareCallList[1]).toBe("lastFunctionStorage"); + }); +}); diff --git a/scripts/__tests__/HorizonXText/adapters/ReduxAdapterPromiseMiddleware.js b/scripts/__tests__/HorizonXText/adapters/ReduxAdapterPromiseMiddleware.js new file mode 100644 index 00000000..509706cf --- /dev/null +++ b/scripts/__tests__/HorizonXText/adapters/ReduxAdapterPromiseMiddleware.js @@ -0,0 +1,96 @@ +export const ActionType = { + Pending: 'PENDING', + Fulfilled: 'FULFILLED', + Rejected: 'REJECTED', +}; + +export const promise = store => next => action => { + //let result = next(action); + store._horizonXstore.$queue.dispatch(action); + return result; +}; + +export function createPromise(config = {}) { + const defaultTypes = [ActionType.Pending, ActionType.Fulfilled, ActionType.Rejected]; + const PROMISE_TYPE_SUFFIXES = config.promiseTypeSuffixes || defaultTypes; + const PROMISE_TYPE_DELIMITER = config.promiseTypeDelimiter || '_'; + + return store => { + const { dispatch } = store; + + return next => action => { + /** + * Instantiate variables to hold: + * (1) the promise + * (2) the data for optimistic updates + */ + let promise; + let data; + + /** + * There are multiple ways to dispatch a promise. The first step is to + * determine if the promise is defined: + * (a) explicitly (action.payload.promise is the promise) + * (b) implicitly (action.payload is the promise) + * (c) as an async function (returns a promise when called) + * + * If the promise is not defined in one of these three ways, we don't do + * anything and move on to the next middleware in the middleware chain. + */ + + // Step 1a: Is there a payload? + if (action.payload) { + const PAYLOAD = action.payload; + + // Step 1.1: Is the promise implicitly defined? + if (isPromise(PAYLOAD)) { + promise = PAYLOAD; + } + + // Step 1.2: Is the promise explicitly defined? + else if (isPromise(PAYLOAD.promise)) { + promise = PAYLOAD.promise; + data = PAYLOAD.data; + } + + // Step 1.3: Is the promise returned by an async function? + else if (typeof PAYLOAD === 'function' || typeof PAYLOAD.promise === 'function') { + promise = PAYLOAD.promise ? PAYLOAD.promise() : PAYLOAD(); + data = PAYLOAD.promise ? PAYLOAD.data : undefined; + + // Step 1.3.1: Is the return of action.payload a promise? + if (!isPromise(promise)) { + // If not, move on to the next middleware. + return next({ + ...action, + payload: promise, + }); + } + } + + // Step 1.4: If there's no promise, move on to the next middleware. + else { + return next(action); + } + + // Step 1b: If there's no payload, move on to the next middleware. + } else { + return next(action); + } + + /** + * Instantiate and define constants for: + * (1) the action type + * (2) the action meta + */ + const TYPE = action.type; + const META = action.meta; + + /** + * Instantiate and define constants for the action type suffixes. + * These are appended to the end of the action type. + */ + const [PENDING, FULFILLED, REJECTED] = PROMISE_TYPE_SUFFIXES; + }; + }; +} diff --git a/scripts/__tests__/HorizonXText/adapters/ReduxAdapterThunk.test.js b/scripts/__tests__/HorizonXText/adapters/ReduxAdapterThunk.test.js new file mode 100644 index 00000000..3785b92c --- /dev/null +++ b/scripts/__tests__/HorizonXText/adapters/ReduxAdapterThunk.test.js @@ -0,0 +1,34 @@ +import { createStore, applyMiddleware, thunk } from '../../../../libs/horizon/src/horizonx/adapters/redux'; + +describe('Redux thunk', () => { + it('should use apply thunk middleware', async () => { + const MAX_TODOS = 5; + + function addTodosIfAllowed(todoText) { + return (dispatch, getState) => { + const state = getState(); + + if (state.todos.length < MAX_TODOS) { + dispatch({ type: 'ADD_TODO', text: todoText }); + } + }; + } + + const todoStore = createStore( + (state = { todos: [] }, action) => { + if (action.type === 'ADD_TODO') { + return { todos: state.todos?.concat(action.text) }; + } + return state; + }, + null, + applyMiddleware(thunk) + ); + + for (let i = 0; i < 10; i++) { + todoStore.dispatch(addTodosIfAllowed('todo no.' + i)); + } + + expect(todoStore.getState().todos.length).toBe(5); + }); +}); diff --git a/scripts/__tests__/HorizonXText/adapters/ReduxReactAdapter.test.js b/scripts/__tests__/HorizonXText/adapters/ReduxReactAdapter.test.js new file mode 100644 index 00000000..5977671a --- /dev/null +++ b/scripts/__tests__/HorizonXText/adapters/ReduxReactAdapter.test.js @@ -0,0 +1,358 @@ +import horizon, * as Horizon from '@cloudsop/horizon/index.ts'; +import { + batch, + connect, + createStore, + Provider, + useDispatch, + useSelector, + useStore, + createSelectorHook, + createDispatchHook, +} from '../../../../libs/horizon/src/horizonx/adapters/redux'; +import { triggerClickEvent } from '../../jest/commonComponents'; + +const BUTTON = 'button'; +const BUTTON2 = 'button2'; +const RESULT = 'result'; +const CONTAINER = 'container'; + +function getE(id) { + return document.getElementById(id); +} + +describe('Redux/React binding adapter', () => { + beforeEach(() => { + const container = document.createElement('div'); + container.id = CONTAINER; + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(getE(CONTAINER)); + }); + + it('Should create provider context', async () => { + const reduxStore = createStore((state = 'state', action) => state); + + const Child = () => { + const store = useStore(); + return
{store.getState()}
; + }; + + const Wrapper = () => { + return ( + + + + ); + }; + + Horizon.render(, getE(CONTAINER)); + + expect(getE(RESULT).innerHTML).toBe('state'); + }); + + it('Should use dispatch', async () => { + const reduxStore = createStore((state = 0, action) => { + if (action.type === 'ADD') return state + 1; + return state; + }); + + const Child = () => { + const store = useStore(); + const dispatch = useDispatch(); + return ( +
+

{store.getState()}

+ +
+ ); + }; + + const Wrapper = () => { + return ( + + + + ); + }; + + Horizon.render(, getE(CONTAINER)); + + expect(reduxStore.getState()).toBe(0); + + Horizon.act(() => { + triggerClickEvent(getE(CONTAINER), BUTTON); + }); + + expect(reduxStore.getState()).toBe(1); + }); + + it('Should use selector', async () => { + const reduxStore = createStore((state = 0, action) => { + if (action.type === 'ADD') return state + 1; + return state; + }); + + const Child = () => { + const count = useSelector(state => state); + const dispatch = useDispatch(); + return ( +
+

{count}

+ +
+ ); + }; + + const Wrapper = () => { + return ( + + + + ); + }; + + Horizon.render(, getE(CONTAINER)); + + expect(getE(RESULT).innerHTML).toBe('0'); + + Horizon.act(() => { + triggerClickEvent(getE(CONTAINER), BUTTON); + triggerClickEvent(getE(CONTAINER), BUTTON); + }); + + expect(getE(RESULT).innerHTML).toBe('2'); + }); + + it('Should use connect', async () => { + const reduxStore = createStore( + (state, action) => { + switch (action.type) { + case 'INCREMENT': + return { + ...state, + value: state.negative ? state.value - action.amount : state.value + action.amount, + }; + case 'TOGGLE': + return { + ...state, + negative: !state.negative, + }; + default: + return state; + } + }, + { negative: false, value: 0 } + ); + + const Child = connect( + (state, ownProps) => { + // map state to props + return { ...state, ...ownProps }; + }, + (dispatch, ownProps) => { + // map dispatch to props + return { + increment: () => dispatch({ type: 'INCREMENT', amount: ownProps.amount }), + }; + }, + (stateProps, dispatchProps, ownProps) => { + //merge props + return { stateProps, dispatchProps, ownProps }; + }, + {} + )(props => { + const n = props.stateProps.negative; + return ( +
+
+ {n ? '-' : '+'} + {props.stateProps.value} +
+ +
+ ); + }); + + const Wrapper = () => { + const [amount, setAmount] = Horizon.useState(5); + return ( + + + + + ); + }; + + Horizon.render(, getE(CONTAINER)); + + expect(getE(RESULT).innerHTML).toBe('+0'); + + Horizon.act(() => { + triggerClickEvent(getE(CONTAINER), BUTTON); + }); + + expect(getE(RESULT).innerHTML).toBe('+5'); + + Horizon.act(() => { + triggerClickEvent(getE(CONTAINER), BUTTON2); + }); + + Horizon.act(() => { + triggerClickEvent(getE(CONTAINER), BUTTON); + }); + + expect(getE(RESULT).innerHTML).toBe('+8'); + }); + + it('Should batch dispatches', async () => { + const reduxStore = createStore((state = 0, action) => { + if (action.type == 'ADD') return state + 1; + return state; + }); + + let renderCounter = 0; + + function Counter() { + renderCounter++; + + const value = useSelector(state => state); + const dispatch = useDispatch(); + + return ( +
+

{value}

+ +
+ ); + } + + Horizon.render( + + + , + getE(CONTAINER) + ); + + expect(getE(RESULT).innerHTML).toBe('0'); + expect(renderCounter).toBe(1); + + Horizon.act(() => { + triggerClickEvent(getE(CONTAINER), BUTTON); + }); + + expect(getE(RESULT).innerHTML).toBe('10'); + expect(renderCounter).toBe(2); + }); + + it('Should use multiple contexts', async () => { + const counterStore = createStore((state = 0, action) => { + if (action.type === 'ADD') return state + 1; + return state; + }); + + const toggleStore = createStore((state = false, action) => { + if (action.type === 'TOGGLE') return !state; + return state; + }); + + const counterContext = horizon.createContext(); + const toggleContext = horizon.createContext(); + + function Counter() { + const count = createSelectorHook(counterContext)(); + const dispatch = createDispatchHook(counterContext)(); + + return ( + + ); + } + + function Toggle() { + const check = createSelectorHook(toggleContext)(); + const dispatch = createDispatchHook(toggleContext)(); + + return ( + + ); + } + + function Wrapper() { + return ( +
+ + + + + + + +
+ ); + } + + Horizon.render(, getE(CONTAINER)); + + expect(getE(BUTTON).innerHTML).toBe('0'); + expect(getE(BUTTON2).innerHTML).toBe('false'); + + Horizon.act(() => { + triggerClickEvent(getE(CONTAINER), BUTTON); + triggerClickEvent(getE(CONTAINER), BUTTON2); + }); + + expect(getE(BUTTON).innerHTML).toBe('1'); + expect(getE(BUTTON2).innerHTML).toBe('true'); + }); +}); diff --git a/scripts/__tests__/HorizonXText/class/ClassException.test.js b/scripts/__tests__/HorizonXText/class/ClassException.test.js new file mode 100644 index 00000000..64cea5aa --- /dev/null +++ b/scripts/__tests__/HorizonXText/class/ClassException.test.js @@ -0,0 +1,69 @@ +import * as Horizon from '@cloudsop/horizon/index.ts'; +import { clearStore, createStore, useStore } from '../../../../libs/horizon/src/horizonx/store/StoreHandler'; +import { Text } from '../../jest/commonComponents'; + +describe('测试 Class VNode 清除时,对引用清除', () => { + const { unmountComponentAtNode } = Horizon; + let container = null; + let globalState = { + name: 'bing dun dun', + isWin: true, + isShow: true, + }; + + beforeEach(() => { + // 创建一个 DOM 元素作为渲染目标 + container = document.createElement('div'); + document.body.appendChild(container); + + createStore({ + id: 'user', + state: globalState, + actions: { + setWin: (state, val) => { + state.isWin = val; + }, + hide: state => { + state.isShow = false; + }, + updateName: (state, val) => { + state.name = val; + }, + }, + }); + }); + + afterEach(() => { + // 退出时进行清理 + unmountComponentAtNode(container); + container.remove(); + container = null; + + clearStore('user'); + }); + + it('test observer.clearByNode', () => { + class Child extends Horizon.Component { + userStore = useStore('user'); + + render() { + // Do not modify the store data in the render method. Otherwise, an infinite loop may occur. + this.userStore.updateName(this.userStore.name === 'bing dun dun' ? 'huo dun dun' : 'bing dun dun'); + + return ( +
+ + +
+ ); + } + } + + expect(() => { + Horizon.render(, container); + }).toThrow( + 'The number of updates exceeds the upper limit 50.\n' + + ' A component maybe repeatedly invokes setState on componentWillUpdate or componentDidUpdate.' + ); + }); +}); diff --git a/scripts/__tests__/HorizonXText/class/ClassStateArray.test.js b/scripts/__tests__/HorizonXText/class/ClassStateArray.test.js new file mode 100644 index 00000000..c61aed9c --- /dev/null +++ b/scripts/__tests__/HorizonXText/class/ClassStateArray.test.js @@ -0,0 +1,220 @@ +import * as Horizon from '@cloudsop/horizon/index.ts'; +import { clearStore, createStore, useStore } from '../../../../libs/horizon/src/horizonx/store/StoreHandler'; +import { App, Text, triggerClickEvent } from '../../jest/commonComponents'; + +describe('在Class组件中,测试store中的Array', () => { + const { unmountComponentAtNode } = Horizon; + let container = null; + beforeEach(() => { + // 创建一个 DOM 元素作为渲染目标 + container = document.createElement('div'); + document.body.appendChild(container); + + const persons = [ + { name: 'p1', age: 1 }, + { name: 'p2', age: 2 }, + ]; + + createStore({ + id: 'user', + state: { + type: 'bing dun dun', + persons: persons, + }, + actions: { + addOnePerson: (state, person) => { + state.persons.push(person); + }, + delOnePerson: state => { + state.persons.pop(); + }, + clearPersons: state => { + state.persons = null; + }, + }, + }); + }); + + afterEach(() => { + // 退出时进行清理 + unmountComponentAtNode(container); + container.remove(); + container = null; + + clearStore('user'); + }); + + const newPerson = { name: 'p3', age: 3 }; + + class Parent extends Horizon.Component { + userStore = useStore('user'); + + addOnePerson = () => { + this.userStore.addOnePerson(newPerson); + }; + + delOnePerson = () => { + this.userStore.delOnePerson(); + }; + + render() { + return ( +
+ + +
{this.props.children}
+
+ ); + } + } + + it('测试Array方法: push()、pop()', () => { + class Child extends Horizon.Component { + userStore = useStore('user'); + + render() { + return ( +
+ +
+ ); + } + } + + Horizon.render(, container); + + expect(container.querySelector('#hasPerson').innerHTML).toBe('has new person: 2'); + // 在Array中增加一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container.querySelector('#hasPerson').innerHTML).toBe('has new person: 3'); + + // 在Array中删除一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'delBtn'); + }); + expect(container.querySelector('#hasPerson').innerHTML).toBe('has new person: 2'); + }); + + it('测试Array方法: entries()、push()、shift()、unshift、直接赋值', () => { + let globalStore = null; + + class Child extends Horizon.Component { + userStore = useStore('user'); + + constructor(props) { + super(props); + globalStore = this.userStore; + } + + render() { + const nameList = []; + const entries = this.userStore.$state.persons?.entries(); + if (entries) { + for (const entry of entries) { + nameList.push(entry[1].name); + } + } + + return ( +
+ +
+ ); + } + } + + Horizon.render(, container); + + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + // push + globalStore.$state.persons.push(newPerson); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2 p3'); + + // shift + globalStore.$state.persons.shift({ name: 'p0', age: 0 }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p2 p3'); + + // 赋值[2] + globalStore.$state.persons[2] = { name: 'p4', age: 4 }; + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p2 p3 p4'); + + // 重新赋值[2] + globalStore.$state.persons[2] = { name: 'p5', age: 5 }; + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p2 p3 p5'); + + // unshift + globalStore.$state.persons.unshift({ name: 'p1', age: 1 }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2 p3 p5'); + + // 重新赋值 null + globalStore.$state.persons = null; + expect(container.querySelector('#nameList').innerHTML).toBe('name list: '); + + // 重新赋值 [{ name: 'p1', age: 1 }] + globalStore.$state.persons = [{ name: 'p1', age: 1 }]; + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1'); + }); + + it('测试Array方法: forEach()', () => { + let globalStore = null; + + class Child extends Horizon.Component { + userStore = useStore('user'); + + constructor(props) { + super(props); + globalStore = this.userStore; + } + + render() { + const nameList = []; + this.userStore.$state.persons?.forEach(per => { + nameList.push(per.name); + }); + + return ( +
+ +
+ ); + } + } + + Horizon.render(, container); + + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + // push + globalStore.$state.persons.push(newPerson); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2 p3'); + + // shift + globalStore.$state.persons.shift({ name: 'p0', age: 0 }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p2 p3'); + + // 赋值[2] + globalStore.$state.persons[2] = { name: 'p4', age: 4 }; + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p2 p3 p4'); + + // 重新赋值[2] + globalStore.$state.persons[2] = { name: 'p5', age: 5 }; + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p2 p3 p5'); + + // unshift + globalStore.$state.persons.unshift({ name: 'p1', age: 1 }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2 p3 p5'); + + // 重新赋值 null + globalStore.$state.persons = null; + expect(container.querySelector('#nameList').innerHTML).toBe('name list: '); + + // 重新赋值 [{ name: 'p1', age: 1 }] + globalStore.$state.persons = [{ name: 'p1', age: 1 }]; + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1'); + }); +}); diff --git a/scripts/__tests__/HorizonXText/class/ClassStateMap.test.js b/scripts/__tests__/HorizonXText/class/ClassStateMap.test.js new file mode 100644 index 00000000..071ad650 --- /dev/null +++ b/scripts/__tests__/HorizonXText/class/ClassStateMap.test.js @@ -0,0 +1,340 @@ +import * as Horizon from '@cloudsop/horizon/index.ts'; +import { clearStore, createStore, useStore } from '../../../../libs/horizon/src/horizonx/store/StoreHandler'; +import { App, Text, triggerClickEvent } from '../../jest/commonComponents'; + +describe('在Class组件中,测试store中的Map', () => { + const { unmountComponentAtNode } = Horizon; + let container = null; + beforeEach(() => { + // 创建一个 DOM 元素作为渲染目标 + container = document.createElement('div'); + document.body.appendChild(container); + + const persons = new Map([ + ['p1', 1], + ['p2', 2], + ]); + + createStore({ + id: 'user', + state: { + type: 'bing dun dun', + persons: persons, + }, + actions: { + addOnePerson: (state, person) => { + state.persons.set(person.name, person.age); + }, + delOnePerson: (state, person) => { + state.persons.delete(person.name); + }, + clearPersons: state => { + state.persons.clear(); + }, + }, + }); + }); + + afterEach(() => { + // 退出时进行清理 + unmountComponentAtNode(container); + container.remove(); + container = null; + + clearStore('user'); + }); + + const newPerson = { name: 'p3', age: 3 }; + + class Parent extends Horizon.Component { + userStore = useStore('user'); + + addOnePerson = () => { + this.userStore.addOnePerson(newPerson); + }; + delOnePerson = () => { + this.userStore.delOnePerson(newPerson); + }; + clearPersons = () => { + this.userStore.clearPersons(); + }; + + render() { + return ( +
+ + + +
{this.props.children}
+
+ ); + } + } + + it('测试Map方法: set()、delete()、clear()', () => { + class Child extends Horizon.Component { + userStore = useStore('user'); + + render() { + return ( +
+ +
+ ); + } + } + + Horizon.render(, container); + + expect(container.querySelector('#size').innerHTML).toBe('persons number: 2'); + // 在Map中增加一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container.querySelector('#size').innerHTML).toBe('persons number: 3'); + + // 在Map中删除一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'delBtn'); + }); + expect(container.querySelector('#size').innerHTML).toBe('persons number: 2'); + + // clear Map + Horizon.act(() => { + triggerClickEvent(container, 'clearBtn'); + }); + expect(container.querySelector('#size').innerHTML).toBe('persons number: 0'); + }); + + it('测试Map方法: keys()', () => { + class Child extends Horizon.Component { + userStore = useStore('user'); + + render() { + const nameList = []; + const keys = this.userStore.$state.persons.keys(); + for (const key of keys) { + nameList.push(key); + } + + return ( +
+ +
+ ); + } + } + + Horizon.render(, container); + + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + // 在Map中增加一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2 p3'); + + // 在Map中删除一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'delBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + + // clear Map + Horizon.act(() => { + triggerClickEvent(container, 'clearBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: '); + }); + + it('测试Map方法: values()', () => { + class Child extends Horizon.Component { + userStore = useStore('user'); + + render() { + const ageList = []; + const values = this.userStore.$state.persons.values(); + for (const val of values) { + ageList.push(val); + } + + return ( +
+ +
+ ); + } + } + + Horizon.render(, container); + + expect(container.querySelector('#ageList').innerHTML).toBe('age list: 1 2'); + // 在Map中增加一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container.querySelector('#ageList').innerHTML).toBe('age list: 1 2 3'); + + // 在Map中删除一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'delBtn'); + }); + expect(container.querySelector('#ageList').innerHTML).toBe('age list: 1 2'); + + // clear Map + Horizon.act(() => { + triggerClickEvent(container, 'clearBtn'); + }); + expect(container.querySelector('#ageList').innerHTML).toBe('age list: '); + }); + + it('测试Map方法: entries()', () => { + class Child extends Horizon.Component { + userStore = useStore('user'); + + render() { + const nameList = []; + const entries = this.userStore.$state.persons.entries(); + for (const entry of entries) { + nameList.push(entry[0]); + } + + return ( +
+ +
+ ); + } + } + + Horizon.render(, container); + + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + // 在Map中增加一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2 p3'); + + // 在Map中删除一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'delBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + + // clear Map + Horizon.act(() => { + triggerClickEvent(container, 'clearBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: '); + }); + + it('测试Map方法: forEach()', () => { + class Child extends Horizon.Component { + userStore = useStore('user'); + + render() { + const nameList = []; + this.userStore.$state.persons.forEach((val, key) => { + nameList.push(key); + }); + + return ( +
+ +
+ ); + } + } + + Horizon.render(, container); + + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + // 在Map中增加一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2 p3'); + + // 在Map中删除一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'delBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + + // clear Map + Horizon.act(() => { + triggerClickEvent(container, 'clearBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: '); + }); + + it('测试Map方法: has()', () => { + class Child extends Horizon.Component { + userStore = useStore('user'); + + render() { + return ( +
+ +
+ ); + } + } + + Horizon.render(, container); + + expect(container.querySelector('#hasPerson').innerHTML).toBe('has new person: false'); + // 在Map中增加一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container.querySelector('#hasPerson').innerHTML).toBe('has new person: true'); + }); + + it('测试Map方法: for of()', () => { + class Child extends Horizon.Component { + userStore = useStore('user'); + + render() { + const nameList = []; + for (const per of this.userStore.$state.persons) { + nameList.push(per[0]); + } + + return ( +
+ +
+ ); + } + } + + Horizon.render(, container); + + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + // 在Map中增加一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2 p3'); + + // 在Map中删除一个对象 + Horizon.act(() => { + triggerClickEvent(container, 'delBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: p1 p2'); + + // clear Map + Horizon.act(() => { + triggerClickEvent(container, 'clearBtn'); + }); + expect(container.querySelector('#nameList').innerHTML).toBe('name list: '); + }); +}); diff --git a/scripts/__tests__/HorizonXText/clear/ClassVNodeClear.test.js b/scripts/__tests__/HorizonXText/clear/ClassVNodeClear.test.js new file mode 100644 index 00000000..a2226b1c --- /dev/null +++ b/scripts/__tests__/HorizonXText/clear/ClassVNodeClear.test.js @@ -0,0 +1,119 @@ +import * as Horizon from '@cloudsop/horizon/index.ts'; +import { clearStore, createStore, useStore } from '../../../../libs/horizon/src/horizonx/store/StoreHandler'; +import { Text, triggerClickEvent } from '../../jest/commonComponents'; +import { getObserver } from '../../../../libs/horizon/src/horizonx/proxy/ProxyHandler'; + +describe('测试 Class VNode 清除时,对引用清除', () => { + const { unmountComponentAtNode } = Horizon; + let container = null; + let globalState = { + name: 'bing dun dun', + isWin: true, + isShow: true, + }; + + beforeEach(() => { + // 创建一个 DOM 元素作为渲染目标 + container = document.createElement('div'); + document.body.appendChild(container); + + createStore({ + id: 'user', + state: globalState, + actions: { + setWin: (state, val) => { + state.isWin = val; + }, + hide: state => { + state.isShow = false; + }, + updateName: (state, val) => { + state.name = val; + }, + }, + }); + }); + + afterEach(() => { + // 退出时进行清理 + unmountComponentAtNode(container); + container.remove(); + container = null; + + clearStore('user'); + }); + + it('test observer.clearByNode', () => { + class App extends Horizon.Component { + userStore = useStore('user'); + + render() { + return ( +
+ + {this.userStore.isShow && } +
+ ); + } + } + + class Parent extends Horizon.Component { + userStore = useStore('user'); + + setWin = () => { + this.userStore.setWin(!this.userStore.isWin); + }; + + render() { + return ( +
+ + {this.userStore.isWin && } +
+ ); + } + } + + class Child extends Horizon.Component { + userStore = useStore('user'); + + render() { + // this.userStore.updateName(this.userStore.name === 'bing dun dun' ? 'huo dun dun' : 'bing dun dun'); + + return ( +
+ + +
+ ); + } + } + + Horizon.render(, container); + + // Parent and Child hold the isWin key + expect(getObserver(globalState).keyVNodes.get('isWin').size).toBe(2); + + Horizon.act(() => { + triggerClickEvent(container, 'toggleBtn'); + }); + // Parent hold the isWin key + expect(getObserver(globalState).keyVNodes.get('isWin').size).toBe(1); + + Horizon.act(() => { + triggerClickEvent(container, 'toggleBtn'); + }); + // Parent and Child hold the isWin key + expect(getObserver(globalState).keyVNodes.get('isWin').size).toBe(2); + + Horizon.act(() => { + triggerClickEvent(container, 'hideBtn'); + }); + // no component hold the isWin key + expect(getObserver(globalState).keyVNodes.get('isWin')).toBe(undefined); + }); +}); diff --git a/scripts/__tests__/HorizonXText/clear/FunctionVNodeClear.test.js b/scripts/__tests__/HorizonXText/clear/FunctionVNodeClear.test.js new file mode 100644 index 00000000..ead0453a --- /dev/null +++ b/scripts/__tests__/HorizonXText/clear/FunctionVNodeClear.test.js @@ -0,0 +1,114 @@ +import * as Horizon from '@cloudsop/horizon/index.ts'; +import { clearStore, createStore, useStore } from '../../../../libs/horizon/src/horizonx/store/StoreHandler'; +import { Text, triggerClickEvent } from '../../jest/commonComponents'; +import { getObserver } from '../../../../libs/horizon/src/horizonx/proxy/ProxyHandler'; + +describe('测试VNode清除时,对引用清除', () => { + const { unmountComponentAtNode } = Horizon; + let container = null; + let globalState = { + name: 'bing dun dun', + isWin: true, + isShow: true, + }; + + beforeEach(() => { + // 创建一个 DOM 元素作为渲染目标 + container = document.createElement('div'); + document.body.appendChild(container); + + createStore({ + id: 'user', + state: globalState, + actions: { + setWin: (state, val) => { + state.isWin = val; + }, + hide: state => { + state.isShow = false; + }, + }, + }); + }); + + afterEach(() => { + // 退出时进行清理 + unmountComponentAtNode(container); + container.remove(); + container = null; + + clearStore('user'); + }); + + it('test observer.clearByNode', () => { + class App extends Horizon.Component { + userStore = useStore('user'); + + render() { + return ( +
+ + {this.userStore.isShow && } +
+ ); + } + } + + class Parent extends Horizon.Component { + userStore = useStore('user'); + + setWin = () => { + this.userStore.setWin(!this.userStore.isWin); + }; + + render() { + return ( +
+ + {this.userStore.isWin && } +
+ ); + } + } + + class Child extends Horizon.Component { + userStore = useStore('user'); + + render() { + return ( +
+ + +
+ ); + } + } + + Horizon.render(, container); + + // Parent and Child hold the isWin key + expect(getObserver(globalState).keyVNodes.get('isWin').size).toBe(2); + + Horizon.act(() => { + triggerClickEvent(container, 'toggleBtn'); + }); + // Parent hold the isWin key + expect(getObserver(globalState).keyVNodes.get('isWin').size).toBe(1); + + Horizon.act(() => { + triggerClickEvent(container, 'toggleBtn'); + }); + // Parent and Child hold the isWin key + expect(getObserver(globalState).keyVNodes.get('isWin').size).toBe(2); + + Horizon.act(() => { + triggerClickEvent(container, 'hideBtn'); + }); + // no component hold the isWin key + expect(getObserver(globalState).keyVNodes.get('isWin')).toBe(undefined); + }); +}); diff --git a/scripts/__tests__/HorizonXText/edgeCases/proxy.test.js b/scripts/__tests__/HorizonXText/edgeCases/proxy.test.js new file mode 100644 index 00000000..a643f311 --- /dev/null +++ b/scripts/__tests__/HorizonXText/edgeCases/proxy.test.js @@ -0,0 +1,21 @@ +import { createProxy } from '../../../../libs/horizon/src/horizonx/proxy/ProxyHandler'; + +describe('Proxy', () => { + const arr = []; + + it('Should not double wrap proxies', async () => { + const proxy1 = createProxy(arr); + + const proxy2 = createProxy(proxy1); + + expect(proxy1 === proxy2).toBe(true); + }); + + it('Should re-use existing proxy of same object', async () => { + const proxy1 = createProxy(arr); + + const proxy2 = createProxy(arr); + + expect(proxy1 === proxy2).toBe(true); + }); +}); diff --git a/scripts/__tests__/jest/commonComponents.js b/scripts/__tests__/jest/commonComponents.js index 6abda0f8..4823b9b7 100644 --- a/scripts/__tests__/jest/commonComponents.js +++ b/scripts/__tests__/jest/commonComponents.js @@ -1,9 +1,8 @@ - // eslint-disable-next-line @typescript-eslint/no-unused-vars import * as Horizon from '@cloudsop/horizon/index.ts'; import { getLogUtils } from './testUtils'; -export const App = (props) => { +export const App = props => { const Parent = props.parent; const Child = props.child; @@ -16,8 +15,15 @@ export const App = (props) => { ); }; -export const Text = (props) => { - const LogUtils =getLogUtils(); +export const Text = props => { + const LogUtils = getLogUtils(); LogUtils.log(props.text); return

{props.text}

; }; + +export function triggerClickEvent(container, id) { + const event = new MouseEvent('click', { + bubbles: true, + }); + container.querySelector(`#${id}`).dispatchEvent(event); +} diff --git a/scripts/rollup/rollup.config.js b/scripts/rollup/rollup.config.js index fb40e560..4d5cf9be 100644 --- a/scripts/rollup/rollup.config.js +++ b/scripts/rollup/rollup.config.js @@ -1,6 +1,7 @@ import nodeResolve from '@rollup/plugin-node-resolve'; import babel from '@rollup/plugin-babel'; import path from 'path'; +import fs from 'fs'; import replace from '@rollup/plugin-replace'; import copy from './copy-plugin'; import { terser } from 'rollup-plugin-terser'; @@ -11,6 +12,14 @@ const extensions = ['.js', '.ts']; const libDir = path.join(__dirname, '../../libs/horizon'); const rootDir = path.join(__dirname, '../..'); const outDir = path.join(rootDir, 'build', 'horizon'); + +if (!fs.existsSync(path.join(rootDir, 'build'))) { + fs.mkdirSync(path.join(rootDir, 'build')); +} +if (!fs.existsSync(outDir)) { + fs.mkdirSync(outDir); +} + const outputResolve = (...p) => path.resolve(outDir, ...p); function genConfig(mode) {