Repository: Tencent/tdesign-mobile-vue
Pull request #332 (2022-08-31 07:36:13)

test(image-viewer): add unit test for image-viewer
<!--
首先，感谢你的贡献！😄
请阅读并遵循 [TDesign 贡献指南](https://github.com/Tencent/tdesign/blob/main/docs/contributing.md)，填写以下 pull request 的信息。
PR 在维护者审核通过后会合并，谢谢！
-->

### 🤔 这个 PR 的性质是？

- [ ] 日常 bug 修复
- [ ] 新特性提交
- [ ] 文档改进
- [ ] 演示代码改进
- [ ] 组件样式/交互改进
- [ ] CI/CD 改进
- [ ] 重构
- [ ] 代码风格优化
- [x] 测试用例
- [ ] 分支合并
- [ ] 其他

### 🔗 相关 Issue

<!--
1. 描述相关需求的来源，如相关的 issue 讨论链接。
-->

[tdesign-mobile-vue#271](https://github.com/Tencent/tdesign-mobile-vue/issues/271)

### 💡 需求背景和解决方案

<!--
1. 要解决的具体问题。
2. 列出最终的 API 实现和用法。
3. 涉及UI/交互变动需要有截图或 GIF。
-->

模拟 touch 系列事件时使用了部分开源的工具代码，后续可以考虑放在统一的文件夹

### 📝 更新日志

<!--
从用户角度描述具体变化，以及可能的 breaking change 和其他风险。
-->

- test(image-viewer): 添加 image-viewer 的单元测试

- [ ] 本条 PR 不需要纳入 Changelog

### ☑️ 请求合并前的自查清单

⚠️ 请自检并全部**勾选全部选项**。⚠️

- [x] 文档已补充或无须补充
- [x] 代码演示已提供或无须提供
- [x] TypeScript 定义已补充或无须补充
- [x] Changelog 已提供或无须提供


anlyyao: @palmcivet   Branches 和 Functions 覆盖率都低于80， 可以补充一下 index-change 事件，  visible 属性～
LeeJim: 可以删掉 site/web/test-coverage.js 文件，不需要提交此文件的变更

Patch:
diff --git a/getting-started.md b/getting-started.md
new file mode 100644
index 00000000..a074dfa5
--- /dev/null
+++ b/getting-started.md
@@ -0,0 +1,41 @@
+---
+title: Vue3 for Mobile
+description: TDesign 适配移动端的组件库，适合在 vue 3 技术栈项目中使用。
+spline: explain
+---
+
+
+
+<div style="background: rgba(0, 168, 112, .1); display: flex; align-items: center; line-height: 20px; padding: 14px 24px; border-radius: 3px; color: #555a65">
+  <svg fill="none" viewBox="0 0 16 16" width="16px" height="16px" style="margin-right: 5px">
+    <path fill="#00a870" d="M8 15A7 7 0 108 1a7 7 0 000 14zM7.4 4h1.2v1.2H7.4V4zm.1 2.5h1V12h-1V6.5z" fillOpacity="0.9"></path>
+  </svg>
+  目前组件库处于 Beta 阶段，快速迭代中，请留意版本变化。
+</div>
+
+## 安装
+
+目前组件库已发布一期组件的测试版本，还在快速迭代，注意留意版本变化
+
+```bash
+npm i tdesign-mobile-vue
+```
+
+## 基础使用
+
+推荐使用 Webpack 或 Rollup 等支持 tree-shaking 特性的构建工具，无需额外配置即可实现组件按需引入：
+
+```js
+import { createApp } from 'vue';
+import TDesign from 'tdesign-mobile-vue';
+
+const app = createApp(App);
+app.use(TDesign);
+```
+
+
+## 浏览器兼容性
+
+| [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/edge/edge_48x48.png" alt="Edge" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)<br/>Edge | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/firefox/firefox_48x48.png" alt="Firefox" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)<br/>Firefox | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/chrome/chrome_48x48.png" alt="Chrome" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)<br/>Chrome | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/safari/safari_48x48.png" alt="Safari" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)<br/>Safari |
+| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| >= 12                                                                                                                                                                                                  | >= 18                                                                                                                                                                                                   | >= 49                                                                                                                                                                                               | >= 10                                                                                                                                                                                               |
diff --git a/package.json b/package.json
index de497356..cdebbc1a 100644
--- a/package.json
+++ b/package.json
@@ -178,9 +178,8 @@
     }
   },
   "lint-staged": {
-    "*.{vue,ts,tsx,js}": [
-      "eslint --fix",
-      "prettier --write"
+    "*.{vue,ts,tsx}": [
+      "eslint --fix"
     ]
   }
 }
diff --git a/site/docs.config.js b/site/docs.config.js
index 6a136ffd..5729f35e 100644
--- a/site/docs.config.js
+++ b/site/docs.config.js
@@ -8,7 +8,7 @@ export default {
           title: '快速开始',
           name: 'getting-started',
           path: '/mobile-vue/getting-started',
-          component: () => import('@docs/getting-started.md'),
+          component: () => import('@/../getting-started.md'),
         },
         {
           title: '更新日志',
diff --git a/site/docs/getting-started.md b/site/docs/getting-started.md
deleted file mode 100644
index 89777962..00000000
--- a/site/docs/getting-started.md
+++ /dev/null
@@ -1,44 +0,0 @@
----
-title: Vue3 for Mobile
-description: TDesign 适配移动端的组件库，适合在 vue 3 技术栈项目中使用。
-spline: explain
----
-
-
-
-<div style="background: rgba(0, 168, 112, .1); display: flex; align-items: center; line-height: 20px; padding: 14px 24px; border-radius: 3px; color: #555a65">
-  <svg fill="none" viewBox="0 0 16 16" width="16px" height="16px" style="margin-right: 5px">
-    <path fill="#00a870" d="M8 15A7 7 0 108 1a7 7 0 000 14zM7.4 4h1.2v1.2H7.4V4zm.1 2.5h1V12h-1V6.5z" fillOpacity="0.9"></path>
-  </svg>
-  目前组件库处于 Beta 阶段，快速迭代中，请留意版本变化。
-</div>
-
-## 安装
-
-目前组件库已发布一期组件的测试版本，还在快速迭代，注意留意版本变化
-
-```bash
-npm i tdesign-mobile-vue
-```
-
-## 基础使用
-
-推荐使用 Webpack 或 Rollup 等支持 tree-shaking 特性的构建工具，无需额外配置即可实现组件按需引入：
-
-```js
-import { createApp } from 'vue';
-import TDesign from 'tdesign-mobile-vue';
-
-const app = createApp(App);
-app.use(TDesign);
-```
-
-## 浏览器兼容性
-
-| [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/firefox/firefox_48x48.png" alt="Firefox" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)<br/>Firefox | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/chrome/chrome_48x48.png" alt="Chrome" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)<br/>Chrome | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/safari-ios/safari-ios_48x48.png" alt="iOS Safari" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)<br/> iOS Safari| [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/samsung-internet/samsung-internet_48x48.png" alt="Samsung" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)<br/>Samsung | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/opera/opera_48x48.png" alt="Opera" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)<br/>Opera |[<img src="https://user-images.githubusercontent.com/51158141/189169679-71e045f6-9b9b-4baf-8b9f-e045a40216f5.png" alt="Android Browser" width="24px" height="24px" />]()<br/>Android Browser|
-| --------- | --------- | --------- | --------- | --------- |--------- |
-| Firefox >=104| Chrome >=105| iOS Safari >=12.2| Samsung >=10.2 | Opera >=64 | Android Browser >=105 |
-
-详情参见[移动端组件库浏览器兼容性说明](https://github.com/Tencent/tdesign/wiki/Browser-Compatibility)
-
-
diff --git a/site/vite.config.js b/site/vite.config.js
index f45a56ef..6a2f183f 100644
--- a/site/vite.config.js
+++ b/site/vite.config.js
@@ -4,8 +4,6 @@ import vue from '@vitejs/plugin-vue';
 import { defineConfig } from 'vite';
 import vueJsx from '@vitejs/plugin-vue-jsx';
 
-const resolvePath = (r) => path.resolve(__dirname, r);
-
 const publicPathMap = {
   preview: '/',
   intranet: '/mobile-vue/',
@@ -49,10 +47,9 @@ export default ({ mode }) => {
     root: '.',
     resolve: {
       alias: {
-        '@': resolvePath('../src'),
-        '@docs': resolvePath('./docs'),
-        '@common': resolvePath('../src/_common'),
-        'tdesign-mobile-vue': resolvePath('../src'),
+        '@': path.resolve(__dirname, '../src'),
+        'tdesign-mobile-vue': path.resolve(__dirname, '../src'),
+        '@common': path.resolve(__dirname, '../src/_common'),
       },
     },
     server: {
diff --git a/src/_common b/src/_common
index 51955cca..d9ad437c 160000
--- a/src/_common
+++ b/src/_common
@@ -1,1 +1,1 @@
-Subproject commit 51955ccaf67c8498080ea9fdd628af4b0421b717
+Subproject commit d9ad437c0ded605ac3699f2fa19b98337a4edb4a
diff --git a/src/image-viewer/demos/mobile.vue b/src/image-viewer/demos/mobile.vue
index afe0ca10..fa461a06 100644
--- a/src/image-viewer/demos/mobile.vue
+++ b/src/image-viewer/demos/mobile.vue
@@ -16,7 +16,6 @@
 </template>
 <script lang="ts">
 import { ref, defineComponent, Ref } from 'vue';
-import { TdImageViewerProps } from '../type';
 
 export default defineComponent({
   setup() {
diff --git a/src/image-viewer/image-viewer.vue b/src/image-viewer/image-viewer.vue
index 2abe8b07..4b1f15a2 100644
--- a/src/image-viewer/image-viewer.vue
+++ b/src/image-viewer/image-viewer.vue
@@ -35,8 +35,6 @@ import {
   defineComponent,
   reactive,
   watch,
-  nextTick,
-  PropType,
   getCurrentInstance,
   CSSProperties,
   SetupContext,
@@ -46,7 +44,8 @@ import config from '../config';
 import ImageViewerProps from './props';
 import { renderTNode, TNode, useEmitEvent, useDefault, useTouch } from '../shared';
 import { TdImageViewerProps } from './type';
-import { Toast } from '..';
+import { Swiper as TSwiper, SwiperItem as TSwiperItem, SwiperNavigation } from '../swiper';
+import TOverlay from '../overlay';
 
 export type TriggerType = 'close-btn' | 'overlay' | 'esc';
 const { prefix } = config;
@@ -65,6 +64,9 @@ export default defineComponent({
   name,
   components: {
     CloseCircleFilledIcon,
+    TSwiper,
+    TSwiperItem,
+    TOverlay,
     TNode,
   },
   props: ImageViewerProps,
@@ -88,7 +90,7 @@ export default defineComponent({
     const closeBtnTNode = computed(() => {
       return renderTNode(internalInstance, 'closeBtn');
     });
-    const navigation = computed(() => {
+    const navigation = computed<SwiperNavigation>(() => {
       if (props.showIndex) {
         return { type: 'fraction' };
       }
@@ -126,7 +128,6 @@ export default defineComponent({
       event.preventDefault();
       event.stopPropagation();
       const { touches } = event;
-      const { offsetX } = touch;
 
       touch.start(event);
 
diff --git a/src/swiper/swiper.vue b/src/swiper/swiper.vue
index b79ed4ff..9024226b 100644
--- a/src/swiper/swiper.vue
+++ b/src/swiper/swiper.vue
@@ -59,7 +59,7 @@ import {
   ComponentPublicInstance,
 } from 'vue';
 import { ChevronLeftIcon, ChevronRightIcon } from 'tdesign-icons-vue-next';
-import { SwipeDirection, useSwipe } from '@vueuse/core';
+import { useSwipe } from '@vueuse/core';
 import SwiperProps from './props';
 import config from '../config';
 import { renderTNode, useDefault, TNode } from '../shared';
@@ -109,16 +109,16 @@ export default defineComponent({
       return activeIndex + 1;
     });
     const childCount = computed(() => state.children.length);
-    const getContainer = (): HTMLDivElement => self?.proxy?.$el.querySelector('.t-swiper__container');
+    const getContainer = (): HTMLDivElement => self?.proxy?.$el.querySelector(`.${name}__container`);
     // const getContainer = (): HTMLDivElement => swiperContainer.value as any;
     const initSwiper = () => {
       const _swiperContainer = getContainer();
       _swiperContainer.querySelectorAll('.copy-item').forEach((ele) => {
         _swiperContainer.removeChild(ele);
       });
-      const items = _swiperContainer.querySelectorAll('.t-swiper-item');
+      const items = _swiperContainer.querySelectorAll(`.${name}-item`);
       state.itemLength = _swiperContainer.children?.length || 0;
-      const itemWidth = _swiperContainer.querySelector('.t-swiper-item')?.getBoundingClientRect().width || 0;
+      const itemWidth = _swiperContainer.querySelector(`.${name}-item`)?.getBoundingClientRect().width || 0;
       state.itemWidth = itemWidth;
       if (items.length <= 0) return false;
       if (
diff --git a/src/toast/toast.vue b/src/toast/toast.vue
index e750c7fd..d2b69949 100644
--- a/src/toast/toast.vue
+++ b/src/toast/toast.vue
@@ -53,17 +53,15 @@ export default defineComponent({
       },
     ]);
 
-    const customOverlayProps = computed(() => {
-      const toastOverlayProps = {
-        preventScrollThrough: props.preventScrollThrough,
-        visible: props.showOverlay,
-      };
+    const toastOverlayProps = {
+      preventScrollThrough: props.preventScrollThrough,
+      visible: props.showOverlay,
+    };
 
-      return {
-        ...props.overlayProps,
-        ...toastOverlayProps,
-      };
-    });
+    const customOverlayProps = computed(() => ({
+      ...props.overlayProps,
+      ...toastOverlayProps,
+    }));
 
     return {
       name: ref(name),


Test patch:
diff --git a/site/web/test-coverage.js b/site/web/test-coverage.js
index d35a1fbb..1f662e7e 100644
--- a/site/web/test-coverage.js
+++ b/site/web/test-coverage.js
@@ -1,1 +1,1 @@
-module.exports = {"button":{"statements":"98.57%","branches":"58.82%","functions":"100%","lines":"98.57%"},"checkbox":{"statements":"93.47%","branches":"75.8%","functions":"80%","lines":"93.47%"},"drawer":{"statements":"100%","branches":"93.75%","functions":"100%","lines":"100%"},"fab":{"statements":"100%","branches":"100%","functions":"100%","lines":"100%"},"overlay":{"statements":"92.55%","branches":"100%","functions":"20%","lines":"92.55%"},"popup":{"statements":"95%","branches":"64.7%","functions":"55.55%","lines":"95%"},"shared":{"statements":"70.58%","branches":"63.15%","functions":"25%","lines":"70.58%"},"switch":{"statements":"98.05%","branches":"92.85%","functions":"100%","lines":"98.05%"},"toast":{"statements":"100%","branches":"80%","functions":"100%","lines":"100%"}}
+module.exports = {"button":{"statements":"98.57%","branches":"58.82%","functions":"100%","lines":"98.57%"},"checkbox":{"statements":"93.47%","branches":"75.8%","functions":"80%","lines":"93.47%"},"drawer":{"statements":"100%","branches":"93.75%","functions":"100%","lines":"100%"},"fab":{"statements":"100%","branches":"100%","functions":"100%","lines":"100%"},"overlay":{"statements":"92.55%","branches":"100%","functions":"20%","lines":"92.55%"},"popup":{"statements":"95%","branches":"64.7%","functions":"55.55%","lines":"95%"},"shared":{"statements":"70.58%","branches":"61.11%","functions":"25%","lines":"70.58%"},"stepper":{"statements":"100%","branches":"93.18%","functions":"100%","lines":"100%"},"switch":{"statements":"98.05%","branches":"92.85%","functions":"100%","lines":"98.05%"}}
\ No newline at end of file
diff --git a/src/action-sheet/__test__/index.test.jsx b/src/action-sheet/__test__/index.test.jsx
deleted file mode 100644
index a3b2e92f..00000000
--- a/src/action-sheet/__test__/index.test.jsx
+++ /dev/null
@@ -1,176 +0,0 @@
-import { describe, it, expect, vi } from 'vitest';
-import { DOMWrapper, mount } from '@vue/test-utils';
-import ActionSheet from '../action-sheet.vue'
-import MenuGrid from '../menu-grid.vue'
-import { ref } from 'vue';
-
-describe('ActionSheet', () => {
-  describe('props', () => {
-    it ('showCancel', async () => {
-      const wrapper = mount(ActionSheet, {
-        props: {
-          items: [],
-          visible: true
-        }
-      })
-      expect(wrapper.find('.t-action-sheet__action').exists()).toEqual(true)
-      await wrapper.setProps({
-        showCancel: false
-      })
-      expect(wrapper.find('.t-action-sheet__action').exists()).toEqual(false)
-    })
-
-    it ('cancelText', async () => {
-      const wrapper = mount(ActionSheet, {
-        props: {
-          items: [],
-          visible: true
-        }
-      })
-      expect(wrapper.find('.t-action-sheet__action').text()).toEqual('取消')
-      await wrapper.setProps({
-        cancelText: 'Cancel'
-      })
-      expect(wrapper.find('.t-action-sheet__action').text()).toEqual('Cancel')
-    })
-
-    it ('type', async () => {
-      const wrapper = mount(ActionSheet, {
-        props: {
-          type: 'list',
-          items: [],
-          visible: true
-        }
-      })
-      expect(wrapper.find('.t-action-sheet__panel-list').exists()).toEqual(true)
-      expect(wrapper.find('.t-action-sheet__panel-grid').exists()).toEqual(false)
-      await wrapper.setProps({
-        type: 'grid'
-      })
-      expect(wrapper.find('.t-action-sheet__panel-list').exists()).toEqual(false)
-      expect(wrapper.find('.t-action-sheet__panel-grid').exists()).toEqual(true)
-    })
-
-    it ('count', async () => {
-      const wrapper = mount(ActionSheet, {
-        props: {
-          type: 'grid',
-          items: ['1', '2', '3'],
-          visible: true,
-          count: 2
-        }
-      })
-      expect(wrapper.findComponent(MenuGrid).props().count).toEqual(2)
-      await wrapper.setProps({
-        count: 3
-      })
-      expect(wrapper.findComponent(MenuGrid).props().count).toEqual(3)
-    })
-
-    it ('visible', async () => {
-      const wrapper = mount(ActionSheet, {
-        props: {
-          visible: true,
-          items: []
-        }
-      })
-      expect(wrapper.find('.t-popup--content').isVisible()).toEqual(true)
-      await wrapper.setProps({
-        visible: false
-      })
-      expect(wrapper.find('.t-popup--content').isVisible()).toEqual(false)
-    })
-  })
-
-  describe('events', () => {
-    it ('onSelected', () => {
-      const onSelected = vi.fn()
-      const wrapper = mount(() => <ActionSheet visible items={['确定', '删除']} onSelected={onSelected} />)
-      wrapper.find('button').trigger('click')
-      expect(onSelected).toHaveBeenCalledTimes(1)
-      expect(onSelected).toHaveBeenLastCalledWith('确定', 0)
-      wrapper.findAll('button')[1].trigger('click')
-      expect(onSelected).toHaveBeenCalledTimes(2)
-      expect(onSelected).toHaveBeenLastCalledWith('删除', 1)
-    })
-
-    it('onCancel', () => {
-      const onCancel = vi.fn()
-      const wrapper = mount(() => <ActionSheet visible items={['确定', '删除']} onCancel={onCancel} />)
-      wrapper.find('.t-action-sheet__action').trigger('click')
-      expect(onCancel).toHaveBeenCalledTimes(1)
-    })
-
-    it('onClose', () => {
-      const onClose = vi.fn()
-      const wrapper = mount(() => <ActionSheet visible items={['确定', '删除']} onClose={onClose} />)
-      wrapper.find('.t-overlay').trigger('click')
-      expect(onClose).toHaveBeenCalledTimes(1)
-    })
-  })
-})
-
-describe('MenuGrid', () => {
-  function triggerTouchEvent (event, ele, clientX) {
-    ele.trigger(event, {
-      touches: [
-        {
-          clientX
-        }
-      ],
-      changedTouches: [
-        {
-          clientX
-        }
-      ]
-    })
-  }
-  async function simulateSwipe(ele, dir) {
-    await triggerTouchEvent('touchstart', ele, 0)
-    await triggerTouchEvent('touchmove', ele, 0)
-    const distance = 51
-    await triggerTouchEvent('touchend', ele, dir === 'prev' ? distance : -distance)
-  }
-
-  it ('count', async () => {
-    const wrapper = mount(MenuGrid, {
-      props: {
-        items: [{
-          label: '1'
-        }, {
-          label: '2'
-        }, {
-          label: '3'
-        }],
-        count: 1
-      }
-    })
-    expect(wrapper.findAll('.t-action-sheet__menu')).toHaveLength(3)
-    await wrapper.setProps({
-      count: 2
-    })
-    expect(wrapper.findAll('.t-action-sheet__menu')).toHaveLength(2)
-  })
-
-  it ('touch move', async () => {
-    const wrapper = mount(MenuGrid, {
-      props: {
-        items: [{
-          label: '1'
-        }, {
-          label: '2'
-        }, {
-          label: '3'
-        }],
-        count: 1
-      }
-    })
-
-    Object.defineProperty(wrapper.element, 'offsetWidth', { configurable: true, value: 500 })
-    const slider = wrapper.find('.t-action-sheet__menu-slider')
-    await simulateSwipe(slider, 'next')
-    expect(getComputedStyle(slider.element).transform).toEqual('translate3d(-500px, 0, 0)')
-    await simulateSwipe(slider, 'prev')
-    expect(getComputedStyle(slider.element).transform).toEqual('translate3d(0px, 0, 0)')
-  })
-})
diff --git a/src/image-viewer/__test__/__snapshots__/index.test.jsx.snap b/src/image-viewer/__test__/__snapshots__/index.test.jsx.snap
new file mode 100644
index 00000000..e0c64b07
--- /dev/null
+++ b/src/image-viewer/__test__/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,109 @@
+// Vitest Snapshot v1
+
+exports[`ImageViewer > function > : onIndexChange 1`] = `
+<transition-stub
+  class="t-image-viewer"
+>
+  <div
+    class="t-overlay t-overlay--active"
+    style="z-index: 1000; transition-duration: 300ms;"
+  >
+    
+    
+    <div
+      class="t-image-viewer__close-icon"
+    >
+      <svg
+        class="t-icon t-icon-close-circle-filled"
+        fill="none"
+        height="1em"
+        viewBox="0 0 16 16"
+        width="1em"
+      >
+        <path
+          d="M15 8A7 7 0 101 8a7 7 0 0014 0zM5.67 4.95L8 7.29l2.33-2.34.7.7L8.7 8l2.34 2.35-.71.7L8 8.71l-2.33 2.34-.7-.7L7.3 8 4.96 5.65l.71-.7z"
+          fill="currentColor"
+          fill-opacity="0.9"
+        />
+      </svg>
+    </div>
+    <div
+      class="t-swiper t-image-viewer__swipe"
+      style="overflow: hidden;"
+    >
+      <div
+        class="t-swiper__container"
+        data-is-trust="true"
+        style="flex-direction: row; transform: translateX(-0px); transition: transform 300ms;"
+      >
+        
+        
+        <div
+          class="t-swiper-item t-image-viewer__swipe-item copy-item"
+        >
+          
+          <img
+            class="t-image-viewer__image"
+            src="https://imgcache.qq.com/open_proj/proj_qcloud_v2/rocket_images/1606728019829_yw760ok1jmpbep14i.png"
+            style="transition-duration: .3s;"
+          />
+          
+        </div>
+        <div
+          class="t-swiper-item t-image-viewer__swipe-item"
+        >
+          
+          <img
+            class="t-image-viewer__image"
+            src="https://imgcache.qq.com/open_proj/proj_qcloud_v2/rocket_images/1606728019829_yw760ok1jmpbep14i.png"
+            style="transition-duration: .3s;"
+          />
+          
+        </div>
+        <div
+          class="t-swiper-item t-image-viewer__swipe-item"
+        >
+          
+          <img
+            class="t-image-viewer__image"
+            src="https://imgcache.qq.com/open_proj/proj_qcloud_v2/rocket_images/1606728019829_yw760ok1jmpbep14i.png"
+            style="transition-duration: .3s;"
+          />
+          
+        </div>
+        
+        
+        <div
+          class="t-swiper-item t-image-viewer__swipe-item copy-item"
+        >
+          
+          <img
+            class="t-image-viewer__image"
+            src="https://imgcache.qq.com/open_proj/proj_qcloud_v2/rocket_images/1606728019829_yw760ok1jmpbep14i.png"
+            style="transition-duration: .3s;"
+          />
+          
+        </div>
+      </div>
+      
+      <!--v-if-->
+      <span
+        class="t-swiper__pagination t-swiper__pagination-dots t-swiper__pagination-bottom"
+      >
+        
+        <span
+          class="t-swiper-dot"
+        />
+        <span
+          class="t-swiper-dot"
+        />
+        
+        <!--v-if-->
+      </span>
+      
+    </div>
+    
+    
+  </div>
+</transition-stub>
+`;
diff --git a/src/image-viewer/__test__/index.test.jsx b/src/image-viewer/__test__/index.test.jsx
new file mode 100644
index 00000000..2b7b8411
--- /dev/null
+++ b/src/image-viewer/__test__/index.test.jsx
@@ -0,0 +1,120 @@
+import { nextTick, ref } from 'vue';
+import { mount } from '@vue/test-utils';
+import { CloseCircleFilledIcon, AddCircleIcon } from 'tdesign-icons-vue-next';
+import { describe, it, vi } from 'vitest';
+import ImageViewer from '../image-viewer.vue';
+import { Swiper } from '../../swiper';
+import { trigger, triggerZoom } from './touch';
+
+const images = ref([
+  'https://imgcache.qq.com/open_proj/proj_qcloud_v2/rocket_images/1606728019829_yw760ok1jmpbep14i.png',
+  'https://imgcache.qq.com/open_proj/proj_qcloud_v2/rocket_images/1606728019829_yw760ok1jmpbep14i.png',
+  'https://imgcache.qq.com/open_proj/proj_qcloud_v2/rocket_images/1606728019829_yw760ok1jmpbep14i.png',
+]);
+
+describe('ImageViewer', () => {
+  describe('props', () => {
+    it(': closeBtn', async () => {
+      const wrapper = mount(<ImageViewer v-model:images={images.value} visible={true} closeBtn={false} />);
+
+      expect(wrapper.findComponent(CloseCircleFilledIcon).exists()).toBe(false);
+    });
+
+    it(': images', async () => {
+      const emptyImages = mount(<ImageViewer visible={true} />);
+      expect(emptyImages.find('.t-swiper-item').exists()).toBe(false);
+
+      const wrapper = mount(<ImageViewer v-model:images={images.value} visible={true} />);
+
+      await nextTick();
+      expect(wrapper.findAll('.t-swiper-item').length - 2).toBe(images.value.length);
+
+      images.value.pop();
+      await nextTick();
+      expect(wrapper.findAll('.t-swiper-item').length - 2).toBe(images.value.length);
+    });
+
+    it(': maxZoom', async () => {
+      const wrapper = mount(<ImageViewer v-model:images={images.value} visible={true} maxZoom={2} />);
+
+      const target = wrapper.find('.t-swiper-item');
+      triggerZoom(target, 200, 200, 'in');
+      expect(wrapper.vm.imageStyle).toMatchObject({ transform: `scale(${2}, ${2})` });
+
+      triggerZoom(target, 200, 200, 'out');
+      expect(wrapper.vm.imageStyle).not.toHaveProperty('transform');
+
+      // 测试 toggleScale
+      trigger(target, 'touchstart', 50, 50);
+      trigger(target, 'touchend', 50, 50);
+
+      await new Promise((resolve, reject) => {
+        setTimeout(() => {
+          resolve();
+        }, 100); // 小于 TAP_TIME
+      });
+      trigger(target, 'touchstart', 50, 50);
+      trigger(target, 'touchend', 50, 50);
+    });
+
+    it(': showIndex', async () => {
+      const wrapper = mount(<ImageViewer v-model:images={images.value} visible={true} showIndex={true} />);
+
+      expect(wrapper.find('.t-swiper__pagination-fraction').exists()).toBe(true);
+    });
+
+    it(': visible', async () => {
+      const visible = ref(false);
+      const wrapper = mount(<ImageViewer v-model:images={images.value} v-model:visible={visible.value} />);
+
+      expect(wrapper.findComponent(Swiper).exists()).toBe(false);
+      visible.value = true;
+    });
+  });
+
+  describe('slots', () => {
+    it(': closeBtn', () => {
+      const wrapper = mount(
+        <ImageViewer v-model:images={images.value} visible={true} closeBtn={() => <AddCircleIcon />} />,
+      );
+
+      expect(wrapper.findComponent(AddCircleIcon).exists()).toBe(true);
+    });
+  });
+
+  describe('event', () => {
+    it(': close', async () => {
+      const wrapper = mount(<ImageViewer v-model:images={images.value} visible={true} />);
+
+      await wrapper.findComponent(CloseCircleFilledIcon).trigger('click');
+      expect(wrapper.emitted()).toHaveProperty('close');
+    });
+  });
+
+  describe('function', () => {
+    it(': onClose', async () => {
+      const onClose = vi.fn();
+
+      const wrapper = mount(<ImageViewer v-model:images={images.value} visible={true} onClose={onClose} />);
+
+      await wrapper.findComponent(CloseCircleFilledIcon).trigger('click');
+      expect(onClose).toHaveBeenCalled();
+    });
+
+    it(': onIndexChange', async () => {
+      const onIndexChange = vi.fn();
+
+      const wrapper = mount(<ImageViewer v-model:images={images.value} visible={true} onIndexChange={onIndexChange} />);
+
+      const target = wrapper.find('.t-swiper__container');
+
+      await trigger(target, 'touchstart', 0, 0);
+      await trigger(target, 'touchmove', 30, 0);
+      await trigger(target, 'touchmove', 60, 0);
+      await trigger(target, 'touchmove', 120, 0);
+      await trigger(target, 'touchend', 120, 0);
+
+      expect(wrapper.element).toMatchSnapshot();
+    });
+  });
+});
diff --git a/src/image-viewer/__test__/touch.ts b/src/image-viewer/__test__/touch.ts
new file mode 100644
index 00000000..fafcd52d
--- /dev/null
+++ b/src/image-viewer/__test__/touch.ts
@@ -0,0 +1,75 @@
+/**
+ * https://github.com/vant-ui/vant/blob/dev/packages/vant/test/event.ts
+ */
+
+import { ComponentPublicInstance, nextTick } from 'vue';
+import { VueWrapper, DOMWrapper } from '@vue/test-utils';
+
+function getTouch(el: Element | Window, x: number, y: number) {
+  return {
+    identifier: Date.now(),
+    target: el,
+    pageX: x,
+    pageY: y,
+    clientX: x,
+    clientY: y,
+    radiusX: 2.5,
+    radiusY: 2.5,
+    rotationAngle: 10,
+    force: 0.5,
+  };
+}
+
+// trigger pointer/touch event
+export function trigger(
+  wrapper: VueWrapper<ComponentPublicInstance<any, any, any>> | DOMWrapper<Element> | Element | Window,
+  eventName: string,
+  x = 0,
+  y = 0,
+  options: any = {},
+) {
+  const el = 'element' in wrapper ? wrapper.element : wrapper;
+  const touchList = options.touchList || [getTouch(el, x, y)];
+
+  if (options.x || options.y) {
+    touchList.push(getTouch(el, options.x, options.y));
+  }
+
+  const event = document.createEvent('CustomEvent');
+  event.initCustomEvent(eventName, true, true, {});
+
+  Object.assign(event, {
+    clientX: x,
+    clientY: y,
+    touches: touchList,
+    targetTouches: touchList,
+    changedTouches: touchList,
+  });
+
+  el.dispatchEvent(event);
+
+  return nextTick();
+}
+
+function triggerTwoFingerTouchmove(el: Element | DOMWrapper<Element>, x: number, y: number) {
+  trigger(el, 'touchmove', -x, -y, { x, y });
+}
+
+// simulate zoom
+export function triggerZoom(el: Element | DOMWrapper<Element>, x: number, y: number, direction: 'in' | 'out' = 'in') {
+  trigger(el, 'touchstart', 0, 0, { x, y });
+
+  if (direction === 'in') {
+    triggerTwoFingerTouchmove(el, x / 4, y / 4);
+    triggerTwoFingerTouchmove(el, x / 3, y / 3);
+    triggerTwoFingerTouchmove(el, x / 2, y / 2);
+    triggerTwoFingerTouchmove(el, x, y);
+  } else if (direction === 'out') {
+    triggerTwoFingerTouchmove(el, x, y);
+    triggerTwoFingerTouchmove(el, x / 2, y / 2);
+    triggerTwoFingerTouchmove(el, x / 3, y / 3);
+    triggerTwoFingerTouchmove(el, x / 4, y / 4);
+  }
+
+  trigger(el, 'touchend', 0, 0, { touchList: [] });
+}
diff --git a/src/progress/__test__/index.test.jsx b/src/progress/__test__/index.test.jsx
deleted file mode 100644
index 434926f7..00000000
--- a/src/progress/__test__/index.test.jsx
+++ /dev/null
@@ -1,104 +0,0 @@
-import { describe, it, expect, vi } from 'vitest';
-import { getBackgroundColor } from '../utils'
-import Progress from '../progress.vue'
-import { mount } from '@vue/test-utils';
-
-describe('Progress', () => {
-   // jsdom 不支持 linear-gradient 等 css 样式 https://github.com/jsdom/jsdom/issues/2166
-  //  这里对 getBackgroundColor 方法单独验证
-  describe('utils/getBackgroundColor', () => {
-    it('color types', () => {
-      expect(getBackgroundColor('red'))
-        .toEqual('red')
-
-      expect(getBackgroundColor(['#000', '#fff']))
-        .toEqual('linear-gradient( 90deg,#000,#fff )')
-
-      expect(getBackgroundColor({
-        '0%': '#f00', '100%': '#0ff'
-      }))
-        .toEqual('linear-gradient(to right, #f00 0%,#0ff 100%)')
-    })
-  })
-
-  describe('props', () => {
-    it(': color', async () => {
-      const utils = await import('../utils')
-      const wrapper = mount(Progress, {
-        props: { color: 'orange' }
-      })
-      const percentBar = wrapper.find('.t-progress__bar-percent')
-      expect(getComputedStyle(percentBar.element).backgroundColor).toBe('orange')
-
-      await wrapper.setProps({
-        color: 'red'
-      })
-      expect(getComputedStyle(percentBar.element).backgroundColor).toBe('red')
-    })
-
-    it(': label', async () => {
-      const wrapper = mount(Progress)
-      expect(wrapper.find('.t-progress__label').exists()).toEqual(true)
-      await wrapper.setProps({
-        label: false
-      })
-      expect(wrapper.find('.t-progress__label').exists()).toEqual(false)
-    })
-
-    it(': percentage', async () => {
-      const wrapper = mount(Progress, {
-        props: { percentage: 50 }
-      })
-      const percentBar = wrapper.find('.t-progress__bar-percent')
-      expect(getComputedStyle(percentBar.element).width).toBe('50%')
-      await wrapper.setProps({
-        percentage: 80
-      })
-      expect(getComputedStyle(percentBar.element).width).toBe('80%')
-    })
-
-    it(': status', async () => {
-      const wrapper = mount(Progress, {
-        props: { status: 'success' }
-      })
-
-      expect(wrapper.classes()).toContain('t-progress--status--success')
-      await wrapper.setProps({
-        status: 'error'
-      })
-      expect(wrapper.classes()).not.toContain('t-progress--status--success')
-      expect(wrapper.classes()).toContain('t-progress--status--error')
-    })
-
-    it(': strokeWidth', async () => {
-      const wrapper = mount(Progress, {
-        props: { strokeWidth: 10 }
-      })
-      expect(getComputedStyle(wrapper.find('.t-progress__bar').element).height).toEqual('10px')
-      await wrapper.setProps({
-        strokeWidth: 8
-      })
-      expect(getComputedStyle(wrapper.find('.t-progress__bar').element).height).toEqual('8px')
-    })
-
-    it(': trackColor', async () => {
-      const wrapper = mount(Progress, {
-        props: { trackColor: 'orange' }
-      })
-      expect(getComputedStyle(wrapper.find('.t-progress__bar').element).backgroundColor).toEqual('orange')
-      await wrapper.setProps({
-        trackColor: 'white'
-      })
-      expect(getComputedStyle(wrapper.find('.t-progress__bar').element).backgroundColor).toEqual('white')
-    })
-  })
-
-  describe('slots', () => {
-    it(': label', () => {
-      const wrapper = mount(Progress, {
-        slots: { label: '25%' }
-      })
-      expect(wrapper.find('.t-progress__label').text()).toEqual('25%')
-    })
-  })
-})
diff --git a/src/toast/__test__/index.test.jsx b/src/toast/__test__/index.test.jsx
deleted file mode 100644
index 51b05ad7..00000000
--- a/src/toast/__test__/index.test.jsx
+++ /dev/null
@@ -1,87 +0,0 @@
-import { describe, it, expect, vi } from 'vitest';
-import Toast from '../toast.vue'
-import Overlay from '../../overlay'
-import { mount } from '@vue/test-utils';
-import { LoadingIcon } from 'tdesign-icons-vue-next';
-
-describe('Toast', () => {
-  describe('props', () => {
-    it(': direction', async () => {
-      const wrapper = mount(Toast, {
-        direction: 'row'
-      })
-      expect(wrapper.find('.t-toast').classes()).toContain('t-toast--row')
-      await wrapper.setProps({
-        direction: 'column'
-      })
-      expect(wrapper.find('.t-toast').classes()).toContain('t-toast--column')
-    })
-
-    it(': icon', async () => {
-      const icon = () => <LoadingIcon />
-
-      const wrapper = mount(Toast, {
-        props: {
-          icon
-        }
-      })
-      expect(wrapper.find('.t-toast > svg').classes()).toContain('t-icon')
-    })
-
-    it(': message', async () => {
-      const wrapper = mount(Toast, {
-        props: {
-          message: 'Loading'
-        }
-      })
-      expect(wrapper.find('.t-toast').text()).toEqual('Loading')
-    })
-
-    it(': overlayProps', async () => {
-      const wrapper = mount(Toast)
-      await wrapper.setProps({
-        overlayProps: {
-          duration: 1000
-        }
-      })
-      expect(wrapper.findComponent(Overlay).vm.duration).toEqual(1000)
-    })
-
-    it (': placement', async () => {
-      const wrapper = mount(Toast, {
-        props: {
-          placement: 'top'
-        }
-      })
-      expect(wrapper.find('.t-toast').classes()).toContain('t-toast--top')
-      await wrapper.setProps({
-        placement: 'bottom'
-      })
-      expect(wrapper.find('.t-toast').classes()).not.toContain('t-toast--top')
-      expect(wrapper.find('.t-toast').classes()).toContain('t-toast--bottom')
-    })
-
-    it(': showOverlay', async () => {
-      const wrapper = mount(Toast, {
-        props: {
-          showOverlay: true
-        }
-      })
-      expect(wrapper.findComponent(Overlay).vm.visible).toEqual(true)
-      await wrapper.setProps({
-        showOverlay: false
-      })
-      expect(wrapper.findComponent(Overlay).vm.visible).toEqual(false)
-    })
-
-    it(': theme', async () => {
-      const wrapper = mount(Toast, {
-        props: {
-          theme: 'loading'
-        }
-      })
-
-      expect(wrapper.findComponent(LoadingIcon).exists()).toEqual(true)
-    })
-  })
-})