From fbf7e8370ba029b60a210f68256abde29ca7afbe Mon Sep 17 00:00:00 2001 From: * <*> Date: Mon, 19 Sep 2022 17:19:07 +0800 Subject: [PATCH] Match-id-0a9b956d94031f8d8f03fb90913325154133063d --- libs/horizon/src/horizonx/adapters/redux.ts | 32 +-- .../src/horizonx/adapters/reduxReact.ts | 77 ++++--- libs/horizon/src/horizonx/proxy/watch.ts | 8 + .../src/horizonx/store/StoreHandler.ts | 21 +- package.json | 5 +- .../StoreFunctionality/basicAccess.test.tsx | 54 +++-- .../StoreFunctionality/watch.test.tsx | 132 ++++++++++++ .../adapters/ReduxReactAdapter.test.tsx | 2 +- .../HorizonXText/adapters/connectTest.tsx | 46 ++++ .../edgeCases/multipleStores.test.tsx | 199 ++++++++++++++++++ 10 files changed, 506 insertions(+), 70 deletions(-) create mode 100644 libs/horizon/src/horizonx/proxy/watch.ts create mode 100644 scripts/__tests__/HorizonXText/StoreFunctionality/watch.test.tsx create mode 100644 scripts/__tests__/HorizonXText/adapters/connectTest.tsx create mode 100644 scripts/__tests__/HorizonXText/edgeCases/multipleStores.test.tsx diff --git a/libs/horizon/src/horizonx/adapters/redux.ts b/libs/horizon/src/horizonx/adapters/redux.ts index 4372e0fa..ed17b5d4 100644 --- a/libs/horizon/src/horizonx/adapters/redux.ts +++ b/libs/horizon/src/horizonx/adapters/redux.ts @@ -32,34 +32,34 @@ export type ReduxMiddleware = ( type Reducer = (state: any, action: ReduxAction) => any; -function mergeData(state,data){ - console.log('merging data',{state,data}); - if(!data){ +function mergeData(state, data) { + console.log('merging data', { state, data }); + if (!data) { console.log('!data'); - state.stateWrapper=data; + state.stateWrapper = data; return; } - if(Array.isArray(data) && Array.isArray(state?.stateWrapper)){ + if (Array.isArray(data) && Array.isArray(state?.stateWrapper)) { console.log('data is array'); state.stateWrapper.length = data.length; - data.forEach((item,idx) => { - if(item!=state.stateWrapper[idx]){ - state.stateWrapper[idx]=item; + data.forEach((item, idx) => { + if (item != state.stateWrapper[idx]) { + state.stateWrapper[idx] = item; } }); return; } - if(typeof data === 'object' && typeof state?.stateWrapper === 'object'){ + if (typeof data === 'object' && typeof state?.stateWrapper === 'object') { console.log('data is object'); Object.keys(state.stateWrapper).forEach(key => { - if(!data.hasOwnProperty(key)) delete state.stateWrapper[key]; - }) + if (!data.hasOwnProperty(key)) delete state.stateWrapper[key]; + }); - Object.entries(data).forEach(([key,value])=>{ - if(state.stateWrapper[key]!==value){ - state.stateWrapper[key]=value; + Object.entries(data).forEach(([key, value]) => { + if (state.stateWrapper[key] !== value) { + state.stateWrapper[key] = value; } }); return; @@ -100,7 +100,7 @@ export function createStore(reducer: Reducer, preloadedState?: any, enhancers?): const result = { reducer, - getState: function() { + getState: function () { return store.$s.stateWrapper; }, subscribe: listener => { @@ -169,7 +169,7 @@ export function bindActionCreators(actionCreators: ActionCreators, dispatch: Dis return boundActionCreators; } -export function compose(middlewares: ReduxMiddleware[]) { +export function compose(...middlewares: ReduxMiddleware[]) { return (store: ReduxStoreHandler, extraArgument: any) => { let val; middlewares.reverse().forEach((middleware: ReduxMiddleware, index) => { diff --git a/libs/horizon/src/horizonx/adapters/reduxReact.ts b/libs/horizon/src/horizonx/adapters/reduxReact.ts index 0954fbb3..09e4b081 100644 --- a/libs/horizon/src/horizonx/adapters/reduxReact.ts +++ b/libs/horizon/src/horizonx/adapters/reduxReact.ts @@ -4,7 +4,8 @@ import { createContext } from '../../renderer/components/context/CreateContext'; import { createElement } from '../../external/JSXElement'; import { BoundActionCreator } from './redux'; import { ReduxAction } from './redux'; -import { ReduxStoreHandler } from '../store/StoreHandler' +import { ReduxStoreHandler } from '../store/StoreHandler'; +import { VNode } from '../../renderer/Types'; const DefaultContext = createContext(null); type Context = typeof DefaultContext; @@ -22,15 +23,15 @@ export function Provider({ return createElement(Context.Provider, { value: store }, children); } -export function createStoreHook(context: Context) { +export function createStoreHook(context: Context): () => ReduxStoreHandler { return () => { - return useContext(context); + return useContext(context) as unknown as ReduxStoreHandler; }; } export function createSelectorHook(context: Context): (selector?: (any) => any) => any { - const store = (createStoreHook(context)() as unknown) as ReduxStoreHandler; - return function(selector = state => state) { + const store = createStoreHook(context)() as unknown as ReduxStoreHandler; + return function (selector = state => state) { const [b, fr] = useState(false); useEffect(() => { @@ -44,9 +45,9 @@ export function createSelectorHook(context: Context): (selector?: (any) => any) }; } -export function createDispatchHook(context: Context): ()=>BoundActionCreator { - const store = (createStoreHook(context)() as unknown) as ReduxStoreHandler; - return function() { +export function createDispatchHook(context: Context): () => BoundActionCreator { + const store = createStoreHook(context)() as unknown as ReduxStoreHandler; + return function () { return action => { store.dispatch(action); }; @@ -84,28 +85,46 @@ export const useStore = () => { // 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, +type MapStateToPropsP = (state: any, ownProps: OwnProps) => StateProps; +type MapDispatchToPropsP = + | { [key: string]: (...args: any[]) => ReduxAction } + | ((dispatch: (action: ReduxAction) => any, ownProps: OwnProps) => DispatchProps); +type MergePropsP = ( + stateProps: StateProps, + dispatchProps: DispatchProps, + ownProps: OwnProps +) => MergedProps; + +type WrappedComponent = (props: OwnProps) => ReturnType; +type OriginalComponent = (props: MergedProps) => ReturnType; +type Connector = (Component: OriginalComponent) => WrappedComponent; + +export function connect( + mapStateToProps: MapStateToPropsP = () => ({} as StateProps), + mapDispatchToProps: MapDispatchToPropsP = () => ({} as DispatchProps), + mergeProps: MergePropsP = ( + stateProps, + dispatchProps, + ownProps + ): MergedProps => ({ ...stateProps, ...dispatchProps, ...ownProps } as MergedProps), options?: { areStatesEqual?: (oldState: any, newState: any) => boolean; - context?: any; // TODO: type this + context?: Context; } -) { +): Connector { if (!options) { options = {}; } - return Component => { + //this component should bear the type returned from mapping functions + return (Component: OriginalComponent): WrappedComponent => { const useStore = createStoreHook(options?.context || DefaultContext); - function Wrapper(props) { + //this component should mimic original type of component used + const Wrapper: WrappedComponent = (props: OwnProps) => { const [f, forceReload] = useState(true); - const store = (useStore() as unknown) as ReduxStoreHandler; + const store = useStore(); useEffect(() => { const unsubscribe = store.subscribe(() => forceReload(!f)); @@ -119,36 +138,34 @@ export function connect( mappedState: {}, }) as { current: { - state: {}; - mappedState: {}; + state: { [key: string]: any }; + mappedState: StateProps; }; }; - let mappedState; + let mappedState: StateProps; if (options?.areStatesEqual) { if (options.areStatesEqual(previous.current.state, store.getState())) { - mappedState = previous.current.mappedState; + mappedState = previous.current.mappedState as StateProps; } else { - mappedState = mapStateToProps ? mapStateToProps(store.getState(), props) : {}; + mappedState = mapStateToProps ? mapStateToProps(store.getState(), props) : ({} as StateProps); previous.current.mappedState = mappedState; } } else { - mappedState = mapStateToProps ? mapStateToProps(store.getState(), props) : {}; + mappedState = mapStateToProps ? mapStateToProps(store.getState(), props) : ({} as StateProps); previous.current.mappedState = mappedState; } - let mappedDispatch: { dispatch?: (action) => void } = {}; + let mappedDispatch: DispatchProps = {} as DispatchProps; if (mapDispatchToProps) { if (typeof mapDispatchToProps === 'object') { Object.entries(mapDispatchToProps).forEach(([key, value]) => { - mappedDispatch[key] = (...args) => { + mappedDispatch[key] = (...args: ReduxAction[]) => { store.dispatch(value(...args)); }; }); } else { mappedDispatch = mapDispatchToProps(store.dispatch, props); } - } else { - mappedDispatch.dispatch = store.dispatch; } const mergedProps = ( mergeProps || @@ -161,7 +178,7 @@ export function connect( const node = createElement(Component, mergedProps); return node; - } + }; return Wrapper; }; diff --git a/libs/horizon/src/horizonx/proxy/watch.ts b/libs/horizon/src/horizonx/proxy/watch.ts new file mode 100644 index 00000000..87f55280 --- /dev/null +++ b/libs/horizon/src/horizonx/proxy/watch.ts @@ -0,0 +1,8 @@ +export function watch(stateVariable:any,listener:(stateVariable:any)=>void){ + listener = listener.bind(null,stateVariable); + stateVariable.addListener(listener); + + return ()=>{ + stateVariable.removeListener(listener); + } +} \ No newline at end of file diff --git a/libs/horizon/src/horizonx/store/StoreHandler.ts b/libs/horizon/src/horizonx/store/StoreHandler.ts index 00d4695e..f8055275 100644 --- a/libs/horizon/src/horizonx/store/StoreHandler.ts +++ b/libs/horizon/src/horizonx/store/StoreHandler.ts @@ -35,9 +35,7 @@ type StoreHandler, C extends UserComp $queue: QueuedStoreActions; $a: StoreActions; $c: UserComputedValues; -} & { [K in keyof S]: S[K] } & - { [K in keyof A]: Action } & - { [K in keyof C]: ReturnType }; +} & { [K in keyof S]: S[K] } & { [K in keyof A]: Action } & { [K in keyof C]: ReturnType }; type PlannedAction> = { action: string; @@ -76,6 +74,7 @@ export function createStore, C extend //create a local shalow copy to ensure consistency (if user would change the config object after store creation) config = { id: config.id, + /* @ts-ignore*/ options: config.options, state: config.state, actions: config.actions ? { ...config.actions } : undefined, @@ -87,6 +86,7 @@ export function createStore, C extend throw new Error('store obj must be pure object'); } + /* @ts-ignore*/ const proxyObj = createProxy(config.state, !config.options?.reduxAdapter); proxyObj.$pending = false; @@ -103,7 +103,7 @@ export function createStore, C extend const $a: Partial> = {}; const $queue: Partial> = {}; const $c: Partial> = {}; - const handler = ({ + const handler = { $subscribe, $unsubscribe, $a: $a as StoreActions, @@ -111,7 +111,7 @@ export function createStore, C extend $c: $c as ComputedValues, $config: config, $queue: $queue as QueuedStoreActions, - } as unknown) as StoreHandler; + } as unknown as StoreHandler; function tryNextAction() { if (!plannedActions.length) { @@ -193,6 +193,9 @@ export function createStore, C extend Object.keys(config.state).forEach(key => { Object.defineProperty(handler, key, { get: () => proxyObj[key], + set: value => { + proxyObj[key] = value; + }, }); }); } @@ -205,7 +208,7 @@ export function createStore, C extend } export function clearVNodeObservers(vNode) { - if(!vNode.observers) return; + if (!vNode.observers) return; vNode.observers.forEach(observer => { observer.clearByVNode(vNode); }); @@ -220,14 +223,14 @@ function hookStore() { if (!processingVNode) { return; } - + if (!processingVNode.observers) { processingVNode.observers = new Set(); } if (processingVNode.tag === FunctionComponent) { // from FunctionComponent - const vNodeRef = (useRef(null) as unknown) as { current: VNode }; + const vNodeRef = useRef(null) as unknown as { current: VNode }; vNodeRef.current = processingVNode; useEffect(() => { @@ -239,7 +242,7 @@ function hookStore() { } else if (processingVNode.tag === ClassComponent) { // from ClassComponent if (!processingVNode.classComponentWillUnmount) { - processingVNode.classComponentWillUnmount = function(vNode) { + processingVNode.classComponentWillUnmount = function (vNode) { clearVNodeObservers(vNode); vNode.observers = null; }; diff --git a/package.json b/package.json index 500c07f5..96429935 100644 --- a/package.json +++ b/package.json @@ -34,11 +34,11 @@ "@babel/plugin-transform-object-super": "7.16.7", "@babel/plugin-transform-parameters": "7.16.7", "@babel/plugin-transform-react-jsx": "7.16.7", + "@babel/plugin-transform-react-jsx-source": "^7.16.7", "@babel/plugin-transform-runtime": "7.16.7", "@babel/plugin-transform-shorthand-properties": "7.16.7", "@babel/plugin-transform-spread": "7.16.7", "@babel/plugin-transform-template-literals": "7.16.7", - "@babel/plugin-transform-react-jsx-source": "^7.16.7", "@babel/preset-env": "7.16.7", "@babel/preset-typescript": "7.16.7", "@rollup/plugin-babel": "^5.3.1", @@ -66,5 +66,8 @@ "engines": { "node": ">=10.x", "npm": ">=7.x" + }, + "dependencies": { + "ejs": "^3.1.8" } } diff --git a/scripts/__tests__/HorizonXText/StoreFunctionality/basicAccess.test.tsx b/scripts/__tests__/HorizonXText/StoreFunctionality/basicAccess.test.tsx index 9d95c95f..9647bb1d 100644 --- a/scripts/__tests__/HorizonXText/StoreFunctionality/basicAccess.test.tsx +++ b/scripts/__tests__/HorizonXText/StoreFunctionality/basicAccess.test.tsx @@ -36,6 +36,34 @@ describe('Basic store manipulation', () => { expect(document.getElementById(RESULT_ID)?.innerHTML).toBe('1'); }); + it('Should use direct setters', () => { + function App() { + const logStore = useLogStore(); + + return ( +
+ +

{logStore.logs[0]}

+
+ ); + } + + Horizon.render(, container); + + Horizon.act(() => { + triggerClickEvent(container, BUTTON_ID); + }); + + expect(document.getElementById(RESULT_ID)?.innerHTML).toBe('q'); + }); + it('Should use actions and update components', () => { function App() { const logStore = useLogStore(); @@ -74,7 +102,7 @@ describe('Basic store manipulation', () => { increment: state => { state.count++; }, - doublePlusOne: function(state) { + doublePlusOne: function (state) { state.count = state.count * 2; this.increment(); }, @@ -115,20 +143,20 @@ describe('Basic store manipulation', () => { count: 2, }, actions: { - doublePlusOne: function(state) { + doublePlusOne: function (state) { state.count = this.double + 1; }, }, - computed:{ - double: (state) => { - return state.count*2 - } - } + computed: { + double: state => { + return state.count * 2; + }, + }, }); - + function App() { const incrementStore = useIncrementStore(); - + return (
); } - + Horizon.render(, container); - + Horizon.act(() => { triggerClickEvent(container, BUTTON_ID); }); - + expect(document.getElementById(RESULT_ID)?.innerHTML).toBe('5'); - }) + }); }); diff --git a/scripts/__tests__/HorizonXText/StoreFunctionality/watch.test.tsx b/scripts/__tests__/HorizonXText/StoreFunctionality/watch.test.tsx new file mode 100644 index 00000000..806c6029 --- /dev/null +++ b/scripts/__tests__/HorizonXText/StoreFunctionality/watch.test.tsx @@ -0,0 +1,132 @@ +import { createStore } from "@cloudsop/horizon/src/horizonx/store/StoreHandler"; +import { watch } from "@cloudsop/horizon/src/horizonx/proxy/watch"; + +describe("watch",()=>{ + it('shouhld watch promitive state variable', async()=>{ + const useStore = createStore({ + state:{ + variable:'x' + }, + actions:{ + change:(state)=>state.variable = "a" + } + }); + + const store = useStore(); + let counter = 0; + + watch(store.$s,(state)=>{ + counter++; + expect(state.variable).toBe('a'); + }) + + store.change(); + + expect(counter).toBe(1); + }); + it('shouhld watch object variable', async()=>{ + const useStore = createStore({ + state:{ + variable:'x' + }, + actions:{ + change:(state)=>state.variable = "a" + } + }); + + const store = useStore(); + let counter = 0; + + store.$s.watch('variable',()=>{ + counter++; + }) + + store.change(); + + expect(counter).toBe(1); + }); + + it('shouhld watch array item', async()=>{ + const useStore = createStore({ + state:{ + arr:['x'] + }, + actions:{ + change:(state)=>state.arr[0]='a' + } + }); + + const store = useStore(); + let counter = 0; + + store.arr.watch('0',()=>{ + counter++; + }) + + store.change(); + + expect(counter).toBe(1); + }); + + it('shouhld watch collection item', async()=>{ + const useStore = createStore({ + state:{ + collection:new Map([ + ['a', 'a'], + ]) + }, + actions:{ + change:(state)=>state.collection.set('a','x') + } + }); + + const store = useStore(); + let counter = 0; + + store.collection.watch('a',()=>{ + counter++; + }) + + store.change(); + + expect(counter).toBe(1); + }); + + it('should watch multiple variables independedntly', async()=>{ + const useStore = createStore({ + state:{ + bool1:true, + bool2:false + }, + actions:{ + toggle1:state=>state.bool1=!state.bool1, + toggle2:state=>state.bool2=!state.bool2 + } + }); + + let counter1=0; + let counterAll=0; + const store = useStore(); + + watch(store.$s,()=>{ + counterAll++; + }) + + store.$s.watch('bool1',()=>{ + counter1++; + }); + + store.toggle1(); + store.toggle1(); + + store.toggle2(); + + store.toggle1(); + + store.toggle2(); + store.toggle2(); + + expect(counter1).toBe(3); + expect(counterAll).toBe(6); + }) +}) \ No newline at end of file diff --git a/scripts/__tests__/HorizonXText/adapters/ReduxReactAdapter.test.tsx b/scripts/__tests__/HorizonXText/adapters/ReduxReactAdapter.test.tsx index 4f6feec6..12fc9eb9 100644 --- a/scripts/__tests__/HorizonXText/adapters/ReduxReactAdapter.test.tsx +++ b/scripts/__tests__/HorizonXText/adapters/ReduxReactAdapter.test.tsx @@ -293,4 +293,4 @@ describe('Redux/React binding adapter', () => { expect(getE(BUTTON).innerHTML).toBe('1'); expect(getE(BUTTON2).innerHTML).toBe('true'); }); -}); +}); \ No newline at end of file diff --git a/scripts/__tests__/HorizonXText/adapters/connectTest.tsx b/scripts/__tests__/HorizonXText/adapters/connectTest.tsx new file mode 100644 index 00000000..a11b8249 --- /dev/null +++ b/scripts/__tests__/HorizonXText/adapters/connectTest.tsx @@ -0,0 +1,46 @@ +import { createElement } from '../../../../libs/horizon/src/external/JSXElement'; +import { createDomTextVNode } from '../../../../libs/horizon/src/renderer/vnode/VNodeCreator'; +import { createStore } from '../../../../libs/horizon/src/horizonx/adapters/redux'; +import { connect } from '../../../../libs/horizon/src/horizonx/adapters/reduxReact'; + +createStore((state: number = 0, action): number => { + if (action.type === 'add') return state + 1; + return 0; +}); + +type WrappedButtonProps = { add: () => void; count: number; text: string }; + +function Button(props: WrappedButtonProps) { + const { add, count, text } = props; + return createElement( + 'button', + { + onClick: add, + }, + createDomTextVNode(text), + createDomTextVNode(': '), + createDomTextVNode(count) + ); +} + +const connector = connect( + state => ({ count: state }), + dispatch => ({ + add: (): void => { + dispatch({ type: 'add' }); + }, + }), + (stateProps, dispatchProps, ownProps: { text: string }) => ({ + add: dispatchProps.add, + count: stateProps.count, + text: ownProps.text, + }) +); + +const ConnectedButton = connector(Button); + +function App() { + return createElement('div', {}, createElement(ConnectedButton, { text: 'click' })); +} + +export default App; diff --git a/scripts/__tests__/HorizonXText/edgeCases/multipleStores.test.tsx b/scripts/__tests__/HorizonXText/edgeCases/multipleStores.test.tsx new file mode 100644 index 00000000..63fb74e5 --- /dev/null +++ b/scripts/__tests__/HorizonXText/edgeCases/multipleStores.test.tsx @@ -0,0 +1,199 @@ +//@ts-ignore +import Horizon, { createStore } from '@cloudsop/horizon/index.ts'; +import { triggerClickEvent } from '../../jest/commonComponents'; +import { describe, beforeEach, afterEach, it, expect } from '@jest/globals'; + +const { unmountComponentAtNode } = Horizon; + +const useStore1 = createStore({ + state:{ counter:1 }, + actions:{ + add:(state)=>state.counter++, + reset: (state)=>state.counter=1 + } +}) + +const useStore2 = createStore({ + state:{ counter2:1 }, + actions:{ + add2:(state)=>state.counter2++, + reset: (state)=>state.counter2=1 + } +}) + +describe('Using multiple stores', () => { + let container: HTMLElement | null = null; + + const BUTTON_ID = 'btn'; + const BUTTON_ID2 = 'btn2'; + const RESULT_ID = 'result'; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + useStore1().reset(); + useStore2().reset(); + }); + + afterEach(() => { + unmountComponentAtNode(container); + container?.remove(); + container = null; + }); + + it('Should use multiple stores in class component', () => { + class App extends Horizon.Component{ + render(){ + const {counter,add} = useStore1(); + const {counter2, add2} = useStore2(); + + return ( +
+ + +

{counter} {counter2}

+
+ ) + } + } + + Horizon.render(, container); + + expect(document.getElementById(RESULT_ID)?.innerHTML).toBe('1 1'); + Horizon.act(() => { + triggerClickEvent(container, BUTTON_ID); + + }); + expect(document.getElementById(RESULT_ID)?.innerHTML).toBe('2 1'); + + Horizon.act(() => { + triggerClickEvent(container, BUTTON_ID2); + + }); + expect(document.getElementById(RESULT_ID)?.innerHTML).toBe('2 2'); + + }); + + it('Should use use stores in cycles and multiple methods', () => { + interface App { + store:any, + store2:any + } + class App extends Horizon.Component{ + constructor(){ + super(); + this.store = useStore1(); + this.store2 = useStore2() + } + + render(){ + const {counter,add} = useStore1(); + const store2 = useStore2(); + const {counter2, add2} = store2; + + for(let i=0; i<100; i++){ + const {counter,add} = useStore1(); + const store2 = useStore2(); + const {counter2, add2} = store2; + } + + return ( +
+ + +

{counter} {counter2}

+
+ ) + } + } + + Horizon.render(, container); + + expect(document.getElementById(RESULT_ID)?.innerHTML).toBe('1 1'); + Horizon.act(() => { + triggerClickEvent(container, BUTTON_ID); + + }); + expect(document.getElementById(RESULT_ID)?.innerHTML).toBe('2 1'); + + Horizon.act(() => { + triggerClickEvent(container, BUTTON_ID2); + + }); + expect(document.getElementById(RESULT_ID)?.innerHTML).toBe('2 2'); + + }); + + it('Should use multiple stores in function component', () => { + function App() { + const {counter,add} = useStore1(); + const store2 = useStore2(); + const {counter2, add2} = store2; + + return ( +
+ + +

{counter} {counter2}

+
+ ); + } + + Horizon.render(, container); + + expect(document.getElementById(RESULT_ID)?.innerHTML).toBe('1 1'); + Horizon.act(() => { + triggerClickEvent(container, BUTTON_ID); + + }); + expect(document.getElementById(RESULT_ID)?.innerHTML).toBe('2 1'); + + Horizon.act(() => { + triggerClickEvent(container, BUTTON_ID2); + + }); + expect(document.getElementById(RESULT_ID)?.innerHTML).toBe('2 2'); + }); +}); \ No newline at end of file