diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c2c13480b0..cf45cba7c1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,6 +2,10 @@ name: test on: [push, pull_request] +permissions: + contents: read + pull-requests: read + jobs: tsc: runs-on: ubuntu-latest @@ -19,6 +23,16 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: detect RN component changes + id: changes + uses: dorny/paths-filter@v4 + with: + filters: | + rn_components: + - 'packages/webpack-plugin/lib/runtime/components/react/**' + - 'packages/webpack-plugin/test/runtime/react-native/components/**' + - 'packages/webpack-plugin/test/__mocks__/**' + - 'packages/webpack-plugin/jest.config.react.json' - name: Cache node modules id: cache uses: actions/cache@v4 @@ -59,4 +73,8 @@ jobs: run: npm run build:tsc - name: exec unit test - run: npm t && npm t --prefix test/e2e/miniprogram-project && npm t --prefix test/e2e/plugin-project + run: npm run test:base && npm t --prefix test/e2e/miniprogram-project && npm t --prefix test/e2e/plugin-project + + - name: exec RN component unit test + if: steps.changes.outputs.rn_components == 'true' + run: npm run test:react -w @mpxjs/webpack-plugin diff --git a/.gitignore b/.gitignore index acf5492470..c35a6afc40 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ docs-vitepress/.vitepress/dist docs-vitepress/.vitepress/cache dev-dist packages/webpack-plugin/lib/runtime/components/react/dist/ +coverage/ packages/perf/dist/ .agent-workflows/ .agents/skills/mpx/ diff --git a/docs-vitepress/guide/rn/component.md b/docs-vitepress/guide/rn/component.md index 6a56a3f781..4a31fda4c3 100644 --- a/docs-vitepress/guide/rn/component.md +++ b/docs-vitepress/guide/rn/component.md @@ -345,8 +345,7 @@ movable-view的可移动区域。 | 事件名 | 说明 | | ----------------| ------------------ | -| change | radio-group 中选中项发生改变时触发 change 事件,`detail = { value: [ 选中的 radio 的 value 的数组 ] }` | - +| change | radio-group 中选中项发生改变时触发 change 事件,`detail = { value: [ 选中的 radio 的 value 的数组 ] }` | ### form 表单。 diff --git a/package.json b/package.json index 07391f02f5..4711857d48 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "lint": "eslint --ext .js,.ts,.tsx,.jsx packages/", "fix": "eslint --fix --ext .js,.ts,.tsx,.jsx packages/", "lint:js": "eslint --ext .js packages/", - "test": "NODE_OPTIONS=--experimental-vm-modules jest --passWithNoTests", + "test": "npm run test:base && npm run test:react -w @mpxjs/webpack-plugin", + "test:base": "NODE_OPTIONS=--experimental-vm-modules jest --passWithNoTests", "test:review-loop": "jest --config .agents/skills/review-loop/jest.config.json --runInBand", "release": "npm run lint && npm run test && npx lerna version", "lerna:publish": "lerna publish from-package --yes", diff --git a/packages/webpack-plugin/jest.config.json b/packages/webpack-plugin/jest.config.json index 0b3dd4bdb5..07350b2f66 100644 --- a/packages/webpack-plugin/jest.config.json +++ b/packages/webpack-plugin/jest.config.json @@ -4,8 +4,16 @@ }, "testEnvironment": "jsdom", "moduleNameMapper": { - "\\.(css|styl)$": "identity-obj-proxy" + "\\.(css|styl)$": "identity-obj-proxy", + "^@mpxjs/utils$": "/../utils/src/index.js" }, - "setupFiles": ["/test/runtime/react-native/setup.js"], - "testTimeout": 10000 + "testPathIgnorePatterns": [ + "/node_modules/", + "/dist/", + "test/runtime/react-native/components/" + ], + "transformIgnorePatterns": [ + "node_modules/(?!(\\.pnpm/)?(react-native|@react-native|react-native-.*|@react-navigation|@mpxjs))" + ], + "setupFiles": ["/test/runtime/react-native/setup.js"] } diff --git a/packages/webpack-plugin/jest.config.react.json b/packages/webpack-plugin/jest.config.react.json new file mode 100644 index 0000000000..361071a1f2 --- /dev/null +++ b/packages/webpack-plugin/jest.config.react.json @@ -0,0 +1,47 @@ +{ + "transform": { + "^.+\\.(js|jsx|ts|tsx)$": ["babel-jest", { "rootMode": "upward" }] + }, + "testEnvironment": "node", + "coverageProvider": "v8", + "moduleNameMapper": { + "\\.(css|styl)$": "identity-obj-proxy", + "^@mpxjs/utils$": "/../utils/src/index.js", + "^react-native$": "/test/__mocks__/react-native-pure.js", + "^react-native-safe-area-context$": "/test/__mocks__/react-native-safe-area-context.js", + "^react-native-linear-gradient$": "/test/__mocks__/react-native-linear-gradient.js", + "^react-native-gesture-handler$": "/test/__mocks__/react-native-gesture-handler.js", + "^react-native-reanimated$": "/test/__mocks__/react-native-reanimated.js", + "^react-native-fast-image$": "/test/__mocks__/react-native-fast-image.js", + "^@d11/react-native-fast-image$": "/test/__mocks__/react-native-fast-image.js", + "\\.(png|jpg|jpeg|gif|webp)$": "/test/__mocks__/fileMock.js" + }, + "setupFilesAfterEnv": [ + "/test/runtime/react-native/components/setup.js" + ], + "testMatch": [ + "/test/runtime/react-native/components/**/*.test.(js|jsx|ts|tsx)" + ], + "testPathIgnorePatterns": [ + "/node_modules/", + "/dist/" + ], + "collectCoverageFrom": [ + "lib/runtime/components/react/**/*.{ts,tsx}", + "!lib/runtime/components/react/**/*.d.ts", + "!lib/runtime/components/react/dist/**", + "!lib/runtime/components/react/mpx-canvas/html.ts", + "!lib/runtime/components/react/mpx-picker/type.ts" + ], + "coverageThreshold": { + "global": { + "statements": 90, + "functions": 90, + "lines": 90, + "branches": 90 + } + }, + "transformIgnorePatterns": [ + "node_modules/(?!(\\.pnpm/)?(react-native|@react-native|react-native-.*|@react-navigation|@mpxjs))" + ] +} diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-camera.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-camera.tsx index 06ae4b8427..7c55c41b56 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-camera.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-camera.tsx @@ -20,7 +20,7 @@ interface CameraProps { flash?: 'auto' | 'on' | 'off' 'frame-size'?: 'small' | 'medium' | 'large' style?: Record - bindstop?: () => void + bindstop?: (event: Record) => void binderror?: (error: { message: string }) => void bindinitdone?: (result: { type: string, data: string }) => void bindscancode?: (result: { type: string, data: string }) => void @@ -177,7 +177,7 @@ const _camera = forwardRef, CameraProps>((props: Ca }, [bindinitdone, maxZoom]) const onStopped = useCallback(() => { - bindstop && bindstop() + bindstop && bindstop(getCustomEvent('stop', {}, { layoutRef })) }, [bindstop]) const camera: CameraRef = useMemo(() => ({ diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-canvas/Image.ts b/packages/webpack-plugin/lib/runtime/components/react/mpx-canvas/Image.ts index e34fa0714c..074dbfecf5 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-canvas/Image.ts +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-canvas/Image.ts @@ -13,8 +13,8 @@ export class Image { canvas: any; width: number; height: number; - private _loadListener: any; - private _errorListener: any; + private _removeLoadListener?: () => void; + private _removeErrorListener?: () => void; private _onload: ((...args: any[]) => void); private _onerror: ((...args: any[]) => void); [key: string]: any; @@ -70,12 +70,8 @@ export class Image { set onload (callback: ((...args: any[]) => void)) { this._onload = callback - if (this._loadListener) { - this.canvas.removeMessageListener(this._loadListener) - } - if (callback) { - this._loadListener = this.addEventListener('load', callback) - } + this._removeLoadListener?.() + this._removeLoadListener = callback ? this.addEventListener('load', callback) : undefined } get onload (): ((...args: any[]) => void) { @@ -84,12 +80,8 @@ export class Image { set onerror (callback: ((...args: any[]) => void)) { this._onerror = callback - if (this._errorListener) { - this.canvas.removeMessageListener(this._errorListener) - } - if (callback) { - this._errorListener = this.addEventListener('error', callback) - } + this._removeErrorListener?.() + this._removeErrorListener = callback ? this.addEventListener('error', callback) : undefined } get onerror () : ((...args: any[]) => void) { diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-canvas/index.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-canvas/index.tsx index b2841d8834..83fca7a3ac 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-canvas/index.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-canvas/index.tsx @@ -200,7 +200,10 @@ const _Canvas = forwardRef, CanvasPr } const removeMessageListener = (listener: any) => { - canvasRef.current.listeners.splice(canvasRef.current.listeners.indexOf(listener), 1) + const index = canvasRef.current.listeners.indexOf(listener) + if (index > -1) { + canvasRef.current.listeners.splice(index, 1) + } } const onMessage = useCallback((e: { nativeEvent: { data: string } }) => { @@ -288,22 +291,22 @@ const _Canvas = forwardRef, CanvasPr allowUniversalAccessFromFileURLs: true }) ) + } else { + canvasComponent = createElement(View, innerProps, createElement(WebView, { + ref: (element: WebViewInstance | null) => { + if (canvasRef.current) { + canvasRef.current.webview = element + } + }, + style: [stylesheet.webview, { height, width }], + source: { html }, + originWhitelist: originWhitelist, + onMessage: onMessage, + onLoad: onLoad, + scrollEnabled: false + })) } - canvasComponent = createElement(View, innerProps, createElement(WebView, { - ref: (element: WebViewInstance | null) => { - if (canvasRef.current) { - canvasRef.current.webview = element - } - }, - style: [stylesheet.webview, { height, width }], - source: { html }, - originWhitelist: originWhitelist, - onMessage: onMessage, - onLoad: onLoad, - scrollEnabled: false - })) - if (hasPositionFixed) { canvasComponent = createElement(Portal, null, canvasComponent) } diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-checkbox-group.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-checkbox-group.tsx index 1e63226338..8579c41254 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-checkbox-group.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-checkbox-group.tsx @@ -138,7 +138,7 @@ const CheckboxGroup = forwardRef< bindchange && bindchange( getCustomEvent( - 'tap', + 'change', evt, { layoutRef, diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-form.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-form.tsx index 7851f6e544..12ad2865bb 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-form.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-form.tsx @@ -23,7 +23,7 @@ interface FormProps { value: any } }) => void - bindreset?: () => void + bindreset?: (evt: Record) => void } const _Form = forwardRef, FormProps>((fromProps: FormProps, ref): JSX.Element => { @@ -98,7 +98,7 @@ const _Form = forwardRef, FormProps>((fromProps: For const reset = () => { const { bindreset } = propsRef.current - bindreset && bindreset() + bindreset && bindreset(getCustomEvent('reset', {}, { layoutRef }, propsRef.current)) formValuesMap.forEach(item => item.resetValue()) } return { diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-picker/date.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-picker/date.tsx index 4508887a9e..643665cef2 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-picker/date.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-picker/date.tsx @@ -121,7 +121,7 @@ const valueChanged2Obj = (currentObj: FormatObj, value: number[], limit = 3) => const rangeArr = currentObj.rangeArr if (limit === 3 && (currentValue[0] !== value[0] || currentValue[1] !== value[1])) { - const days = daysInMonth(value[0], value[1] + 1) + const days = daysInMonth(value[0] + START_YEAR, value[1] + 1) rangeArr[2] = days const maxIndex = days.length - 1 if (value[2] > maxIndex) { diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-picker/index.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-picker/index.tsx index 5118c965cd..10758ad863 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-picker/index.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-picker/index.tsx @@ -250,7 +250,7 @@ const Picker = forwardRef, PickerProps>( } const onCancel = () => { - bindcancel?.() + bindcancel?.(getCustomEvent('cancel', {}, { layoutRef }, props)) hide() } diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-radio-group.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-radio-group.tsx index fc0a69ec03..903c710718 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-radio-group.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-radio-group.tsx @@ -118,7 +118,7 @@ const radioGroup = forwardRef< bindchange && bindchange( getCustomEvent( - 'tap', + 'change', evt, { layoutRef, diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-sticky-header.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-sticky-header.tsx index 86a06de3f1..b82a7dd6f0 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-sticky-header.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-sticky-header.tsx @@ -57,7 +57,7 @@ const _StickyHeader = forwardRef, StickyHead const headerTopRef = useRef(0) useEffect(() => { - registerStickyHeader({ key: id, updatePosition }) + registerStickyHeader({ id, updatePosition }) return () => { unregisterStickyHeader(id) } diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-swiper.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-swiper.tsx index d8621b671e..f6c88d4f95 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-swiper.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-swiper.tsx @@ -30,6 +30,7 @@ import Portal from './mpx-portal' * ✘ snap-to-edge */ type EaseType = 'default' | 'linear' | 'easeInCubic' | 'easeOutCubic' | 'easeInOutCubic' +type ChangeSource = '' | 'touch' | 'autoplay' type StrAbsoType = 'absoluteX' | 'absoluteY' type StrVelocityType = 'velocityX' | 'velocityY' type EventDataType = { @@ -201,6 +202,7 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr const preMarginShared = useSharedValue(preMargin) const nextMarginShared = useSharedValue(nextMargin) const autoplayShared = useSharedValue(autoplay) + const changeSource = useSharedValue('') // 默认前后补位的元素个数 const patchElmNum = circular ? (preMargin ? 2 : 1) : 0 const patchElmNumShared = useSharedValue(patchElmNum) @@ -419,6 +421,7 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr duration: easeDuration, easing: easeMap[easeingFunc] }, () => { + changeSource.value = 'autoplay' currentIndex.value = nextIndex runOnJS(runOnJSCallback)('loop') }) @@ -434,6 +437,7 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr const initOffset = -step.value * patchElmNumShared.value + preMarginShared.value // 将开始位置设置为真正的位置 offset.value = initOffset + changeSource.value = 'autoplay' currentIndex.value = nextIndex runOnJS(runOnJSCallback)('loop') }) @@ -445,6 +449,7 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr duration: easeDuration, easing: easeMap[easeingFunc] }, () => { + changeSource.value = 'autoplay' currentIndex.value = nextIndex runOnJS(runOnJSCallback)('loop') }) @@ -474,8 +479,8 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr } }, []) - function handleSwiperChange (current: number) { - const eventData = getCustomEvent('change', {}, { detail: { current, source: 'touch' }, layoutRef: layoutRef }) + function handleSwiperChange (current: number, source: ChangeSource = 'touch') { + const eventData = getCustomEvent('change', {}, { detail: { current, source }, layoutRef: layoutRef }) bindchange && bindchange(eventData) } @@ -508,6 +513,7 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr duration: easeDuration, easing: easeMap[easeingFunc] }, () => { + changeSource.value = '' currentIndex.value = propCurrent }) } else { @@ -526,7 +532,7 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr useAnimatedReaction(() => currentIndex.value, (newIndex: number, preIndex: number) => { // 这里必须传递函数名, 直接写()=> {}形式会报 访问了未sharedValue信息 if (newIndex !== preIndex && bindchange && !isFirstRef.current) { - runOnJS(runOnJSCallback)('handleSwiperChange', newIndex, propCurrent) + runOnJS(runOnJSCallback)('handleSwiperChange', newIndex, changeSource.value) } isFirstRef.current = false }) @@ -552,6 +558,7 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr childrenLength.value = children.length if (children.length - 1 < currentIndex.value) { pauseLoop() + changeSource.value = '' currentIndex.value = 0 offset.value = getOffset(0, step.value) if (autoplay && children.length > 1) { @@ -655,6 +662,7 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr easing: easeMap[easeingFunc] }, () => { if (touchfinish.value !== false) { + changeSource.value = 'touch' currentIndex.value = selectedIndex offset.value = resetOffset runOnJS(runOnJSCallback)('resumeLoop') @@ -666,6 +674,7 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr easing: easeMap[easeingFunc] }, () => { if (touchfinish.value !== false) { + changeSource.value = 'touch' currentIndex.value = selectedIndex runOnJS(runOnJSCallback)('resumeLoop') } @@ -688,6 +697,7 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr easing: easeMap[easeingFunc] }, () => { if (touchfinish.value !== false) { + changeSource.value = 'touch' currentIndex.value = moveToIndex runOnJS(runOnJSCallback)('resumeLoop') } @@ -794,6 +804,7 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr const offsetHalf = computeHalf() if (childrenLength.value > 1 && offsetHalf) { const { selectedIndex } = getTargetPosition({ transdir: moveDistance } as EventEndType) + changeSource.value = 'touch' currentIndex.value = selectedIndex } // 2. 非循环: 处理用户一直拖拽到临界点的场景,如果放到onFinalize无法阻止offset.value更新为越界的值 diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-video.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-video.tsx index 5e5d6dc312..b177b704e8 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-video.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-video.tsx @@ -213,7 +213,7 @@ const MpxVideo = forwardRef, VideoProps>((videoProp } function handleEnd () { - bindended!(getCustomEvent('end', {}, { layoutRef }, propsRef.current)) + bindended!(getCustomEvent('ended', {}, { layoutRef }, propsRef.current)) } function handleWaiting ({ isBuffering }: OnBufferData) { @@ -259,7 +259,7 @@ const MpxVideo = forwardRef, VideoProps>((videoProp function handleAndroidControlsVisibilityChange ({ isVisible }: OnControlsVisibilityChange) { bindcontrolstoggle!( - getCustomEvent('progress', + getCustomEvent('controlstoggle', {}, { detail: { @@ -292,7 +292,7 @@ const MpxVideo = forwardRef, VideoProps>((videoProp } function handleError ({ error }: OnVideoErrorData) { - binderror && binderror(getCustomEvent('play', {}, { detail: { errMsg: error.localizedFailureReason }, layoutRef }, propsRef.current)) + binderror && binderror(getCustomEvent('error', {}, { detail: { errMsg: error.localizedFailureReason }, layoutRef }, propsRef.current)) } function play () { diff --git a/packages/webpack-plugin/package.json b/packages/webpack-plugin/package.json index a705255e72..c4835d43b4 100644 --- a/packages/webpack-plugin/package.json +++ b/packages/webpack-plugin/package.json @@ -87,18 +87,23 @@ "url": "https://github.com/didi/mpx/issues" }, "scripts": { - "test": "jest --passWithNoTests", + "test": "jest --passWithNoTests --testTimeout=10000", + "test:react": "jest --config jest.config.react.json --coverage", "copy-icons": "cp -r ./lib/runtime/components/react/mpx-icon/icons ./lib/runtime/components/react/dist/mpx-icon/icons", "build": "rimraf ./lib/runtime/components/react/dist && tsc -p ./lib/runtime/components/react/tsconfig.json && npm run copy-icons" }, "devDependencies": { "@d11/react-native-fast-image": "^8.6.12", + "@testing-library/react-native": "^12.9.0", "@mpxjs/api-proxy": "^2.11.1", "@types/babel-traverse": "^6.25.4", "@types/babel-types": "^7.0.4", "@types/glob": "^8.1.0", "@types/react": "^18.2.79", + "babel-jest": "^29.7.0", "glob": "^11.0.2", + "react": "18.2.0", + "react-test-renderer": "18.2.0", "react-native": "^0.74.5", "react-native-gesture-handler": "^2.18.1", "react-native-linear-gradient": "^2.8.3", diff --git a/packages/webpack-plugin/test/__mocks__/fileMock.js b/packages/webpack-plugin/test/__mocks__/fileMock.js new file mode 100644 index 0000000000..0e56c5b5f7 --- /dev/null +++ b/packages/webpack-plugin/test/__mocks__/fileMock.js @@ -0,0 +1 @@ +module.exports = 'test-file-stub' diff --git a/packages/webpack-plugin/test/__mocks__/react-native-fast-image.js b/packages/webpack-plugin/test/__mocks__/react-native-fast-image.js new file mode 100644 index 0000000000..88fb4c33f0 --- /dev/null +++ b/packages/webpack-plugin/test/__mocks__/react-native-fast-image.js @@ -0,0 +1,18 @@ +import React from 'react' +import { Image } from 'react-native' + +const FastImage = (props) => { + return React.createElement(Image, { + ...props, + testID: props.testID || 'fast-image' + }) +} + +FastImage.resizeMode = { + contain: 'contain', + cover: 'cover', + stretch: 'stretch', + center: 'center' +} + +export default FastImage diff --git a/packages/webpack-plugin/test/__mocks__/react-native-gesture-handler.js b/packages/webpack-plugin/test/__mocks__/react-native-gesture-handler.js new file mode 100644 index 0000000000..440e640dd3 --- /dev/null +++ b/packages/webpack-plugin/test/__mocks__/react-native-gesture-handler.js @@ -0,0 +1,125 @@ +import React from 'react' +import { View } from 'react-native' + +const GestureDetector = ({ children, gesture, ...props }) => { + return React.createElement(View, { + ...props, + testID: 'gesture-detector' + }, children) +} + +// Mock ScrollView from gesture-handler (enhanced ScrollView) +let lastScrollViewRef + +const ScrollView = React.forwardRef((props, ref) => { + const instanceRef = React.useRef({ + scrollTo: jest.fn(), + measureLayout: jest.fn() + }) + lastScrollViewRef = instanceRef.current + React.useImperativeHandle(ref, () => instanceRef.current) + return React.createElement('ScrollView', props) +}) + +// Mock RefreshControl from gesture-handler +const RefreshControl = React.forwardRef((props, ref) => + React.createElement('View', { ...props, ref, testID: 'refresh-control' }) +) + +let lastPanGesture +let lastNativeGesture + +const createMockGesture = () => { + const gesture = { + activeOffsetX: jest.fn(() => gesture), + activeOffsetY: jest.fn(() => gesture), + failOffsetY: jest.fn(() => gesture), + failOffsetX: jest.fn(() => gesture), + requireExternalGestureToFail: jest.fn(() => gesture), + withRef: jest.fn(() => gesture), + onTouchesDown: jest.fn((callback) => { + gesture.onTouchesDownCallback = callback + return gesture + }), + onTouchesMove: jest.fn((callback) => { + gesture.onTouchesMoveCallback = callback + return gesture + }), + onTouchesUp: jest.fn((callback) => { + gesture.onTouchesUpCallback = callback + return gesture + }), + onStart: jest.fn((callback) => { + gesture.onStartCallback = callback + return gesture + }), + onFinalize: jest.fn((callback) => { + gesture.onFinalizeCallback = callback + return gesture + }), + enabled: jest.fn(() => gesture), + shouldCancelWhenOutside: jest.fn(() => gesture), + hitSlop: jest.fn(() => gesture), + runOnJS: jest.fn(() => gesture), + simultaneousWithExternalGesture: jest.fn(() => gesture), + onBegin: jest.fn((callback) => { + gesture.onBeginCallback = callback + return gesture + }), + onUpdate: jest.fn((callback) => { + gesture.onUpdateCallback = callback + return gesture + }), + onEnd: jest.fn((callback) => { + gesture.onEndCallback = callback + return gesture + }) + } + return gesture +} + +const createMockPanGesture = () => { + lastPanGesture = createMockGesture() + return lastPanGesture +} + +const createMockNativeGesture = () => { + lastNativeGesture = createMockGesture() + return lastNativeGesture +} + +const Gesture = { + Pan: jest.fn(() => createMockPanGesture()), + Native: jest.fn(() => createMockNativeGesture()), + Tap: jest.fn(() => createMockGesture()), + LongPress: jest.fn(() => createMockGesture()), + Pinch: jest.fn(() => createMockGesture()), + Rotation: jest.fn(() => createMockGesture()), + Fling: jest.fn(() => createMockGesture()) +} + +const __getLastPanGesture = () => lastPanGesture +const __getLastNativeGesture = () => lastNativeGesture +const __getLastScrollViewRef = () => lastScrollViewRef +const __resetScrollViewRefs = () => { + lastScrollViewRef = undefined +} + +export { + GestureDetector, + Gesture, + ScrollView, + RefreshControl, + __getLastPanGesture, + __getLastNativeGesture, + __getLastScrollViewRef, + __resetScrollViewRefs +} + +// For compatibility, also export as default +export default { + GestureDetector, + Gesture, + ScrollView, + RefreshControl +} diff --git a/packages/webpack-plugin/test/__mocks__/react-native-linear-gradient.js b/packages/webpack-plugin/test/__mocks__/react-native-linear-gradient.js new file mode 100644 index 0000000000..a6d25739f0 --- /dev/null +++ b/packages/webpack-plugin/test/__mocks__/react-native-linear-gradient.js @@ -0,0 +1,11 @@ +import React from 'react' +import { View } from 'react-native' + +const LinearGradient = (props) => { + return React.createElement(View, { + ...props, + testID: props.testID || 'linear-gradient' + }, props.children) +} + +export default LinearGradient diff --git a/packages/webpack-plugin/test/__mocks__/react-native-pure.js b/packages/webpack-plugin/test/__mocks__/react-native-pure.js new file mode 100644 index 0000000000..69d17be2eb --- /dev/null +++ b/packages/webpack-plugin/test/__mocks__/react-native-pure.js @@ -0,0 +1,369 @@ +// 纯 React Native Mock,保持原始组件名称用于快照测试 +import React from 'react' + +// 创建自定义组件,保持 RN 组件名称 +const createRNComponent = (displayName) => { + const Component = React.forwardRef((props, ref) => { + const { children, ...otherProps } = props + // 使用 React.createElement 创建带有正确 displayName 的元素 + return React.createElement(displayName, { ...otherProps, ref }, children) + }) + Component.displayName = displayName + return Component +} + +const createMeasuredRNComponent = (displayName) => { + const Component = React.forwardRef((props, ref) => { + const { children, ...otherProps } = props + React.useImperativeHandle(ref, () => ({ + measure: jest.fn((callback) => callback(0, 0, 50, 50, 0, 0)), + measureLayout: jest.fn((parent, callback) => callback(0, 0)) + })) + return React.createElement(displayName, otherProps, children) + }) + Component.displayName = displayName + return Component +} + +const eventListeners = {} +const createEmitter = () => ({ + addListener: jest.fn((type, listener) => { + eventListeners[type] = eventListeners[type] || [] + eventListeners[type].push(listener) + return { + remove: jest.fn(() => { + eventListeners[type] = (eventListeners[type] || []).filter((item) => item !== listener) + }) + } + }), + removeListener: jest.fn((type, listener) => { + eventListeners[type] = (eventListeners[type] || []).filter((item) => item !== listener) + }), + removeAllListeners: jest.fn((type) => { + if (type) { + delete eventListeners[type] + } else { + Object.keys(eventListeners).forEach((key) => delete eventListeners[key]) + } + }), + emit: jest.fn((type, ...args) => { + ;(eventListeners[type] || []).forEach((listener) => listener(...args)) + }) +}) + +// 基础组件 Mock - 保持 RN 组件名称 +export const View = createRNComponent('View') +export const Text = createRNComponent('Text') +export const ScrollView = createRNComponent('ScrollView') +export const Image = createRNComponent('Image') +export const TouchableOpacity = createRNComponent('TouchableOpacity') +export const TouchableHighlight = createRNComponent('TouchableHighlight') +export const TouchableWithoutFeedback = createRNComponent('TouchableWithoutFeedback') +export const Pressable = createRNComponent('Pressable') +export const SafeAreaView = createRNComponent('SafeAreaView') +export const KeyboardAvoidingView = createRNComponent('KeyboardAvoidingView') +export const RefreshControl = createRNComponent('RefreshControl') +export const ActivityIndicator = createRNComponent('ActivityIndicator') +export const Modal = createRNComponent('Modal') +export const Switch = createRNComponent('Switch') +export const Slider = createRNComponent('Slider') +export const FlatList = createRNComponent('FlatList') +let lastSectionListRef +export const SectionList = React.forwardRef((props, ref) => { + const instanceRef = React.useRef({ + scrollToLocation: jest.fn() + }) + lastSectionListRef = instanceRef.current + React.useImperativeHandle(ref, () => instanceRef.current) + return React.createElement('SectionList', props) +}) +SectionList.displayName = 'SectionList' +export const __getLastSectionListRef = () => lastSectionListRef +export const VirtualizedList = createRNComponent('VirtualizedList') + +// TextInput 需要特殊处理,支持 value 和 onChangeText +export const TextInput = React.forwardRef((props, ref) => { + const { value, onChangeText, multiline, numberOfLines, ...otherProps } = props + return React.createElement('TextInput', { + ...otherProps, + ref, + value, + multiline, + numberOfLines, + onChangeText + }) +}) +TextInput.displayName = 'TextInput' + +// 样式系统 +export const StyleSheet = { + create: (styles) => styles, + flatten: (style) => { + if (Array.isArray(style)) { + return Object.assign({}, ...style.filter(Boolean)) + } + return style || {} + }, + compose: (style1, style2) => [style1, style2], + hairlineWidth: 1, + absoluteFill: { + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + right: 0 + }, + absoluteFillObject: { + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + right: 0 + } +} + +export const processColor = jest.fn((color) => { + const colors = { + black: 0xff000000, + white: 0xffffffff, + transparent: 0x00000000 + } + if (typeof color !== 'string') return color + if (colors[color]) return colors[color] + if (/^#([0-9a-f]{6})$/i.test(color)) { + return parseInt(color.slice(1), 16) + } + if (/^#([0-9a-f]{8})$/i.test(color)) { + return parseInt(color.slice(1), 16) + } + return null +}) + +// 布局系统 +export const Dimensions = { + get: jest.fn(() => ({ + width: 375, + height: 812, + scale: 2, + fontScale: 1 + })), + addEventListener: jest.fn(), + removeEventListener: jest.fn() +} + +// 平台检测 +export const Platform = { + OS: 'ios', + Version: '14.0', + select: jest.fn((options) => options.ios || options.default), + isPad: false, + isTesting: true, + constants: {} +} + +// PixelRatio +export const PixelRatio = { + get: jest.fn(() => 2), + getFontScale: jest.fn(() => 1), + getPixelSizeForLayoutSize: jest.fn((size) => size * 2), + roundToNearestPixel: jest.fn((size) => Math.round(size)) +} + +// Appearance +export const Appearance = { + getColorScheme: jest.fn(() => 'light'), + addChangeListener: jest.fn(), + removeChangeListener: jest.fn() +} + +// Keyboard +export const Keyboard = { + addListener: jest.fn(() => ({ remove: jest.fn() })), + removeListener: jest.fn(), + removeAllListeners: jest.fn(), + isVisible: jest.fn(() => false), + dismiss: jest.fn() +} + +// DeviceInfo (if needed) +export const DeviceInfo = { + isTablet: jest.fn(() => false) +} + +// StatusBar +export const StatusBar = createRNComponent('StatusBar') +StatusBar.setBarStyle = jest.fn() +StatusBar.setBackgroundColor = jest.fn() +StatusBar.setHidden = jest.fn() + +// Alert +export const Alert = { + alert: jest.fn(), + prompt: jest.fn() +} + +// Linking +export const Linking = { + openURL: jest.fn(), + canOpenURL: jest.fn(() => Promise.resolve(true)), + getInitialURL: jest.fn(() => Promise.resolve(null)) +} + +// AppState +export const AppState = { + currentState: 'active', + addEventListener: jest.fn(), + removeEventListener: jest.fn() +} + +// BackHandler +export const BackHandler = { + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + exitApp: jest.fn() +} + +// NetInfo +export const NetInfo = { + fetch: jest.fn(() => Promise.resolve({ isConnected: true })), + addEventListener: jest.fn() +} + +// DeviceEventEmitter +export const DeviceEventEmitter = createEmitter() + +// NativeEventEmitter +export const NativeEventEmitter = jest.fn().mockImplementation(() => createEmitter()) + +// PanResponder +export const PanResponder = { + create: jest.fn(() => ({ + panHandlers: {} + })) +} + +// Animated +export const Animated = { + View: createMeasuredRNComponent('Animated.View'), + Text: createRNComponent('Animated.Text'), + ScrollView: createRNComponent('Animated.ScrollView'), + Image: createRNComponent('Animated.Image'), + FlatList: createRNComponent('Animated.FlatList'), + SectionList: createRNComponent('Animated.SectionList'), + + Value: jest.fn(() => ({ + setValue: jest.fn(), + setOffset: jest.fn(), + flattenOffset: jest.fn(), + extractOffset: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + removeAllListeners: jest.fn(), + interpolate: jest.fn(() => ({ + addListener: jest.fn(), + removeListener: jest.fn() + })) + })), + + ValueXY: jest.fn(() => ({ + setValue: jest.fn(), + setOffset: jest.fn(), + flattenOffset: jest.fn(), + extractOffset: jest.fn(), + resetAnimation: jest.fn(), + stopAnimation: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + getLayout: jest.fn(() => ({ left: 0, top: 0 })), + getTranslateTransform: jest.fn(() => []) + })), + + timing: jest.fn(() => ({ + start: jest.fn() + })), + + spring: jest.fn(() => ({ + start: jest.fn() + })), + + decay: jest.fn(() => ({ + start: jest.fn() + })), + + sequence: jest.fn(), + parallel: jest.fn(), + stagger: jest.fn(), + delay: jest.fn(), + loop: jest.fn(), + + event: jest.fn(), + createAnimatedComponent: jest.fn((component) => component), + add: jest.fn(() => ({ + interpolate: jest.fn(() => 0) + })), + subtract: jest.fn(() => ({ + interpolate: jest.fn(() => 0) + })), + + Easing: { + linear: jest.fn(), + ease: jest.fn(), + quad: jest.fn(), + cubic: jest.fn(), + poly: jest.fn(), + sin: jest.fn(), + circle: jest.fn(), + exp: jest.fn(), + elastic: jest.fn(), + back: jest.fn(), + bounce: jest.fn(), + bezier: jest.fn(), + in: jest.fn(), + out: jest.fn(), + inOut: jest.fn() + } +} + +// Easing 独立导出 +export const Easing = Animated.Easing + +// 默认导出 +export default { + View, + Text, + TextInput, + ScrollView, + Image, + TouchableOpacity, + TouchableHighlight, + TouchableWithoutFeedback, + Pressable, + SafeAreaView, + KeyboardAvoidingView, + RefreshControl, + ActivityIndicator, + Modal, + Switch, + Slider, + FlatList, + SectionList, + VirtualizedList, + StyleSheet, + Dimensions, + Platform, + PixelRatio, + Appearance, + Keyboard, + DeviceInfo, + StatusBar, + Alert, + Linking, + AppState, + BackHandler, + NetInfo, + DeviceEventEmitter, + NativeEventEmitter, + PanResponder, + Animated, + Easing +} diff --git a/packages/webpack-plugin/test/__mocks__/react-native-reanimated.js b/packages/webpack-plugin/test/__mocks__/react-native-reanimated.js new file mode 100644 index 0000000000..a3afa3f5e1 --- /dev/null +++ b/packages/webpack-plugin/test/__mocks__/react-native-reanimated.js @@ -0,0 +1,120 @@ +import React from 'react' + +const MockAnimatedView = React.forwardRef((props, ref) => { + React.useImperativeHandle(ref, () => ({ + measure: jest.fn((callback) => callback(0, 0, 50, 50, 0, 0)), + measureLayout: jest.fn((parent, callback) => callback(0, 0)) + })) + return React.createElement('View', props) +}) + +const MockAnimatedScrollView = React.forwardRef((props, ref) => { + React.useImperativeHandle(ref, () => ({ + scrollTo: jest.fn() + })) + return React.createElement('ScrollView', props) +}) + +const AnimatedDefault = { + View: MockAnimatedView, + ScrollView: MockAnimatedScrollView, + createAnimatedComponent: jest.fn((Component) => Component) +} + +export default AnimatedDefault + +const animatedReactions = [] + +export const useSharedValue = jest.fn((initial) => React.useRef({ value: initial }).current) +export const useAnimatedRef = jest.fn(() => React.createRef()) +export const useScrollViewOffset = jest.fn(() => ({ value: 0 })) +export const useAnimatedStyle = jest.fn((styleFactory) => styleFactory()) +export const useAnimatedReaction = jest.fn((prepare, react) => { + const reactionRef = React.useRef() + if (!reactionRef.current) { + const value = prepare() + reactionRef.current = { prepare, react, value } + animatedReactions.push(reactionRef.current) + react(value, undefined) + } else { + reactionRef.current.prepare = prepare + reactionRef.current.react = react + } + React.useEffect(() => { + return () => { + const index = animatedReactions.indexOf(reactionRef.current) + if (index > -1) animatedReactions.splice(index, 1) + } + }, []) +}) +export const __flushAnimatedReactions = jest.fn(() => { + animatedReactions.forEach((reaction) => { + const value = reaction.prepare() + if (value !== reaction.value) { + const previous = reaction.value + reaction.value = value + reaction.react(value, previous) + } + }) +}) +export const __resetAnimatedReactions = jest.fn(() => { + animatedReactions.splice(0, animatedReactions.length) +}) +export const withTiming = jest.fn((toValue, config, callback) => { + if (callback) callback(true) + return toValue +}) +export const withSequence = jest.fn() +export const withDelay = jest.fn() +export const withSpring = jest.fn((toValue, config, callback) => { + if (callback) callback() + return toValue +}) +export const withDecay = jest.fn((config, callback) => { + if (callback) callback() + return config.velocity || 0 +}) +export const runOnJS = jest.fn((fn) => fn) +export const runOnUI = jest.fn((fn) => fn) +export const cancelAnimation = jest.fn() +export const makeMutable = jest.fn((value) => ({ value })) +export const interpolate = jest.fn((value, inputRange, outputRange) => { + if (!inputRange.length) return outputRange[0] + if (value <= inputRange[0]) return outputRange[0] + const lastIndex = inputRange.length - 1 + if (value >= inputRange[lastIndex]) return outputRange[lastIndex] + const index = inputRange.findIndex((item) => value <= item) + const startIndex = Math.max(index - 1, 0) + const startInput = inputRange[startIndex] + const endInput = inputRange[index] + const startOutput = outputRange[startIndex] + const endOutput = outputRange[index] + if (typeof startOutput !== 'number' || typeof endOutput !== 'number') return startOutput + return startOutput + (endOutput - startOutput) * ((value - startInput) / (endInput - startInput)) +}) + +export const Extrapolation = { + CLAMP: 'clamp', + EXTEND: 'extend', + IDENTITY: 'identity' +} + +export const Easing = { + linear: 'linear', + ease: 'ease', + quad: 'quad', + cubic: 'cubic', + poly: jest.fn((exp) => `poly(${exp})`), + sin: 'sin', + circle: 'circle', + exp: 'exp', + elastic: jest.fn(() => 'elastic'), + back: jest.fn(() => 'back'), + bounce: 'bounce', + bezier: jest.fn(() => 'bezier'), + in: jest.fn((easing) => `in(${easing})`), + out: jest.fn((easing) => `out(${easing})`), + inOut: jest.fn((easing) => `inOut(${easing})`), + step0: 'step0', + step1: 'step1' +} diff --git a/packages/webpack-plugin/test/__mocks__/react-native-safe-area-context.js b/packages/webpack-plugin/test/__mocks__/react-native-safe-area-context.js new file mode 100644 index 0000000000..6413585ac1 --- /dev/null +++ b/packages/webpack-plugin/test/__mocks__/react-native-safe-area-context.js @@ -0,0 +1,23 @@ +// Mock for react-native-safe-area-context +const React = require('react') + +const mockInitialWindowMetrics = { + insets: { top: 0, left: 0, right: 0, bottom: 0 }, + frame: { x: 0, y: 0, width: 375, height: 812 } +} + +const SafeAreaProvider = ({ children }) => React.createElement('View', {}, children) +const SafeAreaConsumer = ({ children }) => children(mockInitialWindowMetrics) +const SafeAreaView = ({ children, ...props }) => React.createElement('View', props, children) + +const useSafeAreaInsets = () => mockInitialWindowMetrics.insets +const useSafeAreaFrame = () => mockInitialWindowMetrics.frame + +module.exports = { + SafeAreaProvider, + SafeAreaConsumer, + SafeAreaView, + useSafeAreaInsets, + useSafeAreaFrame, + initialWindowMetrics: mockInitialWindowMetrics +} diff --git a/packages/webpack-plugin/test/platform/common/style-strip-condition.spec.js b/packages/webpack-plugin/test/platform/common/style-strip-condition.spec.js index 9e0be40d80..aa6b0aa067 100644 --- a/packages/webpack-plugin/test/platform/common/style-strip-condition.spec.js +++ b/packages/webpack-plugin/test/platform/common/style-strip-condition.spec.js @@ -1,3 +1,5 @@ +/** @jest-environment node */ + const postcss = require('postcss') const stylus = require('stylus') const { SourceMapConsumer } = require('source-map') diff --git a/packages/webpack-plugin/test/platform/wx/style/style-rn.spec.js b/packages/webpack-plugin/test/platform/wx/style/style-rn.spec.js index f38a28ad32..76284e3dee 100644 --- a/packages/webpack-plugin/test/platform/wx/style/style-rn.spec.js +++ b/packages/webpack-plugin/test/platform/wx/style/style-rn.spec.js @@ -1025,6 +1025,20 @@ describe('React Native style validation for CSS variables', () => { expect(config.error).toHaveBeenCalled() }) + test('should convert unitless line-height longhand to percentage', () => { + const css = '.text { font-size: 16px; line-height: 1.5; }' + const config = createConfig() + + const result = getClassMap({ content: css, filename: 'test.css', ...config }) + + expect(result.text).toEqual({ + fontSize: '16', + lineHeight: '"150%"' + }) + expect(config.warn).not.toHaveBeenCalled() + expect(config.error).not.toHaveBeenCalled() + }) + test('should expand font shorthand subset', () => { // 必填项 font-size / font-family;前导段顺序不敏感,识别 font-style / small-caps / font-weight; // line-height 数值按 formatLineHeight 转百分比;font-family 取首值、去引号 diff --git a/packages/webpack-plugin/test/runtime/react-native/components/animation-hooks.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/animation-hooks.test.tsx new file mode 100644 index 0000000000..dcafc51f3c --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/animation-hooks.test.tsx @@ -0,0 +1,415 @@ +import React from 'react' +import { render } from '@testing-library/react-native' +import { View } from 'react-native' +import * as Reanimated from 'react-native-reanimated' +import useAnimationHooks from '../../../../lib/runtime/components/react/animationHooks' +import useAnimationAPIHooks from '../../../../lib/runtime/components/react/animationHooks/useAnimationAPIHooks' +import useTransitionHooks from '../../../../lib/runtime/components/react/animationHooks/useTransitionHooks' +import { + formatAnimatedKeys, + getAnimation, + getInitialVal, + getTransformObj, + getUnit, + isTransform +} from '../../../../lib/runtime/components/react/animationHooks/utils' + +describe('react runtime animation hooks', () => { + beforeEach(() => { + jest.clearAllMocks() + const withDelay = Reanimated.withDelay as jest.Mock + const withSequence = Reanimated.withSequence as jest.Mock + withDelay.mockImplementation((delay, animation) => animation) + withSequence.mockImplementation((...animations) => animations[animations.length - 1]) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('formats animation utility values and delegates timing options', () => { + const callback = jest.fn() + + expect(isTransform('translateX')).toBe(true) + expect(isTransform('opacity')).toBe(false) + expect(getTransformObj([{ translateX: 10 }, { scaleY: 2 }])).toEqual({ + translateX: 10, + scaleY: 2 + }) + expect(getInitialVal({ transform: [{ translateX: 12 }] }, 'translateX')).toBe(12) + expect(getInitialVal({}, 'opacity')).toBe(1) + expect(formatAnimatedKeys(['opacity', 'translateX', 'scaleY'])).toEqual([ + 'opacity', + ['translateX', 'scaleY'] + ]) + expect(getUnit('1.5s')).toBe(1500) + expect(getUnit('200ms')).toBe(200) + expect(getUnit('bad')).toBe(0) + + expect(getAnimation({ key: 'opacity', value: 0.5 }, { duration: 100, easing: 'linear' as any }, callback)).toBe(0.5) + expect(Reanimated.withTiming).toHaveBeenCalledWith(0.5, { + duration: 100, + easing: 'linear' + }, callback) + + getAnimation({ key: 'translateX', value: 20 }, { delay: 50, duration: 200, easing: 'ease' as any }) + expect(Reanimated.withDelay).toHaveBeenCalledWith(50, 20) + }) + + it('drives animation API actions and emits transitionend metadata', () => { + const transitionend = jest.fn() + let hookResult: ReturnType | undefined + const animation = { + id: 7, + actions: [ + { + animatedOption: { + duration: 100, + delay: 20, + timingFunction: 'ease-in' as const, + transformOrigin: '10px 20px' + }, + rules: new Map([ + ['opacity', 0.5], + ['width', '50%'] + ]), + transform: new Map([ + ['translateX', 20] + ]) + }, + { + animatedOption: { + duration: 50, + transformOrigin: '20px 30px' + }, + rules: new Map([ + ['opacity', 1] + ]), + transform: new Map([ + ['translateX', 40] + ]) + } + ] + } + const layoutRef = { + current: { + offsetLeft: 5, + offsetTop: 6 + } + } + + const Probe = () => { + hookResult = useAnimationHooks({ + layoutRef, + animation, + 'enable-animation': 'api', + style: { + opacity: 1, + width: 100, + transform: [{ translateX: 0 }] + }, + bindtransitionend: transitionend, + 'data-name': 'card' + } as any) + return + } + + render() + + expect(hookResult?.enableStyleAnimation).toBe(true) + expect(Reanimated.withTiming).toHaveBeenCalledWith(0.5, expect.objectContaining({ + duration: 100, + easing: 'in(poly(3))' + }), expect.any(Function)) + expect(Reanimated.withDelay).toHaveBeenCalledWith(20, 0.5) + expect(Reanimated.withSequence).toHaveBeenCalled() + expect(transitionend).toHaveBeenCalledWith(expect.objectContaining({ + type: 'transitionend', + detail: expect.objectContaining({ + elapsedTime: 0.1, + finished: true + }), + target: expect.objectContaining({ + id: 7, + offsetLeft: 5, + offsetTop: 6 + }) + })) + }) + + it('updates transition styles on subsequent renders', () => { + const transitionend = jest.fn() + const Probe = ({ style }: { style: Record }) => { + const animationStyle = useTransitionHooks({ + style, + transitionend + } as any) + return + } + const initialStyle = { + transition: 'opacity 100ms ease-in 50ms, transform 0.2s cubic-bezier(0, 0, 1, 1)', + opacity: 0, + transform: [{ translateX: 0 }, { scaleX: 1 }] + } + + const { rerender } = render() + rerender( + + ) + + expect(Reanimated.Easing.bezier).toHaveBeenCalledWith(0, 0, 1, 1) + expect(Reanimated.withTiming).toHaveBeenCalledWith(1, expect.objectContaining({ + duration: 100, + easing: 'in(poly(3))' + }), expect.any(Function)) + expect(Reanimated.withDelay).toHaveBeenCalledWith(50, 1) + expect(transitionend).toHaveBeenCalledWith(true, undefined, 100) + }) + + it('reports unsupported or unstable animation declarations', () => { + const error = jest.spyOn(console, 'error').mockImplementation(jest.fn()) + let hookResult: ReturnType | undefined + const animation = { + id: 1, + actions: [] + } + + const Probe = ({ props }: { props: Record }) => { + hookResult = useAnimationHooks(Object.assign({ + layoutRef: { current: {} } + }, props) as any) + return + } + + const { unmount } = render() + expect(hookResult?.enableStyleAnimation).toBe(false) + expect(error).toHaveBeenCalledWith(expect.stringContaining('CSS animation is not supported yet')) + unmount() + + const apiRender = render() + apiRender.rerender() + expect(error).toHaveBeenCalledWith(expect.stringContaining('The animation type should be stable')) + }) + + it('parses invalid transition tokens with explicit runtime errors', () => { + const error = jest.spyOn(console, 'error').mockImplementation(jest.fn()) + const Probe = () => { + const animationStyle = useTransitionHooks({ + style: { + transition: 'all 100ms, width 1s step-start, opacity 1s allow-discrete, height 1s inherit' + } + } as any) + return + } + + render() + + expect(error).toHaveBeenCalledWith(expect.stringContaining('transition-property is not supported')) + expect(error).toHaveBeenCalledWith(expect.stringContaining('step-start,step-end,steps() is not supported')) + expect(error).toHaveBeenCalledWith(expect.stringContaining('transition-behavior is not supported')) + expect(error).toHaveBeenCalledWith(expect.stringContaining('global values is not supported')) + }) + + it('returns animated styles for animation API hook renders', () => { + const Probe = () => { + const animationStyle = useAnimationAPIHooks({ + style: { + opacity: 0.2, + transform: [{ scaleX: 1 }] + }, + animation: { + id: 3, + actions: [ + { + animatedOption: { duration: 100 }, + rules: new Map([ + ['opacity', 0.8] + ]), + transform: new Map([ + ['scaleX', 2] + ]) + } + ] + } + } as any) + return + } + + render() + + expect(Reanimated.useAnimatedStyle).toHaveBeenCalled() + expect(Reanimated.withTiming).toHaveBeenCalledWith(0.8, expect.objectContaining({ + duration: 100 + }), expect.any(Function)) + }) + + it('merges transition longhands and emits bindtransitionend events', () => { + const bindtransitionend = jest.fn() + let hookResult: ReturnType | undefined + const transitionStyle = { + transitionProperty: 'opacity, width', + transitionDuration: '100ms', + transitionTimingFunction: 'ease', + transitionDelay: '10ms, 20ms, 30ms', + opacity: 0, + width: 100 + } + const Probe = ({ style }: { style: Record }) => { + hookResult = useAnimationHooks({ + layoutRef: { current: { offsetLeft: 2, offsetTop: 3 } }, + enableAnimation: 'transition', + bindtransitionend, + style + } as any) + return + } + + const { rerender } = render() + rerender() + + expect(hookResult?.enableStyleAnimation).toBe(true) + expect(Reanimated.withTiming).toHaveBeenCalledWith(1, expect.objectContaining({ + duration: 100 + }), expect.any(Function)) + expect(bindtransitionend).toHaveBeenCalledWith(expect.objectContaining({ + type: 'transitionend', + target: expect.objectContaining({ offsetLeft: 2, offsetTop: 3 }) + })) + }) + + it('normalizes transition percent, auto and mismatched value types', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(jest.fn()) + const makeMutable = Reanimated.makeMutable as jest.Mock + const withTiming = Reanimated.withTiming as jest.Mock + const Probe = ({ width }: { width?: number | string }) => { + const animationStyle = useTransitionHooks({ + style: { + transition: 'width 100ms', + width + } + } as any) + return + } + const runCase = (initial: number | string, next?: number | string) => { + makeMutable.mockClear() + withTiming.mockClear() + warn.mockClear() + const result = render() + result.rerender() + result.unmount() + return makeMutable.mock.results[0].value.value + } + + expect(runCase(10, '50%')).toBe('50%') + expect(withTiming).toHaveBeenCalledWith('50%', expect.objectContaining({ duration: 100 })) + expect(warn).not.toHaveBeenCalled() + + expect(runCase('10%', 0.5)).toBe('50%') + expect(withTiming).toHaveBeenCalledWith('50%', expect.objectContaining({ duration: 100 })) + expect(warn).not.toHaveBeenCalled() + + expect(runCase(10, 'auto')).toBe('auto') + expect(withTiming).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Value types of property width')) + + expect(runCase(10, 'invalid')).toBe('invalid') + expect(withTiming).toHaveBeenCalledWith('invalid', expect.objectContaining({ duration: 100 })) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Value types of property width')) + + expect(runCase(10, undefined)).toBe('auto') + expect(withTiming).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Value types of property width')) + }) + + it('handles animation API fallbacks and transformed animated styles', () => { + const error = jest.spyOn(console, 'error').mockImplementation(jest.fn()) + const animation = { + id: 9, + actions: [ + { + animatedOption: {}, + rules: new Map(), + transform: new Map() + }, + { + animatedOption: { duration: 100 }, + rules: new Map([ + ['width', 0.5], + ['opacity', 'invalid'], + ['height', 'auto'] + ]), + transform: new Map([ + ['scaleX', 2] + ]) + } + ] + } + const Probe = ({ value }: { value: number }) => { + const animationStyle = useAnimationAPIHooks({ + style: { + width: '10%', + height: 10, + opacity: value, + transform: [{ scaleX: 1 }] + }, + animation + } as any) + return + } + const NoActionsProbe = () => { + const animationStyle = useAnimationAPIHooks({ style: {}, animation: { id: 10 } } as any) + return + } + const DisabledAnimationProbe = () => { + const animationStyle = useAnimationAPIHooks({ style: {} } as any) + return + } + const TransformFallbackProbe = () => { + const animationStyle = useAnimationAPIHooks({ + style: {}, + animation: { + id: 11, + actions: [{ + animatedOption: { duration: 100 }, + rules: new Map(), + transform: new Map([['scaleX', 2]]) + }] + } + } as any) + return + } + + const probeRender = render() + probeRender.rerender() + const noActionsRender = render() + noActionsRender.rerender() + const disabledAnimationRender = render() + disabledAnimationRender.rerender() + const transformFallback = render() + transformFallback.rerender() + + expect(probeRender.getByTestId('api-fallback-probe').props.style).toEqual({ + transformOrigin: ['50%', '50%', 0], + width: '50%', + opacity: 'invalid', + height: 'auto', + transform: [{ scaleX: 2 }] + }) + expect(noActionsRender.getByTestId('no-actions-probe').props.style).toEqual({ + transformOrigin: ['50%', '50%', 0] + }) + expect(disabledAnimationRender.getByTestId('disabled-animation-probe').props.style).toEqual({}) + expect(transformFallback.getByTestId('transform-fallback-probe').props.style).toEqual({ + transformOrigin: ['50%', '50%', 0], + transform: [{ scaleX: 2 }] + }) + expect(Reanimated.withSequence).toHaveBeenCalled() + expect(error).toHaveBeenCalledWith(expect.stringContaining('Value types of property opacity')) + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/get-inner-listeners.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/get-inner-listeners.test.tsx new file mode 100644 index 0000000000..3b35186257 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/get-inner-listeners.test.tsx @@ -0,0 +1,187 @@ +import React from 'react' +import { act, render, screen } from '@testing-library/react-native' +import { View } from 'react-native' +import useInnerProps, { getCustomEvent } from '../../../../lib/runtime/components/react/getInnerListeners' +import { createTouchEvent } from './helpers' + +function EventProbe ({ eventProps, rawConfig }: { eventProps?: Record, rawConfig?: Record }) { + const innerProps = useInnerProps(eventProps, ['removed'], rawConfig) + return React.createElement(View, Object.assign({ testID: 'event-probe' }, innerProps)) +} + +function DefaultProbe () { + return React.createElement(View, Object.assign({ testID: 'default-probe' }, useInnerProps())) +} + +describe('getInnerListeners', () => { + afterEach(() => { + jest.useRealTimers() + }) + + it('builds custom events with defaults, dataset and layout offsets', () => { + const persist = jest.fn() + expect(getCustomEvent(undefined, undefined, {})).toEqual(expect.objectContaining({ + type: '', + detail: {}, + target: expect.objectContaining({ id: '', offsetLeft: 0, offsetTop: 0 }) + })) + + expect(getCustomEvent('change', { target: { nativeID: 'native' }, persist }, { + detail: { value: 1 }, + layoutRef: { current: { offsetLeft: 3, offsetTop: 4 } } + }, { + id: 'picker', + 'data-kind': 'city' + })).toEqual(expect.objectContaining({ + type: 'change', + detail: { value: 1 }, + persist, + target: expect.objectContaining({ + id: 'picker', + dataset: { kind: 'city' }, + offsetLeft: 3, + offsetTop: 4 + }) + })) + }) + + it('handles catch, capture, longpress, movement, cancel and disabled tap branches', () => { + jest.useFakeTimers() + const handlers = { + catchtap: jest.fn(), + catchlongpress: jest.fn(), + catchtouchstart: jest.fn(), + catchtouchmove: jest.fn(), + catchtouchend: jest.fn(), + catchtouchcancel: jest.fn(), + 'capture-catchtap': jest.fn(), + 'capture-catchlongpress': jest.fn(), + 'capture-catchtouchstart': jest.fn(), + 'capture-catchtouchmove': jest.fn(), + 'capture-catchtouchend': jest.fn(), + 'capture-catchtouchcancel': jest.fn() + } + const { rerender } = render( + + ) + let probe = screen.getByTestId('event-probe') + expect(probe.props.removed).toBeUndefined() + expect(probe.props.children).toBeUndefined() + + const longpressEvent: any = createTouchEvent() + longpressEvent._targetInst = { pendingProps: { parentId: 'child', 'data-child': 'yes' } } + probe.props.onTouchStart(longpressEvent) + act(() => { + jest.advanceTimersByTime(350) + }) + expect(longpressEvent.persist).toHaveBeenCalled() + expect(longpressEvent.stopPropagation).toHaveBeenCalled() + expect(handlers.catchtouchstart).toHaveBeenCalledWith(expect.objectContaining({ type: 'touchstart' })) + expect(handlers.catchlongpress).toHaveBeenCalledWith(expect.objectContaining({ + type: 'longpress', + detail: { x: 10, y: 15 }, + currentTarget: expect.objectContaining({ id: 'root', offsetLeft: 7, offsetTop: 8 }), + target: expect.objectContaining({ id: 'child', dataset: { child: 'yes' } }) + })) + + probe.props.onTouchEnd(createTouchEvent({ touches: [] })) + expect(handlers.catchtap).not.toHaveBeenCalled() + + const stoppedLongpressEvent: any = createTouchEvent() + stoppedLongpressEvent._stoppedEventTypes = new Set(['longpress']) + probe.props.onTouchStart(stoppedLongpressEvent) + act(() => { + jest.advanceTimersByTime(350) + }) + expect(handlers.catchlongpress).toHaveBeenCalledTimes(1) + probe.props.onTouchCancel(createTouchEvent({ touches: [] })) + + const tapStart: any = createTouchEvent() + const tapEnd: any = createTouchEvent({ touches: [] }) + probe.props.onTouchStart(tapStart) + probe.props.onTouchEnd(tapEnd) + expect(tapEnd.stopPropagation).toHaveBeenCalled() + expect(handlers.catchtouchend).toHaveBeenCalledWith(expect.objectContaining({ type: 'touchend' })) + expect(handlers.catchtap).toHaveBeenCalledTimes(1) + + const captureStart: any = createTouchEvent({ + changedTouches: [{ identifier: 2, pageX: 10, pageY: 20 }] + }) + const captureEnd: any = createTouchEvent({ + touches: [], + changedTouches: [{ identifier: 2, pageX: 10, pageY: 20 }] + }) + probe.props.onTouchStartCapture(captureStart) + probe.props.onTouchEndCapture(captureEnd) + probe.props.onTouchEnd(captureEnd) + expect(captureStart.stopPropagation).toHaveBeenCalled() + expect(captureEnd.stopPropagation).toHaveBeenCalled() + expect(handlers['capture-catchtouchstart']).toHaveBeenCalledWith(expect.objectContaining({ type: 'touchstart' })) + expect(handlers['capture-catchtouchend']).toHaveBeenCalledWith(expect.objectContaining({ type: 'touchend' })) + expect(handlers['capture-catchtap']).toHaveBeenCalledTimes(1) + expect(handlers.catchtap).toHaveBeenCalledTimes(1) + + const moveStart: any = createTouchEvent({ + changedTouches: [{ identifier: 3, pageX: 10, pageY: 20 }] + }) + probe.props.onTouchStart(moveStart) + probe.props.onTouchMove(createTouchEvent({ + changedTouches: [{ identifier: 3, pageX: 20, pageY: 30 }] + })) + probe.props.onTouchEnd(createTouchEvent({ + touches: [], + changedTouches: [{ identifier: 3, pageX: 20, pageY: 30 }] + })) + expect(handlers.catchtouchmove).toHaveBeenCalled() + + probe.props.onTouchStart(createTouchEvent({ + touches: [ + { identifier: 4, pageX: 10, pageY: 20 }, + { identifier: 5, pageX: 30, pageY: 40 } + ] + })) + probe.props.onTouchCancel(createTouchEvent({ touches: [] })) + probe.props.onTouchStartCapture(createTouchEvent()) + probe.props.onTouchMoveCapture(createTouchEvent()) + probe.props.onTouchCancelCapture(createTouchEvent({ touches: [] })) + expect(handlers.catchtouchcancel).toHaveBeenCalled() + expect(handlers['capture-catchtouchmove']).toHaveBeenCalled() + expect(handlers['capture-catchtouchcancel']).toHaveBeenCalled() + + const bindtap = jest.fn() + rerender() + probe = screen.getByTestId('event-probe') + probe.props.onTouchStart(createTouchEvent()) + probe.props.onTouchEnd(createTouchEvent({ touches: [] })) + expect(bindtap).not.toHaveBeenCalled() + }) + + it('returns only regular props when no event handlers are registered', () => { + render() + expect(screen.getByTestId('default-probe').props).toEqual({ testID: 'default-probe' }) + }) + + it('uses zero current-target offsets when touch layout is unavailable', () => { + const bindtap = jest.fn() + render() + const probe = screen.getByTestId('event-probe') + + probe.props.onTouchStart(createTouchEvent()) + probe.props.onTouchEnd(createTouchEvent({ touches: [] })) + + expect(bindtap).toHaveBeenCalledWith(expect.objectContaining({ + currentTarget: expect.objectContaining({ offsetLeft: 0, offsetTop: 0 }) + })) + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/helpers.ts b/packages/webpack-plugin/test/runtime/react-native/components/helpers.ts new file mode 100644 index 0000000000..4a03a8e01f --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/helpers.ts @@ -0,0 +1,41 @@ +import { act, fireEvent } from '@testing-library/react-native' + +type TouchOverrides = { + touches?: Array> + changedTouches?: Array> +} + +const touchPoint = { identifier: 1, pageX: 10, pageY: 20 } + +export function createTouchEvent (overrides: TouchOverrides = {}) { + const changedTouches = overrides.changedTouches || [touchPoint] + return { + nativeEvent: { + timestamp: 1, + pageX: 10, + pageY: 20, + touches: overrides.touches || changedTouches, + changedTouches + }, + currentTarget: {}, + target: {}, + persist: jest.fn(), + stopPropagation: jest.fn(), + preventDefault: jest.fn() + } +} + +export function fireTap (element: any) { + fireEvent(element, 'touchStart', createTouchEvent()) + fireEvent(element, 'touchEnd', createTouchEvent({ touches: [] })) +} + +export function getTouchableView (views: Array) { + return views.find((node) => node.props.onTouchStart && node.props.onTouchEnd) +} + +export async function flushImageSize () { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) +} diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-async-suspense.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-async-suspense.test.tsx new file mode 100644 index 0000000000..ba83c662a5 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-async-suspense.test.tsx @@ -0,0 +1,238 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import React from 'react' +import { act, fireEvent, render, screen } from '@testing-library/react-native' +import { Text } from 'react-native' +import { resetMpxRuntimeGlobals } from './rn-component-test-utils' + +const AsyncSuspense = require('../../../../lib/runtime/components/react/mpx-async-suspense').default + +beforeEach(() => { + jest.clearAllMocks() + resetMpxRuntimeGlobals() +}) + +describe('MpxAsyncSuspense', () => { + it('loads a page chunk, caches it and renders fallback on errors', async () => { + const Loaded = (props: { title: string }) => {props.title} + const Loading = () => loading page + const Fallback = ({ onReload }: { onReload: () => void }) => ( + fallback page + ) + + render( + Loading} + getFallback={() => Fallback} + getChildren={() => Promise.resolve(Loaded)} + /> + ) + + expect(screen.getByText('loading page')).toBeTruthy() + await act(async () => { + await Promise.resolve() + }) + expect(screen.getByText('loaded page')).toBeTruthy() + + const { unmount } = render( + Promise.reject(new Error('unused'))} + /> + ) + expect(screen.getByText('cached page')).toBeTruthy() + unmount() + + render( + Fallback} + getChildren={() => Promise.reject(Object.assign(new Error('timeout'), { type: 'timeout' }))} + /> + ) + await act(async () => { + await Promise.resolve() + }) + expect(screen.getByText('fallback page')).toBeTruthy() + expect((global as any).mpxGlobal.__mpx.config.rnConfig.onLazyLoadPageError).toHaveBeenCalledWith({ + subpackage: 'pkg-error', + errType: 'timeout' + }) + }) + + it('notifies lazyLoad callbacks for component chunks', async () => { + const ComponentFallback = () => component fallback + + render( + ComponentFallback} + getChildren={() => Promise.reject(Object.assign(new Error('network'), { type: 'network' }))} + /> + ) + + expect(screen.getByText('component fallback')).toBeTruthy() + await act(async () => { + await Promise.resolve() + }) + expect((global as any).__mpxAppCbs.lazyLoad[0]).toHaveBeenCalledWith({ + type: 'subpackage', + subpackage: ['component-pkg'], + errMsg: 'loadSubpackage: network' + }) + }) + + it('renders no component placeholder when fallback is omitted', async () => { + const componentRender = render( + Promise.reject(Object.assign(new Error('network'), { type: 'network' }))} + /> + ) + + expect(componentRender.toJSON()).toBeNull() + await act(async () => { + await Promise.resolve() + }) + expect(componentRender.toJSON()).toBeNull() + expect((global as any).__mpxAppCbs.lazyLoad[0]).toHaveBeenCalledWith({ + type: 'subpackage', + subpackage: ['empty-component-pkg'], + errMsg: 'loadSubpackage: network' + }) + }) + + it('ignores resolved and rejected chunk callbacks after unmount', async () => { + const StaleLoaded = () => stale loaded + const LatestLoaded = () => latest loaded + let resolveStale: (value: React.ReactNode) => void = () => undefined + let rejectStale: (reason: Error) => void = () => undefined + const staleResolvePromise = new Promise((resolve) => { + resolveStale = resolve + }) + const staleRejectPromise = new Promise((_resolve, reject) => { + rejectStale = reject + }) + + const staleResolveRender = render( + () => stale loading} + getChildren={() => staleResolvePromise} + /> + ) + expect(screen.getByText('stale loading')).toBeTruthy() + staleResolveRender.unmount() + await act(async () => { + resolveStale(StaleLoaded) + await staleResolvePromise + }) + + const getLatestChildren = jest.fn(() => Promise.resolve(LatestLoaded)) + render( + () => latest loading} + getChildren={getLatestChildren} + /> + ) + expect(screen.getByText('latest loading')).toBeTruthy() + await act(async () => { + await Promise.resolve() + }) + expect(screen.getByText('latest loaded')).toBeTruthy() + expect(getLatestChildren).toHaveBeenCalledTimes(1) + + const pageErrorHandler = (global as any).mpxGlobal.__mpx.config.rnConfig.onLazyLoadPageError + pageErrorHandler.mockClear() + const staleRejectRender = render( + () => reject loading} + getChildren={() => staleRejectPromise} + /> + ) + expect(screen.getByText('reject loading')).toBeTruthy() + staleRejectRender.unmount() + await act(async () => { + rejectStale(Object.assign(new Error('late error'), { type: 'late' })) + await staleRejectPromise.catch(() => undefined) + }) + expect(pageErrorHandler).not.toHaveBeenCalled() + }) + + it('shows page fallback when no lazy-load error handler is configured', async () => { + const Fallback = () => fallback without handler + ;(global as any).mpxGlobal.__mpx.config.rnConfig.onLazyLoadPageError = undefined + + render( + Fallback} + getChildren={() => Promise.reject(Object.assign(new Error('offline'), { type: 'offline' }))} + /> + ) + + await act(async () => { + await Promise.resolve() + }) + expect(screen.getByText('fallback without handler')).toBeTruthy() + }) + + it('renders default loading and retries from default fallback', async () => { + const Loaded = () => default loaded + const getChildren = jest + .fn() + .mockRejectedValueOnce(Object.assign(new Error('offline'), { type: 'offline' })) + .mockResolvedValueOnce(Loaded) + + render( + + ) + + expect(screen.getByTestId('fast-image')).toBeTruthy() + await act(async () => { + await Promise.resolve() + }) + expect(screen.getByText('网络出了点问题,请查看网络环境')).toBeTruthy() + + fireEvent(screen.getByText('点击重试').parent, 'press') + await act(async () => { + await Promise.resolve() + }) + expect(screen.getByText('default loaded')).toBeTruthy() + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-button.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-button.test.tsx new file mode 100644 index 0000000000..c3071edd0d --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-button.test.tsx @@ -0,0 +1,313 @@ +import React from 'react' +import { act, render, screen } from '@testing-library/react-native' +import MpxButton from '../../../../lib/runtime/components/react/mpx-button' +import MpxForm from '../../../../lib/runtime/components/react/mpx-form' +import { RouteContext } from '../../../../lib/runtime/components/react/context' +import { fireTap } from './helpers' + +const mockPortal = jest.fn() + +jest.mock('../../../../lib/runtime/components/react/mpx-portal', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mockReact = require('react') + return ({ children }: { children: any }) => { + mockPortal(children) + return mockReact.createElement(mockReact.Fragment, null, children) + } +}) + +describe('MpxButton', () => { + beforeEach(() => { + mockPortal.mockClear() + }) + + it('handles tap and form actions', () => { + const bindtap = jest.fn() + const bindsubmit = jest.fn() + const bindreset = jest.fn() + + render( + + + Submit + + + Reset + + + Disabled + + + ) + + act(() => { + fireTap(screen.getByTestId('submit-button')) + }) + expect(bindtap).toHaveBeenCalled() + expect(bindsubmit).toHaveBeenCalled() + + act(() => { + fireTap(screen.getByTestId('reset-button')) + }) + expect(bindreset).toHaveBeenCalled() + + act(() => { + fireTap(screen.getByTestId('disabled-button')) + }) + expect(bindtap).toHaveBeenCalledTimes(1) + }) + + it('warns unsupported background style and still renders fixed branch', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + + try { + render( + + Fixed + + ) + + expect(screen.getByTestId('fixed-button')).toBeTruthy() + expect(mockPortal).toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Button does not support background image-related styles!')) + } finally { + warnSpy.mockRestore() + } + }) + + it('renders loading state and removes shorthand defaults', () => { + render( + <> + + Loading + + + + ) + + const button = screen.getByTestId('loading-button') + expect(button.props.style).toEqual(expect.objectContaining({ + margin: 0, + padding: 0 + })) + expect(button.props.style.marginHorizontal).toBeUndefined() + expect(button.props.style.paddingHorizontal).toBeUndefined() + expect(screen.getAllByTestId('loading')).toHaveLength(2) + }) + + it('handles share open type and warning branches', async () => { + jest.useFakeTimers() + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + const shareHandler = jest.fn() + const onShareAppMessage = jest.fn(() => ({ + title: 'fallback title', + promise: new Promise((resolve) => { + expect(resolve).toBeDefined() + }) + })) + const globalAny = global as any + const originalMpx = globalAny.__mpx + const originalGetCurrentPages = globalAny.getCurrentPages + + try { + globalAny.__mpx = { + config: { + rnConfig: { + projectName: 'DemoProject', + openTypeHandler: { + onShareAppMessage: shareHandler + } + } + } + } + globalAny.getCurrentPages = () => [{ + route: '/pages/index', + __webViewUrl: 'https://example.com', + getPageId: () => 1, + onShareAppMessage + }] + + render( + + + Share + + + Bad + + + Missing + + + ) + + act(() => { + fireTap(screen.getByTestId('share-button')) + jest.advanceTimersByTime(3000) + }) + await act(async () => { + await Promise.resolve() + }) + expect(onShareAppMessage).toHaveBeenCalledWith(expect.objectContaining({ + from: 'button', + webViewUrl: 'https://example.com' + })) + expect(shareHandler).toHaveBeenCalledWith({ + title: 'fallback title', + path: '/pages/index' + }) + + act(() => { + fireTap(screen.getByTestId('bad-open-type-button')) + fireTap(screen.getByTestId('missing-open-type-button')) + }) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('open-type not support bad')) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Unregistered onUserInfo event')) + } finally { + globalAny.__mpx = originalMpx + globalAny.getCurrentPages = originalGetCurrentPages + warnSpy.mockRestore() + jest.useRealTimers() + } + }) + + it('handles getUserInfo success and unsupported open type environment', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + const globalAny = global as any + const originalMpx = globalAny.__mpx + const bindgetuserinfo = jest.fn() + + try { + globalAny.__mpx = { + config: { + rnConfig: { + openTypeHandler: { + onUserInfo: { nickName: 'Mpx' } + } + } + } + } + + const { rerender } = render( + + User + + ) + + act(() => { + fireTap(screen.getByTestId('user-info-button')) + }) + await act(async () => { + await Promise.resolve() + }) + expect(bindgetuserinfo).toHaveBeenCalledWith({ nickName: 'Mpx' }) + + globalAny.__mpx = { + config: {} + } + rerender( + + Unsupported + + ) + act(() => { + fireTap(screen.getByTestId('unsupported-env-button')) + }) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Environment not supported')) + } finally { + globalAny.__mpx = originalMpx + warnSpy.mockRestore() + } + }) + + it('handles share defaults without page share handler and missing current page', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + const shareHandler = jest.fn() + const globalAny = global as any + const originalMpx = globalAny.__mpx + const originalGetCurrentPages = globalAny.getCurrentPages + + try { + globalAny.__mpx = { + config: { + rnConfig: { + openTypeHandler: { + onShareAppMessage: shareHandler + } + } + } + } + globalAny.getCurrentPages = () => [{ + getPageId: () => 1 + }] + + const { rerender } = render( + + + Share default + + + ) + act(() => { + fireTap(screen.getByTestId('share-default-button')) + }) + expect(shareHandler).toHaveBeenCalledWith({ + title: 'AwesomeProject', + path: '' + }) + + globalAny.getCurrentPages = () => [] + rerender( + + + Missing page + + + ) + act(() => { + fireTap(screen.getByTestId('share-missing-page-button')) + }) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Current page not found')) + } finally { + globalAny.__mpx = originalMpx + globalAny.getCurrentPages = originalGetCurrentPages + warnSpy.mockRestore() + } + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-camera.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-camera.test.tsx new file mode 100644 index 0000000000..f4aabbff1a --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-camera.test.tsx @@ -0,0 +1,295 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import React from 'react' +import { act, fireEvent, render, screen } from '@testing-library/react-native' +import { resetMpxRuntimeGlobals, renderWithRoute } from './rn-component-test-utils' + +const mockWatch = jest.fn(() => jest.fn()) +jest.mock('@mpxjs/core', () => ({ + watch: mockWatch +})) + +let mockCodeScannerOptions: any +let mockCameraFormatArgs: any +const mockCameraMethods = { + takePhoto: jest.fn(() => Promise.resolve({ path: '/tmp/photo.jpg' })), + startRecording: jest.fn(), + stopRecording: jest.fn(() => Promise.resolve()) +} + +jest.mock('react-native-vision-camera', () => { + const mockReact = require('react') + const MockCamera = mockReact.forwardRef((props: any, ref: any) => { + mockReact.useImperativeHandle(ref, () => mockCameraMethods) + return mockReact.createElement('Camera', Object.assign({ testID: 'mock-camera' }, props)) + }) + return { + __esModule: true, + Camera: MockCamera, + useCameraDevice: jest.fn((position) => ({ position, maxZoom: 8 })), + useCameraFormat: jest.fn((device, args) => { + mockCameraFormatArgs = { device, args } + return { width: 1920, height: 1080 } + }), + useCodeScanner: jest.fn((options) => { + mockCodeScannerOptions = options + return options + }) + } +}) + +const MpxCamera = require('../../../../lib/runtime/components/react/mpx-camera').default +const { RouteContext } = require('../../../../lib/runtime/components/react/context') + +beforeEach(() => { + jest.clearAllMocks() + resetMpxRuntimeGlobals() + mockCodeScannerOptions = null + mockCameraFormatArgs = null + mockCameraMethods.takePhoto.mockResolvedValue({ path: '/tmp/photo.jpg' }) + mockCameraMethods.startRecording.mockImplementation((options) => { + options.onRecordingFinished({ path: '/tmp/video.mp4', duration: 2 }) + }) + mockCameraMethods.stopRecording.mockResolvedValue(undefined) +}) + +describe('MpxCamera', () => { + it('renders after permission, emits scan/init/stop events and exposes navigation camera methods', async () => { + const navigation: any = {} + const bindinitdone = jest.fn() + const bindstop = jest.fn() + const bindscancode = jest.fn() + + renderWithRoute( + , + navigation + ) + + await act(async () => { + await Promise.resolve() + }) + + const camera = screen.getByTestId('mock-camera') + expect(camera.props.device.position).toBe('front') + expect(camera.props.torch).toBe('on') + expect(mockCameraFormatArgs.args[0].photoResolution).toBe('max') + fireEvent(camera, 'initialized') + fireEvent(camera, 'stopped') + mockCodeScannerOptions.onCodeScanned([ + { type: 'qr', value: 'https://example.com', frame: { x: '1', y: '2', width: '3', height: '4' } } + ]) + + expect(bindinitdone).toHaveBeenCalledWith(expect.objectContaining({ + type: 'initdone', + detail: { maxZoom: 8 } + })) + expect(bindstop).toHaveBeenCalledWith(expect.objectContaining({ type: 'stop' })) + expect(bindscancode).toHaveBeenCalledWith(expect.objectContaining({ + type: 'scancode', + detail: expect.objectContaining({ + result: 'https://example.com', + type: 'QR_CODE', + scanArea: [1, 2, 3, 4] + }) + })) + mockCodeScannerOptions.onCodeScanned([ + { type: 'ean_13', value: '978' } + ]) + expect(bindscancode).toHaveBeenLastCalledWith(expect.objectContaining({ + detail: { + result: '978', + type: 'EAN_13', + scanArea: [0, 0, 0, 0] + } + })) + + const fail = jest.fn() + const complete = jest.fn() + navigation.camera.takePhoto({ fail, complete }) + expect(fail).toHaveBeenCalledWith({ errMsg: 'Not allow to invoke takePhoto in \'scanCode\' mode.' }) + expect(complete).toHaveBeenCalled() + navigation.camera.startRecord() + navigation.camera.stopRecord() + expect(mockCameraMethods.startRecording).not.toHaveBeenCalled() + expect(mockCameraMethods.stopRecording).not.toHaveBeenCalled() + }) + + it('supports photo and recording APIs in normal mode', async () => { + jest.useFakeTimers() + const navigation: any = {} + renderWithRoute(, navigation) + await act(async () => { + await Promise.resolve() + }) + + const photoSuccess = jest.fn() + const photoComplete = jest.fn() + await act(async () => { + navigation.camera.setZoom(3) + navigation.camera.takePhoto({ quality: 'high', success: photoSuccess, complete: photoComplete }) + await Promise.resolve() + }) + expect(mockCameraMethods.takePhoto).toHaveBeenCalledWith({ quality: 90 }) + expect(photoSuccess).toHaveBeenCalledWith({ errMsg: 'takePhoto:ok', tempImagePath: '/tmp/photo.jpg' }) + + const recordSuccess = jest.fn() + navigation.camera.startRecord({ timeout: 500, success: recordSuccess }) + expect(recordSuccess).toHaveBeenCalledWith({ errMsg: 'startRecord:ok' }) + const stopSuccess = jest.fn() + navigation.camera.stopRecord({ success: stopSuccess }) + await act(async () => { + await Promise.resolve() + jest.advanceTimersByTime(250) + }) + expect(stopSuccess).toHaveBeenCalledWith({ + errMsg: 'stopRecord:ok', + tempVideoPath: '/tmp/video.mp4', + duration: 2000 + }) + jest.useRealTimers() + }) + + it('handles recording errors, timeout stop failures and page active state', async () => { + jest.useFakeTimers() + const navigation: any = {} + let watchCallback: Function = jest.fn() + mockWatch.mockImplementationOnce((getter, callback) => { + watchCallback = callback + return jest.fn() + }) + ;(global as any).__mpxPageStatusMap = { 1: 'show' } + mockCameraMethods.startRecording.mockImplementationOnce(() => undefined) + mockCameraMethods.stopRecording.mockRejectedValueOnce(new Error('timeout stop failed')) + + renderWithRoute(, navigation) + await act(async () => { + await Promise.resolve() + }) + + const timeoutCallback = jest.fn() + navigation.camera.startRecord({ timeout: 1, timeoutCallback }) + act(() => { + mockCameraMethods.startRecording.mock.calls[0][0].onRecordingError(new Error('recording bad')) + watchCallback('hide') + }) + expect(timeoutCallback).toHaveBeenCalledWith(expect.objectContaining({ + errMsg: 'startRecord:fail during recording' + })) + expect(screen.getByTestId('mock-camera').props.isActive).toBe(false) + + act(() => { + watchCallback('show') + }) + expect(screen.getByTestId('mock-camera').props.isActive).toBe(true) + + navigation.camera.startRecord({ timeout: 1 }) + act(() => { + jest.advanceTimersByTime(1000) + }) + await act(async () => { + await Promise.resolve() + }) + expect(mockCameraMethods.stopRecording).toHaveBeenCalled() + jest.useRealTimers() + }) + + it('renders when permission hook is not configured', async () => { + delete (global as any).__mpx.config.rnConfig.cameraPermission + renderWithRoute() + await act(async () => { + await Promise.resolve() + }) + expect(screen.getByTestId('mock-camera')).toBeTruthy() + }) + + it('handles camera permission and method failure paths', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + ;(global as any).__mpx.config.rnConfig.cameraPermission = jest.fn(() => Promise.reject(new Error('denied'))) + const navigation: any = {} + const { queryByTestId, rerender, unmount } = renderWithRoute( + , + navigation, + 2 + ) + await act(async () => { + await Promise.resolve() + }) + expect(queryByTestId('mock-camera')).toBeNull() + + ;(global as any).__mpx.config.rnConfig.cameraPermission = jest.fn(() => Promise.resolve(true)) + navigation.camera = { stale: true } + rerender( + + + + ) + await act(async () => { + await Promise.resolve() + }) + expect(queryByTestId('mock-camera')).toBeNull() + expect(warnSpy).toHaveBeenCalled() + + unmount() + const methodNavigation: any = {} + renderWithRoute(, methodNavigation) + await act(async () => { + await Promise.resolve() + }) + mockCameraMethods.takePhoto.mockRejectedValueOnce(new Error('photo failed')) + const photoFail = jest.fn() + await act(async () => { + methodNavigation.camera.takePhoto({ fail: photoFail }) + await Promise.resolve() + }) + expect(photoFail).toHaveBeenCalledWith({ errMsg: 'takePhoto:fail' }) + + mockCameraMethods.startRecording.mockImplementationOnce(() => { + throw new Error('start failed') + }) + const recordFail = jest.fn() + methodNavigation.camera.startRecord({ fail: recordFail }) + expect(recordFail).toHaveBeenCalledWith({ errMsg: 'startRecord:fail start failed' }) + + mockCameraMethods.stopRecording.mockRejectedValueOnce(new Error('stop failed')) + const stopFail = jest.fn() + await act(async () => { + methodNavigation.camera.stopRecord({ fail: stopFail }) + await Promise.resolve() + }) + expect(stopFail).toHaveBeenCalledWith({ errMsg: 'stopRecord:fail stop failed' }) + warnSpy.mockRestore() + }) + + it('does not render without a matching camera device', async () => { + const { useCameraDevice } = require('react-native-vision-camera') + ;(useCameraDevice as jest.Mock).mockReturnValueOnce(null).mockReturnValueOnce(null) + const noDevice = renderWithRoute() + await act(async () => { + await Promise.resolve() + }) + expect(noDevice.queryByTestId('mock-camera')).toBeNull() + }) + + it('renders with default props outside route context', async () => { + const noRoute = render() + await act(async () => { + await Promise.resolve() + }) + const camera = noRoute.getByTestId('mock-camera') + expect(camera.props.device.position).toBe('back') + expect(camera.props.torch).toBe('auto') + expect(mockCameraFormatArgs.args[0]).toEqual({ + photoResolution: { width: 1920, height: 1080 }, + videoResolution: { width: 1920, height: 1080 } + }) + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-canvas.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-canvas.test.tsx new file mode 100644 index 0000000000..558ed4729f --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-canvas.test.tsx @@ -0,0 +1,332 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import React from 'react' +import { act, fireEvent, render } from '@testing-library/react-native' +import { Platform } from 'react-native' +import { expectPortalHostRendered, getWebViews, renderWithPortalHost, resetMpxRuntimeGlobals } from './rn-component-test-utils' + +jest.mock('react-native-webview', () => { + const mockReact = require('react') + const MockWebView = mockReact.forwardRef((props: any, ref: any) => { + const instanceRef = mockReact.useRef({ + goBack: jest.fn(), + injectJavaScript: jest.fn(), + postMessage: jest.fn(), + reload: jest.fn() + }) + mockReact.useImperativeHandle(ref, () => instanceRef.current) + return mockReact.createElement('WebView', Object.assign({ testID: props.testID || 'mock-webview' }, props)) + }) + return { + __esModule: true, + WebView: MockWebView, + default: MockWebView + } +}) + +const MpxCanvas = require('../../../../lib/runtime/components/react/mpx-canvas').default +const { WEBVIEW_TARGET } = require('../../../../lib/runtime/components/react/mpx-canvas/utils') +const CanvasGradient = require('../../../../lib/runtime/components/react/mpx-canvas/CanvasGradient').default + +beforeEach(() => { + jest.clearAllMocks() + resetMpxRuntimeGlobals() +}) + +describe('MpxCanvas', () => { + it('bridges canvas messages, context, image and imageData helpers', async () => { + const binderror = jest.fn() + const ref = React.createRef() + render() + + const webView = getWebViews()[0] + act(() => { + fireEvent(webView, 'load') + }) + + const canvas = ref.current.getNodeInstance().instance.node + const context = canvas.getContext('2d') + expect(context).toBe(canvas.context2D) + expect(canvas.getContext('webgl')).toBeNull() + context.fillStyle = '#ff0000' + context.fillRect(0, 0, 10, 10) + + const image = canvas.createImage(20, 30) + const staleOnload = jest.fn() + const onload = jest.fn() + const onerror = jest.fn() + image.onload = staleOnload + image.onerror = onerror + image.onload = onload + fireEvent(webView, 'message', { + nativeEvent: { + data: JSON.stringify({ + type: 'event', + meta: {}, + payload: { + type: 'event', + payload: { + type: 'load', + target: { + [WEBVIEW_TARGET]: image[WEBVIEW_TARGET], + width: 22 + } + } + } + }) + } + }) + expect(onload).toHaveBeenCalledWith(expect.objectContaining({ + type: 'load', + target: expect.objectContaining({ width: 22 }) + })) + expect(staleOnload).not.toHaveBeenCalled() + + fireEvent(webView, 'message', { + nativeEvent: { + data: JSON.stringify({ + type: 'event', + meta: {}, + payload: { + type: 'event', + payload: { + type: 'error', + target: { + [WEBVIEW_TARGET]: image[WEBVIEW_TARGET] + } + } + } + }) + } + }) + expect(onerror).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + target: image + })) + expect(image.onload).toBe(onload) + expect(image.onerror).toBe(onerror) + image.onload = null + image.onerror = null + expect(image.onload).toBeNull() + expect(image.onerror).toBeNull() + ;['load', 'error'].forEach((type) => { + fireEvent(webView, 'message', { + nativeEvent: { + data: JSON.stringify({ + type: 'event', + meta: {}, + payload: { + type: 'event', + payload: { + type, + target: { + [WEBVIEW_TARGET]: image[WEBVIEW_TARGET] + } + } + } + }) + } + }) + }) + expect(onload).toHaveBeenCalledTimes(1) + expect(onerror).toHaveBeenCalledTimes(1) + + const imageData = canvas.createImageData([1, 2, 3, 4], 1, 1) + expect(imageData.canvas).toBe(canvas) + fireEvent(webView, 'message', { + nativeEvent: { + data: JSON.stringify({ + type: 'error', + payload: { message: 'canvas failed' } + }) + } + }) + expect(binderror).toHaveBeenCalledWith(expect.objectContaining({ + detail: { errMsg: 'canvas failed' } + })) + }) + + it('resolves canvas postMessage json/blob and constructor payloads', async () => { + jest.useFakeTimers() + const ref = React.createRef() + render() + const webView = getWebViews()[0] + act(() => { + fireEvent(webView, 'load') + }) + const canvas = ref.current.getNodeInstance().instance.node + const jsonPromise = canvas.postMessage({ + type: 'exec', + payload: { + target: 'canvas', + method: 'toDataURL', + args: [] + } + }) + const jsonId = Object.keys(canvas.bus._messageListeners)[0] + act(() => { + fireEvent(webView, 'message', { + nativeEvent: { + data: JSON.stringify({ + id: jsonId, + type: 'json', + payload: { ok: true }, + meta: {} + }) + } + }) + }) + await expect(jsonPromise).resolves.toEqual({ ok: true }) + + const blobPromise = canvas.postMessage({ + type: 'exec', + payload: { + target: 'canvas', + method: 'toBlob', + args: [] + } + }) + const blobId = Object.keys(canvas.bus._messageListeners)[0] + act(() => { + fireEvent(webView, 'message', { + nativeEvent: { + data: JSON.stringify({ + id: blobId, + type: 'blob', + payload: 'Ym9keQ==', + meta: {} + }) + } + }) + }) + await expect(blobPromise).resolves.toBe('decoded:Ym9keQ==') + + const listener = jest.fn() + canvas.addMessageListener(listener) + fireEvent(webView, 'message', { + nativeEvent: { + data: JSON.stringify({ + type: 'json', + meta: { + constructor: 'CanvasGradient', + target: 'gradient-id' + }, + args: [], + payload: { color: 'red' } + }) + } + }) + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ + color: 'red', + [WEBVIEW_TARGET]: 'gradient-id' + })) + jest.useRealTimers() + }) + + it('removes canvas message listeners and resolves postMessage errors', async () => { + const binderror = jest.fn() + const ref = React.createRef() + render() + const webView = getWebViews()[0] + act(() => { + fireEvent(webView, 'load') + }) + const canvas = ref.current.getNodeInstance().instance.node + const listener = jest.fn() + const removeListener = canvas.addMessageListener(listener) + const retainedListener = jest.fn() + canvas.addMessageListener(retainedListener) + removeListener() + canvas.removeMessageListener(jest.fn()) + fireEvent(webView, 'message', { + nativeEvent: { + data: JSON.stringify({ + type: 'json', + meta: {}, + payload: { ignored: true } + }) + } + }) + expect(listener).not.toHaveBeenCalled() + expect(retainedListener).toHaveBeenCalledWith({ ignored: true }) + + const errorPromise = canvas.postMessage({ + type: 'exec', + payload: { + target: 'canvas', + method: 'toDataURL', + args: [] + } + }) + const errorId = Object.keys(canvas.bus._messageListeners)[0] + act(() => { + canvas.bus.handle({ + id: errorId, + type: 'error', + payload: { message: 'post failed' } + }) + }) + await errorPromise + expect(binderror).toHaveBeenCalledWith(expect.objectContaining({ + detail: { errMsg: 'post failed' } + })) + }) + + it('renders fixed-position canvas through portal', () => { + const fixedRender = renderWithPortalHost( + + ) + expect(getWebViews().length).toBeGreaterThan(0) + expectPortalHostRendered(fixedRender.toJSON(), 'fixed-canvas') + }) + + it('renders Android canvas with platform-specific WebView props', () => { + const runtimeGlobal = global as any + const platform = Platform as any + const originalMode = runtimeGlobal.__mpx_mode__ + const originalVersion = platform.Version + + try { + runtimeGlobal.__mpx_mode__ = 'android' + platform.Version = 29 + render() + + const webView = getWebViews()[0] + expect(webView.props).toEqual(expect.objectContaining({ + overScrollMode: 'never', + mixedContentMode: 'always', + scalesPageToFit: false, + javaScriptEnabled: true, + domStorageEnabled: true, + thirdPartyCookiesEnabled: true, + allowUniversalAccessFromFileURLs: true + })) + expect(webView.props.style).toEqual(expect.arrayContaining([ + expect.objectContaining({ opacity: 0.99 }) + ])) + } finally { + runtimeGlobal.__mpx_mode__ = originalMode + platform.Version = originalVersion + } + }) + + it('constructs gradients locally and forwards gradient methods', () => { + const postMessage = jest.fn() + const gradient = new CanvasGradient({ postMessage }) + + gradient.addColorStop(0, 'red') + gradient.postMessage({ type: 'custom' }) + + expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ + type: 'construct', + payload: expect.objectContaining({ constructor: 'CanvasGradient' }) + })) + expect(postMessage).toHaveBeenCalledWith({ + type: 'exec', + payload: expect.objectContaining({ + method: 'addColorStop', + args: [0, 'red'] + }) + }) + expect(postMessage).toHaveBeenCalledWith({ type: 'custom' }) + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-checkbox-group.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-checkbox-group.test.tsx new file mode 100644 index 0000000000..f0d2a5bebb --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-checkbox-group.test.tsx @@ -0,0 +1,139 @@ +import React, { useContext, useEffect } from 'react' +import { act, render, screen } from '@testing-library/react-native' +import MpxCheckbox from '../../../../lib/runtime/components/react/mpx-checkbox' +import MpxCheckboxGroup from '../../../../lib/runtime/components/react/mpx-checkbox-group' +import MpxForm from '../../../../lib/runtime/components/react/mpx-form' +import { FormContext } from '../../../../lib/runtime/components/react/context' +import { fireTap } from './helpers' +import { expectPortalHostRendered, renderWithPortalHost } from './rn-component-test-utils' + +describe('MpxCheckboxGroup', () => { + it('registers with form submit, reset and cleanup', () => { + let formContext: any + const bindsubmit = jest.fn() + + const FormProbe = () => { + const context = useContext(FormContext) + useEffect(() => { + formContext = context + }, [context]) + return null + } + + const { unmount } = render( + + + + A + + + B + + + + + ) + + act(() => { + formContext.submit() + }) + expect(bindsubmit).toHaveBeenCalledWith(expect.objectContaining({ + detail: { + value: { + checks: ['a'] + } + } + })) + + act(() => { + formContext.reset() + }) + expect(formContext.formValuesMap.get('checks').getValue()).toEqual([]) + + unmount() + expect(formContext.formValuesMap.has('checks')).toBe(false) + }) + + it('notifies changes', () => { + const bindchange = jest.fn() + + render( + + + Apple + + + Banana + + + ) + + act(() => { + fireTap(screen.getByTestId('checkbox-apple')) + }) + expect(bindchange).toHaveBeenCalledWith(expect.objectContaining({ + type: 'change', + detail: { + value: expect.arrayContaining(['apple', 'banana']) + } + })) + }) + + it('syncs controlled checked state into form group', () => { + let formContext: any + + const FormProbe = () => { + const context = useContext(FormContext) + useEffect(() => { + formContext = context + }, [context]) + return null + } + + const { rerender } = render( + + + + Check + + + + + ) + + expect(formContext.formValuesMap.get('checks').getValue()).toEqual([]) + + rerender( + + + + Check + + + + + ) + + expect(formContext.formValuesMap.get('checks').getValue()).toEqual(['sync']) + }) + + it('renders fixed groups in portal', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(jest.fn()) + const fixedRender = renderWithPortalHost( + + Fixed + + ) + + expectPortalHostRendered(fixedRender.toJSON(), 'fixed-checkbox-group') + + render( + + + Unnamed + + + ) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('name attribute is required')) + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-checkbox.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-checkbox.test.tsx new file mode 100644 index 0000000000..13f6540052 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-checkbox.test.tsx @@ -0,0 +1,82 @@ +import React from 'react' +import { act, render, screen, waitFor } from '@testing-library/react-native' +import MpxCheckbox from '../../../../lib/runtime/components/react/mpx-checkbox' +import { createTouchEvent } from './helpers' + +const mockPortal = jest.fn() + +jest.mock('../../../../lib/runtime/components/react/mpx-portal', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mockReact = require('react') + return ({ children }: { children: any }) => { + mockPortal(children) + return mockReact.createElement(mockReact.Fragment, null, children) + } +}) + +describe('MpxCheckbox', () => { + beforeEach(() => { + mockPortal.mockClear() + }) + + it('renders fixed checkbox through portal with background warning', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + + render( + + Fixed check + + ) + + await waitFor(() => { + expect(screen.getByTestId('fixed-checkbox')).toBeTruthy() + }) + expect(mockPortal).toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Checkbox does not support background image-related styles!')) + + warnSpy.mockRestore() + }) + + it('handles disabled and controlled checked states', () => { + const bindtap = jest.fn() + const onChange = jest.fn() + const ref = React.createRef() + const { rerender } = render( + + Disabled check + + ) + + act(() => { + ref.current.getNodeInstance().instance.change(createTouchEvent() as any) + }) + expect(bindtap).not.toHaveBeenCalled() + expect(onChange).not.toHaveBeenCalled() + + rerender( + + Controlled check + + ) + const controlledCheckbox = screen.getByTestId('controlled-checkbox') + const checkedIcon = controlledCheckbox.findAll((node: any) => node.type === 'Image')[0] + expect(checkedIcon.props.style).toEqual(expect.objectContaining({ + opacity: 1, + tintColor: '#ff0000' + })) + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-form.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-form.test.tsx new file mode 100644 index 0000000000..706dd09f10 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-form.test.tsx @@ -0,0 +1,69 @@ +import React, { useContext, useEffect } from 'react' +import { act, render } from '@testing-library/react-native' +import MpxForm from '../../../../lib/runtime/components/react/mpx-form' +import MpxSwitch from '../../../../lib/runtime/components/react/mpx-switch' +import { FormContext } from '../../../../lib/runtime/components/react/context' + +describe('MpxForm', () => { + it('registers fields and submits/reset values', () => { + let formContext: any + const bindsubmit = jest.fn() + const bindreset = jest.fn() + + const FormProbe = () => { + const context = useContext(FormContext) + useEffect(() => { + formContext = context + }, [context]) + return null + } + + render( + + + + + ) + + expect(formContext.formValuesMap.has('enabled')).toBe(true) + + act(() => { + formContext.submit() + }) + expect(bindsubmit).toHaveBeenCalledWith(expect.objectContaining({ + detail: { + value: { + enabled: true + } + } + })) + + act(() => { + formContext.reset() + }) + expect(bindreset).toHaveBeenCalledWith(expect.objectContaining({ type: 'reset' })) + expect(formContext.formValuesMap.get('enabled').getValue()).toBe(false) + }) + + it('skips fields without getters and supports forms without event callbacks', () => { + let formContext: any + const resetValue = jest.fn() + const FormProbe = () => { + formContext = useContext(FormContext) + return null + } + + render( + + + + ) + formContext.formValuesMap.set('empty', { resetValue }) + + act(() => { + formContext.submit() + formContext.reset() + }) + expect(resetValue).toHaveBeenCalled() + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-icon.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-icon.test.tsx new file mode 100644 index 0000000000..9c8eea152f --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-icon.test.tsx @@ -0,0 +1,33 @@ +import React from 'react' +import { render, screen } from '@testing-library/react-native' +import MpxIcon from '../../../../lib/runtime/components/react/mpx-icon' +import { expectPortalHostRendered, renderWithPortalHost } from './rn-component-test-utils' + +describe('MpxIcon', () => { + it('renders core props', () => { + render( + + ) + + const icon = screen.getByTestId('basic-icon') + expect(icon.props.source).toBe('test-file-stub') + expect(icon.props.style).toEqual(expect.objectContaining({ + width: 16, + height: 16, + tintColor: '#123456' + })) + }) + + it('renders fixed icon through portal', () => { + const { toJSON } = renderWithPortalHost( + + ) + + expect(screen.getByTestId('fixed-icon')).toBeTruthy() + expectPortalHostRendered(toJSON(), 'fixed-icon') + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-image.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-image.test.tsx new file mode 100644 index 0000000000..058bb8b89d --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-image.test.tsx @@ -0,0 +1,340 @@ +import React from 'react' +import { act, fireEvent, render, screen } from '@testing-library/react-native' +import { Image as RNImage } from 'react-native' +import MpxImage from '../../../../lib/runtime/components/react/mpx-image' +import { flushImageSize } from './helpers' + +const mockPortal = jest.fn() + +jest.mock('../../../../lib/runtime/components/react/mpx-portal', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mockReact = require('react') + return ({ children }: { children: any }) => { + mockPortal(children) + return mockReact.createElement(mockReact.Fragment, null, children) + } +}) + +jest.mock('react-native-svg/css', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mockReact = require('react') + const SvgCssUri = (props: any) => mockReact.createElement('SvgCssUri', props) + const LocalSvg = (props: any) => mockReact.createElement('LocalSvg', props) + return { + SvgCssUri, + LocalSvg + } +}) + +describe('MpxImage', () => { + beforeEach(() => { + mockPortal.mockClear() + }) + + it('handles load, error and layout modes', async () => { + const bindload = jest.fn() + const binderror = jest.fn() + + const { rerender } = render( + + ) + + const image = screen.getByTestId('plain-image') + fireEvent(image, 'load', { + persist: jest.fn(), + nativeEvent: { + source: { width: 64, height: 32 } + } + }) + expect(bindload).toHaveBeenCalledWith(expect.objectContaining({ + type: 'load', + detail: { width: 64, height: 32 } + })) + + fireEvent(image, 'error', { + nativeEvent: { + error: 'load failed' + } + }) + expect(binderror).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + detail: { errMsg: 'load failed' } + })) + + rerender( + + ) + + const layoutImage = screen.getByTestId('layout-image') + fireEvent(layoutImage, 'layout', { + nativeEvent: { + layout: { width: 200, height: 100 } + } + }) + await flushImageSize() + expect(screen.getByTestId('layout-image').props.style.height).toBe(200) + fireEvent(screen.getByTestId('layout-image'), 'layout', { + nativeEvent: { + layout: { width: 200, height: 200 } + } + }) + expect(screen.getByTestId('layout-image').props.style.height).toBe(200) + fireEvent(screen.getByTestId('layout-image'), 'layout', { + nativeEvent: { + layout: { width: 250, height: 100 } + } + }) + expect(screen.getByTestId('layout-image').props.style.height).toBe(250) + expect(layoutImage.props.style).toEqual(expect.objectContaining({ + width: 200, + overflow: 'hidden' + })) + expect(mockPortal).toHaveBeenCalled() + }) + + it('handles svg image sources and errors', () => { + const bindload = jest.fn() + const binderror = jest.fn() + const { UNSAFE_getByType, rerender } = render( + + ) + + const svg = UNSAFE_getByType('SvgCssUri' as any) + act(() => { + fireEvent(svg, 'layout', { + nativeEvent: { + layout: { width: 80, height: 40 } + } + }) + }) + expect(bindload).toHaveBeenCalledWith(expect.objectContaining({ + type: 'load', + detail: { width: 80, height: 40 } + })) + + fireEvent(svg, 'error', new Error('bad svg')) + expect(binderror).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + detail: { errMsg: 'bad svg' } + })) + + rerender( + + ) + const localSvg = UNSAFE_getByType('LocalSvg' as any) + act(() => { + fireEvent(localSvg, 'layout', { + nativeEvent: { + layout: { width: 40, height: 20 } + } + }) + }) + expect(bindload).toHaveBeenCalledWith(expect.objectContaining({ + detail: { width: 40, height: 20 } + })) + }) + + it.each([ + ['top', [{ translateX: 60 }]], + ['bottom', [{ translateY: 60 }, { translateX: 60 }]], + ['center', [{ translateY: 30 }, { translateX: 60 }]], + ['left', [{ translateY: 30 }]], + ['right', [{ translateY: 30 }, { translateX: 120 }]], + ['top left', undefined], + ['top right', [{ translateX: 120 }]], + ['bottom left', [{ translateY: 60 }]], + ['bottom right', [{ translateY: 60 }, { translateX: 120 }]] + ])('renders layout mode %s', async (mode, transform) => { + const { UNSAFE_getByType } = render( + + ) + await flushImageSize() + + const image = UNSAFE_getByType(RNImage) + expect(screen.getByTestId(`mode-image-${mode}`)).toBeTruthy() + expect(image.props.resizeMode).toBe('stretch') + expect(image.props.style).toEqual(expect.objectContaining({ + width: 120, + height: 60 + })) + if (transform) { + expect(image.props.style.transform).toEqual(transform) + } else { + expect(image.props.style.transform).toBeUndefined() + } + }) + + it('calculates heightFix layout from image ratio', async () => { + const { UNSAFE_getByType } = render( + + ) + await flushImageSize() + + const image = UNSAFE_getByType(RNImage) + expect(screen.getByTestId('mode-image-heightFix').props.style).toEqual(expect.objectContaining({ + width: 240, + height: 120 + })) + expect(image.props.resizeMode).toBe('stretch') + expect(image.props.style).toEqual(expect.objectContaining({ + width: '100%', + height: '100%' + })) + }) + + it('falls back to default widthFix height when image size cannot be resolved', async () => { + render( + + ) + await flushImageSize() + + const image = screen.getByTestId('failed-size-image') + expect(image.props.style).toEqual(expect.objectContaining({ + width: 200, + height: 240, + overflow: 'hidden' + })) + }) + + it('handles default source load and unresolved asset metadata', async () => { + const bindload = jest.fn() + render( + + ) + + fireEvent(screen.getByTestId('default-source-image'), 'load', { + persist: jest.fn(), + nativeEvent: {} + }) + await flushImageSize() + expect(bindload).toHaveBeenCalledWith(expect.objectContaining({ + detail: { width: 100, height: 100 } + })) + + ;(RNImage.resolveAssetSource as jest.Mock) + .mockReturnValueOnce(undefined) + .mockReturnValueOnce({ uri: 'asset-no-size.png' }) + .mockReturnValueOnce({ uri: 'asset-no-size.png' }) + + const { UNSAFE_getAllByType } = render( + <> + + + + ) + await flushImageSize() + expect(UNSAFE_getAllByType(RNImage)).toHaveLength(2) + expect(screen.getByTestId('asset-no-size-image').props.style).toEqual(expect.objectContaining({ + width: 320, + height: 120 + })) + }) + + it('handles svg fit, zero-size layout and unknown modes', () => { + const { UNSAFE_getAllByType } = render( + <> + + + + + ) + + const svgs = UNSAFE_getAllByType('SvgCssUri' as any) + fireEvent(svgs[0], 'layout', { + nativeEvent: { + layout: { width: 50, height: 25 } + } + }) + fireEvent(svgs[1], 'layout', { + nativeEvent: { + layout: { width: 0, height: 25 } + } + }) + fireEvent(svgs[2], 'layout', { + nativeEvent: { + layout: { width: 50, height: 25 } + } + }) + const updatedSvgs = UNSAFE_getAllByType('SvgCssUri' as any) + expect(updatedSvgs[0].props.style.transform).toEqual([ + { translateY: 15 }, + { translateX: 0 }, + { scale: 2 } + ]) + expect(updatedSvgs[1].props.style.transform).toBeUndefined() + expect(updatedSvgs[2].props.style.transform).toBeUndefined() + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-inline-text.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-inline-text.test.tsx new file mode 100644 index 0000000000..fdb03e3247 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-inline-text.test.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import { render, screen } from '@testing-library/react-native' +import MpxInlineText from '../../../../lib/runtime/components/react/mpx-inline-text' +import { TextPassThroughContext } from '../../../../lib/runtime/components/react/context' + +describe('MpxInlineText', () => { + it('uses inherited text style and ignores local style', () => { + render( + + + Context child + + + ) + + const inlineText = screen.getByTestId('context-inline') + expect(inlineText.props.numberOfLines).toBe(1) + expect(inlineText.props.style).toEqual(expect.objectContaining({ color: 'red', fontSize: 16 })) + }) + + it('renders without inherited text props', () => { + render( + + Plain child + + ) + + const inlineText = screen.getByTestId('plain-inline') + expect(inlineText.props.allowFontScaling).toBe(false) + expect(inlineText.props.style).toBeUndefined() + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-input.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-input.test.tsx new file mode 100644 index 0000000000..13173849b0 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-input.test.tsx @@ -0,0 +1,511 @@ +import React from 'react' +import { render, screen, fireEvent, act } from '@testing-library/react-native' +import MpxInput from '../../../../lib/runtime/components/react/mpx-input' + +describe('MpxInput', () => { + // 基础功能测试 + it('should render with basic props', () => { + render( + + ) + + const inputElement = screen.getByTestId('basic-input') + expect(inputElement.props.value).toBe('test value') + expect(inputElement.props.placeholder).toBe('Enter text') + }) + + // 参数化测试 - 不同输入类型 + it.each([ + ['text', 'text'], + ['number', 'numeric'], + ['idcard', 'text'], + ['digit', 'decimal'] + ])('should handle input type %s with inputMode %s', (type, expectedInputMode) => { + render( + + ) + + const inputElement = screen.getByTestId('type-input') + expect(inputElement.props.inputMode).toEqual(expectedInputMode) + expect(inputElement.props.keyboardType).toBeUndefined() + }) + + it('should prefer explicit keyboard-type over input type mapping', () => { + render( + + ) + + const inputElement = screen.getByTestId('keyboard-type-input') + expect(inputElement.props.inputMode).toBeUndefined() + expect(inputElement.props.keyboardType).toBe('phone-pad') + }) + + // 参数化测试 - 属性组合 + it.each([ + { password: true, disabled: false, multiline: false }, + { password: false, disabled: true, multiline: false }, + { password: false, disabled: false, multiline: true }, + { focus: true, 'auto-focus': false }, + { focus: false, 'auto-focus': true }, + { focus: true, 'auto-focus': true } + ])('should handle property combinations: %p', (props) => { + render( + + ) + + const inputElement = screen.getByTestId('combo-input') + expect(inputElement.props.secureTextEntry).toBe(!!props.password) + expect(inputElement.props.editable).toBe(!props.disabled) + expect(inputElement.props.multiline).toBe(props.multiline) + expect(inputElement.props.autoFocus).toBe(!!(props.focus || props['auto-focus'])) + }) + + // 事件处理综合测试 + it('should handle all input events', () => { + const mockBindinput = jest.fn() + const mockBindfocus = jest.fn() + const mockBindblur = jest.fn() + const mockBindconfirm = jest.fn() + const mockBindselectionchange = jest.fn() + + render( + + ) + + const inputElement = screen.getByTestId('event-input') + + // 触发所有事件 + fireEvent(inputElement, 'change', { nativeEvent: { text: 'new text', selection: { start: 8, end: 8 } } }) + fireEvent(inputElement, 'focus') + fireEvent(inputElement, 'blur') + fireEvent(inputElement, 'submitEditing') + fireEvent(inputElement, 'selectionChange', { nativeEvent: { selection: { start: 4, end: 4 } } }) + + expect(mockBindinput).toHaveBeenCalledWith(expect.objectContaining({ + detail: { value: 'new text', cursor: 8 } + })) + expect(mockBindfocus).toHaveBeenCalledWith(expect.objectContaining({ + detail: expect.objectContaining({ value: 'new text' }) + })) + expect(mockBindblur).toHaveBeenCalledWith(expect.objectContaining({ + detail: { value: 'new text', cursor: 8 } + })) + expect(mockBindconfirm).toHaveBeenCalledWith(expect.objectContaining({ + detail: { value: 'new text' } + })) + expect(mockBindselectionchange).toHaveBeenCalledWith(expect.objectContaining({ + detail: { selectionStart: 4, selectionEnd: 4 } + })) + }) + + // 表单集成测试 + it('should integrate with form context', () => { + const mockFormValuesMap = new Map() + const TestFormProvider = ({ children }: { children: any }) => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const React = require('react') + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { FormContext } = require('../../../../lib/runtime/components/react/context') + return React.createElement(FormContext.Provider, { + value: { formValuesMap: mockFormValuesMap, submit: jest.fn(), reset: jest.fn() } + }, children) + } + + // 测试有 name 的情况 + const { unmount } = render( + + + + ) + + expect(mockFormValuesMap.has('username')).toBe(true) + const formField = mockFormValuesMap.get('username') + expect(formField.getValue()).toBe('test user') + + // 测试 resetValue 功能 + act(() => { + formField.resetValue() + }) + expect(mockFormValuesMap.get('username').getValue()).toBe('') + expect(screen.getByTestId('form-input').props.value).toBe('') + + unmount() + expect(mockFormValuesMap.has('username')).toBe(false) // 验证清理 + }) + + // 警告测试 + it('should warn when form lacks name attribute', () => { + const mockFormValuesMap = new Map() + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + + const TestFormProvider = ({ children }: { children: any }) => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const React = require('react') + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { FormContext } = require('../../../../lib/runtime/components/react/context') + return React.createElement(FormContext.Provider, { + value: { formValuesMap: mockFormValuesMap, submit: jest.fn(), reset: jest.fn() } + }, children) + } + + try { + render( + + + + ) + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('If a form component is used, the name attribute is required.') + ) + } finally { + warnSpy.mockRestore() + } + }) + + // 值解析边界测试 + it('should handle value parsing edge cases', () => { + const testCases = [ + { value: undefined, maxlength: 140, expected: '', expectedMaxLength: 140 }, + { value: 'very long text', maxlength: 5, expected: 'very ', expectedMaxLength: 5 }, + { value: 'short', maxlength: -1, expected: 'short', expectedMaxLength: undefined }, + { value: 12345, maxlength: 140, expected: '12345', expectedMaxLength: 140 }, + { value: 'any text', maxlength: 0, expected: '', expectedMaxLength: 0 } + ] + + const { rerender } = render() + testCases.forEach(({ value, maxlength, expected, expectedMaxLength }) => { + rerender( + + ) + const inputElement = screen.getByTestId('parse-input') + expect(inputElement.props.value).toBe(expected) + expect(inputElement.props.maxLength).toBe(expectedMaxLength) + }) + }) + + // 选择和光标测试 + it('should handle selection and cursor positioning', () => { + // 测试 selection-start 和 selection-end + const { rerender } = render( + + ) + + let inputElement = screen.getByTestId('selection-input') + expect(inputElement.props.selection.start).toBe(5) + expect(inputElement.props.selection.end).toBe(11) // tmpValue.current.length + + // 测试 cursor 属性 + rerender( + + ) + + inputElement = screen.getByTestId('selection-input') + expect(inputElement.props.selection.start).toBe(7) + expect(inputElement.props.selection.end).toBe(7) + + // 测试没有 selection 的情况 + rerender( + + ) + + inputElement = screen.getByTestId('selection-input') + expect(inputElement.props.selection).toBeUndefined() + }) + + // 多行和确认类型组合测试 + it('should handle multiline and confirm-type combinations', () => { + const testCases = [ + { multiline: true, confirmType: 'return', autoHeight: false, expectedEnterKeyHint: undefined, expectedSubmitBehavior: 'newline', expectedTextAlignVertical: 'top', expectedHeight: undefined }, + { multiline: true, confirmType: 'done', autoHeight: true, expectedEnterKeyHint: 'done', expectedSubmitBehavior: 'blurAndSubmit', expectedTextAlignVertical: 'top', expectedHeight: 'auto' }, + { multiline: false, confirmType: 'search', autoHeight: undefined, expectedEnterKeyHint: 'search', expectedSubmitBehavior: 'blurAndSubmit', expectedTextAlignVertical: 'auto', expectedHeight: undefined } + ] + + testCases.forEach((testCase) => { + render( + + ) + + const inputElement = screen.getByTestId('multiline-confirm-input') + expect(inputElement.props.enterKeyHint).toBe(testCase.expectedEnterKeyHint) + expect(inputElement.props.submitBehavior).toBe(testCase.expectedSubmitBehavior) + expect(inputElement.props.textAlignVertical).toBe(testCase.expectedTextAlignVertical) + expect(inputElement.props.style.height).toBe(testCase.expectedHeight) + }) + }) + + // 键盘避让和触摸事件测试 + it('should handle keyboard avoidance and touch events', () => { + const mockBindfocus = jest.fn() + const mockKeyboardAvoidRef = { current: null } + const TestKeyboardAvoidProvider = ({ children }: { children: any }) => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const React = require('react') + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { KeyboardAvoidContext } = require('../../../../lib/runtime/components/react/context') + return React.createElement(KeyboardAvoidContext.Provider, { + value: mockKeyboardAvoidRef + }, children) + } + + render( + + + + ) + + const inputElement = screen.getByTestId('keyboard-input') + + // 测试触摸事件 + const touchEndEvent = { nativeEvent: {} as any } + fireEvent(inputElement, 'touchStart') + fireEvent(inputElement, 'touchEnd', touchEndEvent) + + expect(mockKeyboardAvoidRef.current).toEqual(expect.objectContaining({ + cursorSpacing: 10, + adjustPosition: true, + holdKeyboard: false, + readyToShow: true + })) + expect((mockKeyboardAvoidRef.current as any).ref).toBeTruthy() + expect(touchEndEvent.nativeEvent.origin).toBe('input') + + const focusEvent = { persist: jest.fn(), nativeEvent: {}, target: {} } + fireEvent(inputElement, 'focus', focusEvent) + expect(focusEvent.persist).toHaveBeenCalled() + expect(typeof (mockKeyboardAvoidRef.current as any).onKeyboardShow).toBe('function') + + const onKeyboardShow = (mockKeyboardAvoidRef.current as any).onKeyboardShow + act(() => { + onKeyboardShow() + }) + expect(mockBindfocus).toHaveBeenCalledWith(expect.objectContaining({ + detail: expect.objectContaining({ value: 'test' }) + })) + + const keyboardAvoid = mockKeyboardAvoidRef.current as any + keyboardAvoid.keyboardHeight = 300 + fireEvent(inputElement, 'focus', focusEvent) + expect(mockBindfocus).toHaveBeenLastCalledWith(expect.objectContaining({ + detail: expect.objectContaining({ height: 300 }) + })) + }) + + // 多行内容尺寸变化测试 + it('should handle multiline content size changes', () => { + const mockBindlinechange = jest.fn() + + render( + + ) + + const inputElement = screen.getByTestId('multiline-size-input') + + // 测试内容尺寸变化 + fireEvent(inputElement, 'contentSizeChange', { + nativeEvent: { contentSize: { width: 200, height: 60 } } + }) + + fireEvent(inputElement, 'contentSizeChange', { + nativeEvent: { contentSize: { width: 200, height: 120 } } + }) + + fireEvent(inputElement, 'contentSizeChange', { + nativeEvent: { contentSize: { width: 200, height: 120 } } + }) + fireEvent(inputElement, 'contentSizeChange', { + nativeEvent: { contentSize: { width: 0, height: 120 } } + }) + + expect(mockBindlinechange).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: 'linechange', + detail: { height: 60, lineHeight: 60, lineCount: 1 } + })) + expect(mockBindlinechange).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: 'linechange', + detail: { height: 120, lineHeight: 60, lineCount: 2 } + })) + expect(mockBindlinechange).toHaveBeenCalledTimes(2) + }) + + // Portal 渲染测试 + it('should render in Portal when position is fixed', () => { + const mockPortal = jest.fn() + + try { + jest.isolateModules(() => { + jest.doMock('react', () => React) + jest.doMock('../../../../lib/runtime/components/react/mpx-portal', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mockReact = require('react') + return ({ children }: { children: any }) => { + mockPortal(children) + return mockReact.createElement('View', { testID: 'input-portal' }, children) + } + }) + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const MockedMpxInput = require('../../../../lib/runtime/components/react/mpx-input').default + + render( + React.createElement(MockedMpxInput, { + testID: 'portal-input', + value: 'Portal content', + 'enable-var': true, + style: { position: 'fixed' } + }) + ) + }) + + expect(mockPortal).toHaveBeenCalled() + expect(screen.getByTestId('input-portal')).toBeTruthy() + expect(screen.getByTestId('portal-input').props.style).toEqual(expect.objectContaining({ + position: 'absolute' + })) + } finally { + jest.dontMock('react') + jest.dontMock('../../../../lib/runtime/components/react/mpx-portal') + } + }) + + // onChange 返回值处理测试 + it('should apply bindinput return values and update without bindinput', () => { + const mockBindinput = jest.fn() + .mockReturnValueOnce('modified value') + .mockReturnValueOnce(undefined) + + render( + + ) + + const inputElement = screen.getByTestId('onchange-input') + + // 测试返回字符串的情况 + fireEvent(inputElement, 'change', { + nativeEvent: { text: 'new text', selection: { start: 8, end: 8 } } + }) + expect(screen.getByTestId('onchange-input').props.value).toBe('modified value') + + // 测试返回 undefined 的情况 + fireEvent(inputElement, 'change', { + nativeEvent: { text: 'another text', selection: { start: 12, end: 12 } } + }) + + expect(mockBindinput).toHaveBeenCalledTimes(2) + expect(screen.getByTestId('onchange-input').props.value).toBe('another text') + + render( + + ) + fireEvent(screen.getByTestId('no-bindinput'), 'change', { + nativeEvent: { text: 'changed', selection: { start: 7, end: 7 } } + }) + expect(screen.getByTestId('no-bindinput').props.value).toBe('changed') + }) + + it('should ignore duplicate changes and derive missing cursor positions', () => { + const mockBindinput = jest.fn() + + render( + + ) + + const inputElement = screen.getByTestId('deduplicated-input') + fireEvent(inputElement, 'change', { + nativeEvent: { text: 'test', selection: { start: 4, end: 4 } } + }) + expect(mockBindinput).not.toHaveBeenCalled() + + fireEvent(inputElement, 'change', { + nativeEvent: { text: 'different', selection: undefined } + }) + expect(mockBindinput).toHaveBeenCalledWith(expect.objectContaining({ + detail: { value: 'different', cursor: 5 } + })) + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-keyboard-avoiding-view-android.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-keyboard-avoiding-view-android.test.tsx new file mode 100644 index 0000000000..510e05f7e3 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-keyboard-avoiding-view-android.test.tsx @@ -0,0 +1,107 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import React from 'react' +import { act, render } from '@testing-library/react-native' +import { Keyboard, Text } from 'react-native' +import { withTiming } from 'react-native-reanimated' + +;(global as any).__mpx_mode__ = 'android' + +const MpxKeyboardAvoidingView = require('../../../../lib/runtime/components/react/mpx-keyboard-avoiding-view').default +const { KeyboardAvoidContext } = require('../../../../lib/runtime/components/react/context') + +beforeEach(() => { + jest.clearAllMocks() + ;(global as any).__mpx_mode__ = 'android' + ;(global as any).mpxGlobal = { + __mpx: { + config: { + rnConfig: { + enableNativeKeyboardAvoiding: true + } + } + } + } +}) + +afterAll(() => { + (global as any).__mpx_mode__ = 'ios' +}) + +describe('MpxKeyboardAvoidingView android', () => { + it('uses android keyboard listeners and native avoiding offsets', () => { + const keyboardAvoid = { + current: { + cursorSpacing: 20, + ref: { + current: { + measure: jest.fn((callback) => { + // eslint-disable-next-line node/no-callback-literal + callback(0, 0, 100, 40, 0, 620) + }), + isFocused: () => true, + blur: jest.fn() + } + }, + adjustPosition: true, + onKeyboardShow: jest.fn() + } + } + + render( + + + android keyboard body + + + ) + + const show = (Keyboard.addListener as jest.Mock).mock.calls.find(([type]) => type === 'keyboardDidShow')[1] + const hide = (Keyboard.addListener as jest.Mock).mock.calls.find(([type]) => type === 'keyboardDidHide')[1] + act(() => { + show({ endCoordinates: { height: 300, screenY: 500 } }) + hide() + }) + expect(keyboardAvoid.current.onKeyboardShow).toHaveBeenCalled() + expect(keyboardAvoid.current.ref.current.measure).toHaveBeenCalled() + expect(withTiming).toHaveBeenNthCalledWith(1, 20, expect.objectContaining({ duration: 300 }), expect.any(Function)) + expect(withTiming).toHaveBeenNthCalledWith(2, 0, expect.objectContaining({ duration: 300 })) + expect(keyboardAvoid.current.ref.current.blur).toHaveBeenCalled() + }) + + it('falls back to manual offsets when native android avoiding is disabled', () => { + (global as any).mpxGlobal.__mpx.config.rnConfig.enableNativeKeyboardAvoiding = false + const keyboardAvoid = { + current: { + cursorSpacing: 20, + ref: { + current: { + measure: jest.fn((callback) => { + // eslint-disable-next-line node/no-callback-literal + callback(0, 0, 100, 40, 0, 620) + }), + isFocused: () => false, + blur: jest.fn() + } + }, + adjustPosition: true, + onKeyboardShow: jest.fn() + } + } + + render( + + + manual keyboard body + + + ) + + const show = (Keyboard.addListener as jest.Mock).mock.calls.find(([type]) => type === 'keyboardDidShow')[1] + act(() => { + show({ endCoordinates: { height: 260, screenY: 500 } }) + }) + expect(keyboardAvoid.current.onKeyboardShow).toHaveBeenCalled() + expect(keyboardAvoid.current.ref.current.measure).toHaveBeenCalled() + expect(withTiming).toHaveBeenCalledWith(180, expect.objectContaining({ duration: 300 }), expect.any(Function)) + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-keyboard-avoiding-view.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-keyboard-avoiding-view.test.tsx new file mode 100644 index 0000000000..2d6b62f174 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-keyboard-avoiding-view.test.tsx @@ -0,0 +1,165 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import React from 'react' +import { act, fireEvent, render } from '@testing-library/react-native' +import { Keyboard, Text } from 'react-native' +import { withTiming } from 'react-native-reanimated' +import { getViews, resetMpxRuntimeGlobals } from './rn-component-test-utils' + +const MpxKeyboardAvoidingView = require('../../../../lib/runtime/components/react/mpx-keyboard-avoiding-view').default +const { KeyboardAvoidContext } = require('../../../../lib/runtime/components/react/context') + +beforeEach(() => { + jest.clearAllMocks() + resetMpxRuntimeGlobals() +}) + +describe('MpxKeyboardAvoidingView', () => { + it('animates keyboard avoidance and dismisses keyboard on outside touch', () => { + jest.useFakeTimers() + const measure = jest.fn((callback) => { + // eslint-disable-next-line node/no-callback-literal + callback(0, 0, 100, 40, 0, 620) + }) + const blur = jest.fn() + const inputRef = { + current: { + measure, + isFocused: () => true, + blur + } + } + const keyboardAvoid = { + current: { + cursorSpacing: 20, + ref: inputRef, + adjustPosition: true, + onKeyboardShow: jest.fn() + } + } + + render( + + + keyboard body + + + ) + + const show = (Keyboard.addListener as jest.Mock).mock.calls.find(([type]) => type === 'keyboardWillShow')[1] + const hide = (Keyboard.addListener as jest.Mock).mock.calls.find(([type]) => type === 'keyboardWillHide')[1] + act(() => { + show({ endCoordinates: { height: 300, screenY: 500 } }) + jest.advanceTimersByTime(40) + }) + expect(keyboardAvoid.current.onKeyboardShow).toHaveBeenCalled() + expect(measure).toHaveBeenCalled() + expect(withTiming).toHaveBeenNthCalledWith(1, 180, expect.objectContaining({ duration: 250 }), expect.any(Function)) + act(() => { + hide() + }) + expect(withTiming).toHaveBeenNthCalledWith(2, 0, expect.objectContaining({ duration: 250 })) + expect(blur).toHaveBeenCalled() + + ;(Keyboard.isVisible as jest.Mock).mockReturnValueOnce(true) + fireEvent(getViews()[0], 'touchEnd', { nativeEvent: {} }) + expect(Keyboard.dismiss).toHaveBeenCalled() + jest.useRealTimers() + }) + + it('keeps keyboard for input-origin and hold-keyboard touches, then resets focused input', () => { + jest.useFakeTimers() + const blur = jest.fn() + const keyboardAvoid = { + current: { + cursorSpacing: 0, + holdKeyboard: true, + ref: { + current: { + measure: jest.fn((callback) => { + // eslint-disable-next-line node/no-callback-literal + callback(0, 0, 100, 40, 0, 620) + }), + isFocused: () => true, + blur + } + }, + adjustPosition: true, + onKeyboardShow: jest.fn() + } + } + + render( + + + keyboard hold body + + + ) + + ;(Keyboard.isVisible as jest.Mock).mockReturnValue(true) + fireEvent(getViews()[0], 'touchEnd', { nativeEvent: { origin: 'input' } }) + fireEvent(getViews()[0], 'touchMove', { nativeEvent: {} }) + expect(Keyboard.dismiss).not.toHaveBeenCalled() + + keyboardAvoid.current.holdKeyboard = false + const show = (Keyboard.addListener as jest.Mock).mock.calls.find(([type]) => type === 'keyboardWillShow')[1] + const hide = (Keyboard.addListener as jest.Mock).mock.calls.find(([type]) => type === 'keyboardWillHide')[1] + act(() => { + show({ endCoordinates: { height: 200, screenY: 500 } }) + jest.advanceTimersByTime(40) + }) + delete keyboardAvoid.current.onKeyboardShow + act(() => { + hide() + }) + expect(blur).toHaveBeenCalled() + expect(keyboardAvoid.current).toBeNull() + jest.useRealTimers() + }) + + it('handles keyboard hide before show, readyToShow and missing current guards', () => { + jest.useFakeTimers() + const keyboardAvoid: any = { + current: { + readyToShow: true, + ref: { + current: { + measure: jest.fn((callback) => { + // eslint-disable-next-line node/no-callback-literal + callback(0, 0, 100, 40, 0, 620) + }), + isFocused: () => false, + blur: jest.fn() + } + }, + adjustPosition: false, + onKeyboardShow: jest.fn() + } + } + + render( + + + keyboard guard body + + + ) + + const show = (Keyboard.addListener as jest.Mock).mock.calls.find(([type]) => type === 'keyboardWillShow')[1] + const hide = (Keyboard.addListener as jest.Mock).mock.calls.find(([type]) => type === 'keyboardWillHide')[1] + act(() => { + hide() + show({ endCoordinates: { height: 100, screenY: 700 } }) + show({ endCoordinates: { height: 120, screenY: 680 } }) + jest.advanceTimersByTime(40) + }) + expect(keyboardAvoid.current.readyToShow).toBe(false) + + keyboardAvoid.current = null + act(() => { + show({ endCoordinates: { height: 100, screenY: 700 } }) + jest.advanceTimersByTime(40) + }) + jest.useRealTimers() + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-label.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-label.test.tsx new file mode 100644 index 0000000000..d259230e93 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-label.test.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import { act, render, screen } from '@testing-library/react-native' +import MpxCheckbox from '../../../../lib/runtime/components/react/mpx-checkbox' +import MpxCheckboxGroup from '../../../../lib/runtime/components/react/mpx-checkbox-group' +import MpxLabel from '../../../../lib/runtime/components/react/mpx-label' +import MpxRadio from '../../../../lib/runtime/components/react/mpx-radio' +import MpxRadioGroup from '../../../../lib/runtime/components/react/mpx-radio-group' +import { fireTap } from './helpers' +import { expectPortalHostRendered, renderWithPortalHost } from './rn-component-test-utils' + +describe('MpxLabel', () => { + it('triggers child checkbox change and its own tap handler', () => { + const bindtap = jest.fn() + const bindchange = jest.fn() + + render( + + + + Agree + + + + ) + + act(() => { + fireTap(screen.getByTestId('basic-label')) + }) + + expect(bindtap).toHaveBeenCalled() + expect(bindchange).toHaveBeenCalledWith(expect.objectContaining({ + detail: { value: ['agree'] } + })) + }) + + it('triggers child radio change', () => { + const bindchange = jest.fn() + + render( + + + + Agree + + + + ) + + act(() => { + fireTap(screen.getByTestId('radio-label')) + }) + + expect(bindchange).toHaveBeenCalledWith(expect.objectContaining({ + detail: { value: 'agree' } + })) + }) + + it('warns for background images and renders fixed labels in portal', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(jest.fn()) + + const fixedRender = renderWithPortalHost( + + Fixed label + + ) + + expectPortalHostRendered(fixedRender.toJSON(), 'fixed-label') + expect(screen.getByTestId('fixed-label').props.children.props.children).toBe('Fixed label') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('does not support background image')) + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-movable-area.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-movable-area.test.tsx new file mode 100644 index 0000000000..ef7c906590 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-movable-area.test.tsx @@ -0,0 +1,110 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import React from 'react' +import { act, fireEvent, render, screen } from '@testing-library/react-native' +import { Text } from 'react-native' +import { expectPortalHostRendered, renderWithPortalHost, renderWithRoute, resetMpxRuntimeGlobals } from './rn-component-test-utils' + +const MpxMovableArea = require('../../../../lib/runtime/components/react/mpx-movable-area').default +const MpxMovableView = require('../../../../lib/runtime/components/react/mpx-movable-view').default + +beforeEach(() => { + jest.clearAllMocks() + resetMpxRuntimeGlobals() +}) + +describe('MpxMovableArea', () => { + it('uses default dimensions when style is omitted', () => { + render( + + unstyled area + + ) + + expect(screen.getByTestId('unstyled-area').props.style).toEqual(expect.objectContaining({ + width: 10, + height: 10 + })) + }) + + it('provides movable area context and dispatches movable view gesture events', () => { + const bindchange = jest.fn() + const bindtouchstart = jest.fn() + const bindtouchmove = jest.fn() + const bindtouchend = jest.fn() + const { __getLastPanGesture } = require('react-native-gesture-handler') + + renderWithRoute( + + + move me + + , + { layout: { top: 5 } } + ) + + fireEvent(screen.getByTestId('area-movable'), 'layout', { + nativeEvent: { layout: { width: 50, height: 50 } } + }) + + const gesture = __getLastPanGesture() + const touchEvent: any = { + changedTouches: [{ x: 0, y: 0, absoluteX: 0, absoluteY: 10 }], + allTouches: [{ x: 0, y: 0, absoluteX: 0, absoluteY: 10 }] + } + act(() => { + gesture.onTouchesDownCallback(touchEvent) + gesture.onStartCallback() + gesture.onTouchesMoveCallback({ + changedTouches: [{ x: 400, y: 300, absoluteX: 400, absoluteY: 310 }], + allTouches: [{ x: 400, y: 300, absoluteX: 400, absoluteY: 310 }] + }) + gesture.onUpdateCallback({ translationX: 400, translationY: 300 }) + gesture.onTouchesUpCallback(touchEvent) + gesture.onEndCallback({ velocityX: 100, velocityY: 60 }) + }) + + expect(bindtouchstart).toHaveBeenCalledWith(expect.objectContaining({ + currentTarget: expect.objectContaining({ id: 'mv' }) + })) + expect(bindtouchmove).toHaveBeenCalledWith(expect.objectContaining({ + changedTouches: [expect.objectContaining({ pageX: 400, pageY: 305 })] + })) + expect(bindtouchend).toHaveBeenCalledWith(expect.objectContaining({ + touches: [], + changedTouches: [expect.objectContaining({ pageX: 0, pageY: 5 })] + })) + expect(bindchange).toHaveBeenCalledWith(expect.objectContaining({ + type: 'change', + detail: { x: 250, y: 150, source: 'touch' }, + target: expect.objectContaining({ id: 'mv' }) + })) + }) + + it('uses default dimensions and renders fixed areas in portal', () => { + const fixedRender = renderWithPortalHost( + + fixed area + + ) + + expectPortalHostRendered(fixedRender.toJSON(), 'default-area') + expect(screen.getByTestId('default-area').props.style).toEqual(expect.objectContaining({ + width: 10, + height: 10, + position: 'absolute' + })) + expect(screen.getByText('fixed area')).toBeTruthy() + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-movable-view.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-movable-view.test.tsx new file mode 100644 index 0000000000..c7daf3d493 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-movable-view.test.tsx @@ -0,0 +1,401 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import React from 'react' +import { act, fireEvent, screen } from '@testing-library/react-native' +import { Text } from 'react-native' +import { renderWithRoute, resetMpxRuntimeGlobals } from './rn-component-test-utils' + +const MpxMovableArea = require('../../../../lib/runtime/components/react/mpx-movable-area').default +const MpxMovableView = require('../../../../lib/runtime/components/react/mpx-movable-view').default +const { RouteContext } = require('../../../../lib/runtime/components/react/context') + +beforeEach(() => { + jest.clearAllMocks() + resetMpxRuntimeGlobals() +}) + +describe('MpxMovableView', () => { + it('handles out-of-bounds, inertia, catch handlers and gesture coordination', () => { + const bindchange = jest.fn() + const catchtouchstart = jest.fn() + const catchtouchmove = jest.fn() + const catchtouchend = jest.fn() + const { __getLastPanGesture } = require('react-native-gesture-handler') + const waitFor = { current: { name: 'wait' } } + const simultaneous = { current: { name: 'simultaneous' } } + + renderWithRoute( + + + bounded + + , + { layout: { top: 0 } } + ) + + fireEvent(screen.getByTestId('bounded-movable'), 'layout', { + nativeEvent: { layout: { width: 50, height: 50 } } + }) + const gesture = __getLastPanGesture() + const startEvent: any = { + changedTouches: [{ x: 0, y: 0, absoluteX: 0, absoluteY: 0 }], + allTouches: [{ x: 0, y: 0, absoluteX: 0, absoluteY: 0 }] + } + act(() => { + gesture.onTouchesDownCallback(startEvent) + gesture.onStartCallback() + gesture.onTouchesMoveCallback({ + changedTouches: [{ x: 80, y: 5, absoluteX: 80, absoluteY: 5 }], + allTouches: [{ x: 80, y: 5, absoluteX: 80, absoluteY: 5 }] + }) + gesture.onUpdateCallback({ translationX: 80, translationY: 5 }) + gesture.onTouchesUpCallback(startEvent) + gesture.onEndCallback({ velocityX: 300, velocityY: 0 }) + }) + + expect(catchtouchstart).toHaveBeenCalledWith(expect.objectContaining({ + currentTarget: expect.objectContaining({ id: 'bounded' }) + })) + expect(catchtouchmove).toHaveBeenCalledWith(expect.objectContaining({ + changedTouches: [expect.objectContaining({ pageX: 80, pageY: 5 })] + })) + expect(catchtouchend).toHaveBeenCalledWith(expect.objectContaining({ touches: [] })) + expect(bindchange).toHaveBeenCalledWith(expect.objectContaining({ + type: 'change', + detail: expect.objectContaining({ source: 'touch-out-of-bounds' }), + target: expect.objectContaining({ id: 'bounded' }) + })) + expect(gesture.activeOffsetX).toHaveBeenCalledWith([-5, 5]) + expect(gesture.simultaneousWithExternalGesture).toHaveBeenCalled() + expect(gesture.requireExternalGestureToFail).toHaveBeenCalled() + }) + + it('handles vertical and disabled movable branches', () => { + const bindvtouchmove = jest.fn() + const bindchange = jest.fn() + const { __getLastPanGesture } = require('react-native-gesture-handler') + const { rerender } = renderWithRoute( + + + vertical movable + + + ) + let gesture = __getLastPanGesture() + act(() => { + gesture.onTouchesDownCallback({ changedTouches: [{ x: 0, y: 0 }], allTouches: [{ absoluteX: 0, absoluteY: 0 }] }) + gesture.onStartCallback() + gesture.onTouchesMoveCallback({ changedTouches: [{ x: 0, y: 30, absoluteX: 0, absoluteY: 30 }], allTouches: [{ absoluteX: 0, absoluteY: 30 }] }) + gesture.onUpdateCallback({ translationX: 0, translationY: 30 }) + }) + expect(bindvtouchmove).toHaveBeenCalledWith(expect.objectContaining({ + changedTouches: [expect.objectContaining({ pageX: 0, pageY: 30 })] + })) + expect(gesture.activeOffsetY).toHaveBeenCalledWith([-5, 5]) + + rerender( + + + + disabled movable + + + + ) + gesture = __getLastPanGesture() + act(() => { + gesture.onStartCallback() + gesture.onUpdateCallback({ translationX: 50, translationY: 50 }) + gesture.onEndCallback({ velocityX: 50, velocityY: 50 }) + }) + expect(screen.getByText('disabled movable')).toBeTruthy() + bindchange.mockClear() + act(() => { + gesture.onUpdateCallback({ translationX: 80, translationY: 80 }) + gesture.onEndCallback({ velocityX: 80, velocityY: 80 }) + }) + expect(bindchange).not.toHaveBeenCalled() + }) + + it('handles prop updates, percent layout and area boundary changes', () => { + const bindchange = jest.fn() + const onLayout = jest.fn() + const { rerender } = renderWithRoute( + + + percent movable + + , + { layout: { top: 0 } } + ) + + fireEvent(screen.getByTestId('percent-movable'), 'layout', { + nativeEvent: { layout: { width: 60, height: 60 } } + }) + expect(onLayout).toHaveBeenCalled() + + rerender( + + + + percent movable + + + + ) + + rerender( + + + + percent movable + + + + ) + + expect(bindchange).toHaveBeenLastCalledWith(expect.objectContaining({ + detail: { x: 40, y: 30, source: '' } + })) + + const renderSmallArea = () => ( + + + + percent movable + + + + ) + + rerender(renderSmallArea()) + // Re-render so the mock animated style reads the offsets updated by the area-size effect. + rerender(renderSmallArea()) + + expect(screen.getByTestId('percent-movable').props.style[1]).toEqual({ + transform: [{ translateX: 0 }, { translateY: 0 }] + }) + }) + + it('handles negative drag, vertical catch movement and out-of-bounds bounce', () => { + const bindchange = jest.fn() + const catchvtouchmove = jest.fn() + const catchtouchmove = jest.fn() + const { __getLastPanGesture } = require('react-native-gesture-handler') + + const firstRender = renderWithRoute( + + + negative movable + + , + { layout: { top: 0 } } + ) + + fireEvent(screen.getByTestId('negative-movable'), 'layout', { + nativeEvent: { layout: { width: 50, height: 50 } } + }) + let gesture = __getLastPanGesture() + act(() => { + gesture.onTouchesDownCallback({ + changedTouches: [{ x: 0, y: 0, absoluteX: 0, absoluteY: 0 }], + allTouches: [{ x: 0, y: 0, absoluteX: 0, absoluteY: 0 }] + }) + gesture.onStartCallback() + gesture.onTouchesMoveCallback({ + changedTouches: [{ x: 1, y: 40, absoluteX: 1, absoluteY: 40 }], + allTouches: [{ x: 1, y: 40, absoluteX: 1, absoluteY: 40 }] + }) + gesture.onUpdateCallback({ translationX: -80, translationY: -80 }) + }) + expect(catchvtouchmove).toHaveBeenCalled() + expect(catchtouchmove).toHaveBeenCalled() + + firstRender.unmount() + bindchange.mockClear() + renderWithRoute( + + + bounce movable + + , + { layout: { top: 0 } } + ) + + fireEvent(screen.getByTestId('bounce-movable'), 'layout', { + nativeEvent: { layout: { width: 50, height: 50 } } + }) + gesture = __getLastPanGesture() + act(() => { + gesture.onTouchesDownCallback({ + changedTouches: [{ x: 0, y: 0, absoluteX: 0, absoluteY: 0 }], + allTouches: [{ x: 0, y: 0, absoluteX: 0, absoluteY: 0 }] + }) + gesture.onStartCallback() + gesture.onTouchesMoveCallback({ + changedTouches: [{ x: -25, y: 10, absoluteX: -25, absoluteY: 10 }], + allTouches: [{ x: -25, y: 10, absoluteX: -25, absoluteY: 10 }] + }) + gesture.onUpdateCallback({ translationX: -25, translationY: 10 }) + gesture.onTouchesMoveCallback({ + changedTouches: [{ x: -25, y: -25, absoluteX: -25, absoluteY: -25 }], + allTouches: [{ x: -25, y: -25, absoluteX: -25, absoluteY: -25 }] + }) + gesture.onUpdateCallback({ translationX: -25, translationY: -25 }) + gesture.onEndCallback({ velocityX: 0, velocityY: 0 }) + }) + + expect(bindchange).toHaveBeenLastCalledWith(expect.objectContaining({ + type: 'change', + detail: { + x: 0, + y: 0, + source: 'touch-out-of-bounds' + } + })) + }) + + it('handles vertical inertia, lower friction clamp and gesture dependency changes', () => { + const bindchange = jest.fn() + const { __getLastPanGesture } = require('react-native-gesture-handler') + const { withTiming } = require('react-native-reanimated') + const waitFor = { current: { name: 'wait-a' } } + const nextWaitFor = { current: { name: 'wait-b' } } + const { rerender } = renderWithRoute( + + + inertia movable + + + ) + + fireEvent(screen.getByTestId('inertia-movable'), 'layout', { + nativeEvent: { layout: { width: 50, height: 50 } } + }) + let gesture = __getLastPanGesture() + expect(gesture.requireExternalGestureToFail).toHaveBeenCalledWith(waitFor) + const animationCallbacks: Array<() => void> = [] + withTiming + .mockImplementationOnce((toValue: number, config: unknown, callback: (finished: boolean) => void) => { + // eslint-disable-next-line node/no-callback-literal + animationCallbacks.push(() => callback(true)) + return toValue + }) + .mockImplementationOnce((toValue: number, config: unknown, callback: (finished: boolean) => void) => { + // eslint-disable-next-line node/no-callback-literal + animationCallbacks.push(() => callback(true)) + return toValue + }) + act(() => { + gesture.onTouchesDownCallback({ changedTouches: [{ x: 0, y: 0 }] }) + gesture.onStartCallback() + gesture.onTouchesMoveCallback({ changedTouches: [{ x: 0, y: 1 }] }) + gesture.onUpdateCallback({ translationX: 0, translationY: 0 }) + bindchange.mockClear() + withTiming.mockClear() + gesture.onEndCallback({ velocityX: 0, velocityY: 10000 }) + animationCallbacks.forEach(callback => callback()) + }) + expect(withTiming).toHaveBeenNthCalledWith(1, 10, expect.objectContaining({ duration: 200 }), expect.any(Function)) + expect(withTiming).toHaveBeenNthCalledWith(2, 50, expect.objectContaining({ duration: 200 }), expect.any(Function)) + expect(bindchange).toHaveBeenLastCalledWith(expect.objectContaining({ + detail: { x: 10, y: 50, source: 'friction' } + })) + + rerender( + + + + inertia movable + + + + ) + gesture = __getLastPanGesture() + expect(gesture.requireExternalGestureToFail).toHaveBeenCalledWith(nextWaitFor) + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-nav.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-nav.test.tsx new file mode 100644 index 0000000000..611defd0d3 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-nav.test.tsx @@ -0,0 +1,83 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import React from 'react' +import { act, fireEvent, render, screen } from '@testing-library/react-native' +import { Text, processColor } from 'react-native' +import { resetMpxRuntimeGlobals } from './rn-component-test-utils' + +const MpxNavModule = require('../../../../lib/runtime/components/react/mpx-nav') +const MpxNav = MpxNavModule.default +const useInnerHeaderHeight = MpxNavModule.useInnerHeaderHeight + +beforeEach(() => { + jest.clearAllMocks() + resetMpxRuntimeGlobals() +}) + +describe('MpxNav', () => { + it('renders nav, updates page config and handles back actions', () => { + const navigation = { + goBack: jest.fn(), + getState: jest.fn(() => ({ routes: [{}, {}] })) + } + render() + + expect(screen.getByText('Home')).toBeTruthy() + fireEvent(screen.UNSAFE_getByType('TouchableWithoutFeedback'), 'press') + expect(navigation.goBack).toHaveBeenCalled() + + act(() => { + navigation.setPageConfig({ navigationBarTitleText: 'Next', navigationBarTextStyle: '#ffffff' }) + }) + expect(screen.getByText('Next')).toBeTruthy() + + const CustomProbe = () => {useInnerHeaderHeight({ navigationStyle: 'custom' } as any)} + render() + expect(screen.getByText('0')).toBeTruthy() + }) + + it('handles custom nav, stack-top back and invalid colors', () => { + const stackTopBack = jest.fn() + ;(global as any).mpxGlobal.__mpx.config.rnConfig.onStackTopBack = stackTopBack + ;(processColor as jest.Mock).mockImplementationOnce(() => { + throw new Error('bad color') + }) + const navigation = { + goBack: jest.fn(), + getState: jest.fn(() => ({ routes: [{}] })) + } + + const customRender = render() + expect(screen.UNSAFE_getByType('StatusBar')).toBeTruthy() + customRender.unmount() + + render() + fireEvent(screen.UNSAFE_getByType('TouchableWithoutFeedback'), 'press') + expect(stackTopBack).toHaveBeenCalled() + expect(navigation.goBack).not.toHaveBeenCalled() + }) + + it('handles empty page config without a back affordance', () => { + (global as any).mpxGlobal.__mpx.config.rnConfig.onStackTopBack = undefined + const navigation = { + goBack: jest.fn(), + getState: jest.fn(() => ({ routes: [{}] })) + } + + render() + expect(screen.UNSAFE_queryByType('TouchableWithoutFeedback' as any)).toBeNull() + + const NormalProbe = () => {useInnerHeaderHeight({} as any)} + render() + expect(screen.getByText('44')).toBeTruthy() + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-navigator.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-navigator.test.tsx new file mode 100644 index 0000000000..1dc5cd8f8e --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-navigator.test.tsx @@ -0,0 +1,50 @@ +import React from 'react' +import { act, render } from '@testing-library/react-native' +import { View } from 'react-native' +import MpxNavigator from '../../../../lib/runtime/components/react/mpx-navigator' +import { fireTap, getTouchableView } from './helpers' + +jest.mock('@mpxjs/api-proxy', () => ({ + navigateTo: jest.fn(), + redirectTo: jest.fn(), + navigateBack: jest.fn(), + reLaunch: jest.fn(), + switchTab: jest.fn() +})) + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const mockNavFns = require('@mpxjs/api-proxy') + +describe('MpxNavigator', () => { + beforeEach(() => { + Object.keys(mockNavFns).forEach((key) => { + mockNavFns[key as keyof typeof mockNavFns].mockClear() + }) + }) + + it('dispatches actions by open type', () => { + const cases = [ + { openType: 'navigate', fn: mockNavFns.navigateTo, args: { url: '/pages/a' } }, + { openType: 'redirect', fn: mockNavFns.redirectTo, args: { url: '/pages/b' } }, + { openType: 'switchTab', fn: mockNavFns.switchTab, args: { url: '/pages/tab' } }, + { openType: 'reLaunch', fn: mockNavFns.reLaunch, args: { url: '/pages/home' } }, + { openType: 'navigateBack', fn: mockNavFns.navigateBack, args: { delta: 2 } } + ] + + cases.forEach(({ openType, fn, args }) => { + const { UNSAFE_getAllByType, unmount } = render( + + Go + + ) + const touchable = getTouchableView(UNSAFE_getAllByType(View)) + expect(touchable).toBeTruthy() + + act(() => { + fireTap(touchable) + }) + expect(fn).toHaveBeenCalledWith(args) + unmount() + }) + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-picker-view-column-android.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-picker-view-column-android.test.tsx new file mode 100644 index 0000000000..63d51d1ae2 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-picker-view-column-android.test.tsx @@ -0,0 +1,53 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import React from 'react' +import { act, fireEvent, render, screen } from '@testing-library/react-native' +import { Text } from 'react-native' + +;(global as any).__mpx_mode__ = 'android' + +const MpxPickerViewColumn = require('../../../../lib/runtime/components/react/mpx-picker-view-column').default + +beforeEach(() => { + jest.clearAllMocks() + ;(global as any).__mpx_mode__ = 'android' + ;(global as any).__mpx = { + config: { + rnConfig: { + onPickerVibrate: jest.fn() + } + } + } +}) + +afterAll(() => { + (global as any).__mpx_mode__ = 'ios' +}) + +describe('MpxPickerViewColumn android', () => { + it('manually finalizes click scrolling on android', () => { + jest.useFakeTimers() + const onSelectChange = jest.fn() + + render( + Zero, One, Two]} + initialIndex={1} + onSelectChange={onSelectChange} + style={{}} + wrapperStyle={{ height: 180, itemHeight: 40 }} + pickerMaskStyle={{}} + pickerIndicatorStyle={{}} + /> + ) + + const scrollView = screen.UNSAFE_getByType('ScrollView') + fireEvent(scrollView, 'touchEnd', { nativeEvent: { locationY: 180 } }) + act(() => { + jest.advanceTimersByTime(250) + }) + expect(onSelectChange).toHaveBeenCalledWith(2) + + jest.useRealTimers() + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-picker-view-column.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-picker-view-column.test.tsx new file mode 100644 index 0000000000..bd830bda75 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-picker-view-column.test.tsx @@ -0,0 +1,120 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import React from 'react' +import { act, fireEvent, render, screen } from '@testing-library/react-native' +import { Text } from 'react-native' +import { resetMpxRuntimeGlobals } from './rn-component-test-utils' + +const MpxPickerViewColumn = require('../../../../lib/runtime/components/react/mpx-picker-view-column').default +const { calcHeightOffsets, calcPickerHeight, createFaces, degToRad } = require('../../../../lib/runtime/components/react/mpx-picker-view-column/pickerViewFaces') +const { + PickerViewColumnAnimationContext, + usePickerViewColumnAnimationContext +} = require('../../../../lib/runtime/components/react/mpx-picker-view/pickerVIewContext') + +beforeEach(() => { + jest.clearAllMocks() + resetMpxRuntimeGlobals() +}) + +describe('MpxPickerViewColumn', () => { + it('handles column layout, timers, vibration and click-to-scroll', () => { + jest.useFakeTimers() + const onSelectChange = jest.fn() + const ref = React.createRef() + const { unmount } = render( + Zero, One, Two]} + initialIndex={0} + onSelectChange={onSelectChange} + style={{}} + wrapperStyle={{ height: 180, itemHeight: 40 }} + pickerMaskStyle={{}} + pickerIndicatorStyle={{}} + enableWheelAnimation={true} + /> + ) + + const scrollView = screen.UNSAFE_getByType('ScrollView') + fireEvent(screen.getByText('Zero'), 'layout', { + nativeEvent: { layout: { height: 50 } } + }) + const scrollTo = ref.current.getNodeInstance().nodeRef.current.scrollTo + fireEvent(scrollView, 'scrollBeginDrag') + fireEvent(scrollView, 'scroll', { nativeEvent: { contentOffset: { y: 55 } } }) + expect((global as any).__mpx.config.rnConfig.onPickerVibrate).toHaveBeenCalled() + fireEvent(scrollView, 'scrollEndDrag', { nativeEvent: { contentOffset: { y: 55 } } }) + act(() => { + jest.advanceTimersByTime(20) + }) + expect(scrollTo).toHaveBeenCalledWith({ x: 0, y: 50, animated: false }) + fireEvent(scrollView, 'momentumScrollEnd', { nativeEvent: { contentOffset: { y: 50 } } }) + expect(onSelectChange).toHaveBeenCalledWith(1) + + scrollTo.mockClear() + fireEvent(scrollView, 'touchEnd', { nativeEvent: { locationY: 180 } }) + expect(scrollTo).toHaveBeenCalledWith({ x: 0, y: 100, animated: true }) + fireEvent(scrollView, 'momentumScrollEnd', { nativeEvent: { contentOffset: { y: 100 } } }) + expect(onSelectChange).toHaveBeenCalledWith(2) + + unmount() + jest.useRealTimers() + }) + + it('handles click offset branches and scroll reset guards', () => { + jest.useFakeTimers() + const onSelectChange = jest.fn() + render( + Zero, One, Two]} + initialIndex={1} + onSelectChange={onSelectChange} + style={{}} + wrapperStyle={{ height: 180, itemHeight: 40 }} + pickerMaskStyle={{}} + pickerIndicatorStyle={{}} + enableWheelAnimation={true} + /> + ) + + const scrollView = screen.UNSAFE_getByType('ScrollView') + delete (global as any).__mpx.config.rnConfig.onPickerVibrate + fireEvent(scrollView, 'scrollBeginDrag') + fireEvent(scrollView, 'scroll', { nativeEvent: { contentOffset: { y: 85 } } }) + fireEvent(scrollView, 'scrollEndDrag', { nativeEvent: { contentOffset: { y: 85 } } }) + fireEvent(scrollView, 'momentumScrollBegin') + jest.advanceTimersByTime(20) + + fireEvent(scrollView, 'momentumScrollEnd', { nativeEvent: { contentOffset: { y: 40 } } }) + fireEvent(scrollView, 'touchEnd', { nativeEvent: { locationY: 165 } }) + fireEvent(scrollView, 'momentumScrollEnd', { nativeEvent: { contentOffset: { y: 80 } } }) + expect(onSelectChange).toHaveBeenCalledWith(2) + + fireEvent(scrollView, 'touchEnd', { nativeEvent: { locationY: 15 } }) + expect(onSelectChange).toHaveBeenCalledTimes(1) + jest.useRealTimers() + }) + + it('calculates picker face geometry', () => { + const faces = createFaces(40, 5) + expect(degToRad(180)).toBe(Math.PI) + expect(calcPickerHeight(faces, 40)).toBe(200) + expect(calcPickerHeight(faces.slice(0, 3), 40)).toBeCloseTo(54.64101615137755) + expect(calcHeightOffsets(40)).toEqual(expect.arrayContaining([20])) + }) + + it('reads the animation offset from picker view context', () => { + const Probe = () => { + const offset = usePickerViewColumnAnimationContext() + return {offset.value} + } + render( + + + + ) + expect(screen.getByText('3')).toBeTruthy() + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-picker-view.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-picker-view.test.tsx new file mode 100644 index 0000000000..a241003cfc --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-picker-view.test.tsx @@ -0,0 +1,73 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react-native' +import { Text } from 'react-native' +import { resetMpxRuntimeGlobals } from './rn-component-test-utils' + +const MpxPickerView = require('../../../../lib/runtime/components/react/mpx-picker-view').default +const MpxPickerViewColumn = require('../../../../lib/runtime/components/react/mpx-picker-view-column').default +const MpxPickerViewColumnItemLite = require('../../../../lib/runtime/components/react/mpx-picker-view-column/pickerViewColumnItemLite').default + +beforeEach(() => { + jest.clearAllMocks() + resetMpxRuntimeGlobals() +}) + +describe('MpxPickerView', () => { + it('normalizes indexes and handles column scrolling variants', () => { + const bindchange = jest.fn() + const { rerender } = render( + + + A + B + + + 1 + 2 + + + ) + + expect(bindchange).toHaveBeenCalledWith(expect.objectContaining({ + type: 'change', + detail: { value: [1, 0], source: 'change' } + })) + + rerender( + + + A + B + + + 1 + 2 + + + ) + bindchange.mockClear() + const scrollView = screen.UNSAFE_getAllByType('ScrollView')[1] + fireEvent(scrollView, 'scrollBeginDrag') + fireEvent(scrollView, 'scroll', { nativeEvent: { contentOffset: { y: 40 } } }) + fireEvent(scrollView, 'scrollEndDrag', { nativeEvent: { contentOffset: { y: 40 } } }) + fireEvent(scrollView, 'momentumScrollBegin') + fireEvent(scrollView, 'momentumScrollEnd', { nativeEvent: { contentOffset: { y: 40 } } }) + fireEvent(scrollView, 'touchEnd', { nativeEvent: { locationY: 100 } }) + expect((global as any).__mpx.config.rnConfig.onPickerVibrate).toHaveBeenCalled() + expect(bindchange).toHaveBeenCalledTimes(1) + expect(bindchange).toHaveBeenCalledWith(expect.objectContaining({ + detail: { value: [1, 1], source: 'change' } + })) + + rerender( + + + C + D + + + ) + expect(screen.UNSAFE_getAllByType(MpxPickerViewColumnItemLite)).toHaveLength(2) + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-picker-wrapper.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-picker-wrapper.test.tsx new file mode 100644 index 0000000000..3eea2b3177 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-picker-wrapper.test.tsx @@ -0,0 +1,70 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react-native' +import { Text } from 'react-native' +import { resetMpxRuntimeGlobals } from './rn-component-test-utils' + +jest.mock('@mpxjs/api-proxy', () => ({ + getWindowInfo: jest.fn(() => ({ + screenHeight: 800, + safeArea: { bottom: 760 } + })) +})) + +jest.mock('../../../../lib/runtime/components/react/mpx-picker/multiSelector', () => { + const mockReact = require('react') + const { Text: MockText } = require('react-native') + const MockMultiSelector = mockReact.forwardRef((props: any, ref: any) => { + mockReact.useImperativeHandle(ref, () => ({ + updateRange: jest.fn(), + updateValue: jest.fn() + })) + return mockReact.createElement( + MockText, + { + testID: 'mock-multi-selector', + onPress: () => props.bindcolumnchange(1, 1) + }, + 'mock multi selector' + ) + }) + return { + __esModule: true, + default: MockMultiSelector + } +}) + +const Picker = require('../../../../lib/runtime/components/react/mpx-picker').default +const Portal = require('../../../../lib/runtime/components/react/mpx-portal').default +const { RouteContext } = require('../../../../lib/runtime/components/react/context') + +beforeEach(() => { + jest.clearAllMocks() + resetMpxRuntimeGlobals() +}) + +describe('MpxPicker wrapper', () => { + it('emits columnchange from picker multiSelector wrapper', () => { + const bindcolumnchange = jest.fn() + + render( + + + + open multi picker + + + + ) + + fireEvent.press(screen.getByTestId('mock-multi-selector')) + expect(bindcolumnchange).toHaveBeenCalledWith(expect.objectContaining({ + detail: { column: 1, value: 1 } + })) + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-picker.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-picker.test.tsx new file mode 100644 index 0000000000..9b6c82fcbe --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-picker.test.tsx @@ -0,0 +1,902 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import React from 'react' +import { act, fireEvent, render, screen } from '@testing-library/react-native' +import { Text } from 'react-native' +import { expectPortalHostRendered, renderWithRoute, resetMpxRuntimeGlobals } from './rn-component-test-utils' + +jest.mock('@mpxjs/api-proxy', () => ({ + getWindowInfo: jest.fn(() => ({ + screenHeight: 800, + safeArea: { bottom: 760 } + })) +})) + +const Picker = require('../../../../lib/runtime/components/react/mpx-picker').default +const PickerDate = require('../../../../lib/runtime/components/react/mpx-picker/date').default +const PickerMultiSelector = require('../../../../lib/runtime/components/react/mpx-picker/multiSelector').default +const PickerRegion = require('../../../../lib/runtime/components/react/mpx-picker/region').default +const PickerSelector = require('../../../../lib/runtime/components/react/mpx-picker/selector').default +const PickerTime = require('../../../../lib/runtime/components/react/mpx-picker/time').default +const MpxPickerView = require('../../../../lib/runtime/components/react/mpx-picker-view').default +const MpxInlineText = require('../../../../lib/runtime/components/react/mpx-inline-text').default +const Portal = require('../../../../lib/runtime/components/react/mpx-portal').default +const PopupBase = require('../../../../lib/runtime/components/react/mpx-popup/popupBase').default +const { FormContext, RouteContext } = require('../../../../lib/runtime/components/react/context') +const { daysInMonthLength } = require('../../../../lib/runtime/components/react/mpx-picker/dateData') +const { regionData } = require('../../../../lib/runtime/components/react/mpx-picker/regionData') + +beforeEach(() => { + jest.clearAllMocks() + resetMpxRuntimeGlobals() +}) + +describe('MpxPicker', () => { + it('calculates Gregorian month lengths at century boundaries', () => { + expect(daysInMonthLength(2000, 2)).toBe(29) + expect(daysInMonthLength(1900, 2)).toBe(28) + expect(daysInMonthLength(2023, 4)).toBe(30) + expect(daysInMonthLength(2023, 1)).toBe(31) + }) + + it('handles century leap years when changing from January to February', () => { + [ + { value: '1900-01-31', expected: '1900-02-28' }, + { value: '2000-01-31', expected: '2000-02-29' } + ].forEach(({ value, expected }) => { + const bindchange = jest.fn() + const dateRender = render() + + fireEvent(dateRender.UNSAFE_getAllByType('ScrollView')[1], 'momentumScrollEnd', { + nativeEvent: { contentOffset: { y: 45 } } + }) + + expect(bindchange).toHaveBeenCalledWith({ detail: { value: expected } }) + dateRender.unmount() + }) + }) + + it('opens picker in a portal, cancels and registers form value', () => { + const bindcancel = jest.fn() + const formValuesMap = new Map() + + renderWithRoute( + + + + open picker + + + + ) + + expect(screen.getByText('Choose city')).toBeTruthy() + expect(formValuesMap.has('city')).toBe(true) + expect(screen.UNSAFE_getByType(PopupBase).props.visible).toBe(false) + fireEvent.press(screen.getByTestId('picker').parent) + expect(screen.UNSAFE_getByType(PopupBase).props.visible).toBe(true) + fireEvent(screen.getByText('取消').parent, 'touchEnd') + expect(bindcancel).toHaveBeenCalledWith(expect.objectContaining({ type: 'cancel' })) + expect(screen.UNSAFE_getByType(PopupBase).props.visible).toBe(false) + }) + + it('opens picker in a portal and confirms value', () => { + const bindchange = jest.fn() + + renderWithRoute( + + + open picker + + + ) + + fireEvent.press(screen.getByTestId('picker').parent) + fireEvent(screen.getByText('确定').parent, 'touchEnd') + expect(bindchange).toHaveBeenCalledWith(expect.objectContaining({ + type: 'change', + detail: { value: '0' } + })) + expect(screen.UNSAFE_getByType(PopupBase).props.visible).toBe(false) + }) + + it('passes mode props only to picker content', () => { + const range = [{ name: 'Beijing' }, { name: 'Shanghai' }] + const selectorRender = renderWithRoute( + + + + ) + const selector = selectorRender.UNSAFE_getByType(PickerSelector) + const selectorTrigger = selectorRender.getByTestId('selector-picker') + expect(selector.props.range).toBe(range) + expect(selector.props['range-key']).toBe('name') + expect(selector.props.style).toBeUndefined() + expect(selector.props.children).toBeUndefined() + expect(selectorTrigger.props.range).toBeUndefined() + expect(selectorTrigger.props['range-key']).toBeUndefined() + selectorRender.unmount() + + const cases = [ + { mode: 'time', component: PickerTime, specificProps: { start: '00:00', end: '23:59' } }, + { mode: 'date', component: PickerDate, specificProps: { fields: 'day' } }, + { mode: 'region', component: PickerRegion, specificProps: { level: 'city', 'custom-item': 'All' } } + ] + cases.forEach(({ mode, component, specificProps }) => { + const pickerRender = renderWithRoute( + + + + ) + const modal = pickerRender.UNSAFE_getByType(component) + const trigger = pickerRender.getByTestId(`${mode}-picker`) + Object.keys(specificProps).forEach((key) => { + expect(modal.props[key]).toBe((specificProps as any)[key]) + expect(trigger.props[key]).toBeUndefined() + }) + pickerRender.unmount() + }) + }) + + it('refreshes popup content and height with the latest props before showing', () => { + const pickerRender = renderWithRoute( + + + + ) + expect(pickerRender.UNSAFE_getByType(PopupBase).props.contentHeight).toBe(310) + + pickerRender.rerender( + + + + + + ) + expect(pickerRender.queryByText('Beijing')).toBeNull() + fireEvent.press(pickerRender.getByTestId('latest-picker').parent) + expect(pickerRender.getByText('Beijing')).toBeTruthy() + expect(pickerRender.UNSAFE_getByType(PopupBase).props.contentHeight).toBe(350) + }) + + it('updates multi-selector content with the latest keyed range', () => { + const pickerRender = renderWithRoute( + + + + ) + expect(pickerRender.getByText('Old')).toBeTruthy() + + pickerRender.rerender( + + + + + + ) + expect(pickerRender.getByText('New')).toBeTruthy() + }) + + it('defaults selector range and passes text styles to trigger children', () => { + const pickerRender = renderWithRoute( + + + Select + + + ) + + expect(pickerRender.UNSAFE_getByType(PickerSelector).props.range).toEqual([]) + expect(pickerRender.getByTestId('styled-picker').props.style).toEqual({ width: 100 }) + expect(pickerRender.getByTestId('picker-text').props.style).toEqual({ + color: 'red', + fontSize: 20 + }) + }) + + it('maps selector and multi selector values', () => { + const selectorChange = jest.fn() + const selectorRender = render( + + ) + selectorChange.mockClear() + fireEvent(selectorRender.UNSAFE_getAllByType('ScrollView')[0], 'momentumScrollEnd', { + nativeEvent: { contentOffset: { y: 45 } } + }) + expect(selectorChange).toHaveBeenCalledWith({ detail: { value: '1' } }) + selectorRender.unmount() + + const multiChange = jest.fn() + const columnChange = jest.fn() + const multiRender = render( + + ) + multiChange.mockClear() + columnChange.mockClear() + fireEvent(multiRender.UNSAFE_getAllByType('ScrollView')[1], 'momentumScrollEnd', { + nativeEvent: { contentOffset: { y: 45 } } + }) + expect(multiChange).toHaveBeenCalledWith({ detail: { value: [0, 1] } }) + expect(columnChange).toHaveBeenCalledWith(1, 1) + }) + + it('calibrates time, date and region values', () => { + const timeChange = jest.fn() + const timeRender = render() + fireEvent(timeRender.UNSAFE_getAllByType('ScrollView')[1], 'momentumScrollEnd', { + nativeEvent: { contentOffset: { y: 30 * 36 } } + }) + expect(timeChange).toHaveBeenCalledWith({ detail: { value: '10:00' } }) + timeRender.unmount() + + const dateChange = jest.fn() + const dateRender = render() + dateChange.mockClear() + fireEvent(dateRender.UNSAFE_getAllByType('ScrollView')[1], 'momentumScrollEnd', { + nativeEvent: { contentOffset: { y: 2 * 45 } } + }) + expect(dateChange).toHaveBeenCalledWith({ detail: { value: '2024-03' } }) + dateRender.unmount() + + const regionChange = jest.fn() + const regionRender = render() + regionChange.mockClear() + fireEvent(regionRender.UNSAFE_getAllByType('ScrollView')[0], 'momentumScrollEnd', { + nativeEvent: { contentOffset: { y: 45 } } + }) + expect(regionChange).toHaveBeenCalledWith({ detail: { value: expect.any(Array) } }) + }) + + it('does not open a disabled picker', () => { + const { queryByText, getByTestId } = renderWithRoute( + + + disabled picker + + + ) + + expect(queryByText('确定')).toBeNull() + fireEvent.press(getByTestId('disabled-picker').parent) + expect(queryByText('确定')).toBeNull() + }) + + it('warns when a picker in a form has no name', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + const formValuesMap = new Map() + renderWithRoute( + + + + + + ) + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('name attribute is required')) + warnSpy.mockRestore() + }) + + it('warns when picker mode is unsupported', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + renderWithRoute( + + + bad picker + + + ) + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Unsupported mode')) + warnSpy.mockRestore() + }) + + it('updates multi-selector range and resets empty values imperatively', () => { + const ref = React.createRef() + const multi = render( + + ) + act(() => { + ref.current.updateRange([['B', 'C'], ['2', '3']]) + ref.current.updateValue([]) + }) + expect(multi.getByText('C')).toBeTruthy() + expect(multi.queryByText('A')).toBeNull() + expect(multi.UNSAFE_getByType(MpxPickerView).props.value).toEqual([0, 0]) + }) + + it('resets picker form fields to mode defaults', () => { + const formValuesMap = new Map() + renderWithRoute( + + + + time picker + + + + ) + + act(() => { + formValuesMap.get('time')!.resetValue({}) + }) + expect(formValuesMap.get('time')!.getValue()).toBe('00:00') + }) + + it('clamps date days and supports date imperative updates', () => { + const bindchange = jest.fn() + const ref = React.createRef() + const dateRender = render( + + ) + + bindchange.mockClear() + fireEvent(dateRender.UNSAFE_getAllByType('ScrollView')[1], 'momentumScrollEnd', { + nativeEvent: { contentOffset: { y: 45 } } + }) + expect(bindchange).toHaveBeenCalledWith({ detail: { value: '2024-02-29' } }) + + act(() => { + ref.current.updateValue('2025-05-20') + }) + expect(dateRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([125, 4, 19]) + bindchange.mockClear() + fireEvent(dateRender.UNSAFE_getAllByType('ScrollView')[2], 'momentumScrollEnd', { + nativeEvent: { contentOffset: { y: 20 * 45 } } + }) + expect(bindchange).toHaveBeenCalledWith({ detail: { value: '2025-05-21' } }) + dateRender.unmount() + + const yearRender = render() + expect(yearRender.UNSAFE_getAllByType('ScrollView')).toHaveLength(1) + }) + + it('handles region custom item levels and imperative updates', () => { + const bindchange = jest.fn() + const ref = React.createRef() + const province = regionData[0].value + const nextProvince = regionData[1].value + const regionRender = render( + + ) + + bindchange.mockClear() + fireEvent(regionRender.UNSAFE_getAllByType('ScrollView')[0], 'momentumScrollEnd', { + nativeEvent: { contentOffset: { y: 45 } } + }) + expect(bindchange).toHaveBeenCalledWith({ detail: { value: [province] } }) + + act(() => { + ref.current.updateValue([nextProvince]) + }) + expect(regionRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([2]) + regionRender.unmount() + + bindchange.mockClear() + const cityRender = render( + + ) + expect(cityRender.UNSAFE_getAllByType('ScrollView')).toHaveLength(2) + fireEvent(cityRender.UNSAFE_getAllByType('ScrollView')[1], 'momentumScrollEnd', { + nativeEvent: { contentOffset: { y: 0 } } + }) + expect(bindchange).toHaveBeenCalledWith({ detail: { value: [province, '全部'] } }) + }) + + it('handles picker defaults, English actions, fixed layout and form cleanup', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + const formValuesMap = new Map() + global.__mpx.i18n.locale = 'en-US' + + const pickerRender = renderWithRoute( + + + + default picker + + + + ) + + expect(screen.getByText('Cancel')).toBeTruthy() + expect(screen.getByText('Confirm')).toBeTruthy() + expectPortalHostRendered(pickerRender.toJSON(), 'default-picker') + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('background image-related styles')) + act(() => { + formValuesMap.get('default')!.resetValue({}) + }) + expect(formValuesMap.get('default')!.getValue()).toBe('0') + pickerRender.unmount() + expect(formValuesMap.has('default')).toBe(false) + warnSpy.mockRestore() + }) + + it('resets multi-selector, date and region form values to mode defaults', () => { + jest.useFakeTimers() + jest.setSystemTime(new Date(2025, 5, 15)) + const defaultRegion = [ + regionData[0].value, + regionData[0].children[0].value, + regionData[0].children[0].children[0].value + ] + const initialRegion = [ + regionData[1].value, + regionData[1].children[0].value, + regionData[1].children[0].children[0].value + ] + const cases = [ + { mode: 'multiSelector', name: 'multi', value: [1], range: [['A', 'B']], initial: [1], expected: [0] }, + { mode: 'date', name: 'date', value: '2024-01-01', initial: '2024-01-01', expected: '2025-06-15' }, + { mode: 'region', name: 'region', value: [regionData[1].value], initial: initialRegion, expected: defaultRegion } + ] + + try { + cases.forEach(({ mode, name, value, range, initial, expected }) => { + const formValuesMap = new Map() + const pickerRender = renderWithRoute( + + + + + + ) + + expect(formValuesMap.get(name)!.getValue()).toEqual(initial) + act(() => { + formValuesMap.get(name)!.resetValue({}) + }) + expect(formValuesMap.get(name)!.getValue()).toEqual(expected) + pickerRender.unmount() + }) + } finally { + jest.useRealTimers() + } + }) + + it('handles selector and multi-selector imperative defaults and keyed range updates', () => { + const selectorRef = React.createRef() + const selectorRender = render( + + ) + expect(selectorRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([1]) + act(() => { + selectorRef.current.updateValue() + }) + expect(selectorRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([0]) + selectorRender.unmount() + + const columnChange = jest.fn() + const multiRef = React.createRef() + const multiRender = render( + + ) + expect(multiRender.getByText('B')).toBeTruthy() + columnChange.mockClear() + act(() => { + multiRef.current.updateValue([]) + }) + expect(multiRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([0, 0]) + expect(columnChange).toHaveBeenCalledWith(0, 0) + act(() => { + multiRef.current.updateRange([{ label: ['C', 'D'] }, { label: ['2'] }], 'label') + }) + expect(multiRender.getByText('C')).toBeTruthy() + multiRender.unmount() + + const numericMultiRender = render( + + ) + expect(numericMultiRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([1]) + numericMultiRender.unmount() + }) + + it('handles time invalid inputs, limits, controlled updates and timer cleanup', () => { + jest.useFakeTimers() + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + const bindchange = jest.fn() + const ref = React.createRef() + const setTimeoutSpy = jest.spyOn(global, 'setTimeout') + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout') + + try { + const timeRender = render( + + ) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('time must be a string')) + expect(timeRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([9, 0]) + + timeRender.rerender() + act(() => { + ref.current.updateValue('12:00') + }) + expect(timeRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([11, 0]) + + bindchange.mockClear() + fireEvent(timeRender.UNSAFE_getAllByType('ScrollView')[1], 'momentumScrollEnd', { + nativeEvent: { contentOffset: { y: 30 * 45 } } + }) + expect(bindchange).toHaveBeenCalledTimes(1) + expect(bindchange).toHaveBeenCalledWith({ detail: { value: '11:00' } }) + act(() => { + jest.runOnlyPendingTimers() + }) + expect(timeRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([11, 0]) + timeRender.rerender() + expect(timeRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([10, 0]) + const initialTimeoutCallCount = setTimeoutSpy.mock.calls.length + act(() => { + timeRender.UNSAFE_getByType(MpxPickerView).props.bindchange({ detail: { value: [12, 0] } }) + }) + const cleanupTimerIndex = setTimeoutSpy.mock.calls.findIndex((args, index) => { + return index >= initialTimeoutCallCount && args.length === 1 + }) + expect(cleanupTimerIndex).toBeGreaterThanOrEqual(initialTimeoutCallCount) + const cleanupTimer = setTimeoutSpy.mock.results[cleanupTimerIndex].value + timeRender.unmount() + expect(clearTimeoutSpy).toHaveBeenCalledWith(cleanupTimer) + } finally { + warnSpy.mockRestore() + setTimeoutSpy.mockRestore() + clearTimeoutSpy.mockRestore() + jest.useRealTimers() + } + }) + + it('parses incomplete time and cancels stale boundary corrections', () => { + jest.useFakeTimers() + const bindchange = jest.fn() + const ref = React.createRef() + const setTimeoutSpy = jest.spyOn(global, 'setTimeout') + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout') + + try { + const incompleteRender = render() + expect(incompleteRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([12, 0]) + incompleteRender.unmount() + + const timeRender = render( + + ) + bindchange.mockClear() + const initialTimeoutCallCount = setTimeoutSpy.mock.calls.length + act(() => { + timeRender.UNSAFE_getByType(MpxPickerView).props.bindchange({ detail: { value: [12, 0] } }) + }) + const correctionTimerIndex = setTimeoutSpy.mock.calls.findIndex((args, index) => { + return index >= initialTimeoutCallCount && args.length === 1 + }) + expect(correctionTimerIndex).toBeGreaterThanOrEqual(initialTimeoutCallCount) + const staleCorrectionTimer = setTimeoutSpy.mock.results[correctionTimerIndex].value + act(() => { + timeRender.UNSAFE_getByType(MpxPickerView).props.bindchange({ detail: { value: [9, 30] } }) + }) + expect(clearTimeoutSpy).toHaveBeenCalledWith(staleCorrectionTimer) + const reportedValues = bindchange.mock.calls.map(([event]) => event.detail.value) + expect(reportedValues).toEqual(expect.arrayContaining(['11:00', '10:00'])) + expect(reportedValues[reportedValues.length - 1]).toBe('10:00') + act(() => { + jest.runOnlyPendingTimers() + }) + expect(timeRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([10, 0]) + + act(() => { + ref.current.updateValue('11:00') + }) + expect(timeRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([11, 0]) + act(() => { + ref.current.updateValue() + }) + expect(timeRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([10, 0]) + } finally { + setTimeoutSpy.mockRestore() + clearTimeoutSpy.mockRestore() + jest.useRealTimers() + } + }) + + it('handles date defaults, incomplete bounds, controlled updates and unchanged values', () => { + jest.useFakeTimers() + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined) + const bindchange = jest.fn() + const ref = React.createRef() + + try { + const dateRender = render( + + ) + expect(dateRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([0, 0, 0]) + expect(bindchange).toHaveBeenCalledWith({ detail: { value: '1900-01-01' } }) + + dateRender.rerender( + + ) + act(() => { + ref.current.updateValue('1900-02-03') + }) + expect(dateRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([0, 1, 2]) + + bindchange.mockClear() + fireEvent(dateRender.UNSAFE_getAllByType('ScrollView')[2], 'momentumScrollEnd', { + nativeEvent: { contentOffset: { y: 3 * 45 } } + }) + expect(bindchange).toHaveBeenCalledWith({ detail: { value: '1900-02-04' } }) + expect(dateRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([0, 1, 3]) + dateRender.rerender( + + ) + expect(dateRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([124, 1]) + dateRender.unmount() + } finally { + errorSpy.mockRestore() + jest.useRealTimers() + } + }) + + it('uses the current date, clamps lower bounds and cancels stale corrections', () => { + jest.useFakeTimers() + jest.setSystemTime(new Date(2025, 6, 15)) + const ref = React.createRef() + const bindchange = jest.fn() + const setTimeoutSpy = jest.spyOn(global, 'setTimeout') + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout') + + try { + const defaultRender = render() + expect(defaultRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([125, 6, 14]) + act(() => { + ref.current.updateValue('2024-02-03') + }) + expect(defaultRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([124, 1, 2]) + act(() => { + ref.current.updateValue() + }) + expect(defaultRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([125, 6, 14]) + defaultRender.unmount() + + const lowerBoundRender = render( + + ) + expect(lowerBoundRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([124, 0, 0]) + lowerBoundRender.unmount() + + const dateRender = render( + + ) + bindchange.mockClear() + const initialTimeoutCallCount = setTimeoutSpy.mock.calls.length + act(() => { + dateRender.UNSAFE_getByType(MpxPickerView).props.bindchange({ detail: { value: [124, 2, 24] } }) + }) + const correctionTimerIndex = setTimeoutSpy.mock.calls.findIndex((args, index) => { + return index >= initialTimeoutCallCount && args.length === 1 + }) + expect(correctionTimerIndex).toBeGreaterThanOrEqual(initialTimeoutCallCount) + const staleCorrectionTimer = setTimeoutSpy.mock.results[correctionTimerIndex].value + act(() => { + dateRender.UNSAFE_getByType(MpxPickerView).props.bindchange({ detail: { value: [124, 2, 4] } }) + }) + expect(clearTimeoutSpy).toHaveBeenCalledWith(staleCorrectionTimer) + const reportedValues = bindchange.mock.calls.map(([event]) => event.detail.value) + expect(reportedValues).toEqual(expect.arrayContaining(['2024-03-25', '2024-03-05'])) + expect(reportedValues[reportedValues.length - 1]).toBe('2024-03-05') + act(() => { + jest.runOnlyPendingTimers() + }) + expect(dateRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([124, 2, 9]) + } finally { + setTimeoutSpy.mockRestore() + clearTimeoutSpy.mockRestore() + jest.useRealTimers() + } + }) + + it('handles full region defaults, custom short-circuit and controlled value updates', () => { + const bindchange = jest.fn() + const province = regionData[1] + const ref = React.createRef() + const regionRender = render( + + ) + expect(regionRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([0, 0, 0]) + act(() => { + ref.current.updateValue([province.value, province.children[0].value, province.children[0].children[1].value]) + }) + expect(regionRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([1, 0, 1]) + + bindchange.mockClear() + fireEvent(regionRender.UNSAFE_getAllByType('ScrollView')[2], 'momentumScrollEnd', { + nativeEvent: { contentOffset: { y: 2 * 45 } } + }) + expect(bindchange).toHaveBeenCalledWith({ + detail: { value: [province.value, province.children[0].value, province.children[0].children[2].value] } + }) + expect(regionRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([1, 0, 2]) + + regionRender.rerender( + + ) + expect(regionRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([0, 0, 0]) + }) + + it('uses the default locale when runtime context is missing', () => { + const previousMpx = global.__mpx + try { + global.__mpx = undefined as any + renderWithRoute( + + + fallback locale picker + + + ) + expect(screen.getByText('取消')).toBeTruthy() + expect(screen.getByText('确定')).toBeTruthy() + } finally { + global.__mpx = previousMpx + } + }) + + it('handles disabled presses and picker mode prop defaults', () => { + const disabledRender = renderWithRoute( + + + disabled picker + + + ) + fireEvent.press(disabledRender.getByTestId('disabled-picker').parent) + expect(disabledRender.UNSAFE_queryByType(PopupBase)).toBeNull() + disabledRender.unmount() + + const selectorRender = render() + expect(selectorRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([0]) + selectorRender.unmount() + + const multiRender = render() + expect(multiRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([]) + multiRender.unmount() + + const timeRender = render() + expect(timeRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([0, 0]) + timeRender.unmount() + + const regionRender = render() + expect(regionRender.UNSAFE_getByType(MpxPickerView).props.value).toEqual([0, 0, 0]) + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-popup.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-popup.test.tsx new file mode 100644 index 0000000000..48ddd3e99b --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-popup.test.tsx @@ -0,0 +1,128 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import React from 'react' +import { fireEvent, render } from '@testing-library/react-native' +import { Text } from 'react-native' +import { withTiming } from 'react-native-reanimated' +import { getViews, resetMpxRuntimeGlobals } from './rn-component-test-utils' + +jest.mock('@mpxjs/api-proxy', () => ({ + getWindowInfo: jest.fn(() => ({ + screenHeight: 800, + safeArea: { bottom: 760 } + })) +})) + +const Portal = require('../../../../lib/runtime/components/react/mpx-portal').default +const { createPopupManager } = require('../../../../lib/runtime/components/react/mpx-popup') +const PopupBase = require('../../../../lib/runtime/components/react/mpx-popup/popupBase').default + +beforeEach(() => { + jest.clearAllMocks() + resetMpxRuntimeGlobals() +}) + +describe('MpxPopup', () => { + it('manages popup lifecycle and PopupBase interactions', () => { + const addSpy = jest.spyOn(Portal, 'add').mockReturnValue(100) + const updateSpy = jest.spyOn(Portal, 'update').mockImplementation(() => undefined) + const removeSpy = jest.spyOn(Portal, 'remove').mockImplementation(() => undefined) + const manager = createPopupManager() + + manager.open(popup child, 1, { contentHeight: 200 }) + manager.show() + manager.update(popup updated) + manager.hide() + manager.remove() + + expect(addSpy).toHaveBeenCalledTimes(1) + expect(addSpy).toHaveBeenCalledWith(expect.anything(), 1) + const addedPopup = addSpy.mock.calls[0][0] as React.ReactElement + expect(addedPopup.props).toEqual(expect.objectContaining({ + contentHeight: 200, + visible: false + })) + expect(addedPopup.props.children.props.children).toBe('popup child') + + expect(updateSpy).toHaveBeenCalledTimes(3) + expect(updateSpy.mock.calls.map(([key]) => key)).toEqual([100, 100, 100]) + const updates = updateSpy.mock.calls.map(([, popup]) => popup as React.ReactElement) + expect(updates[0].props.visible).toBe(true) + expect(updates[1].props.visible).toBe(true) + expect(updates[1].props.children.props.children).toBe('popup updated') + expect(updates[2].props.visible).toBe(false) + expect(updates[2].props.children.props.children).toBe('popup updated') + expect(removeSpy).toHaveBeenCalledWith(100) + addSpy.mockRestore() + updateSpy.mockRestore() + removeSpy.mockRestore() + + const hide = jest.fn() + render( + + popup body + + ) + const views = getViews() + fireEvent(views[0], 'touchEnd') + expect(hide).toHaveBeenCalled() + const stopPropagation = jest.fn() + fireEvent(views[1], 'touchEnd', { stopPropagation }) + expect(stopPropagation).toHaveBeenCalled() + }) + + it('ignores invalid manager transitions and supports a custom modal', () => { + const addSpy = jest.spyOn(Portal, 'add').mockReturnValue(101) + const updateSpy = jest.spyOn(Portal, 'update').mockImplementation(() => undefined) + const removeSpy = jest.spyOn(Portal, 'remove').mockImplementation(() => undefined) + const CustomModal = (props: any) => {props.children} + const manager = createPopupManager({ modal: CustomModal }) + + manager.show() + manager.hide() + manager.update(ignored update) + manager.remove() + manager.open(missing page, undefined) + expect(addSpy).not.toHaveBeenCalled() + expect(updateSpy).not.toHaveBeenCalled() + expect(removeSpy).not.toHaveBeenCalled() + + manager.open(custom popup, 1) + manager.open(duplicate popup, 1) + manager.update(null) + expect(updateSpy).not.toHaveBeenCalled() + expect(addSpy).toHaveBeenCalledTimes(1) + const addedPopup = addSpy.mock.calls[0][0] as React.ReactElement + expect(addedPopup.type).toBe(CustomModal) + expect(addedPopup.props).toEqual(expect.objectContaining({ visible: false })) + manager.remove() + manager.remove() + expect(removeSpy).toHaveBeenCalledTimes(1) + addSpy.mockRestore() + updateSpy.mockRestore() + removeSpy.mockRestore() + }) + + it('runs default PopupBase hide/show animation branches', () => { + const popupRender = render() + let views = getViews() + expect(views[0].props.style[2]).toEqual({ pointerEvents: 'none' }) + fireEvent(views[0], 'touchEnd') + + popupRender.rerender(visible) + views = getViews() + expect(views[0].props.style[2]).toEqual({ pointerEvents: 'auto' }) + popupRender.rerender(hidden) + expect(getViews()[0].props.style[2]).toEqual({ pointerEvents: 'none' }) + expect((withTiming as jest.Mock).mock.calls.map(([value]) => value)).toEqual([1, 0, 0, 370]) + }) + + it('uses PopupBase for picker popup type', () => { + const addSpy = jest.spyOn(Portal, 'add').mockReturnValue(102) + const manager = createPopupManager({ type: 'picker' as any }) + + manager.open(picker popup, 1) + + expect((addSpy.mock.calls[0][0] as React.ReactElement).type).toBe(PopupBase) + addSpy.mockRestore() + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-portal.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-portal.test.tsx new file mode 100644 index 0000000000..4ff06af6f2 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-portal.test.tsx @@ -0,0 +1,112 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import React from 'react' +import { act, render, screen } from '@testing-library/react-native' +import { Text } from 'react-native' +import { renderWithPortalHost, resetMpxRuntimeGlobals } from './rn-component-test-utils' + +const Portal = require('../../../../lib/runtime/components/react/mpx-portal').default +const { ProviderContext, RouteContext, TextPassThroughContext } = require('../../../../lib/runtime/components/react/context') + +beforeEach(() => { + jest.clearAllMocks() + resetMpxRuntimeGlobals() +}) + +describe('MpxPortal', () => { + it('mounts, updates and removes portal children', () => { + const { rerender, queryByText } = renderWithPortalHost( + + portal one + + ) + + expect(screen.getByText('portal one')).toBeTruthy() + + rerender( + + + + portal two + + + + ) + expect(screen.getByText('portal two')).toBeTruthy() + + let key = 0 + act(() => { + key = Portal.add(static portal, 1) + }) + expect(screen.getByText('static portal')).toBeTruthy() + + act(() => { + Portal.update(key, static updated) + }) + expect(screen.getByText('static updated')).toBeTruthy() + + act(() => { + Portal.remove(key) + }) + expect(queryByText('static updated')).toBeNull() + }) + + it('preserves provider and text pass-through contexts in portal children', () => { + const Probe = () => ( + + {(provides: any) => ( + + {(textPassThrough: any) => ( + {`${provides.theme}-${textPassThrough.textStyle.color}`} + )} + + )} + + ) + + renderWithPortalHost( + + + + + + + + ) + + expect(screen.getByText('dark-red')).toBeTruthy() + }) + + it('flushes portal operations queued before the manager ref is mounted', () => { + const QueueProbe = () => { + const initialized = React.useRef(false) + if (!initialized.current) { + initialized.current = true + const key = Portal.add(queued portal, 1) + Portal.update(key, queued updated) + } + return null + } + + renderWithPortalHost() + expect(screen.getByText('queued updated')).toBeTruthy() + expect(screen.queryByText('queued portal')).toBeNull() + }) + + it('flushes removes queued before the manager ref is mounted', () => { + const QueueProbe = () => { + const initialized = React.useRef(false) + if (!initialized.current) { + initialized.current = true + const key = Portal.add(queued removed portal, 1) + Portal.remove(key) + Portal.remove(998) + Portal.add(wrong page, 2) + } + return null + } + + renderWithPortalHost() + expect(screen.queryByText('queued removed portal')).toBeNull() + expect(screen.queryByText('wrong page')).toBeNull() + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-progress.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-progress.test.tsx new file mode 100644 index 0000000000..133f51534e --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-progress.test.tsx @@ -0,0 +1,123 @@ +import React from 'react' +import { render, screen, waitFor } from '@testing-library/react-native' +import { withTiming } from 'react-native-reanimated' +import MpxProgress from '../../../../lib/runtime/components/react/mpx-progress' +import { expectPortalHostRendered, renderWithPortalHost } from './rn-component-test-utils' + +describe('MpxProgress', () => { + it('renders core props and active end event', async () => { + const bindactiveend = jest.fn() + + render( + + ) + + const progress = screen.getByTestId('basic-progress') + expect(progress.children[0].props.style).toEqual(expect.objectContaining({ + height: 10, + backgroundColor: '#eeeeee' + })) + const progressFills = progress.findAll((node: any) => { + return Array.isArray(node.props.style) && node.props.style.some((style: any) => style?.backgroundColor === '#00ff00') + }) + expect(progressFills[0].props.style).toEqual(expect.arrayContaining([ + expect.objectContaining({ backgroundColor: '#00ff00' }) + ])) + + await waitFor(() => { + expect(bindactiveend).toHaveBeenCalledWith({ + type: 'activeend', + detail: { percent: 70 } + }) + }) + }) + + it('distinguishes inactive, forwards and backwards progress', async () => { + const bindactiveend = jest.fn() + const { rerender } = render( + + ) + + const progress = screen.getByTestId('inactive-progress') + expect(progress.children[0].props.style).toEqual(expect.objectContaining({ + height: 6 + })) + expect(bindactiveend).not.toHaveBeenCalled() + + ;(withTiming as jest.Mock).mockClear() + rerender( + + ) + expect(withTiming).toHaveBeenCalledWith(30, expect.objectContaining({ + duration: 10 + }), expect.any(Function)) + + ;(withTiming as jest.Mock).mockClear() + rerender( + + ) + expect(withTiming).toHaveBeenCalledWith(40, expect.objectContaining({ + duration: 40 + }), expect.any(Function)) + + await waitFor(() => { + expect(bindactiveend).toHaveBeenCalledWith({ + type: 'activeend', + detail: { percent: 40 } + }) + }) + }) + + it('renders defaults and places fixed progress in the portal host', () => { + render() + + const progress = screen.getByTestId('default-progress') + expect(progress.props.style).toEqual(expect.objectContaining({ minHeight: 20 })) + expect(progress.children[0].props.style).toEqual(expect.objectContaining({ + height: 6, + backgroundColor: '#EBEBEB' + })) + const progressFill = progress.findAll((node: any) => { + return Array.isArray(node.props.style) && node.props.style.some((style: any) => style?.backgroundColor === '#09BB07') + })[0] + expect(progressFill.props.style).toEqual(expect.arrayContaining([ + expect.objectContaining({ backgroundColor: '#09BB07' }), + expect.objectContaining({ width: '0%' }) + ])) + + const fixedRender = renderWithPortalHost( + + ) + expectPortalHostRendered(fixedRender.toJSON(), 'fixed-progress') + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-radio-group.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-radio-group.test.tsx new file mode 100644 index 0000000000..089612ca02 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-radio-group.test.tsx @@ -0,0 +1,131 @@ +import React, { useContext, useEffect } from 'react' +import { act, render, screen } from '@testing-library/react-native' +import MpxForm from '../../../../lib/runtime/components/react/mpx-form' +import MpxRadio from '../../../../lib/runtime/components/react/mpx-radio' +import MpxRadioGroup from '../../../../lib/runtime/components/react/mpx-radio-group' +import { FormContext } from '../../../../lib/runtime/components/react/context' +import { fireTap } from './helpers' +import { expectPortalHostRendered, renderWithPortalHost } from './rn-component-test-utils' + +describe('MpxRadioGroup', () => { + it('registers with form submit, reset and cleanup', () => { + let formContext: any + const bindsubmit = jest.fn() + + const FormProbe = () => { + const context = useContext(FormContext) + useEffect(() => { + formContext = context + }, [context]) + return null + } + + const { unmount } = render( + + + + A + + + B + + + + + ) + + act(() => { + formContext.submit() + }) + expect(bindsubmit).toHaveBeenCalledWith(expect.objectContaining({ + detail: { + value: { + radio: 'a' + } + } + })) + + act(() => { + formContext.reset() + }) + expect(formContext.formValuesMap.get('radio').getValue()).toBeUndefined() + + unmount() + expect(formContext.formValuesMap.has('radio')).toBe(false) + }) + + it('notifies changes, ignores checked taps and syncs controlled state', () => { + let formContext: any + const bindchange = jest.fn() + + const FormProbe = () => { + const context = useContext(FormContext) + useEffect(() => { + formContext = context + }, [context]) + return null + } + + const { rerender } = render( + + + + City A + + + City B + + + + + ) + + act(() => { + fireTap(screen.getByTestId('radio-a')) + }) + expect(bindchange).not.toHaveBeenCalled() + + act(() => { + fireTap(screen.getByTestId('radio-b')) + }) + expect(bindchange).toHaveBeenCalledWith(expect.objectContaining({ + type: 'change', + detail: { value: 'b' } + })) + + rerender( + + + + City A + + + City B + + + + + ) + expect(formContext.formValuesMap.get('radio').getValue()).toBe('b') + }) + + it('renders fixed groups in portal', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(jest.fn()) + const fixedRender = renderWithPortalHost( + + Fixed + + ) + + expectPortalHostRendered(fixedRender.toJSON(), 'fixed-radio-group') + + render( + + + Unnamed + + + ) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('name attribute is required')) + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-radio.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-radio.test.tsx new file mode 100644 index 0000000000..5a13ba9834 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-radio.test.tsx @@ -0,0 +1,79 @@ +import React from 'react' +import { act, render, screen, waitFor } from '@testing-library/react-native' +import MpxRadio from '../../../../lib/runtime/components/react/mpx-radio' +import { createTouchEvent } from './helpers' + +const mockPortal = jest.fn() + +jest.mock('../../../../lib/runtime/components/react/mpx-portal', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mockReact = require('react') + return ({ children }: { children: any }) => { + mockPortal(children) + return mockReact.createElement(mockReact.Fragment, null, children) + } +}) + +describe('MpxRadio', () => { + beforeEach(() => { + mockPortal.mockClear() + }) + + it('renders fixed radio through portal with background warning', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + + render( + + Fixed radio + + ) + + await waitFor(() => { + expect(screen.getByTestId('fixed-radio')).toBeTruthy() + }) + expect(mockPortal).toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Radio does not support background image-related styles!')) + + warnSpy.mockRestore() + }) + + it('handles disabled and controlled checked states', () => { + const bindtap = jest.fn() + const ref = React.createRef() + const { rerender } = render( + + Disabled radio + + ) + + act(() => { + ref.current.getNodeInstance().instance.change(createTouchEvent() as any) + }) + expect(bindtap).not.toHaveBeenCalled() + + rerender( + + Controlled radio + + ) + const controlledRadio = screen.getByTestId('controlled-radio') + const checkedIcon = controlledRadio.findAll((node: any) => node.type === 'Image')[0] + expect(checkedIcon.props.style).toEqual(expect.objectContaining({ + opacity: 1, + tintColor: '#ff0000' + })) + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-rich-text.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-rich-text.test.tsx new file mode 100644 index 0000000000..0e2cbf924b --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-rich-text.test.tsx @@ -0,0 +1,76 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import React from 'react' +import { act, fireEvent, screen } from '@testing-library/react-native' +import { expectPortalHostRendered, renderWithPortalHost, getWebViews, resetMpxRuntimeGlobals } from './rn-component-test-utils' + +jest.mock('react-native-webview', () => { + const mockReact = require('react') + const MockWebView = mockReact.forwardRef((props: any, ref: any) => { + mockReact.useImperativeHandle(ref, () => ({ + goBack: jest.fn(), + injectJavaScript: jest.fn(), + postMessage: jest.fn(), + reload: jest.fn() + })) + return mockReact.createElement('WebView', Object.assign({ testID: props.testID || 'mock-webview' }, props)) + }) + return { + __esModule: true, + WebView: MockWebView, + default: MockWebView + } +}) + +const MpxRichText = require('../../../../lib/runtime/components/react/mpx-rich-text').default +const { generateHTML } = require('../../../../lib/runtime/components/react/mpx-rich-text/html') + +beforeEach(() => { + jest.clearAllMocks() + resetMpxRuntimeGlobals() +}) + +describe('MpxRichText', () => { + it('renders html and updates measured height', () => { + const richTextRender = renderWithPortalHost( + + ) + + const webView = getWebViews()[0] + expect(webView.props.source.html).toContain('

Hello rich text

') + expectPortalHostRendered(richTextRender.toJSON(), 'rich-text') + act(() => { + fireEvent(webView, 'message', { nativeEvent: { data: '88' } }) + }) + expect(screen.getByTestId('rich-text').props.style.height).toBe(88) + expect(generateHTML('x')).toContain('
x
') + }) + + it('renders string nodes inline and defaults missing node attributes and children', () => { + const stringRender = renderWithPortalHost( + + ) + expect(getWebViews()[0].props.source.html).toContain('raw html') + expect(screen.getByTestId('string-rich-text')).toBeTruthy() + stringRender.unmount() + + renderWithPortalHost( + + ) + expect(getWebViews()[0].props.source.html).toContain('

') + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-root-portal.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-root-portal.test.tsx new file mode 100644 index 0000000000..b88de4b129 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-root-portal.test.tsx @@ -0,0 +1,36 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import React from 'react' +import { screen } from '@testing-library/react-native' +import { Text } from 'react-native' +import { expectPortalHostInline, expectPortalHostRendered, renderWithPortalHost, resetMpxRuntimeGlobals } from './rn-component-test-utils' + +const MpxRootPortal = require('../../../../lib/runtime/components/react/mpx-root-portal').default + +beforeEach(() => { + jest.clearAllMocks() + resetMpxRuntimeGlobals() +}) + +describe('MpxRootPortal', () => { + it('renders root portal conditionally and warns for unsupported style', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + + const rootPortalRender = renderWithPortalHost( + <> + + root portal + + + inline portal + + + ) + + expect(screen.getByText('root portal')).toBeTruthy() + expect(screen.getByText('inline portal')).toBeTruthy() + expectPortalHostRendered(rootPortalRender.toJSON(), 'root-portal') + expectPortalHostInline(rootPortalRender.toJSON(), 'inline-portal') + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('root-portal component does not support the style prop')) + warnSpy.mockRestore() + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-scroll-view.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-scroll-view.test.tsx new file mode 100644 index 0000000000..2f1df74f84 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-scroll-view.test.tsx @@ -0,0 +1,1382 @@ +import React from 'react' +import { render, screen, fireEvent, act } from '@testing-library/react-native' +import { Animated } from 'react-native' + +import MpxScrollView from '../../../../lib/runtime/components/react/mpx-scroll-view' +import MpxView from '../../../../lib/runtime/components/react/mpx-view' +import MpxText from '../../../../lib/runtime/components/react/mpx-text' +import { IntersectionObserverContext } from '../../../../lib/runtime/components/react/context' +import { createTouchEvent } from './helpers' + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { __getLastPanGesture, __getLastScrollViewRef, __resetScrollViewRefs } = require('react-native-gesture-handler') + +const createScrollEvent = (x = 0, y = 0, contentWidth = 300, contentHeight = 1000, layoutWidth = 300, layoutHeight = 400) => ({ + nativeEvent: { + contentOffset: { x, y }, + contentSize: { width: contentWidth, height: contentHeight }, + layoutMeasurement: { width: layoutWidth, height: layoutHeight } + } +}) + +const mockPortal = jest.fn() + +// Mock mpx-portal +jest.mock('../../../../lib/runtime/components/react/mpx-portal', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mockReact = require('react') + return mockReact.forwardRef((props: any, ref: any) => { + mockPortal(props) + return mockReact.createElement('View', { ...props, ref, testID: 'mock-portal' }) + }) +}) + +describe('MpxScrollView', () => { + beforeEach(() => { + mockPortal.mockClear() + __resetScrollViewRefs() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + // 基础滚动功能和属性测试 + it('should handle basic scroll properties and events', () => { + const mockScroll = jest.fn() + const mockScrollToUpper = jest.fn() + const mockScrollToLower = jest.fn() + + const { rerender } = render( + + + Scrollable content + + + ) + + const scrollElement = screen.getByTestId('basic-scroll') + expect(scrollElement).toBeTruthy() + expect(scrollElement.props.horizontal).toBe(false) + + // 测试滚动事件 + fireEvent.scroll(scrollElement, { + nativeEvent: { + contentOffset: { x: 0, y: 100 }, + contentSize: { width: 300, height: 600 }, + layoutMeasurement: { width: 300, height: 200 } + } + }) + expect(mockScroll).toHaveBeenCalledWith(expect.objectContaining({ + detail: { + scrollLeft: 0, + scrollTop: 100, + scrollHeight: 600, + scrollWidth: 300, + deltaX: 0, + deltaY: 100, + layoutMeasurement: { width: 300, height: 200 } + } + })) + + // 测试水平滚动 + rerender( + + + Horizontal content + + + ) + const horizontalScroll = screen.getByTestId('basic-scroll') + expect(horizontalScroll.props.horizontal).toBe(true) + expect(horizontalScroll.props.style).toEqual(expect.objectContaining({ flex: 1 })) + expect(horizontalScroll.props.style).not.toHaveProperty('flexGrow') + }) + + it('should emit scrollend with the final scroll metrics', () => { + const bindscrollend = jest.fn() + + render( + + + + ) + + fireEvent( + screen.getByTestId('scrollend-view'), + 'onMomentumScrollEnd', + createScrollEvent(12, 34, 320, 960, 300, 400) + ) + + expect(bindscrollend).toHaveBeenCalledWith(expect.objectContaining({ + type: 'scrollend', + detail: { + scrollLeft: 12, + scrollTop: 34, + scrollHeight: 960, + scrollWidth: 320, + layoutMeasurement: { width: 300, height: 400 } + } + })) + }) + + // MPX特定属性和警告测试 + it('should handle MPX specific properties and warnings', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + try { + render( + + + Content with conflicting scroll directions + + + ) + + const scrollElement = screen.getByTestId('mpx-props') + expect(scrollElement.props.horizontal).toBe(false) + expect(scrollElement.props.refreshControl).toBeTruthy() + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('scroll-x and scroll-y cannot be set to true at the same time')) + } finally { + warnSpy.mockRestore() + } + }) + + // 下拉刷新功能测试 + it('should handle refresher functionality comprehensively', () => { + const mockRefresherRefresh = jest.fn() + + const { rerender } = render( + + + Pull to refresh + + + Main content + + + ) + + const scrollElement = screen.getByTestId('refresher-scroll') + + // 设置refresher高度 + fireEvent(scrollElement.children[0], 'onLayout', { + nativeEvent: { layout: { height: 60 } } + }) + + const panGesture = __getLastPanGesture() + act(() => { + panGesture.onUpdateCallback({ translationY: 70, velocityY: 0 }) + panGesture.onEndCallback({ translationY: 70, velocityY: 0 }) + }) + + expect(mockRefresherRefresh).toHaveBeenCalledWith(expect.objectContaining({ + type: 'refresherrefresh' + })) + + // 测试刷新状态变化 + rerender( + + + Refreshing... + + + Main content + + + ) + + expect(screen.getByText('Refreshing...')).toBeTruthy() + }) + + // 滚动位置控制和scroll-into-view测试 + it('should handle scroll position controls and scroll-into-view', () => { + jest.useFakeTimers() + const mockSelectRef = jest.fn(() => ({ + getNodeInstance: () => ({ + nodeRef: { + current: { + measureLayout: jest.fn((parent, callback) => { + // eslint-disable-next-line node/no-callback-literal + callback(100, 200) + }) + } + } + }) + })) + + const { rerender } = render( + + + Item 1 + + + Item 2 + + + ) + + act(() => { + jest.runOnlyPendingTimers() + }) + const scrollViewRef = __getLastScrollViewRef() + expect(scrollViewRef.scrollTo).toHaveBeenCalledWith({ + x: 50, + y: 100, + animated: true + }) + scrollViewRef.scrollTo.mockClear() + + // 测试scroll-into-view + rerender( + + + Item 1 + + + Item 2 + + + ) + + act(() => { + jest.runOnlyPendingTimers() + }) + expect(mockSelectRef).toHaveBeenCalledWith('#item2', 'node') + expect(scrollViewRef.scrollTo).toHaveBeenCalledWith({ + x: 100, + y: 210, + animated: false + }) + }) + + // Portal渲染测试 + it('should render in Portal when position is fixed', () => { + render( + + + Fixed positioned content + + + ) + + expect(screen.getByTestId('portal-scroll')).toBeTruthy() + expect(screen.getByTestId('mock-portal')).toBeTruthy() + expect(mockPortal).toHaveBeenCalledWith(expect.objectContaining({ + children: expect.objectContaining({ + props: expect.objectContaining({ + testID: 'portal-scroll' + }) + }) + })) + }) + + // 阈值事件测试 + it('should handle threshold events', () => { + const mockScrollToUpper = jest.fn() + const mockScrollToLower = jest.fn() + const mockScroll = jest.fn() + + render( + + + Threshold content + + + ) + + const scrollElement = screen.getByTestId('threshold-scroll') + + // 先远离顶部,再回到 upper threshold 内,才能覆盖向上滚动触发逻辑 + fireEvent.scroll(scrollElement, { + nativeEvent: { + contentOffset: { x: 0, y: 100 }, + contentSize: { width: 300, height: 800 }, + layoutMeasurement: { width: 300, height: 400 } + } + }) + + fireEvent.scroll(scrollElement, { + nativeEvent: { + contentOffset: { x: 0, y: 15 }, // 小于upper-threshold 20 + contentSize: { width: 300, height: 800 }, + layoutMeasurement: { width: 300, height: 400 } + } + }) + + // 测试lower threshold (距底部小于threshold) + fireEvent.scroll(scrollElement, { + nativeEvent: { + contentOffset: { x: 0, y: 385 }, // 800-400-15=385,距底部15小于lower-threshold 20 + contentSize: { width: 300, height: 800 }, + layoutMeasurement: { width: 300, height: 400 } + } + }) + + expect(mockScrollToUpper).toHaveBeenCalledWith(expect.objectContaining({ + type: 'scrolltoupper', + detail: expect.objectContaining({ direction: 'top' }) + })) + expect(mockScrollToLower).toHaveBeenCalledWith(expect.objectContaining({ + type: 'scrolltolower', + detail: expect.objectContaining({ direction: 'bottom' }) + })) + expect(mockScroll).toHaveBeenCalledTimes(3) + }) + + // 交叉观察器测试 + it('should handle intersection observer functionality', () => { + const throttleMeasure = jest.fn() + render( + + + + Intersection content + + + + ) + + const scrollElement = screen.getByTestId('intersection-scroll') + fireEvent.scroll(scrollElement, createScrollEvent(0, 200)) + expect(throttleMeasure).toHaveBeenCalled() + }) + + // Refs转发测试 + it('should properly forward refs', () => { + const ref = React.createRef() + + render( + + + Ref content + + + ) + + expect(ref.current).toBeTruthy() + expect(screen.getByTestId('ref-scroll')).toBeTruthy() + }) + + // 性能和布局相关测试 + it('should handle layout changes and performance scenarios', () => { + const mockScroll = jest.fn() + const ref = React.createRef() + + const { rerender } = render( + + + Performance content + + + ) + + const scrollElement = screen.getByTestId('performance-scroll') + + // 测试布局变化 + scrollElement.props.onLayout({ + nativeEvent: { + layout: { width: 300, height: 300 } + } + }) + + // 测试内容尺寸变化 + scrollElement.props.onContentSizeChange(300, 800) + expect(ref.current.getNodeInstance().instance.scrollOffset.current).toEqual(expect.objectContaining({ + contentLength: 800, + visibleLength: 300 + })) + + // 测试动态内容变化 + rerender( + + + Updated content with more height + Additional content + + + ) + + screen.getByTestId('performance-scroll').props.onContentSizeChange(300, 1200) + expect(ref.current.getNodeInstance().instance.scrollOffset.current.contentLength).toBe(1200) + expect(mockScroll).not.toHaveBeenCalled() + }) + + it('should update bounce and scroll states before triggering refresh', () => { + const mockRefresherRefresh = jest.fn() + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { withTiming } = require('react-native-reanimated') + + render( + + + Pull to refresh + + + Complex gesture content + + + ) + + const scrollElement = screen.getByTestId('complex-gesture') + + // 设置refresher高度 + fireEvent(scrollElement.children[0], 'onLayout', { + nativeEvent: { layout: { height: 100 } } + }) + + const panGesture = __getLastPanGesture() + act(() => { + panGesture.onUpdateCallback({ translationY: 30, velocityY: 0 }) + }) + expect(screen.getByTestId('complex-gesture').props.bounces).toBe(false) + expect(screen.getByTestId('complex-gesture').props.scrollEnabled).toBe(false) + + withTiming.mockClear() + act(() => { + panGesture.onUpdateCallback({ translationY: -20, velocityY: 0 }) + panGesture.onEndCallback({ translationY: -20, velocityY: 0 }) + }) + expect(screen.getByTestId('complex-gesture').props.bounces).toBe(true) + expect(screen.getByTestId('complex-gesture').props.scrollEnabled).toBe(true) + expect(withTiming).toHaveBeenCalledWith(0) + + act(() => { + panGesture.onUpdateCallback({ translationY: -10, velocityY: 0 }) + }) + expect(screen.getByTestId('complex-gesture').props.scrollEnabled).toBe(true) + + withTiming.mockClear() + act(() => { + panGesture.onUpdateCallback({ translationY: 105, velocityY: 0 }) + panGesture.onEndCallback({ translationY: 105, velocityY: 0 }) + }) + expect(withTiming).toHaveBeenCalledWith(100) + expect(mockRefresherRefresh).toHaveBeenCalledWith(expect.objectContaining({ + type: 'refresherrefresh' + })) + }) + + it('should handle first scrollIntoView change with setTimeout', () => { + jest.useFakeTimers() + const mockSelectRef = jest.fn(() => ({ + getNodeInstance: () => ({ + nodeRef: { + current: { + measureLayout: jest.fn((parent, callback) => { + // eslint-disable-next-line node/no-callback-literal + callback(0, 300) // 模拟元素位置 + }) + } + } + }) + })) + + try { + render( + + + Item 1 + + + Item 2 + + + ) + + expect(mockSelectRef).not.toHaveBeenCalled() + + act(() => { + jest.runOnlyPendingTimers() + }) + + expect(mockSelectRef).toHaveBeenCalledWith('#item2', 'node') + } finally { + jest.useRealTimers() + } + }) + + it('should handle scrollTo function and handleScrollIntoView details', () => { + const ref = React.createRef() + const mockSelectRef = jest.fn(() => ({ + getNodeInstance: () => ({ + nodeRef: { + current: { + measureLayout: jest.fn((parent, callback) => { + // eslint-disable-next-line node/no-callback-literal + callback(150, 250) // 模拟元素位置 + }) + } + } + }) + })) + + const { rerender } = render( + + + Target element + + + ) + + // 通过 rerender 设置 scroll-into-view 来触发 handleScrollIntoView + rerender( + + + Target element + + + ) + + expect(mockSelectRef).toHaveBeenCalledWith('#target', 'node') + expect(ref.current.getNodeInstance().nodeRef.current.scrollTo).toHaveBeenCalledWith({ + x: 165, + y: 250, + animated: false + }) + }) + + it('should handle intersection observer updates correctly', () => { + const mockThrottleMeasure1 = jest.fn() + const mockThrottleMeasure2 = jest.fn() + + const mockIntersectionObservers = { + observer1: { throttleMeasure: mockThrottleMeasure1 }, + observer2: { throttleMeasure: mockThrottleMeasure2 } + } + + render( + + + + Intersection observer content + + + + ) + + const scrollElement = screen.getByTestId('intersection-observer') + + // 触发滚动来调用 updateIntersection + fireEvent.scroll(scrollElement, { + nativeEvent: { + contentOffset: { x: 0, y: 300 }, + contentSize: { width: 300, height: 1000 }, + layoutMeasurement: { width: 300, height: 400 } + } + }) + + expect(mockThrottleMeasure1).toHaveBeenCalled() + expect(mockThrottleMeasure2).toHaveBeenCalled() + }) + + it('should handle onRefresh with undefined refresherTriggered', () => { + jest.useFakeTimers() + const mockRefresherRefresh = jest.fn() + + try { + render( + + + Pull to refresh + + + Main content + + + ) + + let scrollElement = screen.getByTestId('refresh-undefined') + + fireEvent(scrollElement.children[0], 'onLayout', { + nativeEvent: { layout: { height: 100 } } + }) + + const panGesture = __getLastPanGesture() + act(() => { + panGesture.onUpdateCallback({ translationY: 100, velocityY: 0 }) + panGesture.onEndCallback({ translationY: 100, velocityY: 0 }) + }) + + scrollElement = screen.getByTestId('refresh-undefined') + expect(mockRefresherRefresh).toHaveBeenCalled() + expect(scrollElement.props.scrollEnabled).toBe(false) + + act(() => { + jest.advanceTimersByTime(500) + }) + + expect(screen.getByTestId('refresh-undefined').props.scrollEnabled).toBe(true) + } finally { + jest.useRealTimers() + } + }) + + it('should handle sticky scroll and content size changes', () => { + const originalMode = global.__mpx_mode__ + const scrollOffset = { + setValue: jest.fn() + } + try { + global.__mpx_mode__ = 'android' + ;(Animated.Value as jest.Mock).mockReturnValueOnce(scrollOffset) + ;(Animated.event as jest.Mock).mockImplementationOnce((_mapping, config) => { + return (event: any) => config.listener(event) + }) + + const { rerender } = render( + + + Sticky content + + + ) + + const scrollElement = screen.getByTestId('sticky-scroll') + scrollElement.props.onContentSizeChange(300, 800) + + // 先滚动到底部 + scrollElement.props.onScroll(createScrollEvent(0, 500, 300, 800, 300, 300)) + + // 减少内容高度,触发 maxOffset 调整逻辑 + rerender( + + + Reduced content + + + ) + + screen.getByTestId('sticky-scroll').props.onContentSizeChange(300, 400) + + expect(scrollOffset.setValue).toHaveBeenCalledWith(100) + } finally { + global.__mpx_mode__ = originalMode + } + }) + + it('should reset harmony sticky reconciliation after content size changes', () => { + jest.useFakeTimers() + const originalMode = global.__mpx_mode__ + const scrollOffset = { + setValue: jest.fn() + } + try { + global.__mpx_mode__ = 'harmony' + ;(Animated.Value as jest.Mock).mockReturnValueOnce(scrollOffset) + ;(Animated.event as jest.Mock).mockImplementationOnce((_mapping, config) => { + return (event: any) => config.listener(event) + }) + + render( + + + Harmony content + + + ) + + const scrollElement = screen.getByTestId('harmony-sticky') + + // 先触发内容尺寸变化 + scrollElement.props.onContentSizeChange(300, 600) + + // 触发滚动来测试 harmony 模式的特殊逻辑 + scrollElement.props.onScroll(createScrollEvent(0, 200, 300, 600, 300, 300)) + + expect(scrollOffset.setValue).toHaveBeenCalledWith(200) + + act(() => { + jest.advanceTimersByTime(100) + }) + scrollOffset.setValue.mockClear() + scrollElement.props.onScroll(createScrollEvent(0, 250, 300, 600, 300, 300)) + expect(scrollOffset.setValue).not.toHaveBeenCalled() + } finally { + global.__mpx_mode__ = originalMode + } + }) + + it('should handle enhanced drag events', () => { + const mockDragStart = jest.fn() + const mockDragEnd = jest.fn() + + render( + + + Draggable content + + + ) + + const scrollElement = screen.getByTestId('drag-events') + + // 触发 drag start + fireEvent(scrollElement, 'onScrollBeginDrag', { + nativeEvent: { + contentOffset: { x: 0, y: 10 }, + contentSize: { width: 300, height: 800 }, + layoutMeasurement: { width: 300, height: 300 } + } + }) + + expect(mockDragStart).toHaveBeenCalledWith(expect.objectContaining({ + detail: { scrollLeft: 0, scrollTop: 10 } + })) + + // 触发 drag end + fireEvent(scrollElement, 'onScrollEndDrag', { + nativeEvent: { + contentOffset: { x: 0, y: 100 }, + contentSize: { width: 300, height: 800 }, + layoutMeasurement: { width: 300, height: 300 } + } + }) + + expect(mockDragEnd).toHaveBeenCalledWith(expect.objectContaining({ + detail: { scrollLeft: 0, scrollTop: 100 } + })) + }) + + it('should handle bindtouchmove and binddragging', () => { + const mockTouchMove = jest.fn() + const mockDragging = jest.fn() + + const { rerender } = render( + + + Touch content + + + ) + + fireEvent(screen.getByTestId('touch-move-test'), 'touchMove', createTouchEvent()) + expect(mockTouchMove).toHaveBeenCalledWith(expect.objectContaining({ + nativeEvent: expect.objectContaining({ + pageX: 10, + pageY: 20 + }) + })) + + // 测试 enhanced 模式下的 binddragging + rerender( + + + Dragging content + + + ) + + fireEvent(screen.getByTestId('touch-move-test'), 'touchMove', createTouchEvent()) + expect(mockDragging).toHaveBeenCalledWith(expect.objectContaining({ + type: 'dragging', + detail: { + scrollLeft: 0, + scrollTop: 0 + } + })) + }) + + it('should handle gestures during refreshing state', () => { + const mockRefresherRefresh = jest.fn() + + const { rerender } = render( + + + Pull to refresh + + + Main content + + + ) + + const scrollElement = screen.getByTestId('refreshing-gesture') + + // 设置 refresher 高度 + fireEvent(scrollElement.children[0], 'onLayout', { + nativeEvent: { layout: { height: 80 } } + }) + + // 模拟进入 refreshing 状态 + rerender( + + + Refreshing... + + + Main content + + + ) + + const panGesture = __getLastPanGesture() + act(() => { + panGesture.onUpdateCallback({ translationY: 40, velocityY: 0 }) + panGesture.onEndCallback({ translationY: 40, velocityY: 0 }) + panGesture.onUpdateCallback({ translationY: -30, velocityY: -50 }) + panGesture.onEndCallback({ translationY: -30, velocityY: -50 }) + panGesture.onUpdateCallback({ translationY: 70, velocityY: 0 }) + panGesture.onEndCallback({ translationY: 70, velocityY: 0 }) + }) + + expect(mockRefresherRefresh).not.toHaveBeenCalled() + expect(screen.getByText('Refreshing...')).toBeTruthy() + }) + + it('should handle scrollIntoView with scroll-x enabled', () => { + jest.useFakeTimers() + const mockSelectRef = jest.fn(() => ({ + getNodeInstance: () => ({ + nodeRef: { + current: { + measureLayout: jest.fn((parent, callback) => { + // eslint-disable-next-line node/no-callback-literal + callback(200, 50) + }) + } + } + }) + })) + + const { rerender } = render( + + + Item 1 + + + ) + + act(() => { + jest.runOnlyPendingTimers() + }) + const scrollViewRef = __getLastScrollViewRef() + scrollViewRef.scrollTo.mockClear() + + // 设置 scroll-into-view 触发滚动 + rerender( + + + Item 1 + + + ) + + act(() => { + jest.runOnlyPendingTimers() + }) + + expect(mockSelectRef).toHaveBeenCalledWith('#item1', 'node') + expect(scrollViewRef.scrollTo).toHaveBeenCalledWith({ + x: 210, + y: 50, + animated: true + }) + jest.useRealTimers() + }) + + it('should handle refresher without custom refresher content', () => { + const mockRefresh = jest.fn() + + const { rerender } = render( + + + Main content only + + + ) + + // 切换 refresherTriggered + rerender( + + + Main content only + + + ) + + const scrollElement = screen.getByTestId('no-custom-refresher') + expect(scrollElement.props.refreshControl).toBeTruthy() + expect(scrollElement.props.refreshControl.props.refreshing).toBe(true) + }) + + it('should handle simultaneousHandlers and waitFor props', () => { + const mockGesture1 = { current: { name: 'gesture-1' } } + const mockGesture2 = { current: { name: 'gesture-2' } } + + render( + + + Gesture content + + + ) + + const scrollElement = screen.getByTestId('gesture-handlers') + expect(scrollElement.props.simultaneousHandlers).toEqual([mockGesture1, mockGesture2]) + expect(scrollElement.props.waitFor).toEqual([mockGesture1]) + }) + + it('should handle pagingEnabled in enhanced mode', () => { + render( + + + Page 1 + + + ) + + const scrollElement = screen.getByTestId('paging-enhanced') + expect(scrollElement.props.pagingEnabled).toBe(true) + }) + + // prop 形式缺少 selector resolver 时不触发 scrollIntoView 处理 + it('keeps prop scrollIntoView inert without __selectRef', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + try { + render( + + + Item 1 + + + ) + + expect(screen.getByTestId('no-select-ref').props.scrollEnabled).toBe(true) + expect(warnSpy).not.toHaveBeenCalled() + } finally { + warnSpy.mockRestore() + } + }) + + it('should handle onEnd gesture when scroll is enabled', () => { + const mockRefresherRefresh = jest.fn() + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { withTiming } = require('react-native-reanimated') + + render( + + + Refresher + + + Content + + + ) + + const scrollElement = screen.getByTestId('enabled-scroll-gesture') + + fireEvent(scrollElement.children[0], 'onLayout', { + nativeEvent: { layout: { height: 60 } } + }) + + const panGesture = __getLastPanGesture() + withTiming.mockClear() + act(() => { + panGesture.onEndCallback({ translationY: 50, velocityY: 0 }) + }) + + expect(mockRefresherRefresh).not.toHaveBeenCalled() + expect(withTiming).not.toHaveBeenCalled() + }) + + it('should animate imperative scrollTo when duration is provided', () => { + const ref = React.createRef() + const originalRequestAnimationFrame = global.requestAnimationFrame + let now = 0 + const dateNow = jest.spyOn(Date, 'now').mockImplementation(() => now) + const rafCallbacks: Array<(time: number) => void> = [] + global.requestAnimationFrame = jest.fn((callback) => { + rafCallbacks.push(callback as (time: number) => void) + return rafCallbacks.length + }) as any + + try { + render( + + + Wide content + + + ) + + const nodeInstance = ref.current.getNodeInstance() + const nativeScrollTo = nodeInstance.nodeRef.current.scrollTo + nodeInstance.instance.node.scrollTo({ + left: 40, + top: 100, + animated: true, + duration: 100 + }) + + expect(global.requestAnimationFrame).toHaveBeenCalledTimes(1) + expect(nativeScrollTo).not.toHaveBeenCalled() + + now = 50 + act(() => { + rafCallbacks.shift()!(50) + }) + expect(nativeScrollTo).toHaveBeenNthCalledWith(1, { + x: 20, + y: 50, + animated: false + }) + expect(global.requestAnimationFrame).toHaveBeenCalledTimes(2) + + now = 100 + act(() => { + rafCallbacks.shift()!(100) + }) + expect(nativeScrollTo).toHaveBeenNthCalledWith(2, { + x: 40, + y: 100, + animated: false + }) + expect(nativeScrollTo).toHaveBeenNthCalledWith(3, { + x: 40, + y: 100, + animated: false + }) + } finally { + global.requestAnimationFrame = originalRequestAnimationFrame + dateNow.mockRestore() + } + }) + + it('should warn for scrollIntoView unavailable target branches', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + const ref = React.createRef() + const missingSelectRef = jest.fn(() => null) + const missingNodeRef = jest.fn(() => ({ + getNodeInstance: () => ({ + nodeRef: { + current: null + } + }) + })) + const failedMeasureRef = jest.fn(() => ({ + getNodeInstance: () => ({ + nodeRef: { + current: { + measureLayout: jest.fn((parent, success, fail) => { + fail('measure failed') + }) + } + } + }) + })) + const throwingSelectRef = jest.fn(() => { + throw new Error('select failed') + }) + + try { + const { rerender } = render( + + + Target + + + ) + + ref.current.getNodeInstance().instance.node.scrollIntoView('target') + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('__selectRef is not available')) + + rerender( + + + Target + + + ) + ref.current.getNodeInstance().instance.node.scrollIntoView('target') + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Element not found for selector: #target')) + + rerender( + + + Target + + + ) + ref.current.getNodeInstance().instance.node.scrollIntoView('.target') + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Node ref not available for selector: .target')) + + rerender( + + + Target + + + ) + ref.current.getNodeInstance().instance.node.scrollIntoView('#target') + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to measure layout for selector #target')) + + rerender( + + + Target + + + ) + ref.current.getNodeInstance().instance.node.scrollIntoView('target') + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('handleScrollIntoView error for selector target')) + } finally { + warnSpy.mockRestore() + } + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-section-list.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-section-list.test.tsx new file mode 100644 index 0000000000..983ea40562 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-section-list.test.tsx @@ -0,0 +1,353 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react-native' +import { Text } from 'react-native' +import { expectPortalHostRendered, renderWithPortalHost, resetMpxRuntimeGlobals } from './rn-component-test-utils' + +const MpxSectionList = require('../../../../lib/runtime/components/react/mpx-section-list').default +const { __getLastSectionListRef } = require('react-native') +const { __getLastNativeGesture } = require('react-native-gesture-handler') + +const getSectionList = () => screen.UNSAFE_getByType('SectionList') + +beforeEach(() => { + jest.clearAllMocks() + resetMpxRuntimeGlobals() + ;(global as any).__mpxGenericsMap = undefined +}) + +describe('MpxSectionList', () => { + it('converts flat list data into sections and calculates layouts from original indexes', () => { + const itemHeightGetter = jest.fn((item, index) => index + 10) + const headerHeightGetter = jest.fn((item, index) => index + 20) + const footerHeightGetter = jest.fn((item, index) => index + 30) + const listData = [ + { id: 'leading' }, + { id: 'header', isSectionHeader: true }, + { id: 'inside' }, + { id: 'footer', isSectionFooter: true }, + { id: 'trailing' } + ] + + const { rerender } = render( + + ) + + let sectionList = getSectionList() + expect(sectionList.props.sections).toEqual([ + { + headerData: null, + footerData: null, + data: [listData[0]], + hasSectionHeader: false, + hasSectionFooter: false + }, + { + headerData: listData[1], + footerData: listData[3], + data: [listData[2]], + hasSectionHeader: true, + hasSectionFooter: true + }, + { + headerData: null, + footerData: null, + data: [listData[4]], + hasSectionHeader: false, + hasSectionFooter: false + } + ]) + expect(Array.from({ length: 9 }, (_, index) => sectionList.props.getItemLayout(null, index))).toEqual([ + { length: 0, offset: 5, index: 0 }, + { length: 10, offset: 5, index: 1 }, + { length: 0, offset: 15, index: 2 }, + { length: 21, offset: 15, index: 3 }, + { length: 12, offset: 36, index: 4 }, + { length: 33, offset: 48, index: 5 }, + { length: 0, offset: 81, index: 6 }, + { length: 14, offset: 81, index: 7 }, + { length: 0, offset: 95, index: 8 } + ]) + expect(itemHeightGetter.mock.calls).toEqual([ + [listData[0], 0], + [listData[2], 2], + [listData[4], 4] + ]) + expect(headerHeightGetter).toHaveBeenCalledWith(listData[1], 1) + expect(footerHeightGetter).toHaveBeenCalledWith(listData[3], 3) + + const firstHeader = { id: 'first-header', isSectionHeader: true } + const secondHeader = { id: 'second-header', isSectionHeader: true } + const footer = { id: 'footer-only', isSectionFooter: true } + rerender( + + ) + sectionList = getSectionList() + expect(sectionList.props.sections).toEqual([ + expect.objectContaining({ headerData: firstHeader, data: [] }), + expect.objectContaining({ headerData: secondHeader, footerData: footer, data: [] }) + ]) + + rerender( + + ) + expect(getSectionList().props.sections).toEqual([ + expect.objectContaining({ headerData: null, footerData: footer, data: [] }) + ]) + }) + + it('renders configured generic items, section extras and list extras', () => { + const Item = ({ itemData }: any) => {`item:${itemData.id}`} + const SectionHeader = ({ itemData }: any) => {`header:${itemData.id}`} + const SectionFooter = ({ itemData }: any) => {`footer:${itemData.id}`} + const ListHeader = ({ listHeaderData }: any) => {`list-header:${listHeaderData}`} + const ListFooter = ({ listFooterData }: any) => {`list-footer:${listFooterData}`} + ;(global as any).__mpxGenericsMap = { + section: { + item: () => Item, + sectionHeader: () => SectionHeader, + sectionFooter: () => SectionFooter, + listHeader: () => ListHeader, + listFooter: () => ListFooter + } + } + const header = { id: 'h', isSectionHeader: true } + const item = { id: 'i' } + const footer = { id: 'f', isSectionFooter: true } + + render( + + ) + + const props = getSectionList().props + expect(props.renderItem({ item })).toEqual(expect.objectContaining({ + type: Item, + props: { itemData: item } + })) + expect(props.renderSectionHeader({ section: props.sections[0] })).toEqual(expect.objectContaining({ + type: SectionHeader, + props: { itemData: header } + })) + expect(props.renderSectionFooter({ section: props.sections[0] })).toEqual(expect.objectContaining({ + type: SectionFooter, + props: { itemData: footer } + })) + expect(props.renderSectionHeader({ section: { hasSectionHeader: false } })).toBeNull() + expect(props.renderSectionFooter({ section: { hasSectionFooter: false } })).toBeNull() + expect(props.ListHeaderComponent).toEqual(expect.objectContaining({ + type: ListHeader, + props: { listHeaderData: 'top' } + })) + expect(props.ListFooterComponent).toEqual(expect.objectContaining({ + type: ListFooter, + props: { listFooterData: 'bottom' } + })) + }) + + it('omits renderers when generics or list extras are unavailable', () => { + const { rerender } = render( + + ) + + let props = getSectionList().props + expect(props.sections).toEqual([]) + expect(props.renderItem).toBeUndefined() + expect(props.renderSectionHeader).toBeUndefined() + expect(props.renderSectionFooter).toBeUndefined() + expect(props.ListHeaderComponent).toBeNull() + expect(props.ListFooterComponent).toBeNull() + + ;(global as any).__mpxGenericsMap = { section: {} } + rerender( + + ) + props = getSectionList().props + expect(props.ListHeaderComponent).toBeNull() + expect(props.ListFooterComponent).toBeNull() + }) + + it('emits scroll, lower and refresh events with native state', () => { + const bindscroll = jest.fn() + const bindscrolltolower = jest.fn() + const bindrefresherrefresh = jest.fn() + const { rerender } = render( + + ) + + const sectionList = getSectionList() + fireEvent.scroll(sectionList, { + nativeEvent: { contentOffset: { x: 5, y: 42 } } + }) + fireEvent(sectionList, 'endReached') + sectionList.props.refreshControl.props.onRefresh() + + expect(bindscroll).toHaveBeenCalledWith(expect.objectContaining({ + type: 'scroll', + detail: { scrollTop: 42 } + })) + expect(bindscrolltolower).toHaveBeenCalledWith(expect.objectContaining({ type: 'scrolltolower' })) + expect(bindrefresherrefresh).toHaveBeenCalledWith(expect.objectContaining({ type: 'refresherrefresh' })) + expect(sectionList.props.refreshControl.props.refreshing).toBe(true) + + rerender() + const sectionListWithoutListeners = getSectionList() + expect(() => { + fireEvent.scroll(sectionListWithoutListeners, { + nativeEvent: { contentOffset: { x: 0, y: 0 } } + }) + fireEvent(sectionListWithoutListeners, 'endReached') + }).not.toThrow() + expect(sectionListWithoutListeners.props.refreshControl).toBeUndefined() + }) + + it('forwards scrolling options and scrolls raw indexes to mapped locations', () => { + const ref = React.createRef() + const leading = { id: 'leading' } + const header = { id: 'header', isSectionHeader: true } + const item = { id: 'item' } + const footer = { id: 'footer', isSectionFooter: true } + const trailing = { id: 'trailing' } + render( + + ) + + const sectionList = getSectionList() + expect(sectionList.props).toEqual(expect.objectContaining({ + bounces: true, + scrollEventThrottle: 16, + showsVerticalScrollIndicator: false, + stickySectionHeadersEnabled: true, + scrollsToTop: true, + onEndReachedThreshold: 0.25 + })) + expect(sectionList.props.style).toEqual([ + null, + { height: 300, width: 200 }, + { flex: 1 }, + undefined + ]) + expect(__getLastNativeGesture().withRef).toHaveBeenCalledWith(ref.current.gestureRef) + + const sectionListRef = __getLastSectionListRef() + ref.current.scrollToIndex({ index: 0, animated: true, viewOffset: 8, viewPosition: 0.5 }) + ref.current.scrollToIndex({ index: 1 }) + ref.current.scrollToIndex({ index: 2 }) + ref.current.scrollToIndex({ index: 3 }) + ref.current.scrollToIndex({ index: 4 }) + ref.current.scrollToIndex({ index: 99 }) + expect(sectionListRef.scrollToLocation.mock.calls).toEqual([ + [{ itemIndex: 1, sectionIndex: 0, animated: true, viewOffset: 8, viewPosition: 0.5 }], + [{ itemIndex: 0, sectionIndex: 1, animated: undefined, viewOffset: 0, viewPosition: 0 }], + [{ itemIndex: 1, sectionIndex: 1, animated: undefined, viewOffset: 0, viewPosition: 0 }], + [{ itemIndex: 2, sectionIndex: 1, animated: undefined, viewOffset: 0, viewPosition: 0 }], + [{ itemIndex: 1, sectionIndex: 2, animated: undefined, viewOffset: 0, viewPosition: 0 }] + ]) + }) + + it('connects simultaneous and wait-for gestures only when provided', () => { + const simultaneousHandler = { current: {} } + const waitForHandler = { handlerTag: 1 } + const { rerender } = render() + + let gesture = __getLastNativeGesture() + expect(gesture.simultaneousWithExternalGesture).not.toHaveBeenCalled() + expect(gesture.requireExternalGestureToFail).not.toHaveBeenCalled() + + rerender( + + ) + gesture = __getLastNativeGesture() + expect(gesture.simultaneousWithExternalGesture).toHaveBeenCalledWith(simultaneousHandler) + expect(gesture.requireExternalGestureToFail).toHaveBeenCalledWith(waitForHandler) + }) + + it('renders fixed lists through the portal and applies default scrolling behavior', () => { + const sectionListRender = renderWithPortalHost( + + ) + + const sectionList = getSectionList() + expectPortalHostRendered(sectionListRender.toJSON(), 'fixed-list') + expect(sectionList.props).toEqual(expect.objectContaining({ + bounces: false, + scrollEventThrottle: 0, + showsVerticalScrollIndicator: true, + stickySectionHeadersEnabled: false, + scrollsToTop: false, + onEndReachedThreshold: 0.1 + })) + expect(sectionList.props.style[0]).toEqual({ flexGrow: 0 }) + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-simple-text.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-simple-text.test.tsx new file mode 100644 index 0000000000..792cf90c05 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-simple-text.test.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { render, screen } from '@testing-library/react-native' +import MpxSimpleText from '../../../../lib/runtime/components/react/mpx-simple-text' + +describe('MpxSimpleText', () => { + it('renders text with default font scaling and normalized styles', () => { + render( + + Simple child + + ) + + const text = screen.getByTestId('simple-text') + expect(text.props.allowFontScaling).toBe(false) + expect(text.props.style).toEqual(expect.objectContaining({ paddingLeft: 4, boxSizing: 'content-box' })) + }) + + it('passes text styles to nested text and preserves explicit font scaling', () => { + render( + + Nested child + + ) + + expect(screen.getByTestId('parent-text').props.allowFontScaling).toBe(true) + expect(screen.getByTestId('child-text').props.style).toEqual(expect.objectContaining({ + color: '#f00', + fontSize: 20 + })) + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-simple-view.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-simple-view.test.tsx new file mode 100644 index 0000000000..1ae1b9c94c --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-simple-view.test.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import { render, screen } from '@testing-library/react-native' +import MpxInlineText from '../../../../lib/runtime/components/react/mpx-inline-text' +import MpxSimpleText from '../../../../lib/runtime/components/react/mpx-simple-text' +import MpxSimpleView from '../../../../lib/runtime/components/react/mpx-simple-view' + +describe('MpxSimpleView', () => { + it('passes text props through to children', () => { + render( + + + Inline child + + + ) + + const view = screen.getByTestId('simple-view') + expect(view.props.style).toEqual(expect.objectContaining({ padding: 8, boxSizing: 'content-box' })) + + const inlineText = screen.getByTestId('inline-text') + expect(inlineText.props.numberOfLines).toBe(2) + expect(inlineText.props.style).toEqual(expect.objectContaining({ color: '#f00', fontSize: 18 })) + }) + + it('records perf scopes when perf is enabled', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const perf = require('@mpxjs/perf') + perf.scopeStart.mockClear() + perf.scopeEnd.mockClear() + ;(global as any).__mpx_perf_framework__ = true + + render( + + Perf text + + ) + + expect(perf.scopeStart).toHaveBeenCalledWith('simple-view:render:total') + expect(perf.scopeStart).toHaveBeenCalledWith('simple-text:render:total') + expect(perf.scopeEnd).toHaveBeenCalled() + ;(global as any).__mpx_perf_framework__ = false + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-slider.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-slider.test.tsx new file mode 100644 index 0000000000..6914dbe311 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-slider.test.tsx @@ -0,0 +1,156 @@ +import React, { useContext, useEffect } from 'react' +import { act, fireEvent, render, screen } from '@testing-library/react-native' +import MpxForm from '../../../../lib/runtime/components/react/mpx-form' +import MpxSlider from '../../../../lib/runtime/components/react/mpx-slider' +import { FormContext } from '../../../../lib/runtime/components/react/context' + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { __getLastPanGesture } = require('react-native-gesture-handler') + +describe('MpxSlider', () => { + it('handles layout, gestures and form reset', () => { + const bindchange = jest.fn() + const bindchanging = jest.fn() + let formContext: any + + const FormProbe = () => { + const context = useContext(FormContext) + useEffect(() => { + formContext = context + }, [context]) + return null + } + + render( + + + + + ) + + const slider = screen.getByTestId('basic-slider') + const track = slider.findAll((node: any) => node.props.onLayout)[0] + act(() => { + fireEvent(track, 'layout', { + nativeEvent: { + layout: { width: 100 } + } + }) + }) + + const panGesture = __getLastPanGesture() + act(() => { + panGesture.onBeginCallback() + panGesture.onUpdateCallback({ translationX: 30 }) + panGesture.onEndCallback({ translationX: 30 }) + }) + + expect(bindchanging).toHaveBeenCalledWith(expect.objectContaining({ + type: 'changing', + detail: { value: 50 } + })) + expect(bindchange).toHaveBeenCalledWith(expect.objectContaining({ + type: 'change', + detail: { value: 50 } + })) + expect(formContext.formValuesMap.get('volume').getValue()).toBe(50) + + act(() => { + formContext.formValuesMap.get('volume').resetValue() + }) + expect(formContext.formValuesMap.get('volume').getValue()).toBe(20) + }) + + it('handles defaults, string values, bind handlers and form warnings', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + const bindchange = jest.fn() + const bindchanging = jest.fn() + let formContext: any + + const FormProbe = () => { + const context = useContext(FormContext) + useEffect(() => { + formContext = context + }, [context]) + return null + } + + render() + const panGesture = __getLastPanGesture() + act(() => { + panGesture.onBeginCallback() + panGesture.onUpdateCallback({ translationX: 10 }) + }) + + render( + + ) + + const slider = screen.getByTestId('string-slider') + const track = slider.findAll((node: any) => node.props.onLayout)[0] + act(() => { + fireEvent(track, 'layout', { + nativeEvent: { + layout: { width: 80 } + } + }) + }) + const stringPanGesture = __getLastPanGesture() + act(() => { + stringPanGesture.onBeginCallback() + stringPanGesture.onUpdateCallback({ translationX: 40 }) + stringPanGesture.onEndCallback({ translationX: 40 }) + }) + expect(bindchanging).toHaveBeenCalled() + expect(bindchange).toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Step 7 is not a divisor')) + + render( + + + + ) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('name attribute is required')) + + render( + + + + + ) + act(() => { + formContext.formValuesMap.get('stringValue').resetValue() + }) + expect(formContext.formValuesMap.get('stringValue').getValue()).toBe(5) + warnSpy.mockRestore() + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-sticky-header.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-sticky-header.test.tsx new file mode 100644 index 0000000000..bce7af9731 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-sticky-header.test.tsx @@ -0,0 +1,102 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react-native' +import { Animated, Text } from 'react-native' +import { resetMpxRuntimeGlobals } from './rn-component-test-utils' + +const MpxStickyHeader = require('../../../../lib/runtime/components/react/mpx-sticky-header').default +const { ScrollViewContext, StickyContext } = require('../../../../lib/runtime/components/react/context') + +beforeEach(() => { + jest.clearAllMocks() + resetMpxRuntimeGlobals() +}) + +describe('MpxStickyHeader', () => { + it('registers sticky headers and reports stick state changes', () => { + const bindstickontopchange = jest.fn() + const registerStickyHeader = jest.fn() + const unregisterStickyHeader = jest.fn() + const scrollOffset = { + addListener: jest.fn((listener) => { + listener({ value: 10 }) + return 'listener-id' + }), + removeListener: jest.fn() + } + + const { unmount } = render( + + + + sticky header + + + + ) + + expect(registerStickyHeader).toHaveBeenCalledWith({ + id: expect.any(String), + updatePosition: expect.any(Function) + }) + const registeredId = registerStickyHeader.mock.calls[0][0].id + expect(bindstickontopchange).toHaveBeenCalledWith(expect.objectContaining({ + detail: { isStickOnTop: true } + })) + unmount() + expect(unregisterStickyHeader).toHaveBeenCalledWith(registeredId) + expect(scrollOffset.removeListener).toHaveBeenCalledWith('listener-id') + }) + + it('updates position on layout and reports invalid scroll refs', () => { + const registerStickyHeader = jest.fn() + const unregisterStickyHeader = jest.fn() + const start = jest.fn() + const gestureRef = { current: {} } + const stickyRef = React.createRef() + ;(Animated.timing as jest.Mock).mockReturnValue({ start }) + const measured = render( + + + + measured sticky + + + + ) + + const measureLayout = stickyRef.current.getNodeInstance().nodeRef.current.measureLayout + measureLayout.mockImplementation((_parent, onSuccess) => onSuccess(12, 48)) + fireEvent(screen.getByTestId('measured-sticky'), 'layout', { + nativeEvent: { layout: { width: 100, height: 30 } } + }) + expect(measureLayout).toHaveBeenCalledWith(gestureRef.current, expect.any(Function)) + expect(Animated.timing).toHaveBeenCalledWith(expect.anything(), { + toValue: 48, + duration: 0, + useNativeDriver: true + }) + expect(start).toHaveBeenCalled() + measured.unmount() + + const errorHandler = jest.fn() + ;(global as any).mpxGlobal.__mpx.config.errorHandler = errorHandler + render( + + + + invalid sticky + + + + ) + fireEvent(screen.getByTestId('invalid-sticky'), 'layout', { + nativeEvent: { layout: { width: 100, height: 30 } } + }) + expect(errorHandler).toHaveBeenCalledWith( + expect.stringContaining('StickyHeader measureLayout error'), + undefined, + expect.any(Error) + ) + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-sticky-section.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-sticky-section.test.tsx new file mode 100644 index 0000000000..d332d5f27e --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-sticky-section.test.tsx @@ -0,0 +1,67 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import React, { useContext, useEffect } from 'react' +import { fireEvent, render, screen } from '@testing-library/react-native' +import { Animated, Text } from 'react-native' +import { resetMpxRuntimeGlobals } from './rn-component-test-utils' + +const MpxStickyHeader = require('../../../../lib/runtime/components/react/mpx-sticky-header').default +const MpxStickySection = require('../../../../lib/runtime/components/react/mpx-sticky-section').default +const { ScrollViewContext, StickyContext } = require('../../../../lib/runtime/components/react/context') + +beforeEach(() => { + jest.clearAllMocks() + resetMpxRuntimeGlobals() +}) + +describe('MpxStickySection', () => { + it('renders with default props and no registered headers', () => { + const { toJSON } = render() + expect(toJSON()).toEqual(expect.objectContaining({ type: 'View' })) + }) + + it('updates registered sticky headers from section layout', () => { + const updatePosition = jest.fn() + const unregisterPosition = jest.fn() + const Child = () => { + const sticky = useContext(StickyContext) + useEffect(() => { + sticky.registerStickyHeader({ id: 'header', updatePosition }) + sticky.registerStickyHeader({ id: 'removed', updatePosition: unregisterPosition }) + sticky.unregisterStickyHeader('removed') + }, []) + return section child + } + + render( + + + + ) + + fireEvent(screen.getByTestId('sticky-section'), 'layout', { + nativeEvent: { layout: { width: 100, height: 50 } } + }) + expect(updatePosition).toHaveBeenCalled() + expect(unregisterPosition).not.toHaveBeenCalled() + }) + + it('updates every sticky header rendered inside the section', () => { + const start = jest.fn() + ;(Animated.timing as jest.Mock).mockReturnValue({ start }) + + render( + + + first header + second header + + + ) + + fireEvent(screen.getByTestId('sticky-section'), 'layout', { + nativeEvent: { layout: { width: 100, height: 50 } } + }) + expect(Animated.timing).toHaveBeenCalledTimes(2) + expect(start).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-swiper-item.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-swiper-item.test.tsx new file mode 100644 index 0000000000..31b1896688 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-swiper-item.test.tsx @@ -0,0 +1,62 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import React from 'react' +import { render, screen } from '@testing-library/react-native' +import { Text } from 'react-native' +import { resetMpxRuntimeGlobals } from './rn-component-test-utils' + +const MpxSwiperItem = require('../../../../lib/runtime/components/react/mpx-swiper-item').default +const { SwiperContext } = require('../../../../lib/runtime/components/react/context') + +beforeEach(() => { + jest.clearAllMocks() + resetMpxRuntimeGlobals() +}) + +describe('MpxSwiperItem', () => { + it('renders directly from context with scale style', () => { + render( + + + direct item + + + ) + const item = screen.getByTestId('direct-item') + expect(screen.getByText('direct item')).toBeTruthy() + expect(item.props.style).toContainEqual(expect.objectContaining({ + width: '100%', + height: 100, + transform: [{ scale: 0.7 }] + })) + expect(item.props.style).toContainEqual(expect.objectContaining({ marginTop: 4 })) + }) + + it('returns empty animated style before swiper step is ready', () => { + render( + + + + ) + + expect(screen.getByTestId('default-item').props.style).toContainEqual({}) + }) + + it('defaults to horizontal layout without scaling', () => { + render( + + + + ) + + expect(screen.getByTestId('default-layout-item').props.style).toContainEqual({ + width: 100, + height: '100%', + transform: [] + }) + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-swiper.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-swiper.test.tsx new file mode 100644 index 0000000000..cfc94b48f0 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-swiper.test.tsx @@ -0,0 +1,725 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import React from 'react' +import { act, fireEvent, render, screen } from '@testing-library/react-native' +import { Text } from 'react-native' +import { expectPortalHostRendered, renderWithPortalHost, resetMpxRuntimeGlobals } from './rn-component-test-utils' + +const MpxSwiper = require('../../../../lib/runtime/components/react/mpx-swiper').default +const MpxSwiperItem = require('../../../../lib/runtime/components/react/mpx-swiper-item').default + +beforeEach(() => { + jest.clearAllMocks() + const { __resetAnimatedReactions } = require('react-native-reanimated') + __resetAnimatedReactions() + resetMpxRuntimeGlobals() +}) + +afterEach(() => { + jest.useRealTimers() +}) + +describe('MpxSwiper', () => { + it('renders swiper items, pagination and emits touch change from gesture', () => { + const bindchange = jest.fn() + const { __getLastPanGesture } = require('react-native-gesture-handler') + const { __flushAnimatedReactions } = require('react-native-reanimated') + render( + + + A + + + B + + + ) + + expect(screen.getAllByText('A').length).toBeGreaterThan(0) + expect(screen.getByTestId('gesture-detector')).toBeTruthy() + fireEvent(screen.getByTestId('swiper'), 'layout', { + nativeEvent: { layout: { width: 300, height: 120 } } + }) + const gesture = __getLastPanGesture() + act(() => { + gesture.onBeginCallback({ absoluteX: 0, velocityX: 0 }) + gesture.onUpdateCallback({ absoluteX: -180, velocityX: 0 }) + gesture.onFinalizeCallback({ absoluteX: -180, velocityX: 150 }) + __flushAnimatedReactions() + }) + expect(gesture.activeOffsetX).toHaveBeenCalled() + expect(bindchange).toHaveBeenCalledWith(expect.objectContaining({ + type: 'change', + detail: { current: 1, source: 'touch' } + })) + }) + + it('updates from props, autoplay and boundary gestures', () => { + jest.useFakeTimers() + const bindchange = jest.fn() + const { __getLastPanGesture } = require('react-native-gesture-handler') + const { __flushAnimatedReactions, withTiming } = require('react-native-reanimated') + const { rerender } = render( + + + One + + + Two + + + Three + + + ) + + fireEvent(screen.getByTestId('prop-swiper'), 'layout', { + nativeEvent: { layout: { width: 240, height: 100 } } + }) + withTiming.mockClear() + act(() => { + jest.advanceTimersByTime(10) + __flushAnimatedReactions() + }) + expect(withTiming).toHaveBeenCalledWith(-240, expect.objectContaining({ duration: 500 }), expect.any(Function)) + expect(bindchange).toHaveBeenCalledWith(expect.objectContaining({ + detail: { current: 1, source: 'autoplay' } + })) + + bindchange.mockClear() + withTiming.mockClear() + rerender( + + + One + + + Two + + + ) + let gesture = __getLastPanGesture() + act(() => { + gesture.onBeginCallback({ absoluteX: 0, velocityX: 0 }) + gesture.onUpdateCallback({ absoluteX: 260, velocityX: 0 }) + gesture.onFinalizeCallback({ absoluteX: 260, velocityX: 120 }) + __flushAnimatedReactions() + }) + expect(withTiming).toHaveBeenLastCalledWith(-0, expect.objectContaining({ duration: 500 }), expect.any(Function)) + expect(bindchange).toHaveBeenCalledWith(expect.objectContaining({ + detail: { current: 0, source: 'touch' } + })) + + bindchange.mockClear() + withTiming.mockClear() + rerender( + + + One + + + Two + + + ) + fireEvent(screen.getByTestId('circular-swiper'), 'layout', { + nativeEvent: { layout: { width: 240, height: 100 } } + }) + act(() => { + __flushAnimatedReactions() + }) + expect(bindchange).toHaveBeenCalledWith(expect.objectContaining({ + detail: { current: 1, source: '' } + })) + bindchange.mockClear() + withTiming.mockClear() + gesture = __getLastPanGesture() + act(() => { + gesture.onBeginCallback({ absoluteX: 0, velocityX: 0 }) + gesture.onUpdateCallback({ absoluteX: -520, velocityX: 0 }) + gesture.onFinalizeCallback({ absoluteX: -520, velocityX: 150 }) + __flushAnimatedReactions() + }) + expect(withTiming).toHaveBeenLastCalledWith(-870, expect.objectContaining({ duration: 500 }), expect.any(Function)) + expect(bindchange).toHaveBeenCalledWith(expect.objectContaining({ + detail: { current: 0, source: 'touch' } + })) + jest.useRealTimers() + }) + + it('emits bindchange when current updates after layout', () => { + const bindchange = jest.fn() + const { __flushAnimatedReactions } = require('react-native-reanimated') + const { rerender } = render( + + + One + + + Two + + + ) + + fireEvent(screen.getByTestId('change-swiper'), 'layout', { + nativeEvent: { layout: { width: 240, height: 100 } } + }) + rerender( + + + One + + + Two + + + ) + act(() => { + __flushAnimatedReactions() + }) + + expect(bindchange).toHaveBeenCalledWith(expect.objectContaining({ + detail: { current: 1, source: '' } + })) + + bindchange.mockClear() + fireEvent(screen.getByTestId('change-swiper'), 'layout', { + nativeEvent: { layout: { width: 260, height: 100 } } + }) + expect(bindchange).not.toHaveBeenCalled() + }) + + it('rolls back a vertical swiper with one item', () => { + const { __getLastPanGesture } = require('react-native-gesture-handler') + const { withTiming } = require('react-native-reanimated') + render( + + + Only + + + ) + const gesture = __getLastPanGesture() + withTiming.mockClear() + act(() => { + gesture.onBeginCallback({ absoluteY: 0, velocityY: 0 }) + gesture.onUpdateCallback({ absoluteY: 60, velocityY: 0 }) + gesture.onFinalizeCallback({ absoluteY: 60, velocityY: 0 }) + }) + expect(gesture.activeOffsetY).toHaveBeenCalled() + expect(withTiming).toHaveBeenCalledWith(0, expect.objectContaining({ duration: 500 })) + }) + + it('does not attach a gesture detector when gestures are disabled', () => { + render( + + + First + + + Second + + + ) + expect(screen.queryByTestId('gesture-detector')).toBeNull() + }) + + it('renders fixed swipers through the portal', () => { + const fixedRender = renderWithPortalHost( + + + X + + + ) + expectPortalHostRendered(fixedRender.toJSON(), 'fixed-swiper') + }) + + it('handles autoplay terminal page and circular rollover', () => { + jest.useFakeTimers() + const terminalChange = jest.fn() + const circularChange = jest.fn() + const { __flushAnimatedReactions, withTiming } = require('react-native-reanimated') + const firstRender = render( + + + A + + + B + + + ) + + fireEvent(screen.getByTestId('autoplay-last'), 'layout', { + nativeEvent: { layout: { width: 200, height: 100 } } + }) + withTiming.mockClear() + act(() => { + jest.advanceTimersByTime(20) + }) + expect(withTiming).not.toHaveBeenCalled() + expect(terminalChange).not.toHaveBeenCalled() + firstRender.unmount() + + render( + + + A + + + B + + + ) + fireEvent(screen.getByTestId('autoplay-circular'), 'layout', { + nativeEvent: { layout: { width: 200, height: 100 } } + }) + withTiming.mockClear() + act(() => { + jest.advanceTimersByTime(10) + }) + expect(withTiming).toHaveBeenCalledWith(-600, expect.objectContaining({ + duration: 500 + }), expect.any(Function)) + act(() => { + __flushAnimatedReactions() + }) + + expect(circularChange).toHaveBeenCalledWith(expect.objectContaining({ + detail: { current: 0, source: 'autoplay' } + })) + jest.useRealTimers() + }) + + it('handles margin, circular and children length updates', () => { + jest.useFakeTimers() + const bindchange = jest.fn() + const { __flushAnimatedReactions } = require('react-native-reanimated') + const { rerender } = render( + + + One + + + Two + + + Three + + + ) + fireEvent(screen.getByTestId('updating-swiper'), 'layout', { + nativeEvent: { layout: { width: 300, height: 120 } } + }) + + rerender( + + + One + + + Two + + + Three + + + ) + + rerender( + + + One + + + Two + + + ) + act(() => { + __flushAnimatedReactions() + }) + expect(bindchange).toHaveBeenCalledWith(expect.objectContaining({ + detail: { current: 0, source: '' } + })) + act(() => { + jest.advanceTimersByTime(20) + }) + + expect(screen.queryByText('Three')).toBeNull() + expect(screen.getAllByText('One')).toHaveLength(3) + }) + + it('updates external gesture dependencies after rerender', () => { + const { __getLastPanGesture } = require('react-native-gesture-handler') + const waitFor = { current: { name: 'wait-a' } } + const nextWaitFor = { current: { name: 'wait-b' } } + const simultaneous = { current: { name: 'simultaneous-a' } } + const nextSimultaneous = { current: { name: 'simultaneous-b' } } + const { rerender } = render( + + + One + + + Two + + + ) + let gesture = __getLastPanGesture() + expect(gesture.simultaneousWithExternalGesture).toHaveBeenCalledWith(simultaneous) + expect(gesture.requireExternalGestureToFail).toHaveBeenCalledWith(waitFor) + + rerender( + + + One + + + Two + + + ) + gesture = __getLastPanGesture() + expect(gesture.simultaneousWithExternalGesture).toHaveBeenCalledWith(nextSimultaneous) + expect(gesture.requireExternalGestureToFail).toHaveBeenCalledWith(nextWaitFor) + }) + + it('wraps across both circular boundaries', () => { + const { __getLastPanGesture } = require('react-native-gesture-handler') + const { __flushAnimatedReactions, withTiming } = require('react-native-reanimated') + const circularChange = jest.fn() + render( + + + One + + + Two + + + ) + fireEvent(screen.getByTestId('circular-edge'), 'layout', { + nativeEvent: { layout: { width: 200, height: 100 } } + }) + const gesture = __getLastPanGesture() + withTiming.mockClear() + act(() => { + gesture.onBeginCallback({ absoluteX: 0, velocityX: 0 }) + gesture.onUpdateCallback({ absoluteX: 120, velocityX: 0 }) + gesture.onFinalizeCallback({ absoluteX: 120, velocityX: 0 }) + __flushAnimatedReactions() + }) + expect(withTiming).toHaveBeenCalledWith(-0, expect.objectContaining({ duration: 500 }), expect.any(Function)) + expect(circularChange).toHaveBeenCalledWith(expect.objectContaining({ + detail: { current: 1, source: 'touch' } + })) + circularChange.mockClear() + withTiming.mockClear() + act(() => { + gesture.onBeginCallback({ absoluteX: 0, velocityX: 0 }) + gesture.onUpdateCallback({ absoluteX: -450, velocityX: 0 }) + gesture.onFinalizeCallback({ absoluteX: -450, velocityX: 200 }) + __flushAnimatedReactions() + }) + expect(withTiming).toHaveBeenCalledWith(-600, expect.objectContaining({ duration: 500 }), expect.any(Function)) + expect(circularChange).toHaveBeenCalledWith(expect.objectContaining({ + detail: { current: 0, source: 'touch' } + })) + }) + + it('rolls back a circular swiper with one item', () => { + const { __getLastPanGesture } = require('react-native-gesture-handler') + const { withTiming } = require('react-native-reanimated') + render( + + + Only + + + ) + fireEvent(screen.getByTestId('circular-one'), 'layout', { + nativeEvent: { layout: { width: 200, height: 100 } } + }) + const gesture = __getLastPanGesture() + withTiming.mockClear() + act(() => { + gesture.onBeginCallback({ absoluteX: 0, velocityX: 0 }) + gesture.onUpdateCallback({ absoluteX: 80, velocityX: 0 }) + gesture.onFinalizeCallback({ absoluteX: 80, velocityX: 0 }) + }) + expect(withTiming).toHaveBeenCalledWith(0, expect.objectContaining({ duration: 500 })) + }) + + it('handles a vertical swiper without an initial size', () => { + jest.useFakeTimers() + const { __getLastPanGesture } = require('react-native-gesture-handler') + const { cancelAnimation, withTiming } = require('react-native-reanimated') + render( + + + Top + + + Bottom + + + ) + + const gesture = __getLastPanGesture() + act(() => { + gesture.onBeginCallback({ absoluteY: 0, velocityY: 0 }) + jest.advanceTimersByTime(10) + }) + expect(cancelAnimation).not.toHaveBeenCalled() + expect(withTiming).not.toHaveBeenCalled() + + fireEvent(screen.getByTestId('zero-sized-swiper'), 'layout', { + nativeEvent: { layout: { width: 200, height: 100 } } + }) + expect(gesture.activeOffsetY).toHaveBeenCalled() + }) + + it('resumes autoplay after a slow gesture and handles non-circular boundaries', () => { + jest.useFakeTimers() + const { __getLastPanGesture } = require('react-native-gesture-handler') + const { withTiming } = require('react-native-reanimated') + const autoplayRender = render( + + + One + + + Two + + + ) + fireEvent(screen.getByTestId('autoplay-gesture-swiper'), 'layout', { + nativeEvent: { layout: { width: 200, height: 100 } } + }) + let gesture = __getLastPanGesture() + withTiming.mockClear() + act(() => { + gesture.onBeginCallback({ absoluteX: 0, velocityX: 0 }) + gesture.onUpdateCallback({ absoluteX: -40, velocityX: 0 }) + gesture.onFinalizeCallback({ absoluteX: -40, velocityX: 0 }) + }) + expect(withTiming).toHaveBeenCalledWith(0, expect.objectContaining({ duration: 500 }), expect.any(Function)) + expect(jest.getTimerCount()).toBe(1) + autoplayRender.unmount() + + const startBoundaryRender = render( + + + One + + + Two + + + ) + gesture = __getLastPanGesture() + withTiming.mockClear() + act(() => { + gesture.onBeginCallback({ absoluteX: 0, velocityX: 0 }) + gesture.onFinalizeCallback({ absoluteX: 80, velocityX: 0 }) + }) + expect(withTiming).toHaveBeenCalledWith(-0, expect.objectContaining({ duration: 500 }), expect.any(Function)) + startBoundaryRender.unmount() + + render( + + + One + + + Two + + + ) + gesture = __getLastPanGesture() + withTiming.mockClear() + act(() => { + gesture.onBeginCallback({ absoluteX: 0, velocityX: 0 }) + gesture.onUpdateCallback({ absoluteX: -80, velocityX: 0 }) + gesture.onFinalizeCallback({ absoluteX: -80, velocityX: 0 }) + }) + expect(withTiming).toHaveBeenCalledWith(-200, expect.objectContaining({ duration: 500 }), expect.any(Function)) + }) + + it('handles circular upper-boundary reset and slow rollback', () => { + const { __getLastPanGesture } = require('react-native-gesture-handler') + const { withTiming } = require('react-native-reanimated') + const boundaryRender = render( + + + One + + + Two + + + ) + let gesture = __getLastPanGesture() + withTiming.mockClear() + act(() => { + gesture.onBeginCallback({ absoluteX: 0, velocityX: 0 }) + gesture.onUpdateCallback({ absoluteX: 250, velocityX: 0 }) + gesture.onFinalizeCallback({ absoluteX: 250, velocityX: 0 }) + }) + expect(withTiming).toHaveBeenCalled() + boundaryRender.unmount() + + render( + + + One + + + Two + + + ) + gesture = __getLastPanGesture() + withTiming.mockClear() + act(() => { + gesture.onBeginCallback({ absoluteX: 0, velocityX: 0 }) + gesture.onUpdateCallback({ absoluteX: -40, velocityX: 0 }) + gesture.onFinalizeCallback({ absoluteX: -40, velocityX: 0 }) + }) + expect(withTiming).toHaveBeenCalledWith(-200, expect.objectContaining({ duration: 500 }), expect.any(Function)) + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-switch.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-switch.test.tsx new file mode 100644 index 0000000000..7bee306404 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-switch.test.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import { act, fireEvent, render, screen } from '@testing-library/react-native' +import MpxSwitch from '../../../../lib/runtime/components/react/mpx-switch' +import MpxForm from '../../../../lib/runtime/components/react/mpx-form' +import { fireTap } from './helpers' +import { expectPortalHostRendered, renderWithPortalHost } from './rn-component-test-utils' + +describe('MpxSwitch', () => { + it('handles value changes and checkbox mode changes', () => { + const bindchange = jest.fn() + const checkboxChange = jest.fn() + + const { unmount } = render( + + ) + + fireEvent(screen.getByTestId('basic-switch'), 'valueChange', true) + expect(bindchange).toHaveBeenCalledWith(expect.objectContaining({ + detail: { value: true } + })) + expect(screen.getByTestId('basic-switch').props.value).toBe(true) + + unmount() + + render( + + ) + + act(() => { + fireTap(screen.getByTestId('checkbox-switch')) + }) + expect(checkboxChange).toHaveBeenCalledWith(expect.objectContaining({ + detail: { value: true } + })) + }) + + it('renders fixed switches in portal', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(jest.fn()) + const fixedRender = renderWithPortalHost( + + ) + + expectPortalHostRendered(fixedRender.toJSON(), 'fixed-switch') + + render( + + + + ) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('name attribute is required')) + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-text.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-text.test.tsx new file mode 100644 index 0000000000..b984fafc9a --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-text.test.tsx @@ -0,0 +1,507 @@ +import React from 'react' +import { render, screen } from '@testing-library/react-native' +import MpxText from '../../../../lib/runtime/components/react/mpx-text' +import { TextPassThroughContext } from '../../../../lib/runtime/components/react/context' + +// Mock mpx-portal +jest.mock('../../../../lib/runtime/components/react/mpx-portal', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mockReact = require('react') + // eslint-disable-next-line @typescript-eslint/no-var-requires + return mockReact.forwardRef((props: any, ref: any) => { + return mockReact.createElement('View', { ...props, ref, testID: 'mock-portal' }) + }) +}) + +describe('MpxText', () => { + // 综合基础功能测试 + it('should handle basic rendering, nested text and selection properties', () => { + const { rerender } = render( + + Hello World + + ) + + // 基础文本渲染测试 + let textElement = screen.getByTestId('basic-text') + expect(textElement).toBeTruthy() + expect(screen.getByText('Hello World')).toBeTruthy() + + // 测试嵌套文本组件 + rerender( + + Parent text + + Nested text + + More parent text + + ) + + textElement = screen.getByTestId('basic-text') + expect(textElement).toBeTruthy() + + // 测试文本选择属性 + rerender( + + Selectable text + + ) + + textElement = screen.getByTestId('basic-text') + expect(textElement.props.selectable).toBe(true) + expect(textElement.props.allowFontScaling).toBe(false) + + // 测试复杂样式转换 + rerender( + + Complex styled text + + ) + + textElement = screen.getByTestId('basic-text') + expect(textElement.props.style.lineHeight).toBe(24) + }) + + // MPX user-select 属性测试 + it('should handle MPX user-select property', () => { + const { rerender } = render( + + User selectable text + + ) + + let textElement = screen.getByTestId('user-select-text') + expect(textElement.props.selectable).toBe(true) + + // 测试显式设置 selectable 为 false + rerender( + + Non-selectable text + + ) + + textElement = screen.getByTestId('user-select-text') + expect(textElement.props.selectable).toBe(false) + }) + + // Ref 转发测试 + it('should properly forward refs', () => { + const ref = React.createRef() + + render( + + Ref forwarded text + + ) + + // 验证 ref 被正确转发 + expect(ref.current).toBeTruthy() + expect(ref.current).toHaveProperty('getNodeInstance') + expect(typeof ref.current.getNodeInstance).toBe('function') + + // 验证组件正确渲染 + const textElement = screen.getByTestId('ref-text') + expect(textElement).toBeTruthy() + expect(textElement.props.selectable).toBe(true) + }) + + // 普通渲染不应走 Portal + it('should render regular text without Portal', () => { + const { queryByTestId } = render( + + Portal text content + + ) + + const textElement = screen.getByTestId('portal-text') + expect(textElement).toBeTruthy() + expect(textElement.props.style).toEqual(expect.objectContaining({ + color: '#333', + fontSize: 16 + })) + expect(queryByTestId('mock-portal')).toBeNull() + }) + + it('should merge external text pass-through context', () => { + render( + + + Context styled text + + + ) + + const textElement = screen.getByTestId('context-text') + expect(textElement.props.numberOfLines).toBe(1) + expect(textElement.props.style).toEqual(expect.objectContaining({ + color: '#ff0000', + fontSize: 16, + fontWeight: 'bold', + textAlign: 'center' + })) + }) + + // 父级尺寸上下文测试 + it('should handle parent size context', () => { + render( + + + Parent size context text + + + ) + + const textElement = screen.getByTestId('parent-size-text') + expect(textElement).toBeTruthy() + expect(textElement.props.style).toEqual(expect.objectContaining({ + fontSize: 27, + width: 150, + lineHeight: 54 + })) + }) + + // 复杂嵌套和样式继承测试 + it('should handle complex nested text with style inheritance', () => { + render( + + Parent text with + + nested red italic text + + and + + underlined larger text + + back to parent style + + ) + + const textElement = screen.getByTestId('nested-styled-text') + expect(textElement.props.style).toEqual({ + color: '#333', + fontSize: 16, + fontWeight: 'bold', + lineHeight: 24 + }) + expect(screen.getByTestId('nested-red-text').props.style).toEqual({ + color: 'red', + fontSize: 16, + fontStyle: 'italic', + fontWeight: 'bold', + lineHeight: 24 + }) + expect(screen.getByTestId('nested-underlined-text').props.style).toEqual({ + color: '#333', + fontSize: 18, + fontWeight: 'bold', + lineHeight: 27, + textDecorationLine: 'underline' + }) + }) + + // 边界值和特殊情况测试 + it('should handle edge cases and special values', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined) + try { + const { rerender } = render( + + {null} + Edge case text + {false} + {0} + {''} + + ) + + let textElement = screen.getByTestId('edge-text') + expect(textElement).toBeTruthy() + expect(textElement.props.children).toEqual(expect.arrayContaining(['Edge case text', 0])) + + // 测试空文本内容 + rerender( + + {''} + + ) + + textElement = screen.getByTestId('edge-text') + expect(textElement.props.children).toBe('') + expect(screen.queryByText('Edge case text')).toBeNull() + + // 测试只有空白字符的文本 + rerender( + + {'\n \t \n'} + + ) + + textElement = screen.getByTestId('edge-text') + expect(textElement.props.children).toBe('\n \t \n') + expect(warnSpy).not.toHaveBeenCalled() + expect(errorSpy).not.toHaveBeenCalled() + } finally { + warnSpy.mockRestore() + errorSpy.mockRestore() + } + }) + + // 可访问性和交互测试 + it('should handle accessibility and interaction properties', () => { + render( + + Accessible and interactive text + + ) + + const textElement = screen.getByTestId('accessible-text') + expect(textElement.props.accessible).toBe(true) + expect(textElement.props.accessibilityLabel).toBe('Accessible text content') + expect(textElement.props.selectable).toBe(true) + expect(textElement.props.allowFontScaling).toBe(true) + }) + + // 多种样式条件测试 + it('should handle different style conditions that affect Portal usage', () => { + // 测试 position: absolute(不会触发 Portal) + const { queryByTestId, rerender } = render( + + Absolute position text + + ) + + let textElement = screen.getByTestId('position-test') + expect(textElement).toBeTruthy() + expect(queryByTestId('mock-portal')).toBeNull() + + // 测试 position: relative(不会触发 Portal) + rerender( + + Relative position text + + ) + + textElement = screen.getByTestId('position-test') + expect(textElement).toBeTruthy() + expect(queryByTestId('mock-portal')).toBeNull() + + // 测试 position: fixed(会触发 Portal) + rerender( + + Fixed position text + + ) + + textElement = screen.getByTestId('position-test') + expect(textElement).toBeTruthy() + expect(screen.getByTestId('mock-portal')).toBeTruthy() + }) + + // 边界情况:样式动态变化影响 Portal + it('should handle dynamic style changes affecting Portal usage', () => { + const { queryByTestId, rerender } = render( + + Normal text + + ) + + let textElement = screen.getByTestId('dynamic-portal-text') + expect(textElement).toBeTruthy() + expect(queryByTestId('mock-portal')).toBeNull() + + // 动态改变为 fixed 定位(会触发 Portal) + rerender( + + Now fixed text + + ) + + textElement = screen.getByTestId('dynamic-portal-text') + expect(textElement).toBeTruthy() + expect(screen.getByTestId('mock-portal')).toBeTruthy() + + // 再次改变为普通样式 + rerender( + + Back to normal + + ) + + textElement = screen.getByTestId('dynamic-portal-text') + expect(textElement).toBeTruthy() + expect(queryByTestId('mock-portal')).toBeNull() + }) + + it('should decode string and mixed children when decode is enabled', () => { + const previousMpx = global.__mpx + global.__mpx = { + config: { + rnConfig: { + allowFontScaling: true + } + } + } + + try { + const { rerender } = render( + + {'Tom & Jerry <b>' '} + + ) + + let textElement = screen.getByTestId('decoded-text') + expect(textElement.props.children).toBe('Tom & Jerry \' ') + expect(textElement.props.allowFontScaling).toBe(true) + + rerender( + + {'A '} + child + {'<tail>'} + + ) + + textElement = screen.getByTestId('decoded-text') + expect(textElement.props.children[0]).toBe('A ') + expect(textElement.props.children[2]).toBe('') + } finally { + global.__mpx = previousMpx + } + }) + + it('should record render performance scopes when enabled', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const perf = require('@mpxjs/perf') + const runtimeGlobal = global as any + perf.scopeStart.mockClear() + perf.scopeEnd.mockClear() + runtimeGlobal.__mpx_perf_framework__ = true + + try { + render(performance text) + + expect(perf.scopeStart.mock.calls.map(([name]: [string]) => name)).toEqual([ + 'text:render:total', + 'text:render:props', + 'text:render:style', + 'text:render:innerProps', + 'text:render:createElement' + ]) + expect(perf.scopeEnd).toHaveBeenCalledTimes(5) + } finally { + runtimeGlobal.__mpx_perf_framework__ = false + } + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-textarea.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-textarea.test.tsx new file mode 100644 index 0000000000..c8529bf475 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-textarea.test.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import { render, screen } from '@testing-library/react-native' +import MpxTextarea from '../../../../lib/runtime/components/react/mpx-textarea' + +describe('MpxTextarea', () => { + it('renders through input defaults', () => { + render( + + ) + + const textarea = screen.getByTestId('basic-textarea') + expect(textarea.props.multiline).toBe(true) + expect(textarea.props.enterKeyHint).toBeUndefined() + expect(textarea.props.selection).toEqual({ start: 2, end: 2 }) + expect(textarea.props.style).toEqual(expect.objectContaining({ + width: 300, + height: 200 + })) + }) + + it('uses textarea dimensions when style is omitted', () => { + render() + + expect(screen.getByTestId('default-textarea').props.style).toEqual(expect.objectContaining({ + width: 300, + height: 150 + })) + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-video.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-video.test.tsx new file mode 100644 index 0000000000..9ad0b2b9c2 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-video.test.tsx @@ -0,0 +1,199 @@ +import React from 'react' +import { act, fireEvent, render, screen } from '@testing-library/react-native' +import MpxVideo from '../../../../lib/runtime/components/react/mpx-video' + +const mockPortal = jest.fn() +const mockVideoSeek = jest.fn() +const mockVideoResume = jest.fn() +const mockVideoPause = jest.fn() +const mockVideoSetFullScreen = jest.fn() + +jest.mock('../../../../lib/runtime/components/react/mpx-portal', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mockReact = require('react') + return ({ children }: { children: any }) => { + mockPortal(children) + return mockReact.createElement(mockReact.Fragment, null, children) + } +}) + +jest.mock('react-native-video', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mockReact = require('react') + const MockVideo = mockReact.forwardRef((props: any, ref: any) => { + mockReact.useImperativeHandle(ref, () => ({ + seek: mockVideoSeek, + resume: mockVideoResume, + pause: mockVideoPause, + setFullScreen: mockVideoSetFullScreen + })) + return mockReact.createElement('Video', props) + }) + return { + __esModule: true, + default: MockVideo, + DRMType: { + FAIRPLAY: 'fairplay' + } + } +}) + +describe('MpxVideo', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders props and emits media events', () => { + const handlers = { + bindplay: jest.fn(), + bindpause: jest.fn(), + bindended: jest.fn(), + bindtimeupdate: jest.fn(), + bindfullscreenchange: jest.fn(), + bindwaiting: jest.fn(), + binderror: jest.fn(), + bindloadedmetadata: jest.fn(), + bindcontrolstoggle: jest.fn(), + bindseekcomplete: jest.fn() + } + + render( + + ) + + const video = screen.getByTestId('basic-video') + expect(video.props.source).toEqual(expect.objectContaining({ + uri: 'https://example.com/video.mp4', + drm: expect.objectContaining({ + type: 'fairplay', + certificateUrl: 'https://example.com/cert', + licenseServer: 'https://example.com/license' + }) + })) + expect(video.props.paused).toBe(false) + expect(video.props.repeat).toBe(true) + expect(video.props.muted).toBe(true) + expect(video.props.controls).toBe(false) + expect(video.props.poster).toBe('') + expect(video.props.resizeMode).toBe('stretch') + + fireEvent(video, 'load', { + naturalSize: { width: 1920, height: 1080 }, + duration: 12 + }) + fireEvent(video, 'progress', { currentTime: 5 }) + fireEvent(video, 'playbackRateChange', { playbackRate: 1 }) + fireEvent(video, 'playbackRateChange', { playbackRate: 0 }) + fireEvent(video, 'end') + fireEvent(video, 'buffer', { isBuffering: true }) + fireEvent(video, 'seek', { seekTime: 4 }) + fireEvent(video, 'fullscreenPlayerDidPresent') + fireEvent(video, 'fullscreenPlayerWillDismiss') + fireEvent(video, 'controlsVisibilityChange', { isVisible: true }) + fireEvent(video, 'error', { + error: { + localizedFailureReason: 'bad video' + } + }) + + expect(handlers.bindloadedmetadata).toHaveBeenCalledWith(expect.objectContaining({ + type: 'loadedmetadata', + detail: { width: 1920, height: 1080, duration: 12 } + })) + expect(handlers.bindtimeupdate).toHaveBeenCalledWith(expect.objectContaining({ + type: 'timeupdate', + detail: { currentTime: 5, duration: 12 } + })) + expect(handlers.bindplay).toHaveBeenCalledWith(expect.objectContaining({ type: 'play' })) + expect(handlers.bindpause).toHaveBeenCalledWith(expect.objectContaining({ type: 'pause' })) + expect(handlers.bindended).toHaveBeenCalledWith(expect.objectContaining({ type: 'ended' })) + expect(handlers.bindwaiting).toHaveBeenCalledWith(expect.objectContaining({ type: 'waiting' })) + expect(handlers.bindseekcomplete).toHaveBeenCalledWith(expect.objectContaining({ + type: 'seekcomplete', + detail: { position: 4 } + })) + expect(handlers.bindfullscreenchange).toHaveBeenCalledWith(expect.objectContaining({ + type: 'fullscreenchange', + detail: { fullScreen: 1 } + })) + expect(handlers.bindfullscreenchange).toHaveBeenCalledWith(expect.objectContaining({ + type: 'fullscreenchange', + detail: { fullScreen: 0 } + })) + expect(handlers.bindcontrolstoggle).toHaveBeenCalledWith(expect.objectContaining({ + type: 'controlstoggle', + detail: { show: true } + })) + expect(handlers.binderror).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + detail: { errMsg: 'bad video' } + })) + }) + + it('exposes instance methods and handles source variants', () => { + const ref = React.createRef() + const { rerender } = render( + + ) + + const numberVideo = screen.getByTestId('number-video') + expect(numberVideo.props.source).toBe(123) + expect(numberVideo.props.poster).toBe('poster.png') + expect(mockPortal).toHaveBeenCalled() + + act(() => { + const instance = ref.current.getNodeInstance().instance.node + instance.play() + instance.pause() + instance.seek(2) + instance.stop() + instance.requestFullScreen() + instance.exitFullScreen() + }) + expect(mockVideoResume).toHaveBeenCalledTimes(1) + expect(mockVideoPause).toHaveBeenCalledTimes(2) + expect(mockVideoSeek).toHaveBeenNthCalledWith(1, 2) + expect(mockVideoSeek).toHaveBeenNthCalledWith(2, 0) + expect(mockVideoSetFullScreen).toHaveBeenNthCalledWith(1, true) + expect(mockVideoSetFullScreen).toHaveBeenNthCalledWith(2, false) + + rerender( + + ) + + const objectVideo = screen.getByTestId('object-video') + expect(objectVideo.props.source).toEqual({ + uri: 'https://example.com/object.mp4', + headers: { token: '1' } + }) + expect(objectVideo.props.resizeMode).toBe('cover') + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-view.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-view.test.tsx new file mode 100644 index 0000000000..7a57f081a3 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-view.test.tsx @@ -0,0 +1,1102 @@ + +import React from 'react' +import { act, render, screen } from '@testing-library/react-native' +import { Image } from 'react-native' +import MpxView, { __parseBgImageForTest as parseBgImage } from '../../../../lib/runtime/components/react/mpx-view' +import MpxInlineText from '../../../../lib/runtime/components/react/mpx-inline-text' +import { fireTap, flushImageSize } from './helpers' + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { __getLastPanGesture } = require('react-native-gesture-handler') + +// Mock mpx-portal +jest.mock('../../../../lib/runtime/components/react/mpx-portal', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mockReact = require('react') + // eslint-disable-next-line @typescript-eslint/no-var-requires + return mockReact.forwardRef((props: any, ref: any) => { + return mockReact.createElement('View', { ...props, ref, testID: 'mock-portal' }) + }) +}) + +function getBackgroundView () { + return screen.UNSAFE_getAllByType('View').find((node) => { + return node.props.onLayout && node.props.style?.position === 'absolute' && node.props.style?.overflow === 'hidden' + }) +} + +function layoutBackground (width: number, height: number) { + const backgroundView = getBackgroundView() + expect(backgroundView).toBeTruthy() + act(() => { + backgroundView.props.onLayout({ + nativeEvent: { + layout: { width, height } + } + }) + }) + return backgroundView +} + +function layoutBackgroundIfNeeded (width: number, height: number) { + const backgroundView = getBackgroundView() + if (backgroundView) { + act(() => { + backgroundView.props.onLayout({ + nativeEvent: { + layout: { width, height } + } + }) + }) + } + return backgroundView +} + +describe('MpxView', () => { + // 基础渲染和样式测试 + it('should render with basic props and styles', () => { + render( + + Basic content + + ) + + const viewElement = screen.getByTestId('basic-view') + expect(viewElement.props.style).toEqual({ + backgroundColor: '#f0f0f0', + borderRadius: 5, + boxSizing: 'content-box', + padding: 10 + }) + expect(viewElement.children).toHaveLength(1) + expect((viewElement.children[0] as any).props.children).toBe('Basic content') + }) + + it('should handle nested views and complex structure', () => { + render( + + + Nested content + + Sibling content + + ) + + const viewElement = screen.getByTestId('nested-view') + const childView = screen.getByTestId('nested-child-view') + expect(viewElement.props.style).toEqual({ + boxSizing: 'content-box', + padding: 5 + }) + expect(childView.props.style).toEqual({ margin: 2 }) + expect(viewElement.children).toHaveLength(2) + expect((viewElement.children[0] as any).props.testID).toBe('nested-child-view') + expect((childView.children[0] as any).props.children).toBe('Nested content') + expect((viewElement.children[1] as any).props.children).toBe('Sibling content') + }) + + // MPX 特有功能测试 + it('should handle MPX touch events', () => { + const bindtap = jest.fn() + const bindtouchstart = jest.fn() + const bindtouchend = jest.fn() + + render( + + Touch me + + ) + + const viewElement = screen.getByTestId('touchable-view') + fireTap(viewElement) + + expect(bindtouchstart).toHaveBeenCalledWith(expect.objectContaining({ + type: 'touchstart', + detail: { x: 10, y: 20 } + })) + expect(bindtouchend).toHaveBeenCalledWith(expect.objectContaining({ + type: 'touchend', + detail: { x: 10, y: 20 } + })) + expect(bindtap).toHaveBeenCalledWith(expect.objectContaining({ + type: 'tap', + detail: { x: 10, y: 20 } + })) + }) + + it('should handle background properties', () => { + render( + + Background content + + ) + + const viewElement = screen.getByTestId('background-view') + expect(viewElement.props.style).toEqual({ + backgroundColor: '#ff0000', + borderRadius: 10 + }) + layoutBackground(100, 100) + expect(screen.getByTestId('linear-gradient').props).toEqual(expect.objectContaining({ + angle: 45, + colors: ['#ff0000', '#00ff00'], + locations: [0, 1], + useAngle: true + })) + }) + + // 布局和样式测试 + it('should handle flex layout properties', () => { + render( + + Item 1 + Item 2 + + ) + + const viewElement = screen.getByTestId('flex-view') + expect(viewElement.props.style).toEqual({ + alignItems: 'center', + display: 'flex', + flex: 1, + flexDirection: 'row', + flexWrap: 'nowrap', + justifyContent: 'space-between' + }) + expect(viewElement.children).toHaveLength(2) + expect((viewElement.children[0] as any).props.children).toBe('Item 1') + expect((viewElement.children[1] as any).props.children).toBe('Item 2') + }) + + it('should apply complete flex defaults without a flex shorthand', () => { + render() + expect(screen.getByTestId('default-flex-view').props.style).toEqual({ + display: 'flex', + flexBasis: 'auto', + flexDirection: 'row', + flexShrink: 1, + flexWrap: 'nowrap' + }) + }) + + // Ref 转发测试 + it('should properly forward refs', () => { + const ref = React.createRef() + + render( + + Ref content + + ) + + expect(ref.current).toBeTruthy() + }) + + // 可访问性测试 + it('should handle accessibility props', () => { + render( + + Accessible content + + ) + + const viewElement = screen.getByTestId('accessible-view') + expect(viewElement).toBeTruthy() + expect(viewElement.props.accessible).toBe(true) + expect(viewElement.props.accessibilityLabel).toBe('Test view') + }) + + // 边界情况和异常处理测试 + it('should handle edge cases and null values', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined) + try { + const { rerender } = render( + + Edge case content + + ) + + let viewElement = screen.getByTestId('edge-view') + expect(viewElement).toBeTruthy() + expect(screen.getByText('Edge case content')).toBeTruthy() + + // 测试 null children + rerender( + + {null} + Valid content + {false} + + ) + + viewElement = screen.getByTestId('edge-view') + expect(viewElement).toBeTruthy() + expect(screen.queryByText('Edge case content')).toBeNull() + expect(screen.getByText('Valid content')).toBeTruthy() + + // 测试零值和负值样式 + rerender( + + Zero/negative values + + ) + + viewElement = screen.getByTestId('edge-view') + expect(viewElement.props.style).toEqual(expect.objectContaining({ + width: 0, + height: -1, + margin: 0, + padding: -5 + })) + expect(screen.queryByText('Valid content')).toBeNull() + expect(screen.getByText('Zero/negative values')).toBeTruthy() + expect(warnSpy).not.toHaveBeenCalled() + expect(errorSpy).not.toHaveBeenCalled() + } finally { + warnSpy.mockRestore() + errorSpy.mockRestore() + } + }) + + // 背景图片功能测试 + it('should handle background image properties', async () => { + render( + + Background image content + + ) + await flushImageSize() + + const viewElement = screen.getByTestId('bg-image-view') + expect(viewElement).toBeTruthy() + expect(screen.getByTestId('fast-image').props.source).toEqual({ + uri: 'https://example.com/image.jpg' + }) + }) + + it('should handle enabled background without background image', () => { + render() + + expect(screen.getByTestId('empty-background-view')).toBeTruthy() + expect(getBackgroundView()).toBeUndefined() + }) + + // 线性渐变背景测试 + it('should handle linear gradient backgrounds', () => { + render( + + Gradient background + + ) + + const viewElement = screen.getByTestId('gradient-view') + expect(viewElement.props.style).toEqual({ height: 150, width: 300 }) + layoutBackground(300, 150) + expect(screen.getByTestId('linear-gradient').props).toEqual(expect.objectContaining({ + angle: 45, + colors: ['#ff0000', '#00ff00', '#0000ff'], + locations: [0, 0.5, 1], + useAngle: true + })) + }) + + it('should render animated view when animation is enabled', () => { + render( + + ) + + expect(screen.getByTestId('animated-view')).toBeTruthy() + }) + + // 悬停状态测试 + it('should handle hover states and timing', () => { + jest.useFakeTimers() + const hoverStyle = { + backgroundColor: '#00ff00', + transform: [{ scale: 1.1 }] + } + + try { + render( + + Hover me + + ) + + const gesture = __getLastPanGesture() + act(() => { + gesture.onTouchesDownCallback() + jest.advanceTimersByTime(100) + }) + expect(screen.getByTestId('hover-view').props.style).toEqual(expect.objectContaining({ + backgroundColor: '#00ff00', + transform: [{ scale: 1.1 }] + })) + + act(() => { + gesture.onTouchesUpCallback() + jest.advanceTimersByTime(200) + }) + expect(screen.getByTestId('hover-view').props.style).toEqual(expect.objectContaining({ + backgroundColor: '#ff0000' + })) + } finally { + jest.useRealTimers() + } + }) + + // 基础 Portal 功能测试 + it('should handle Portal functionality', () => { + render( + + Portal view content + + ) + + const viewElement = screen.getByTestId('portal-view') + expect(viewElement).toBeTruthy() + expect(screen.getByTestId('mock-portal')).toBeTruthy() + }) + + // 手势事件测试 + it('should handle comprehensive touch and gesture events', () => { + const mockBindtouchstart = jest.fn() + const mockBindtouchend = jest.fn() + + render( + + Gesture area + + ) + + const viewElement = screen.getByTestId('gesture-view') + fireTap(viewElement) + expect(mockBindtouchstart).toHaveBeenCalledWith(expect.objectContaining({ + type: 'touchstart' + })) + expect(mockBindtouchend).toHaveBeenCalledWith(expect.objectContaining({ + type: 'touchend' + })) + }) + + it('should pass text styles through to inline children', () => { + render( + + Context styled view + + ) + + const viewElement = screen.getByTestId('context-view') + expect(viewElement).toBeTruthy() + expect(screen.getByText('Context styled view').props.style).toEqual(expect.objectContaining({ + color: '#ff0000', + fontSize: 16 + })) + }) + + // 父级尺寸上下文测试 + it('should handle parent size context', () => { + render( + + Parent size context view + + ) + + const viewElement = screen.getByTestId('parent-size-view') + expect(viewElement).toBeTruthy() + expect(viewElement.props.style).toEqual(expect.objectContaining({ + width: 300, + height: 150 + })) + expect(screen.getByText('Parent size context view').props.style).toEqual(expect.objectContaining({ + fontSize: 24 + })) + }) + + // 复杂背景属性组合测试 + it('should handle complex background property combinations', () => { + render( + + Complex background + + ) + + const viewElement = screen.getByTestId('complex-bg-view') + expect(viewElement.props.style).toEqual({ + borderRadius: 10, + height: 200, + width: 300 + }) + layoutBackground(300, 200) + expect(screen.getByTestId('linear-gradient').props).toEqual(expect.objectContaining({ + angle: 90, + colors: ['rgba(255,0,0,0.5)', 'rgba(0,255,0,0.5)'], + locations: [0, 1], + useAngle: true + })) + }) + + // 布局变化和尺寸计算测试 + it('should handle layout changes and size calculations', () => { + const mockOnLayout = jest.fn() + + const { rerender } = render( + + Initial size + + ) + + let viewElement = screen.getByTestId('layout-view') + viewElement.props.onLayout({ + nativeEvent: { + layout: { width: 100, height: 100 } + } + }) + expect(mockOnLayout).toHaveBeenCalledWith(expect.objectContaining({ + nativeEvent: { + layout: { width: 100, height: 100 } + } + })) + + // 改变尺寸 + rerender( + + Changed size + + ) + + viewElement = screen.getByTestId('layout-view') + viewElement.props.onLayout({ + nativeEvent: { + layout: { width: 200, height: 150 } + } + }) + expect(mockOnLayout).toHaveBeenCalledTimes(2) + expect(viewElement.props.style).toEqual(expect.objectContaining({ + width: 200, + height: 150 + })) + }) + + it('should resolve self percent styles after layout', () => { + render( + + ) + + let viewElement = screen.getByTestId('self-percent-view') + expect(viewElement.props.style).toEqual(expect.objectContaining({ + borderRadius: 0, + opacity: 0 + })) + + act(() => { + viewElement.props.onLayout({ + nativeEvent: { + layout: { width: 100, height: 100 } + } + }) + }) + + viewElement = screen.getByTestId('self-percent-view') + expect(viewElement.props.style).toEqual(expect.objectContaining({ + borderRadius: 50 + })) + expect(viewElement.props.style.opacity).toBeUndefined() + }) + + // 深度嵌套和复杂结构测试 + it('should handle deeply nested complex structures', () => { + render( + + + Level 1 + + Level 2 + + Level 3 + + Level 4 + + + + + + ) + + const viewElement = screen.getByTestId('deep-nested-view') + const levels = ['#ff0000', '#00ff00', '#0000ff', '#ffff00'].map((backgroundColor, index) => { + const level = screen.getByTestId(`deep-level-${index + 1}`) + expect(level.props.style).toEqual({ backgroundColor, margin: 5 }) + expect((level.children[0] as any).props.children).toBe(`Level ${index + 1}`) + return level + }) + expect(viewElement.props.style).toEqual({ boxSizing: 'content-box', padding: 10 }) + expect((viewElement.children[0] as any).props.testID).toBe('deep-level-1') + levels.slice(1).forEach((level, index) => { + expect((levels[index].children[1] as any).props.testID).toBe(level.props.testID) + }) + }) + + // 边界条件和错误处理测试 + it('should handle boundary conditions and error cases', () => { + const { rerender } = render( + + Boundary case + + ) + + let viewElement = screen.getByTestId('boundary-view') + expect(viewElement).toBeTruthy() + + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined) + + rerender( + + Invalid properties + + ) + + viewElement = screen.getByTestId('boundary-view') + expect(viewElement).toBeTruthy() + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('background use should be stable')) + errorSpy.mockRestore() + }) + + it('should parse supported background-image values and report dropped gradients', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(jest.fn()) + const error = jest.spyOn(console, 'error').mockImplementation(jest.fn()) + + try { + expect(parseBgImage('url("https://example.com/a.png")')).toEqual({ + src: 'https://example.com/a.png', + type: 'image' + }) + expect(parseBgImage('linear-gradient(red 0% 50%, 25%, blue)')).toEqual({ + type: 'linear', + linearInfo: { + direction: '180deg', + colors: ['red', 'red', 'blue'], + locations: [0, 0.5, 1] + } + }) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('color hint')) + + expect(parseBgImage('linear-gradient(red, linear-gradient(blue, green))')).toEqual({}) + expect(error).toHaveBeenCalledWith(expect.stringContaining('多重渐变')) + + expect(parseBgImage('radial-gradient(red, blue)')).toEqual({}) + expect(error).toHaveBeenCalledWith(expect.stringContaining('仅支持 url(...) / linear-gradient(...)')) + } finally { + warn.mockRestore() + error.mockRestore() + } + }) + + it('should render layout-dependent linear gradient backgrounds', () => { + render( + + Gradient with layout + + ) + + const backgroundView = layoutBackground(200, 100) + const gradient = screen.getByTestId('linear-gradient') + const gradientProps = gradient.props + layoutBackground(200, 100) + expect(screen.getByTestId('linear-gradient').props).toBe(gradientProps) + expect(gradient.props.colors).toEqual(['red', 'blue']) + expect(gradient.props.locations).toEqual([0, 1]) + expect(gradient.props.angle).toBeCloseTo(135) + expect(gradient.props.style).toEqual(expect.objectContaining({ + width: '50%', + height: '100%' + })) + expect(backgroundView.props.style).toEqual(expect.objectContaining({ + borderRadius: 6, + overflow: 'hidden' + })) + }) + + it('should resolve cover image background size and percent position after layout', async () => { + render( + + Image with layout + + ) + + layoutBackground(300, 100) + await flushImageSize() + const image = screen.getByTestId('fast-image') + const imageProps = image.props + layoutBackground(300, 100) + expect(screen.getByTestId('fast-image').props).toBe(imageProps) + + expect(image.props.source).toEqual({ + uri: 'https://example.com/cover.jpg' + }) + expect(image.props.style).toEqual(expect.objectContaining({ + width: 300, + height: 300, + right: 0, + top: -50 + })) + }) + + it('should parse gradient interpolation and invalid color stop fallbacks', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(jest.fn()) + const error = jest.spyOn(console, 'error').mockImplementation(jest.fn()) + + try { + expect(parseBgImage('linear-gradient(0.25turn, red, blue)').linearInfo.direction).toBe('0.25turn') + expect(parseBgImage('linear-gradient(1.5707963267948966rad, red, blue)').linearInfo.direction).toBe('1.5707963267948966rad') + expect(parseBgImage('linear-gradient(100grad, red, blue)').linearInfo.direction).toBe('100grad') + expect(parseBgImage('linear-gradient(90deg, tomato, blue)').linearInfo.colors).toEqual(['tomato', 'blue']) + expect(parseBgImage('linear-gradient(100grad, red 0%, green, blue 100%)')).toEqual({ + type: 'linear', + linearInfo: { + direction: '100grad', + colors: ['red', 'green', 'blue'], + locations: [0, 0.5, 1] + } + }) + expect(parseBgImage('linear-gradient(red 10px, blue)')).toEqual({ + type: 'linear', + linearInfo: { + direction: '180deg', + colors: ['red', 'blue'], + locations: [0, 1] + } + }) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('色标位置仅支持百分比')) + + expect(parseBgImage('linear-gradient(30%, 40%)')).toEqual({}) + expect(error).toHaveBeenCalledWith(expect.stringContaining('至少需要 2 个有效色标')) + } finally { + warn.mockRestore() + error.mockRestore() + } + }) + + it('should normalize linear gradient angle units', () => { + const cases = [ + { id: 'turn-gradient', image: 'linear-gradient(0.25turn, red, blue)', angle: 90 }, + { id: 'rad-gradient', image: 'linear-gradient(3.141592653589793rad, red, blue)', angle: 180 }, + { id: 'grad-gradient', image: 'linear-gradient(100grad, red, blue)', angle: 90 }, + { id: 'alias-gradient', image: 'linear-gradient(to right bottom, red, blue)', angle: 135 }, + { id: 'top-right-alias-gradient', image: 'linear-gradient(to right top, red, blue)', angle: 45 }, + { id: 'bottom-left-alias-gradient', image: 'linear-gradient(to left bottom, red, blue)', angle: 225 } + ] + + cases.forEach(({ id, image, angle }) => { + const { unmount } = render( + + {id} + + ) + + layoutBackgroundIfNeeded(100, 100) + expect(screen.getByTestId('linear-gradient').props.angle).toBeCloseTo(angle) + unmount() + }) + }) + + it('should resolve contain and auto image background sizing', async () => { + const cases = [ + { + id: 'contain-image', + backgroundSize: ['contain'], + layout: { width: 300, height: 100 }, + expectedStyle: { width: 100, height: 100 } + }, + { + id: 'auto-height-image', + backgroundSize: ['auto', '50%'], + layout: { width: 300, height: 200 }, + expectedStyle: { width: 100, height: 100 } + }, + { + id: 'auto-width-image', + backgroundSize: ['50%', 'auto'], + layout: { width: 300, height: 200 }, + expectedStyle: { width: 150, height: 150 } + }, + { + id: 'auto-auto-image', + backgroundSize: ['auto', 'auto'], + layout: { width: 300, height: 200 }, + expectedStyle: { width: 100, height: 100 } + } + ] + + for (const item of cases) { + const { unmount } = render( + + {item.id} + + ) + + layoutBackgroundIfNeeded(item.layout.width, item.layout.height) + await flushImageSize() + expect(screen.getByTestId('fast-image').props.style).toEqual(expect.objectContaining(item.expectedStyle)) + unmount() + } + }) + + it('should reuse cached image dimensions and ignore stale image callbacks', () => { + type ImageSizeCallback = (width: number, height: number) => void + const getSize = Image.getSize as jest.Mock + const defaultGetSize = getSize.getMockImplementation() + const imageSizeCallbacks = new Map() + getSize.mockClear() + getSize.mockImplementation((uri, success) => { + imageSizeCallbacks.set(uri, success) + }) + const renderImageView = (src?: string) => ( + + ) + + try { + const imageRender = render(renderImageView('cached')) + act(() => { + imageSizeCallbacks.get('https://example.com/cached.jpg')!(80, 40) + }) + expect(screen.getByTestId('fast-image').props.style).toEqual(expect.objectContaining({ width: 80, height: 40 })) + + imageRender.rerender(renderImageView()) + imageRender.rerender(renderImageView('cached')) + expect(screen.getByTestId('fast-image').props.style).toEqual(expect.objectContaining({ width: 80, height: 40 })) + expect(getSize).toHaveBeenCalledTimes(1) + + imageRender.rerender(renderImageView('stale')) + imageRender.rerender(renderImageView('latest')) + act(() => { + imageSizeCallbacks.get('https://example.com/latest.jpg')!(240, 120) + }) + expect(screen.getByTestId('fast-image').props.source.uri).toContain('latest.jpg') + expect(screen.getByTestId('fast-image').props.style).toEqual(expect.objectContaining({ width: 240, height: 120 })) + + act(() => { + imageSizeCallbacks.get('https://example.com/stale.jpg')!(60, 30) + }) + expect(screen.getByTestId('fast-image').props.style).toEqual(expect.objectContaining({ width: 240, height: 120 })) + expect(getSize).toHaveBeenCalledTimes(3) + } finally { + getSize.mockImplementation(defaultGetSize) + } + }) + + it('should clamp inherited inner background radii at zero', () => { + render( + + ) + expect((screen.getByTestId('inner-radius-view').children[0] as any).props.style).toEqual(expect.objectContaining({ + borderRadius: 0, + overflow: 'hidden' + })) + }) + + it('should normalize numeric and three-part background positions', async () => { + const cases = [ + { + id: 'numeric-position', + backgroundPosition: 5, + expectedStyle: { left: 5, top: 45 } + }, + { + id: 'single-left-position', + backgroundPosition: ['left'], + expectedStyle: { left: 0, top: 45 } + }, + { + id: 'single-top-position', + backgroundPosition: ['top'], + expectedStyle: { left: 45, top: 0 } + }, + { + id: 'two-keyword-position', + backgroundPosition: ['right', 'bottom'], + expectedStyle: { right: 0, bottom: 0 } + }, + { + id: 'three-leading-keywords-position', + backgroundPosition: ['left', 'bottom', 10], + expectedStyle: { left: 0, bottom: 10 } + }, + { + id: 'three-keyword-position', + backgroundPosition: ['bottom', 10, 'right'], + expectedStyle: { bottom: 10, right: 0 } + }, + { + id: 'three-offset-position', + backgroundPosition: ['right', 10, 'bottom'], + expectedStyle: { right: 10, bottom: 0 } + } + ] + + for (const item of cases) { + const { unmount } = render( + + {item.id} + + ) + + layoutBackgroundIfNeeded(100, 100) + await flushImageSize() + expect(screen.getByTestId('fast-image').props.style).toEqual(expect.objectContaining(item.expectedStyle)) + unmount() + } + }) + + it('should record render performance scopes when enabled', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const perf = require('@mpxjs/perf') + const runtimeGlobal = global as any + perf.scopeStart.mockClear() + perf.scopeEnd.mockClear() + runtimeGlobal.__mpx_perf_framework__ = true + + try { + render() + + expect(perf.scopeStart.mock.calls.map(([name]: [string]) => name)).toEqual([ + 'view:render:total', + 'view:render:props', + 'view:render:style', + 'view:render:innerProps', + 'view:render:createElement' + ]) + expect(perf.scopeEnd).toHaveBeenCalledTimes(5) + } finally { + runtimeGlobal.__mpx_perf_framework__ = false + } + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/mpx-web-view.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/mpx-web-view.test.tsx new file mode 100644 index 0000000000..e68a83c2bb --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/mpx-web-view.test.tsx @@ -0,0 +1,313 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import React from 'react' +import { act, fireEvent, screen } from '@testing-library/react-native' +import { getWebViews, renderWithPortalHost, resetMpxRuntimeGlobals } from './rn-component-test-utils' + +const mockNavigateTo = jest.fn(() => Promise.resolve({ errMsg: 'navigateTo:ok' })) +const mockNavigateBack = jest.fn(() => Promise.resolve({ errMsg: 'navigateBack:ok' })) +const mockRedirectTo = jest.fn(() => Promise.resolve({ errMsg: 'redirectTo:ok' })) +const mockReLaunch = jest.fn(() => Promise.resolve({ errMsg: 'reLaunch:ok' })) +const mockSwitchTab = jest.fn(() => Promise.resolve({ errMsg: 'switchTab:ok' })) + +jest.mock('@mpxjs/api-proxy', () => ({ + promisify: jest.fn((apis) => apis), + navigateTo: mockNavigateTo, + navigateBack: mockNavigateBack, + redirectTo: mockRedirectTo, + reLaunch: mockReLaunch, + switchTab: mockSwitchTab +})) + +const mockPreventRemoveState: { enabled?: boolean, callback?: Function } = {} +jest.mock('@react-navigation/native', () => ({ + usePreventRemove: jest.fn((enabled, callback) => { + mockPreventRemoveState.enabled = enabled + mockPreventRemoveState.callback = callback + }) +}), { virtual: true }) + +const mockWebViewInstances: any[] = [] +jest.mock('react-native-webview', () => { + const mockReact = require('react') + const MockWebView = mockReact.forwardRef((props: any, ref: any) => { + const instanceRef = mockReact.useRef({ + goBack: jest.fn(), + injectJavaScript: jest.fn(), + postMessage: jest.fn(), + reload: jest.fn() + }) + mockReact.useImperativeHandle(ref, () => instanceRef.current) + mockReact.useEffect(() => { + mockWebViewInstances.push(instanceRef.current) + }, []) + return mockReact.createElement('WebView', Object.assign({ testID: props.testID || 'mock-webview' }, props)) + }) + return { + __esModule: true, + WebView: MockWebView, + default: MockWebView + } +}) + +const MpxWebView = require('../../../../lib/runtime/components/react/mpx-web-view').default + +beforeEach(() => { + jest.clearAllMocks() + resetMpxRuntimeGlobals() + mockWebViewInstances.length = 0 +}) + +describe('MpxWebView', () => { + it('validates hosts, handles navigation, messages and load events', async () => { + const customApi = (global as any).__mpx.config.rnConfig.webviewConfig.apiImplementations.customApi + const navigation = { + setPageConfig: jest.fn(), + dispatch: jest.fn(), + getState: jest.fn(() => ({ routes: [{ name: 'index' }, { name: 'web' }] })) + } + const bindmessage = jest.fn() + const bindload = jest.fn() + const binderror = jest.fn() + + renderWithPortalHost( + , + navigation + ) + + const webView = getWebViews()[0] + fireEvent(webView, 'navigationStateChange', { + navigationType: 'click', + url: 'https://m.example.com/next', + canGoBack: true + }) + expect((global as any).__mockPage.__webViewUrl).toBe('https://m.example.com/next') + expect(mockPreventRemoveState.enabled).toBe(true) + + fireEvent(webView, 'message', { + nativeEvent: { + url: 'https://m.example.com/page', + data: JSON.stringify({ + type: 'setTitle', + payload: { _documentTitle: ' New title ' } + }) + } + }) + expect(navigation.setPageConfig).toHaveBeenCalledWith({ navigationBarTitleText: 'New title' }) + + fireEvent(webView, 'message', { + nativeEvent: { + url: 'https://m.example.com/page', + data: JSON.stringify({ + type: 'postMessage', + payload: { data: ['hello'] }, + callbackId: 1 + }) + } + }) + expect(bindmessage).toHaveBeenCalledWith(expect.objectContaining({ + type: 'message', + detail: { data: ['hello'] } + })) + + fireEvent(webView, 'message', { + nativeEvent: { + url: 'https://m.example.com/page', + data: JSON.stringify({ + type: 'customApi', + payload: { count: 1 }, + callbackId: 2 + }) + } + }) + await act(async () => { + await Promise.resolve() + }) + expect(customApi).toHaveBeenCalledWith({ count: 1 }) + expect(mockWebViewInstances[0].injectJavaScript).toHaveBeenCalledWith( + expect.stringContaining('"type":"customApi","callbackId":2,"result":{"ok":true}') + ) + + fireEvent(webView, 'httpError', { nativeEvent: { statusCode: 500 } }) + fireEvent(webView, 'loadEnd', { timeStamp: 1, nativeEvent: { url: 'https://m.example.com/page' } }) + expect(binderror).toHaveBeenCalledWith(expect.objectContaining({ + detail: { src: 'https://m.example.com/page', statusCode: 500 } + })) + + fireEvent(webView, 'loadEnd', { timeStamp: 2, nativeEvent: { url: 'https://m.example.com/page' } }) + expect(bindload).toHaveBeenCalledWith(expect.objectContaining({ + detail: { src: 'https://m.example.com/page' } + })) + + act(() => { + mockPreventRemoveState.callback?.({ data: { action: { type: 'GO_BACK' } } }) + }) + expect(mockWebViewInstances[0].goBack).toHaveBeenCalled() + }) + + it('bridges navigation APIs, rejects missing implementations and ignores blocked messages', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + const customApi = (global as any).__mpx.config.rnConfig.webviewConfig.apiImplementations.customApi + const navigation = { + setPageConfig: jest.fn(), + dispatch: jest.fn(), + getState: jest.fn(() => ({ routes: [{ name: 'index' }, { name: 'web' }] })) + } + renderWithPortalHost(, navigation) + const webView = getWebViews()[0] + + const emitMessage = (type: string, callbackId: number, payload = {}) => { + fireEvent(webView, 'message', { + nativeEvent: { + url: 'https://m.example.com/page', + data: JSON.stringify({ type, payload, callbackId }) + } + }) + } + + emitMessage('navigateTo', 1, { url: '/pages/a' }) + emitMessage('navigateBack', 2, { delta: 1 }) + emitMessage('redirectTo', 3, { url: '/pages/b' }) + emitMessage('switchTab', 4, { url: '/pages/tab' }) + emitMessage('reLaunch', 5, { url: '/pages/home' }) + emitMessage('missingApi', 6, { value: 1 }) + fireEvent(webView, 'message', { + nativeEvent: { + url: 'https://blocked.test/page', + data: JSON.stringify({ type: 'customApi', payload: { blocked: true }, callbackId: 7 }) + } + }) + await act(async () => { + await Promise.resolve() + }) + + expect(mockNavigateTo).toHaveBeenCalledWith({ url: '/pages/a' }) + expect(mockNavigateBack).toHaveBeenCalledWith({ delta: 1 }) + expect(mockRedirectTo).toHaveBeenCalledWith({ url: '/pages/b' }) + expect(mockSwitchTab).toHaveBeenCalledWith({ url: '/pages/tab' }) + expect(mockReLaunch).toHaveBeenCalledWith({ url: '/pages/home' }) + expect(mockWebViewInstances[0].injectJavaScript).toHaveBeenCalledWith(expect.stringContaining('missingApi')) + expect(customApi).not.toHaveBeenCalled() + + act(() => { + mockPreventRemoveState.callback?.({ data: { action: { type: 'GO_BACK' } } }) + }) + expect(navigation.dispatch).toHaveBeenCalledWith({ type: 'GO_BACK' }) + warnSpy.mockRestore() + }) + + it('renders empty for missing or disallowed src and reloads initial load errors', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined) + const emptyRender = renderWithPortalHost() + expect(emptyRender.queryByTestId('mock-webview')).toBeNull() + emptyRender.unmount() + + const blockedRender = renderWithPortalHost() + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('domainWhiteLists')) + blockedRender.unmount() + + renderWithPortalHost() + act(() => { + fireEvent(getWebViews()[0], 'error') + }) + expect(screen.getByText('网络不可用,请检查网络设置')).toBeTruthy() + fireEvent(screen.getByText('重新加载').parent, 'touchEnd') + expect(screen.getByTestId('mock-webview')).toBeTruthy() + errorSpy.mockRestore() + }) + + it('handles non-iOS load progress, deferred loadEnd, reload and empty whitelist', () => { + jest.useFakeTimers() + ;(global as any).__mpx_mode__ = 'android' + ;(global as any).__mpx.config.rnConfig.webviewConfig.hostWhitelists = [] + const bindload = jest.fn() + + const loadRender = renderWithPortalHost() + const webView = getWebViews()[0] + fireEvent(webView, 'loadProgress', { nativeEvent: { canGoBack: true } }) + fireEvent(webView, 'loadEnd', { + persist: jest.fn(), + timeStamp: 3, + nativeEvent: { url: 'https://blocked.test/page' } + }) + act(() => { + jest.advanceTimersByTime(0) + }) + expect(bindload).toHaveBeenCalledWith(expect.objectContaining({ + detail: { src: 'https://blocked.test/page' } + })) + loadRender.unmount() + + renderWithPortalHost() + act(() => { + fireEvent(getWebViews()[0], 'error') + }) + fireEvent(screen.getByText('重新加载').parent, 'touchEnd') + expect(screen.getByTestId('mock-webview')).toBeTruthy() + jest.useRealTimers() + }) + + it('handles malformed messages, args payloads and loaded-page errors', async () => { + (global as any).__mpx.config.rnConfig.webviewConfig.hostWhitelists = [] + const customApi = (global as any).__mpx.config.rnConfig.webviewConfig.apiImplementations.customApi + const navigation = { + setPageConfig: jest.fn(), + dispatch: jest.fn(), + getState: jest.fn(() => ({ routes: [{ name: 'index' }, { name: 'web' }] })) + } + + renderWithPortalHost(, navigation) + const webView = getWebViews()[0] + fireEvent(webView, 'navigationStateChange', { + url: 'notaurl', + canGoBack: false + }) + fireEvent(webView, 'message', { + nativeEvent: { + url: 'notaurl', + data: { invalid: true } + } + }) + fireEvent(webView, 'message', { + nativeEvent: { + url: 'notaurl', + data: '{bad json' + } + }) + fireEvent(webView, 'message', { + nativeEvent: { + url: 'notaurl', + data: JSON.stringify({ + type: 'setTitle', + payload: {} + }) + } + }) + fireEvent(webView, 'message', { + nativeEvent: { + url: 'notaurl', + data: JSON.stringify({ + type: 'customApi', + args: [{ count: 2 }], + callbackId: 8 + }) + } + }) + await act(async () => { + await Promise.resolve() + }) + expect(navigation.setPageConfig).not.toHaveBeenCalled() + expect(customApi).toHaveBeenCalledWith({ count: 2 }) + expect(mockWebViewInstances[0].injectJavaScript).toHaveBeenCalledWith( + expect.stringContaining('"type":"customApi","callbackId":8,"result":{"ok":true}') + ) + + fireEvent(webView, 'loadEnd', { timeStamp: 4, nativeEvent: { url: 'notaurl' } }) + fireEvent(webView, 'error') + expect(screen.queryByText('网络不可用,请检查网络设置')).toBeNull() + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/parser.test.ts b/packages/webpack-plugin/test/runtime/react-native/components/parser.test.ts new file mode 100644 index 0000000000..d6735cbda1 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/parser.test.ts @@ -0,0 +1,58 @@ +import { ExpressionParser, parseFunc, ReplaceSource } from '../../../../lib/runtime/components/react/parser' + +describe('react runtime parser helpers', () => { + it('parses arithmetic expressions with units, functions and unary negatives', () => { + const parser = new ExpressionParser( + 'max(10px + 5px * 2, (-4px + 20px) / 2)', + (value) => parseFloat(value), + { + max: Math.max + } + ) + + expect(parser.parse()).toEqual({ + type: 'NUMBER', + value: 20 + }) + }) + + it('throws readable errors for malformed expressions', () => { + expect(() => new ExpressionParser('(1 + 2,').parse()).toThrow('Expected closing parenthesis') + expect(() => new ExpressionParser('(1 + 2').parse()).toThrow() + expect(() => new ExpressionParser('min 1', undefined, { min: Math.min }).parse()).toThrow('Expected opening parenthesis after function') + expect(() => new ExpressionParser('foo(1)', undefined, {}).parse()).toThrow('Unexpected token: foo') + }) + + it('extracts nested function calls and arguments', () => { + expect(parseFunc('calc(10px + max(1px, 2px)) var(--x, rgb(1, 2, 3))', 'calc')).toEqual([ + { + start: 0, + end: 26, + args: ['10px + max(1px, 2px)'] + } + ]) + + expect(parseFunc('var(--x, rgb(1, 2, 3)) var(--y)', 'var')).toEqual([ + { + start: 0, + end: 22, + args: ['--x', 'rgb(1, 2, 3)'] + }, + { + start: 23, + end: 31, + args: ['--y'] + } + ]) + }) + + it('applies ordered source replacements with inclusive end offsets', () => { + const source = new ReplaceSource('linear-gradient(red, blue) url(hero.png)') + + source.replace(0, 25, 'gradient') + source.replace(27, 39, 'image') + + expect(source.source()).toBe('gradient image') + expect(new ReplaceSource('unchanged').source()).toBe('unchanged') + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/rn-component-test-utils.tsx b/packages/webpack-plugin/test/runtime/react-native/components/rn-component-test-utils.tsx new file mode 100644 index 0000000000..fde52dbd32 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/rn-component-test-utils.tsx @@ -0,0 +1,95 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import React from 'react' +import { render, screen } from '@testing-library/react-native' + +const Portal = require('../../../../lib/runtime/components/react/mpx-portal').default +const { RouteContext } = require('../../../../lib/runtime/components/react/context') + +export function resetMpxRuntimeGlobals () { + const runtimeGlobal = global as any + runtimeGlobal.__mpx_mode__ = 'ios' + runtimeGlobal.__mpx = { + i18n: { locale: 'zh-CN' }, + config: { + rnConfig: { + cameraPermission: jest.fn(() => Promise.resolve(true)), + webviewConfig: { + hostWhitelists: ['example.com'], + apiImplementations: { + customApi: jest.fn(() => ({ ok: true })) + } + }, + onPickerVibrate: jest.fn() + } + } + } + runtimeGlobal.__mpxPageStatusMap = {} + runtimeGlobal.__mpxAppCbs = { + lazyLoad: [jest.fn()] + } + runtimeGlobal.__mockPage = { + getPageId: () => 1, + id: 1, + __webViewUrl: '' + } + runtimeGlobal.getCurrentPages = jest.fn(() => [runtimeGlobal.__mockPage]) + runtimeGlobal.atob = jest.fn((value) => `decoded:${value}`) + runtimeGlobal.mpxGlobal = runtimeGlobal.mpxGlobal || { __mpx: { config: {} } } + runtimeGlobal.mpxGlobal.__mpx = runtimeGlobal.mpxGlobal.__mpx || { config: {} } + runtimeGlobal.mpxGlobal.__mpx.config = runtimeGlobal.mpxGlobal.__mpx.config || {} + runtimeGlobal.mpxGlobal.__mpx.config.rnConfig = { + enableNativeKeyboardAvoiding: false, + onLazyLoadPageError: jest.fn(), + onStackTopBack: jest.fn(), + onPickerVibrate: jest.fn() + } +} + +export function renderWithRoute (children: React.ReactNode, navigation: any = {}, pageId = 1) { + return render( + + {children} + + ) +} + +export function renderWithPortalHost (children: React.ReactNode, navigation: any = {}, pageId = 1) { + return renderWithRoute( + + {children} + , + navigation, + pageId + ) +} + +export function getWebViews () { + return screen.UNSAFE_getAllByType('WebView') +} + +export function getViews () { + return screen.UNSAFE_getAllByType('View') +} + +function hasTestID (tree: any, testID: string): boolean { + if (!tree) return false + if (Array.isArray(tree)) { + return tree.some((node) => hasTestID(node, testID)) + } + if (tree.props?.testID === testID) return true + return hasTestID(tree.children, testID) +} + +export function expectPortalHostRendered (tree: any, testID: string) { + expect(Array.isArray(tree)).toBe(true) + const [host, ...portalChildren] = tree + expect(hasTestID(host, testID)).toBe(false) + expect(hasTestID(portalChildren, testID)).toBe(true) +} + +export function expectPortalHostInline (tree: any, testID: string) { + expect(Array.isArray(tree)).toBe(true) + const [host, ...portalChildren] = tree + expect(hasTestID(host, testID)).toBe(true) + expect(hasTestID(portalChildren, testID)).toBe(false) +} diff --git a/packages/webpack-plugin/test/runtime/react-native/components/setup.js b/packages/webpack-plugin/test/runtime/react-native/components/setup.js new file mode 100644 index 0000000000..1721736e72 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/setup.js @@ -0,0 +1,95 @@ +// Mock Easing first to avoid import issues +jest.mock('@mpxjs/perf', () => ({ + scopeStart: jest.fn(() => -1), + scopeEnd: jest.fn() +})) + +jest.doMock('react-native', () => { + const RN = jest.requireActual('react-native') + const React = jest.requireActual('react') + + // Mock RefreshControl + const MockRefreshControl = React.forwardRef((props, ref) => + React.createElement(RN.View, { ...props, ref, testID: props.testID || 'refresh-control' }) + ) + + return { + ...RN, + RefreshControl: MockRefreshControl, + useAnimatedValue: jest.fn(() => ({ + interpolate: jest.fn(() => '0deg') + })), + Animated: Object.assign({}, RN.Animated, { + Image: RN.Animated?.Image || RN.Image, + timing: jest.fn(() => ({})), + loop: jest.fn(() => ({ + start: jest.fn(), + stop: jest.fn() + })) + }), + Easing: { + linear: jest.fn(), + ease: jest.fn(), + in: jest.fn(() => jest.fn()), + out: jest.fn(() => jest.fn()), + inOut: jest.fn(() => jest.fn()), + poly: jest.fn(() => jest.fn()), + bezier: jest.fn(() => jest.fn()), + circle: jest.fn(), + sin: jest.fn(), + exp: jest.fn(), + elastic: jest.fn(() => jest.fn()), + back: jest.fn(() => jest.fn()), + bounce: jest.fn(), + step0: jest.fn(), + step1: jest.fn() + } + } +}) + +// 定义全局变量 +global.__mpx_mode__ = 'ios' // 设置为React Native模式 +global.__DEV__ = false // 设置开发模式标志 +global.__mpx_perf_framework__ = false +global.React = require('react') + +// 与生产环境 styleHelperMixin.ios.js 的 formatValue 保持一致。 +const { Dimensions, Image, StyleSheet } = require('react-native') +const screen = Dimensions.get('screen') +const unit = { + rpx: (value) => value * screen.width / 750, + vw: (value) => value * screen.width / 100, + vh: (value) => value * screen.height / 100 +} +const unitRegExp = /^\s*(-?(?:\d+(?:\.\d+)?|\.\d+))(rpx|vw|vh|px)?\s*$/ +global.__formatValue = jest.fn((value, unitType) => { + if (unitType && typeof unit[unitType] === 'function') return unit[unitType](+value) + if (value === 'hairlineWidth') return StyleSheet.hairlineWidth + const matched = unitRegExp.exec(value) + if (!matched) return value + if (!matched[2] || matched[2] === 'px') return +matched[1] + return unit[matched[2]](+matched[1]) +}) + +// Silence the warning: Animated: `useNativeDriver` is not supported because the native animated module is missing +jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper') + +// Mock Image.getSize for background image functionality +Image.getSize = jest.fn((uri, success, error) => { + if (uri.includes('fail')) { + error && error() + return + } + // Mock successful image loading with default dimensions + setTimeout(() => success(100, 100), 0) +}) +Image.resolveAssetSource = jest.fn((source) => source) + +// Mock mpxGlobal for warnings +global.mpxGlobal = { + __mpx: { + config: { + ignoreWarning: false + } + } +} diff --git a/packages/webpack-plugin/test/runtime/react-native/components/use-nodes-ref.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/use-nodes-ref.test.tsx new file mode 100644 index 0000000000..d2e6bdd3e6 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/use-nodes-ref.test.tsx @@ -0,0 +1,34 @@ +import React, { useRef } from 'react' +import { render } from '@testing-library/react-native' +import { View } from 'react-native' +import useNodesRef from '../../../../lib/runtime/components/react/useNodesRef' + +interface NodeHandler { + getNodeInstance(): any +} + +interface NodeProps { + id: string +} + +const NodeProbe = React.forwardRef(function NodeProbe (props, ref) { + const nodeRef = useRef(null) + useNodesRef(props, ref, nodeRef) + return +}) + +NodeProbe.displayName = 'NodeProbe' + +describe('useNodesRef', () => { + it('uses an empty instance by default and exposes current props', () => { + const ref = React.createRef() + const { rerender } = render() + + const nodeInstance = ref.current?.getNodeInstance() + expect(nodeInstance.instance).toEqual({}) + expect(nodeInstance.props.current).toEqual({ id: 'node' }) + + rerender() + expect(nodeInstance.props.current).toEqual({ id: 'updated-node' }) + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/components/utils.test.tsx b/packages/webpack-plugin/test/runtime/react-native/components/utils.test.tsx new file mode 100644 index 0000000000..084a119428 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/components/utils.test.tsx @@ -0,0 +1,867 @@ +import React, { useRef } from 'react' +import { act, render } from '@testing-library/react-native' +import { Image, Text, View } from 'react-native' +import { + debounce, + flatGesture, + getCurrentPage, + getDefaultAllowFontScaling, + getRestProps, + isBoxSizingAffectingStyle, + isStringChildren, + isText, + omit, + parseUrl, + parseValues, + pickStyle, + renderImage, + resolveTextFontSizePercentStyle, + resolveTextLineHeightPercentStyle, + setStyle, + splitProps, + splitStyle, + transformBoxSizing, + transformShorthand, + traverseStyle, + useDebounceCallback, + useHover, + useLayout, + usePrevious, + useRunOnJSCallback, + useStableCallback, + useTextPassThrough, + useTransformStyle, + useUpdateEffect, + wrapChildren +} from '../../../../lib/runtime/components/react/utils' +import { RouteContext, ScrollViewContext, TextPassThroughContext, VarContext } from '../../../../lib/runtime/components/react/context' + +describe('react runtime utils', () => { + beforeEach(() => { + global.__mpx = global.__mpx || { config: { rnConfig: {} } } + global.__mpx.config = global.__mpx.config || { rnConfig: {} } + global.__mpx.config.rnConfig = global.__mpx.config.rnConfig || {} + global.__mpx.config.rnConfig.allowFontScaling = true + }) + + afterEach(() => { + jest.useRealTimers() + jest.restoreAllMocks() + }) + + it('handles object, url, text and prop helpers', () => { + const CustomText = () => null + Object.assign(CustomText, { isCustomText: true }) + + expect(omit({ a: 1, b: 2, c: 3 }, ['b'])).toEqual({ a: 1, c: 3 }) + expect(parseUrl('url("https://example.com/a.png")')).toBe('https://example.com/a.png') + expect(parseUrl('none')).toBeUndefined() + expect(getRestProps({ role: 'button' }, { id: 'root', role: 'link' }, ['role'])).toEqual({ + role: 'button', + id: 'root' + }) + expect(getDefaultAllowFontScaling()).toBe(true) + expect(isText(native)).toBe(true) + expect(isText()).toBe(true) + expect(isText()).toBe(false) + expect(isStringChildren(['a', 'b'])).toBe(true) + expect(isStringChildren(['a', b])).toBe(false) + expect(parseValues('rgb(1, 2, 3) 10px calc(100% - 1px)')).toEqual([ + 'rgb(1, 2, 3)', + '10px', + 'calc(100% - 1px)' + ]) + }) + + it('handles helper defaults and empty inputs', () => { + const previousMpx = global.__mpx + const previousGetCurrentPages = global.getCurrentPages + const setter = jest.fn() + const presetBoxStyle = { boxSizing: 'border-box' } + + global.__mpx = undefined as any + global.getCurrentPages = undefined as any + transformBoxSizing(presetBoxStyle) + setStyle({ nested: null }, ['nested', 'value'], setter) + + expect(parseUrl()).toBeUndefined() + expect(getRestProps()).toEqual({}) + expect(getDefaultAllowFontScaling()).toBe(false) + expect(isText(null)).toBe(false) + expect(isText(React.createElement('Text'))).toBe(false) + expect(isStringChildren('text')).toBe(true) + expect(isStringChildren(text)).toBe(false) + expect(splitStyle({ width: 10, height: 20 })).toEqual({ + innerStyle: { width: 10, height: 20 } + }) + expect(splitProps({ numberOfLines: 1, id: 'text' })).toEqual({ + textProps: { numberOfLines: 1 }, + innerProps: { id: 'text' } + }) + expect(splitProps({ id: 'text', numberOfLines: 1 })).toEqual({ + textProps: { numberOfLines: 1 }, + innerProps: { id: 'text' } + }) + expect(pickStyle({ width: 10 }, ['width', 'height'])).toEqual({ width: 10 }) + expect(pickStyle(undefined as any, [])).toEqual({}) + expect(presetBoxStyle.boxSizing).toBe('border-box') + expect(setter).not.toHaveBeenCalled() + expect(flatGesture()).toEqual([]) + expect(flatGesture([{ current: false }])).toEqual([]) + expect(flatGesture([{ handlerTag: 0 }, {}])).toEqual([{ handlerTag: 0 }]) + expect(getCurrentPage(1)).toBeUndefined() + + global.__mpx = previousMpx + global.getCurrentPages = previousGetCurrentPages + }) + + it('expands one, two and four-value shorthand styles', () => { + const style = { + margin: '1px 2px 3px 4px', + padding: '5px 6px', + borderRadius: '7px', + gap: '8px', + border: 'invalid-token', + textDecoration: 'underline' + } + const warn = jest.spyOn(console, 'warn').mockImplementation(jest.fn()) + + transformShorthand(style, Object.keys(style), {}) + + expect(style).toEqual(expect.objectContaining({ + marginTop: 1, + marginRight: 2, + marginBottom: 3, + marginLeft: 4, + paddingTop: 5, + paddingRight: 6, + paddingBottom: 5, + paddingLeft: 6, + borderRadius: '7px', + rowGap: 8, + columnGap: 8, + borderWidth: 3, + borderStyle: 'none', + textDecorationLine: 'underline' + })) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('invalid-token')) + }) + + it('resolves nested variable fallbacks and background branches', () => { + let result: ReturnType | undefined + const warn = jest.spyOn(console, 'warn').mockImplementation(jest.fn()) + const Probe = () => { + result = useTransformStyle({ + '--local': 'var(--theme)', + '--un-tone': 'green', + color: 'var(--missing, var(--local))', + backgroundColor: 'var(--un-tone)', + borderRadius: '50%', + width: 'calc(25% + 10px)', + transform: [{ translateX: 'calc(50%)' }], + boxShadow: '', + fontFamily: 42, + backgroundPosition: 'center top', + background: 'linear-gradient(red,blue) red invalid-token center/cover' + }, { + enableVar: true, + transformRadiusPercent: true, + parentWidth: 200, + defaultStyle: { + color: 'black', + paddingTop: 1 + } + }) + return + } + + render( + + + + ) + + expect(result?.hasVarDec).toBe(true) + expect(result?.hasSelfPercent).toBe(true) + expect(result?.normalStyle).toEqual(expect.objectContaining({ + color: 'purple', + backgroundColor: 'red', + borderRadius: 0, + width: 60, + paddingTop: 1, + boxSizing: 'content-box', + backgroundImage: 'linear-gradient(red,blue)', + backgroundPosition: ['50%'], + backgroundSize: ['cover'], + transform: [{ translateX: 0 }] + })) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('invalid-token')) + }) + + it('handles font and flex shorthand boundary forms', () => { + const results: Array> = [] + const error = jest.spyOn(console, 'error').mockImplementation(jest.fn()) + const Probe = () => { + results.push( + useTransformStyle({ font: 16 as any }, {}), + useTransformStyle({ font: 'bold' }, {}), + useTransformStyle({ font: '16px' }, {}), + useTransformStyle({ font: 'normal 16px / 20px Arial' }, {}), + useTransformStyle({ font: '16px/bad Arial' }, {}), + useTransformStyle({ font: '16px/ 20px Arial' }, {}), + useTransformStyle({ font: '16px /20px Arial' }, {}), + useTransformStyle({ flex: 'none' }, {}), + useTransformStyle({ flex: 'initial' }, {}), + useTransformStyle({ flex: 'auto' }, {}), + useTransformStyle({ flex: '2 auto' }, {}), + useTransformStyle({ flex: '2' }, {}) + ) + return null + } + + render() + + expect(results[0].normalStyle.font).toBe(16) + expect(results[1].normalStyle.font).toBeUndefined() + expect(results[2].normalStyle.font).toBeUndefined() + expect(results[3].normalStyle).toEqual(expect.objectContaining({ + fontSize: 16, + lineHeight: 20, + fontFamily: 'Arial' + })) + expect(results[4].normalStyle.lineHeight).toBe('bad') + expect(results[5].normalStyle.lineHeight).toBe(20) + expect(results[6].normalStyle.lineHeight).toBe(20) + expect(results[7].normalStyle).toEqual(expect.objectContaining({ flexGrow: 0, flexShrink: 0 })) + expect(results[8].normalStyle).toEqual(expect.objectContaining({ flexGrow: 0, flexShrink: 1 })) + expect(results[9].normalStyle).toEqual(expect.objectContaining({ flexGrow: 1, flexShrink: 1 })) + expect(results[10].normalStyle).toEqual(expect.objectContaining({ flexGrow: 2, flexShrink: 1 })) + expect(results[11].normalStyle).toEqual(expect.objectContaining({ flexGrow: 2, flexShrink: 1, flexBasis: 0 })) + expect(error).toHaveBeenCalledWith(expect.stringContaining('missing required ')) + expect(error).toHaveBeenCalledWith(expect.stringContaining('missing required ')) + }) + + it('splits and picks style and prop buckets without losing leading fields', () => { + const visited: string[] = [] + const style = { + width: 100, + color: 'red', + backgroundImage: 'url(a.png)', + height: 50 + } + + expect(splitStyle(style, (key) => visited.push(key))).toEqual({ + textStyle: { color: 'red' }, + backgroundStyle: { backgroundImage: 'url(a.png)' }, + innerStyle: { height: 50, width: 100 } + }) + expect(visited).toEqual(['width', 'color', 'backgroundImage', 'height']) + expect(splitProps({ id: 'root', numberOfLines: 2, title: 'hello' })).toEqual({ + textProps: { numberOfLines: 2 }, + innerProps: { title: 'hello', id: 'root' } + }) + expect(pickStyle(style, ['width', 'height'], (key, value) => key === 'height' ? value as number / 2 : value)).toEqual({ + width: 100, + height: 25 + }) + expect(isBoxSizingAffectingStyle('paddingTop')).toBe(true) + + const boxStyle: Record = {} + transformBoxSizing(boxStyle) + expect(boxStyle.boxSizing).toBe('content-box') + }) + + it('expands runtime shorthand styles with defaults and percent resolution', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(jest.fn()) + const style = { + margin: '1px 2px 3px', + gap: '10% 25%', + border: 'solid rebeccapurple', + textShadow: '1px black', + textDecoration: 'underline line-through dotted red', + flexFlow: 'row wrap', + outline: 2 + } + + transformShorthand(style, Object.keys(style), { + parentWidth: 400, + parentHeight: 200 + }) + + expect(style).toEqual(expect.objectContaining({ + marginTop: 1, + marginRight: 2, + marginBottom: 3, + marginLeft: 2, + rowGap: 20, + columnGap: 100, + borderWidth: 3, + borderStyle: 'solid', + borderColor: 'rebeccapurple', + textShadowOffset: { width: 1, height: 0 }, + textShadowColor: 'black', + textDecorationLine: 'underline line-through', + textDecorationStyle: 'dotted', + textDecorationColor: 'red', + flexDirection: 'row', + flexWrap: 'wrap', + outlineWidth: 2, + outlineStyle: 'none' + })) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('missing offset-y')) + }) + + it('traverses and updates nested styles by key path', () => { + const style = { + transform: [ + { translateX: '10px' }, + { scale: 2 } + ], + shadow: { + color: 'red' + } + } + const visits: string[] = [] + + traverseStyle(style, ({ keyPath }) => visits.push(keyPath.join('.'))) + setStyle(style, ['transform', '0', 'translateX'], ({ target, key }) => { + target[key] = 20 + }) + + expect(visits).toEqual([ + 'transform', + 'transform.0', + 'transform.0.translateX', + 'transform.1', + 'transform.1.scale', + 'shadow', + 'shadow.color' + ]) + expect(style.transform[0].translateX).toBe(20) + }) + + it('transforms CSS-like runtime styles through the hook pipeline', () => { + let result: ReturnType | undefined + const style = { + color: 'var(--brand)', + width: 'calc(50% + var(--space))', + paddingTop: 'env(safe-area-inset-top, 8px)', + padding: 4, + border: 'none', + font: 'italic small-caps 700 16px/1.5 "Ping Fang", sans-serif', + fontFamily: '"Override", fallback', + fontWeight: 500, + transformOrigin: 10, + transform: 'rotate(45deg) translate(10px, 20px) scale(2) matrix(1, 0, 0, 1, 5, 6) rotate3d(0, 1, 0, 30deg)', + boxShadow: '1rpx 2px black', + background: 'none', + position: 'fixed' + } + + const Probe = () => { + result = useTransformStyle(style, { + enableVar: true, + parentWidth: 200, + parentHeight: 100, + defaultStyle: { + minWidth: 1, + paddingLeft: 9 + } + }) + return + } + + render( + + + + + + ) + + expect(result?.hasVarDec).toBe(false) + expect(result?.hasPositionFixed).toBe(true) + expect(result?.normalStyle).toEqual(expect.objectContaining({ + color: 'red', + width: 112, + paddingTop: 44, + padding: 4, + paddingLeft: 9, + borderWidth: 0, + fontStyle: 'italic', + fontVariant: 'small-caps', + fontWeight: '500', + fontSize: 16, + lineHeight: '150%', + fontFamily: 'Override', + transformOrigin: '10', + boxShadow: '0.5px 2px black', + backgroundImage: 'none', + backgroundColor: 'transparent', + position: 'absolute', + minWidth: 1, + boxSizing: 'content-box' + })) + expect(result?.normalStyle.transform).toEqual([ + { rotateZ: '45deg' }, + { translateX: 10 }, + { translateY: 20 }, + { scaleX: 2 }, + { scaleY: 2 }, + { matrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 5, 6, 0, 1] }, + { rotateY: '30deg' } + ]) + }) + + it('keeps supported transform pieces while dropping unsupported ones', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(jest.fn()) + let result: ReturnType | undefined + const style = { + transform: 'bad rotateX(10deg) matrix(1,2,3) matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,4,5,6,1) matrix3d(1,2) translate3d(1px,2px,3px) scale3d(2,3,4) rotate3d(1,0) rotate3d(1,0,0,20deg) rotate3d(0,0,1,40deg) rotate3d(1,1,0,50deg) translateZ(1px) skew(10deg,20deg) perspective(100px) unknown(1)' + } + + const Probe = () => { + result = useTransformStyle(style, {}) + return + } + + render() + + expect(result?.normalStyle.transform).toEqual(expect.arrayContaining([ + { rotateX: '10deg' }, + { matrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 4, 5, 6, 1] }, + { translateX: 1 }, + { translateY: 2 }, + { scaleX: 2 }, + { scaleY: 3 }, + { rotateX: '20deg' }, + { rotateZ: '40deg' }, + { skewX: '10deg' }, + { skewY: '20deg' }, + { perspective: 100 } + ])) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('not a valid fn(value) form')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('matrix only supports 6 values')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('matrix only supports 16 values')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('translateZ')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('rotate3d only supports 4 values')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('rotate3d')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('unknown')) + }) + + it('normalizes font, flex and background runtime shorthand styles', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(jest.fn()) + let result: ReturnType | undefined + const style = { + font: 'condensed italic 50% / 120% Arial, sans-serif', + flex: '2 3 10px', + background: 'url(bg.png) no-repeat top left / 10px 20px', + backgroundPosition: 'bottom 5px', + backgroundSize: 'cover auto', + outline: 'none', + textDecoration: 'none' + } + + const Probe = () => { + result = useTransformStyle(style, {}) + return + } + + render() + + expect(result?.normalStyle).toEqual(expect.objectContaining({ + fontStyle: 'italic', + fontSize: '50%', + lineHeight: '120%', + fontFamily: 'Arial', + flexGrow: 2, + flexShrink: 3, + flexBasis: 10, + backgroundImage: 'url(bg.png)', + backgroundRepeat: 'no-repeat', + backgroundPosition: ['left', 'top'], + backgroundSize: ['10px', '20px'], + outlineWidth: 0, + textDecorationLine: 'none' + })) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('condensed')) + }) + + it('reports unresolved variables, self percent bases and malformed calc expressions', () => { + const error = jest.spyOn(console, 'error').mockImplementation(jest.fn()) + let result: ReturnType | undefined + const style = { + color: 'var(--missing)', + fontSize: '50%', + borderRadius: '10%', + width: 'calc(foo)' + } + + const Probe = () => { + result = useTransformStyle(style, { + enableVar: true, + transformRadiusPercent: true + }) + return + } + + render() + + expect(result?.normalStyle.color).toBeUndefined() + expect(result?.normalStyle.fontSize).toBe('50%') + expect(result?.hasSelfPercent).toBe(true) + const errorMessages = error.mock.calls.map((args) => args.map(String).join(' ')) + expect(errorMessages.some((msg) => msg.includes('Can not resolve css var'))).toBe(true) + expect(errorMessages.some((msg) => msg.includes('[fontSize] can not contain % unit'))).toBe(false) + expect(errorMessages.some((msg) => msg.includes('calc(foo) parse error.'))).toBe(true) + }) + + it('updates layout refs and text pass-through hook values', () => { + const setWidth = jest.fn() + const setHeight = jest.fn() + const onLayout = jest.fn() + const propsOnLayout = jest.fn() + // eslint-disable-next-line node/no-callback-literal + const measure = jest.fn((callback) => callback(1, 4, 100, 80, 9, 12)) + let layoutResult: ReturnType | undefined + let passThroughResult: ReturnType | undefined + let resolvedTextStyle + + const Probe = () => { + layoutResult = useLayout({ + props: { + 'enable-offset': true, + onLayout: propsOnLayout + }, + hasSelfPercent: true, + setWidth, + setHeight, + onLayout, + nodeRef: { current: { measure } } as any + }) + passThroughResult = useTextPassThrough( + { color: 'red' }, + { numberOfLines: 1 }, + { enableTextPassThrough: true } + ) + resolvedTextStyle = resolveTextLineHeightPercentStyle( + resolveTextFontSizePercentStyle({ fontSize: '150%', lineHeight: '200%' } as any, { fontSize: 12 }) + ) + return + } + + render( + + + + + + ) + + expect(layoutResult?.layoutStyle).toBeDefined() + act(() => { + layoutResult?.layoutProps.onLayout({ + nativeEvent: { + layout: { width: 100, height: 80 } + } + }) + }) + + expect(setWidth).toHaveBeenCalledWith(100) + expect(setHeight).toHaveBeenCalledWith(80) + expect(layoutResult?.layoutRef.current).toEqual({ + x: 1, + y: 1, + width: 100, + height: 80, + offsetLeft: 9, + offsetTop: 9 + }) + expect(onLayout).toHaveBeenCalled() + expect(propsOnLayout).toHaveBeenCalled() + expect(passThroughResult).toEqual({ + textStyle: { lineHeight: 20, color: 'red' }, + pendingTextProps: { selectable: true, numberOfLines: 1 } + }) + expect(resolvedTextStyle).toEqual({ + fontSize: 18, + lineHeight: 36 + }) + }) + + it('returns stable defaults for inactive layout and text pass-through hooks', () => { + let layoutResult: ReturnType | undefined + let disabledPassThrough: ReturnType | undefined + let enabledPassThrough: ReturnType | undefined + const Probe = () => { + layoutResult = useLayout({ + props: {}, + hasSelfPercent: false, + nodeRef: { current: null } + }) + disabledPassThrough = useTextPassThrough() + enabledPassThrough = useTextPassThrough(undefined, undefined, { enableTextPassThrough: true }) + return null + } + + render() + + expect(layoutResult).toEqual(expect.objectContaining({ + layoutStyle: undefined, + layoutProps: {} + })) + expect(disabledPassThrough).toBeNull() + expect(enabledPassThrough).toEqual({ + textStyle: undefined, + pendingTextProps: undefined + }) + }) + + it('keeps disabled hover gestures from changing hover state', () => { + jest.useFakeTimers() + let hoverResult: ReturnType | undefined + const gestureRef = { current: { id: 'scroll' } } + + const Probe = () => { + hoverResult = useHover({ + enableHover: true, + hoverStartTime: 10, + hoverStayTime: 10, + disabled: true + }) + return + } + + render( + + + + ) + + const gesture = hoverResult?.gesture as any + act(() => { + gesture.onTouchesDownCallback() + jest.advanceTimersByTime(10) + gesture.onTouchesUpCallback() + jest.advanceTimersByTime(10) + }) + + expect(hoverResult?.isHover).toBe(false) + expect(gesture.simultaneousWithExternalGesture).toHaveBeenCalledWith(gestureRef) + }) + + it('runs timing helpers and hook callbacks with stable cleanup behavior', () => { + jest.useFakeTimers() + + const callback = jest.fn() + const debounced = debounce(callback, 20) + debounced('first') + debounced('second') + act(() => { + jest.advanceTimersByTime(20) + }) + expect(callback).toHaveBeenCalledWith('second') + + const clearedCallback = jest.fn() + const clearedDebounced = debounce(clearedCallback, 20) + clearedDebounced() + clearedDebounced.clear() + act(() => { + jest.advanceTimersByTime(20) + }) + expect(clearedCallback).not.toHaveBeenCalled() + + let hookResult: any + const update = jest.fn() + const HookProbe = ({ value }: { value: string }) => { + const callbackMapRef = useRef({ fire: callback }) + useUpdateEffect(() => update(value), [value]) + hookResult = { + previous: usePrevious(value), + stable: useStableCallback(callback), + debounced: useDebounceCallback(callback, 10), + invoke: useRunOnJSCallback(callbackMapRef) + } + return null + } + + const { rerender, unmount } = render() + expect(hookResult.previous).toBeUndefined() + expect(update).not.toHaveBeenCalled() + + rerender() + expect(hookResult.previous).toBe('a') + expect(update).toHaveBeenCalledWith('b') + + hookResult.stable('stable') + hookResult.invoke('fire', 'invoked') + hookResult.debounced('hook-debounced') + act(() => { + jest.advanceTimersByTime(10) + }) + + expect(callback).toHaveBeenCalledWith('stable') + expect(callback).toHaveBeenCalledWith('invoked') + expect(callback).toHaveBeenCalledWith('hook-debounced') + unmount() + expect(hookResult.invoke('fire')).toBeUndefined() + }) + + it('wraps children, renders image components and resolves current pages', () => { + const wrapped = wrapChildren(child, { + hasVarDec: true, + varContext: { '--color': 'red' }, + textPassThrough: { + textStyle: { color: 'red' }, + pendingTextProps: { numberOfLines: 1 } + } + }) + const slowImage = renderImage({ source: { uri: 'a.png' }, style: {} }, false) + const fastImage = renderImage({ source: { uri: 'b.png' }, style: {} }) + const gesture = { current: true } + const refGesture = { current: true } + + global.getCurrentPages = jest.fn(() => [ + { name: 'invalid' }, + { getPageId: () => 1, name: 'home' }, + { getPageId: () => 2, name: 'detail' } + ]) + + expect(React.isValidElement(wrapped)).toBe(true) + expect(slowImage.type).toBe(Image) + expect((fastImage.props as any).source).toEqual({ uri: 'b.png' }) + expect(flatGesture([ + gesture, + { + nodeRefs: [ + { getNodeInstance: () => ({ instance: { gestureRef: refGesture } }) }, + { getNodeInstance: () => null } + ] + } + ])).toEqual([gesture, refGesture, {}]) + expect(getCurrentPage(2)).toEqual({ getPageId: expect.any(Function), name: 'detail' }) + }) + + it('applies fallback variables, percent gaps and direct transforms', () => { + const previousMpx = global.__mpx + const defaultStyle = Object.create({ inherited: 1 }) + defaultStyle.minWidth = 10 + let result: ReturnType | undefined + + try { + global.__mpx = {} as any + const Probe = () => { + result = useTransformStyle({ + color: 'var(--missing, red)', + paddingTop: 'env(unknown-inset)', + rowGap: '50%', + columnGap: '25%', + transform: 'translateX(1px) translateY(2px) scaleX(3) scaleY(4) perspective(5px)', + fontWeight: 0, + transformOrigin: null + }, { + enableVar: true, + parentWidth: 100, + parentHeight: 200, + defaultStyle + }) + return + } + render() + + expect(result?.normalStyle).toEqual(expect.objectContaining({ + color: 'red', + paddingTop: '', + rowGap: 100, + columnGap: 25, + minWidth: 10, + fontWeight: '0', + transformOrigin: null, + transform: [ + { translateX: 1 }, + { translateY: 2 }, + { scaleX: 3 }, + { scaleY: 4 }, + { perspective: 5 } + ] + })) + expect(result?.normalStyle).not.toHaveProperty('inherited') + } finally { + global.__mpx = previousMpx + } + }) + + it('reports stable hook feature changes and handles empty layout events', () => { + const error = jest.spyOn(console, 'error').mockImplementation(jest.fn()) + const setWidth = jest.fn() + const setHeight = jest.fn() + let layoutResult: ReturnType | undefined + let styleResult: ReturnType | undefined + let passThroughResult: ReturnType | undefined + + const Probe = ({ active }: { active: boolean }) => { + styleResult = useTransformStyle(active ? { '--color': 'red' } : {}, {}) + passThroughResult = useTextPassThrough(active ? { color: 'red' } : undefined) + layoutResult = useLayout({ + props: {}, + hasSelfPercent: true, + setWidth, + setHeight, + nodeRef: { current: null } + }) + return + } + + const { rerender } = render() + act(() => { + layoutResult?.layoutProps.onLayout(undefined as any) + }) + expect(setWidth).toHaveBeenCalledWith(0) + expect(setHeight).toHaveBeenCalledWith(0) + + rerender() + expect(styleResult?.hasVarDec).toBe(true) + expect(passThroughResult).toBeNull() + expect(error).toHaveBeenCalledWith(expect.stringContaining('css variable use/declare should be stable')) + expect(error).toHaveBeenCalledWith(expect.stringContaining('text style/props use should be stable')) + }) + + it('handles enabled hover timer replacement and optional stable callbacks', () => { + jest.useFakeTimers() + let hoverResult: ReturnType | undefined + let stableCallback: ReturnType + + const Probe = () => { + hoverResult = useHover({ + enableHover: true, + hoverStartTime: 10, + hoverStayTime: 20 + }) + stableCallback = useStableCallback(undefined) + return null + } + + const { unmount } = render() + const gesture = hoverResult?.gesture as any + act(() => { + gesture.onTouchesDownCallback() + gesture.onTouchesDownCallback() + }) + expect(jest.getTimerCount()).toBe(1) + act(() => { + jest.advanceTimersByTime(10) + }) + expect(hoverResult?.isHover).toBe(true) + act(() => { + gesture.onTouchesUpCallback() + gesture.onTouchesUpCallback() + }) + expect(jest.getTimerCount()).toBe(1) + act(() => { + jest.advanceTimersByTime(20) + }) + expect(hoverResult?.isHover).toBe(false) + expect(stableCallback()).toBeUndefined() + unmount() + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/mpx-picker.spec.ts b/packages/webpack-plugin/test/runtime/react-native/mpx-picker.spec.ts deleted file mode 100644 index 62d0a0f82a..0000000000 --- a/packages/webpack-plugin/test/runtime/react-native/mpx-picker.spec.ts +++ /dev/null @@ -1,343 +0,0 @@ -/// - -const mockOpen = jest.fn() -const mockShow = jest.fn() -const mockHide = jest.fn() -const mockRemove = jest.fn() -const mockUpdate = jest.fn() -const mockUpdateRange = jest.fn() -const mockWarn = jest.fn() -const mockSetState = jest.fn() -const mockUseEffect = jest.fn((effect: any) => effect()) -const mockUseImperativeHandle = jest.fn() -const mockUseRef = jest.fn((init: any) => ({ current: init })) -const mockEmitter = { - addListener: jest.fn(), - emit: jest.fn(), - removeListener: jest.fn() -} - -jest.mock('react-native', () => ({ - StyleSheet: { - hairlineWidth: 1 / 3, - create: (style: any) => style - }, - Text: 'Text', - TouchableWithoutFeedback: 'TouchableWithoutFeedback', - View: 'View', - DeviceEventEmitter: mockEmitter, - NativeEventEmitter: jest.fn(() => mockEmitter) -}), { virtual: false }) - -jest.mock('react-native-gesture-handler', () => ({ - Gesture: { Tap: () => ({}), Pan: () => ({}), LongPress: () => ({}) } -}), { virtual: false }) - -jest.mock('react-native-safe-area-context', () => ({ - initialWindowMetrics: { insets: { top: 0, right: 0, bottom: 0, left: 0 } } -}), { virtual: false }) - -jest.mock('react', () => { - const actual = jest.requireActual('react') - return Object.assign({}, actual, { - forwardRef: (render: any) => render, - createElement: jest.fn((type: any, props: any, ...children: any[]) => ({ type, props: props || {}, children })), - useCallback: (fn: any) => fn, - useContext: jest.fn(() => null), - useEffect: (effect: any, deps: any) => mockUseEffect(effect, deps), - useImperativeHandle: (ref: any, createHandle: any) => mockUseImperativeHandle(ref, createHandle), - useMemo: (factory: any) => factory(), - useRef: (init: any) => mockUseRef(init), - useState: (init: any) => [typeof init === 'function' ? init() : init, mockSetState] - }) -}) - -jest.mock('../../../lib/runtime/components/react/mpx-popup', () => ({ - createPopupManager: () => ({ - open: mockOpen, - show: mockShow, - hide: mockHide, - update: mockUpdate, - remove: mockRemove - }) -})) - -jest.mock('../../../lib/runtime/components/react/mpx-picker/selector', () => ({ - __esModule: true, - default: 'PickerSelector' -})) - -jest.mock('../../../lib/runtime/components/react/mpx-picker/multiSelector', () => ({ - __esModule: true, - default: 'PickerMultiSelector' -})) - -jest.mock('../../../lib/runtime/components/react/mpx-picker/time', () => ({ - __esModule: true, - default: 'PickerTime' -})) - -jest.mock('../../../lib/runtime/components/react/mpx-picker/date', () => ({ - __esModule: true, - default: 'PickerDate' -})) - -jest.mock('../../../lib/runtime/components/react/mpx-picker/region', () => ({ - __esModule: true, - default: 'PickerRegion' -})) - -jest.mock('../../../lib/runtime/components/react/mpx-picker-view', () => ({ - __esModule: true, - default: 'MpxPickerView' -})) - -jest.mock('../../../lib/runtime/components/react/mpx-picker-view-column', () => ({ - __esModule: true, - default: 'MpxPickerViewColumn' -})) - -// eslint-disable-next-line import/first -import Picker from '../../../lib/runtime/components/react/mpx-picker' -// eslint-disable-next-line import/first -import { PickerMode } from '../../../lib/runtime/components/react/mpx-picker/type' -// eslint-disable-next-line import/first -import { TextPassThroughContext } from '../../../lib/runtime/components/react/context' - -const findElementByType = (element: any, type: any): any => { - if (!element) return null - if (Array.isArray(element)) { - let matched = null - element.some((item) => { - matched = findElementByType(item, type) - return !!matched - }) - return matched - } - if (element.type === type) return element - return findElementByType(element.props?.children || element.children, type) -} - -describe('MpxPicker RN runtime', () => { - beforeEach(() => { - mockOpen.mockClear() - mockShow.mockClear() - mockHide.mockClear() - mockRemove.mockClear() - mockUpdate.mockClear() - mockUpdateRange.mockClear() - mockWarn.mockClear() - mockSetState.mockClear() - mockUseEffect.mockClear() - mockUseImperativeHandle.mockClear() - mockUseRef.mockReset() - mockUseRef.mockImplementation((init: any) => ({ current: init })) - const mpxConfig = (global as any).__mpx.config - mpxConfig.ignoreWarning = true - delete mpxConfig.warnHandler - }) - - test('passes selector props to popup content after filtering root props', () => { - const range = [{ name: 'Beijing' }, { name: 'Shanghai' }, { name: 'Guangzhou' }] - const result = (Picker as any)({ - mode: PickerMode.SELECTOR, - value: 1, - range, - 'range-key': 'name', - style: { width: 100 }, - children: 'Select' - }, null) - - const popupContent = mockOpen.mock.calls[0][0] - const selector = findElementByType(popupContent, 'PickerSelector') - const triggerView = findElementByType(result, 'View') - - expect(selector.props.range).toBe(range) - expect(selector.props['range-key']).toBe('name') - expect(selector.props.style).toBeUndefined() - expect(selector.props.children).toBeUndefined() - expect(triggerView.props.range).toBeUndefined() - expect(triggerView.props['range-key']).toBeUndefined() - }) - - test.each([ - [PickerMode.TIME, 'PickerTime', { start: '00:00', end: '23:59' }], - [PickerMode.DATE, 'PickerDate', { fields: 'day' }], - [PickerMode.REGION, 'PickerRegion', { level: 'city', 'custom-item': 'All' }] - ])('filters %s-specific props from the trigger view', (mode, modalType, specificProps) => { - const result = (Picker as any)(Object.assign({ mode, children: 'Select' }, specificProps), null) - const popupContent = mockOpen.mock.calls[0][0] - const modal = findElementByType(popupContent, modalType) - const triggerView = findElementByType(result, 'View') - - Object.keys(specificProps).forEach((key) => { - expect(modal.props[key]).toBe((specificProps as any)[key]) - expect(triggerView.props[key]).toBeUndefined() - }) - }) - - test('updates multi-selector range with the latest range key', () => { - const initialRange = [{ name: ['Old'] }] - const nextRange = [{ label: ['New'] }] - ;(Picker as any)({ - mode: PickerMode.MULTI_SELECTOR, - value: [0], - range: initialRange, - 'range-key': 'name', - children: 'Select' - }, null) - const popupContent = mockOpen.mock.calls[0][0] - const multiSelector = findElementByType(popupContent, 'PickerMultiSelector') - const pickerRef = multiSelector.ref - pickerRef.current = { updateRange: mockUpdateRange } - mockUpdateRange.mockClear() - mockUseEffect.mockClear() - mockUseRef.mockClear() - mockUseRef - .mockImplementationOnce((init: any) => ({ current: init })) - .mockImplementationOnce((init: any) => ({ current: init })) - .mockImplementationOnce(() => pickerRef) - - ;(Picker as any)({ - mode: PickerMode.MULTI_SELECTOR, - value: [0], - range: nextRange, - 'range-key': 'label', - children: 'Select' - }, null) - - expect(mockUpdateRange).toHaveBeenCalledWith(nextRange, 'label') - expect(mockUseEffect).toHaveBeenCalledWith( - expect.any(Function), - [JSON.stringify(nextRange), 'label', PickerMode.MULTI_SELECTOR] - ) - }) - - test('formats an updated multi-selector range with the provided range key', () => { - const PickerMultiSelector = jest.requireActual( - '../../../lib/runtime/components/react/mpx-picker/multiSelector' - ).default - let handler: any - mockUseImperativeHandle.mockImplementationOnce((_ref, createHandle) => { - handler = createHandle() - }) - ;(PickerMultiSelector as any)({ - mode: PickerMode.MULTI_SELECTOR, - value: [0], - range: [{ name: ['Old'] }], - 'range-key': 'name' - }, null) - mockSetState.mockClear() - - handler.updateRange([{ label: ['New'] }], 'label') - - expect(mockSetState).toHaveBeenCalledWith([['New']]) - }) - - test('defaults omitted selector range to empty array', () => { - const result = (Picker as any)({ - mode: PickerMode.SELECTOR, - value: 0, - children: 'Select' - }, null) - - const popupContent = mockOpen.mock.calls[0][0] - const selector = findElementByType(popupContent, 'PickerSelector') - const triggerView = findElementByType(result, 'View') - - expect(selector.props.range).toEqual([]) - expect(triggerView.props.range).toBeUndefined() - }) - - test('updates selector popup content before showing when range changes', () => { - const range = ['Beijing', 'Shanghai'] - ;(Picker as any)({ - mode: PickerMode.SELECTOR, - value: 0, - range: [], - children: 'Select' - }, null) - const result = (Picker as any)({ - mode: PickerMode.SELECTOR, - value: 0, - range, - children: 'Select' - }, null) - - expect(mockUpdate).not.toHaveBeenCalled() - const openCallCount = mockOpen.mock.calls.length - result.props.onPress() - - const popupContent = mockUpdate.mock.calls[mockUpdate.mock.calls.length - 1][0] - const selector = findElementByType(popupContent, 'PickerSelector') - - expect(mockOpen.mock.calls.length).toBe(openCallCount) - expect(mockShow).toHaveBeenCalledTimes(1) - expect(selector.props.range).toBe(range) - }) - - test('updates popup content height before showing when header text changes', () => { - (Picker as any)({ - mode: PickerMode.SELECTOR, - value: 0, - range: ['Beijing'], - children: 'Select' - }, null) - const result = (Picker as any)({ - mode: PickerMode.SELECTOR, - value: 0, - range: ['Beijing'], - 'header-text': 'City', - children: 'Select' - }, null) - - expect(mockOpen.mock.calls[0][2]).toEqual({ contentHeight: 310 }) - result.props.onPress() - - const updateOptions = mockUpdate.mock.calls[mockUpdate.mock.calls.length - 1][1] - - expect(updateOptions).toEqual({ contentHeight: 350 }) - }) - - test('passes text style through to trigger children', () => { - const result = (Picker as any)({ - mode: PickerMode.SELECTOR, - value: 0, - range: ['Beijing'], - style: { - width: 100, - color: 'red', - fontSize: 20 - }, - children: 'Select' - }, null) - - const triggerView = findElementByType(result, 'View') - const textProvider = findElementByType(triggerView, TextPassThroughContext.Provider) - - expect(triggerView.props.style).toEqual({ width: 100 }) - expect(textProvider.props.value.textStyle).toEqual({ - color: 'red', - fontSize: 20 - }) - }) - - test('warns when background image-related styles are used', () => { - const mpxConfig = (global as any).__mpx.config - mpxConfig.ignoreWarning = false - mpxConfig.warnHandler = mockWarn - - const result = (Picker as any)({ - mode: PickerMode.SELECTOR, - value: 0, - range: ['Beijing'], - style: { - backgroundImage: 'url(test.png)' - }, - children: 'Select' - }, null) - - expect(result).toBeTruthy() - expect(mockWarn.mock.calls[0][0]).toBe('Picker does not support background image-related styles!') - }) -}) diff --git a/packages/webpack-plugin/test/runtime/react-native/mpx-view-gradient.spec.ts b/packages/webpack-plugin/test/runtime/react-native/mpx-view-gradient.spec.ts deleted file mode 100644 index dd8ece87f5..0000000000 --- a/packages/webpack-plugin/test/runtime/react-native/mpx-view-gradient.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -/// - -jest.mock('react-native', () => ({ - StyleSheet: { hairlineWidth: 1 / 3 }, - Image: class Image {}, - View: 'View' -}), { virtual: false }) - -jest.mock('react-native-reanimated', () => ({ - __esModule: true, - default: { View: 'AnimatedView' } -}), { virtual: false }) - -jest.mock('react-native-linear-gradient', () => 'LinearGradient', { virtual: false }) - -jest.mock('react-native-gesture-handler', () => ({ - Gesture: { Tap: () => ({}), Pan: () => ({}), LongPress: () => ({}) }, - GestureDetector: 'GestureDetector' -}), { virtual: false }) - -jest.mock('react-native-safe-area-context', () => ({ - initialWindowMetrics: { insets: { top: 0, right: 0, bottom: 0, left: 0 } } -}), { virtual: false }) - -jest.mock('../../../lib/runtime/components/react/animationHooks/index', () => ({ - __esModule: true, - default: () => undefined -})) - -jest.mock('@mpxjs/perf', () => ({ - scopeStart: () => -1, - scopeEnd: () => undefined -})) - -jest.mock('../../../lib/runtime/components/react/mpx-portal', () => ({ - __esModule: true, - default: 'Portal' -})) - -// eslint-disable-next-line import/first -import { __parseBgImageForTest } from '../../../lib/runtime/components/react/mpx-view' - -describe('mpx-view linear-gradient parser', () => { - test('keeps non-deg angle units as explicit gradient direction', () => { - expect(__parseBgImageForTest('linear-gradient(0.25turn, red, blue)').linearInfo.direction).toBe('0.25turn') - expect(__parseBgImageForTest('linear-gradient(1.5707963267948966rad, red, blue)').linearInfo.direction).toBe('1.5707963267948966rad') - expect(__parseBgImageForTest('linear-gradient(100grad, red, blue)').linearInfo.direction).toBe('100grad') - }) - - test('does not strip "to" from colors when angle is explicit', () => { - const { linearInfo } = __parseBgImageForTest('linear-gradient(90deg, tomato, blue)') - - expect(linearInfo.direction).toBe('90deg') - expect(linearInfo.colors).toEqual(['tomato', 'blue']) - }) -})