Repository: Tencent/tdesign-mobile-vue
Pull request #331 (2022-08-30 16:07:55)

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


isanxia: 麻烦 请先review合并 #330 
github-actions[bot]: [<img height="96" alt="失败" src="https://user-images.githubusercontent.com/5378891/75333447-1e63a280-58c1-11ea-975d-235367fd1522.png">](https://github.com/Tencent/tdesign-mobile-vue/actions/runs/2960861379)
<!-- AUTO_PREVIEW_HOOK -->
LeeJim: 单测没过。。

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 317284e4..e78aff15 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,27 @@ toc: false
 spline: explain
 ---
 
+## 🌈 0.10.6 `2022-09-20` 
+### 🚀 Features
+- `Drawer`: 新增单元测试 @anlyyao ([#311](https://github.com/Tencent/tdesign-mobile-vue/pull/311))
+- `Popup`: 新增单元测试 @anlyyao ([#310](https://github.com/Tencent/tdesign-mobile-vue/pull/310))
+- `Stepper`: 新增单元测试 @anlyyao ([#304](https://github.com/Tencent/tdesign-mobile-vue/pull/304))
+- `Overlay`: 新增单元测试 @HelKyle ([#336](https://github.com/Tencent/tdesign-mobile-vue/pull/336))
+- `BackTop`: 新增单元测试 @HelKyle ([#335](https://github.com/Tencent/tdesign-mobile-vue/pull/335))
+- `Toast`: 新增单元测试 @HelKyle ([#334](https://github.com/Tencent/tdesign-mobile-vue/pull/334))
+- `ActionSheet`: 新增单元测试 @HelKyle ([#319](https://github.com/Tencent/tdesign-mobile-vue/pull/319))
+- `Progress`: 新增单元测试 @HelKyle ([#337](https://github.com/Tencent/tdesign-mobile-vue/pull/337))
+- `ImageViewer`: 新增单元测试 @palmcivet ([#332](https://github.com/Tencent/tdesign-mobile-vue/pull/332))
+- `TabBar`: 新增单元测试 @HelKyle ([#314](https://github.com/Tencent/tdesign-mobile-vue/pull/314))
+- `Dialog`: 新增单元测试 @anlyyao ([#361](https://github.com/Tencent/tdesign-mobile-vue/pull/361))
+### 🐞 Bug Fixes
+- `Stepper`: 修复输入非 `number` 字符时，出现 `NaN` 问题 @anlyyao ([#304](https://github.com/Tencent/tdesign-mobile-vue/pull/304))
+- `Stepper`: 修复输入值能超出 `max` 的问题 @anlyyao ([#304](https://github.com/Tencent/tdesign-mobile-vue/pull/304))
+- `Stepper`: 修复 `value` 值小于 `min` 或超出 `max` 时，不触发 `overlimit` 的问题 @anlyyao ([#304](https://github.com/Tencent/tdesign-mobile-vue/pull/304))
+- `Collapse`: 修复 `headerRightContent` 插槽与文档描述不一致的问题 @isanxia ([#330](https://github.com/Tencent/tdesign-mobile-vue/pull/330))
+### 🚧 Others
+- `官网`: 更新浏览器兼容性说明文档 @anlyyao ([#349](https://github.com/Tencent/tdesign-mobile-vue/pull/349))
+
 ## 🌈 0.10.5 `2022-09-06` 
 ### 🚀 Features
 - `Popup`: 新增 `customStyle` 和 `overlayProps` 属性 @anlyyao ([#296](https://github.com/Tencent/tdesign-mobile-vue/pull/296))
diff --git a/package.json b/package.json
index de497356..23e00d3c 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "tdesign-mobile-vue",
   "purename": "tdesign",
-  "version": "0.10.5",
+  "version": "0.10.6",
   "description": "tdesign-mobile-vue",
   "author": "TDesign Group",
   "homepage": "https://tdesign.tencent.com/mobile-vue/",
diff --git a/site/docs.config.js b/site/docs.config.js
index 6a136ffd..328c56c4 100644
--- a/site/docs.config.js
+++ b/site/docs.config.js
@@ -70,7 +70,7 @@ export default {
           path: '/mobile-vue/components/grid',
           component: () => import('@/grid/grid.md'),
         },
-      ]
+      ],
     },
     {
       title: '导航',
diff --git a/site/vite.config.js b/site/vite.config.js
index f45a56ef..7d611c45 100644
--- a/site/vite.config.js
+++ b/site/vite.config.js
@@ -79,7 +79,7 @@ export default ({ mode }) => {
         },
       }),
       vueJsx({
-        isCustomElement
+        isCustomElement,
       }),
       createTDesignPlugin(),
     ],
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/tab-bar/tab-bar-item.vue b/src/tab-bar/tab-bar-item.vue
index 837bdc04..d95ab673 100644
--- a/src/tab-bar/tab-bar-item.vue
+++ b/src/tab-bar/tab-bar-item.vue
@@ -91,6 +91,7 @@ export default defineComponent({
         isSpread.value = !isSpread.value;
         if (!isToggleCurrent.value) {
           updateChild([currentName]);
+          return;
         }
       }
       updateChild(currentName);
diff --git a/src/tab-bar/tab-bar.vue b/src/tab-bar/tab-bar.vue
index e26b570c..01d900c3 100644
--- a/src/tab-bar/tab-bar.vue
+++ b/src/tab-bar/tab-bar.vue
@@ -5,7 +5,7 @@
 </template>
 
 <script lang="ts">
-import { defineComponent, ref, provide, watch, Ref, computed, SetupContext } from 'vue';
+import { defineComponent, ref, provide, Ref, computed, SetupContext } from 'vue';
 import TabBarProps from './props';
 import config from '../config';
 import { useDefault } from '../shared';
diff --git a/src/upload/upload.vue b/src/upload/upload.vue
index 6d4dbf48..1210d666 100644
--- a/src/upload/upload.vue
+++ b/src/upload/upload.vue
@@ -154,9 +154,9 @@ export default defineComponent({
       uploadFiles([file]);
     };
 
-    const handleChange = (event: Event) => {
-      const input = <HTMLInputElement>inputRef.value;
-      if (props.disabled || !input.files) return;
+    const handleChange = () => {
+      const input = inputRef.value;
+      if (props.disabled || !input || !input.files) return;
       const formatFiles = formatFileToUploadFile(input.files);
       emitEvent('select-change', [...formatFiles]);
       uploadFiles(formatFiles);


Test patch:
diff --git a/site/web/test-coverage.js b/site/web/test-coverage.js
index d35a1fbb..ef34f280 100644
--- a/site/web/test-coverage.js
+++ b/site/web/test-coverage.js
@@ -1,1 +1,11 @@
-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: '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%' },
+};
diff --git a/src/collapse/__test__/index.test.jsx b/src/collapse/__test__/index.test.jsx
new file mode 100644
index 00000000..bb792e58
--- /dev/null
+++ b/src/collapse/__test__/index.test.jsx
@@ -0,0 +1,225 @@
+import { ref, reactive } from 'vue';
+import { mount } from '@vue/test-utils';
+import { Collapse, CollapsePanel } from '../index';
+
+describe('Collapse', () => {
+  describe('collapse props', () => {
+    test('defaultExpandAll', async () => {
+      const defaultExpandAll = ref(false);
+      const wrapper = mount({
+        setup() {
+          return () => (
+            <Collapse defaultExpandAll={defaultExpandAll}>
+              <CollapsePanel ref="1" value="1" header="标题1" content="内容1" />
+              <CollapsePanel ref="2" value="2" header="标题2" content="内容2" />
+            </Collapse>
+          );
+        },
+      });
+
+      const panel1 = wrapper.findComponent({ ref: '1' });
+      const panel2 = wrapper.findComponent({ ref: '2' });
+
+      expect(panel1.classes()).not.toContain('t-collapse-panel--active');
+      expect(panel2.classes()).not.toContain('t-collapse-panel--active');
+
+      defaultExpandAll.value = true;
+      await wrapper.vm.$nextTick();
+
+      expect(panel1.classes()).toContain('t-collapse-panel--active');
+      expect(panel2.classes()).toContain('t-collapse-panel--active');
+    });
+
+    test('default-value', async () => {
+      const wrapper = mount({
+        setup() {
+          return () => (
+            <Collapse default-value={['1']}>
+              <CollapsePanel ref="1" value="1" header="标题1" content="内容1" />
+              <CollapsePanel ref="2" value="2" header="标题2" content="内容2" />
+            </Collapse>
+          );
+        },
+      });
+
+      const panel1 = wrapper.findComponent({ ref: '1' });
+      const panel2 = wrapper.findComponent({ ref: '2' });
+
+      expect(panel1.classes()).toContain('t-collapse-panel--active');
+      expect(panel2.classes()).not.toContain('t-collapse-panel--active');
+    });
+
+    test('value', async () => {
+      const val = ref([]);
+      const wrapper = mount({
+        setup() {
+          return () => (
+            <Collapse v-model={val.value}>
+              <CollapsePanel ref="1" value="1" header="标题1" content="内容1" />
+              <CollapsePanel ref="2" value="2" header="标题2" content="内容2" />
+            </Collapse>
+          );
+        },
+      });
+
+      const panel1 = wrapper.findComponent({ ref: '1' });
+      const panel2 = wrapper.findComponent({ ref: '2' });
+      await panel1.find('.t-collapse-panel__header').trigger('click');
+
+      expect(val.value).toHaveLength(1);
+      expect(val.value).toContain('1');
+
+      await panel2.find('.t-collapse-panel__header').trigger('click');
+
+      expect(val.value).toHaveLength(2);
+      expect(val.value).toContain('2');
+    });
+
+    test('expandMutex', async () => {
+      const val = ref([]);
+      const wrapper = mount({
+        setup() {
+          return () => (
+            <Collapse expandMutex v-model={val.value}>
+              <CollapsePanel ref="1" value="1" header="标题1" content="内容1" />
+              <CollapsePanel ref="2" value="2" header="标题2" content="内容2" />
+            </Collapse>
+          );
+        },
+      });
+
+      const panel1 = wrapper.findComponent({ ref: '1' });
+      const panel2 = wrapper.findComponent({ ref: '2' });
+      await panel1.find('.t-collapse-panel__header').trigger('click');
+
+      expect(val.value).toHaveLength(1);
+      expect(val.value).toContain('1');
+
+      await panel2.find('.t-collapse-panel__header').trigger('click');
+
+      expect(val.value).toHaveLength(1);
+      expect(val.value).toContain('2');
+    });
+
+    test('onChange', async () => {
+      const handleChange = vi.fn();
+      const wrapper = mount({
+        setup() {
+          return () => (
+            <Collapse onChange={handleChange}>
+              <CollapsePanel ref="1" value="1" header="标题" content="内容" />
+            </Collapse>
+          );
+        },
+      });
+
+      const panel = wrapper.findComponent({ ref: '1' });
+      await panel.find('.t-collapse-panel__header').trigger('click')
+
+      expect(handleChange).toHaveBeenCalled();
+    });
+
+    test('disabled', async () => {
+      const disabled = ref(false);
+      const handleChange = vi.fn();
+      const wrapper = mount({
+        setup() {
+          return () => (
+            <Collapse disabled={disabled.value} onChange={handleChange}>
+              <CollapsePanel ref="1" value="1" header="标题" content="内容" />
+            </Collapse>
+          );
+        },
+      });
+
+      const panel = wrapper.findComponent({ ref: '1' });
+      await panel.find('.t-collapse-panel__header').trigger('click');
+
+      expect(handleChange).toHaveBeenCalled();
+
+      disabled.value = true;
+      await wrapper.vm.$nextTick();
+      await panel.find('.t-collapse-panel__header').trigger('click');
+
+      expect(handleChange).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('collapse-panel props', () => {
+    test('header and content', async () => {
+      const wrapper = mount({
+        setup() {
+          return () => (
+            <Collapse>
+              <CollapsePanel ref="1" value="1" header="标题" content="内容" />
+            </Collapse>
+          );
+        },
+      });
+
+      const panel = wrapper.findComponent({ ref: '1' });
+
+      expect(panel.find('.t-collapse-panel__title').text()).toBe('标题');
+      expect(panel.find('.t-collapse-panel__body').text()).toBe('内容');
+    });
+
+    test('disabled', async () => {
+      const handleChange = vi.fn();
+      const wrapper = mount({
+        setup() {
+          return () => (
+            <Collapse onChange={handleChange}>
+              <CollapsePanel ref="1" value="1" disabled header="标题" content="内容" />
+            </Collapse>
+          );
+        },
+      });
+
+      const panel = wrapper.findComponent({ ref: '1' });
+
+      await panel.find('.t-collapse-panel__header').trigger('click')
+
+      expect(handleChange).not.toHaveBeenCalled();
+    });
+
+    test('headerRightContent', async () => {
+      const wrapper = mount({
+        setup() {
+          return () => (
+            <Collapse>
+              <CollapsePanel ref="1" value="1" headerRightContent="操作" header="标题" content="内容" />
+            </Collapse>
+          );
+        },
+      });
+
+      const panel = wrapper.findComponent({ ref: '1' });
+
+      expect(panel.find('.t-collapse-panel__header-right').text()).toBe('操作');
+    });
+
+    test('slot', async () => {
+      const wrapper = mount({
+        setup() {
+          return () => (
+            <Collapse>
+              <CollapsePanel ref="1" value="1">
+                {{
+                  header: () => <h4>标题</h4>,
+                  headerRightContent: () => <span>操作</span>,
+                  default: () => <div>内容</div>,
+                }}
+              </CollapsePanel>
+            </Collapse>
+          );
+        },
+      });
+
+      const panel = wrapper.findComponent({ ref: '1' });
+
+      expect(panel.find('.t-collapse-panel__title > h4').html()).toBe('<h4>标题</h4>');
+      expect(panel.find('.t-collapse-panel__header-extra > span').html()).toBe('<span>操作</span>');
+      expect(panel.find('.t-collapse-panel__body > div').html()).toBe('<div>内容</div>');
+    });
+  });
+});
diff --git a/src/dialog/__test__/index.test.jsx b/src/dialog/__test__/index.test.jsx
new file mode 100644
index 00000000..5e83cecb
--- /dev/null
+++ b/src/dialog/__test__/index.test.jsx
@@ -0,0 +1,183 @@
+import { mount } from '@vue/test-utils';
+import { describe, it, expect, vi } from 'vitest';
+import { nextTick } from 'vue';
+import Dialog from '../dialog.vue';
+import Button from '../../button/button.vue';
+
+describe('dialog', () => {
+  describe('props', () => {
+    it(': visible	', async () => {
+      const visible = true;
+      const title = 'this is a title';
+      const wrapper = mount(Dialog, {
+        props: {
+          visible,
+          title,
+        },
+      });
+
+      expect(wrapper.find('.t-overlay').isVisible()).toBe(true)
+      expect(wrapper.find('.t-dialog__header').text()).toBe(title);
+      await wrapper.setProps({
+        visible: false,
+      })
+      expect(wrapper.find('.t-overlay').isVisible()).toBe(false)
+
+    })
+
+    it(': title', async () => {
+      const visible = true;
+      const title = 'old title';
+      const wrapper = mount(Dialog, {
+        props: {
+          visible,
+          title,
+        },
+      });
+      expect(wrapper.find('.t-dialog__header').text()).toBe(title);
+      const newTitle = 'new title'
+      await wrapper.setProps({
+        title: newTitle
+      })
+      expect(wrapper.find('.t-dialog__header').text()).toBe(newTitle);
+    })
+
+    it(': content', async () => {
+      const visible = true;
+      const title = 'this is title';
+      const content = 'content is string';
+
+      const wrapper = mount(Dialog, {
+        props: {
+          visible,
+          title,
+          content,
+        }
+      });
+
+      expect(wrapper.find('.t-dialog__body').text()).toBe(content);
+      const newContent = 'new content';
+      await wrapper.setProps({
+        content: newContent
+      })
+      expect(wrapper.find('.t-dialog__body').text()).toBe(newContent);
+    });
+
+    it(': buttonLayout', async () => {
+      const visible = true;
+      const selector = 't-dialog__vertical-footer';
+      const wrapper = mount(Dialog, {
+        props: {
+          visible,
+        },
+      });
+      expect(wrapper.find('.t-dialog__footer').classes().includes(selector)).toBeFalsy();
+      await wrapper.setProps({
+        buttonLayout: 'vertical'
+      })
+      expect(wrapper.find('.t-dialog__footer').classes().includes(selector)).toBeTruthy();
+    })
+
+    it(': width', async () => {
+      const visible = true;
+      // width 为 string 类型
+      const width = '80%';
+      const wrapper = mount(Dialog, {
+        props: {
+          visible,
+          width,
+        },
+      });
+      expect(wrapper.find('.t-dialog').attributes('style').includes(`width: ${width}`)).toBeTruthy();
+      // width 为 number 类型
+      const newWidth = 320;
+      await wrapper.setProps({
+        width: newWidth
+      })
+      expect(wrapper.find('.t-dialog').attributes('style').includes(`width: ${newWidth}px`)).toBeTruthy();
+    })
+
+    it(': zIndex', async () => {
+      const visible = true;
+      const zIndex = 11500;
+      const wrapper = mount(Dialog, {
+        props: {
+          visible,
+          zIndex: zIndex,
+        },
+      });
+      // expect(wrapper).toMatchSnapshot()
+      expect(wrapper.find('.t-dialog').attributes('style').includes(`z-index: ${zIndex}`)).toBeTruthy();
+      const newZIndex = 12000;
+      await wrapper.setProps({
+        zIndex: newZIndex
+      })
+      expect(wrapper.find('.t-dialog').attributes('style').includes(`z-index: ${newZIndex}`)).toBeTruthy();
+    })
+
+    it(': closeOnOverlayClick', async () => {
+      let triggerOrigin = {};
+      const onClose = vi.fn((trigger) => {
+        triggerOrigin = trigger;
+      });
+      const onOverlayClick = vi.fn();
+      const closeOverlayClick = true;
+      const visible = true;
+      const wrapper = mount(Dialog, {
+        props: {
+          visible,
+          closeOverlayClick,
+          onClose,
+          onOverlayClick
+        },
+      });
+
+      const $overlay = wrapper.findComponent({ name: 't-overlay' })
+      expect($overlay.exists()).toBeTruthy();
+      $overlay.vm.$emit('click');
+      await nextTick();
+      expect(onClose).toBeCalledTimes(1)
+      expect(onOverlayClick).toBeCalledTimes(1)
+      expect(triggerOrigin).toBe('overlay')
+    })
+
+  });
+
+  describe('event', () => {
+    it(': cancel && confirm', async () => {
+      let triggerOrigin = {};
+      const visible = true;
+      const cancelBtn = 'cancel';
+      const confirmBtn = 'confirm';
+      const title = 'this is a title';
+      const onClose = vi.fn((trigger) => {
+        triggerOrigin = trigger;
+      });
+      const onCancel = vi.fn();
+      const onConfirm = vi.fn();
+      const wrapper = mount(Dialog, {
+        props: {
+          visible,
+          title,
+          cancelBtn,
+          confirmBtn,
+          onCancel,
+          onConfirm,
+          onClose
+        },
+      });
+      // expect(wrapper).toMatchSnapshot()
+      // footer
+      const $buttons = wrapper.findAllComponents(Button);
+      expect($buttons).toHaveLength(2);
+      expect($buttons.at(0).text()).toBe(cancelBtn);
+      expect($buttons.at(1).text()).toBe(confirmBtn);
+      $buttons.at(0).trigger('click');
+      expect(onClose).toBeCalledTimes(1)
+      expect(triggerOrigin).toBe('cancel')
+      expect(onCancel).toBeCalledTimes(1)
+      $buttons.at(1).trigger('click');
+      expect(onConfirm).toBeCalledTimes(1)
+    });
+  });
+})
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/tab-bar/__test__/__snapshots__/index.test.jsx.snap b/src/tab-bar/__test__/__snapshots__/index.test.jsx.snap
new file mode 100644
index 00000000..63a6bfcd
--- /dev/null
+++ b/src/tab-bar/__test__/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,152 @@
+// Vitest Snapshot v1
+
+exports[`TabBar > props > snapshot 1`] = `
+<div
+  class="t-tab-bar t-tab-bar--bordered t-tab-bar--fixed"
+  role="tablist"
+>
+  
+  
+  <div
+    aria-label="TabBar"
+    aria-selected="false"
+    class="t-tab-bar-item t-no-border"
+    name="label_1"
+    role="tab"
+    text="标签一"
+  >
+    <div
+      class="t-tab-bar-item__content"
+    >
+      <div
+        class="t-tab-bar-item__icon"
+      >
+        <div
+          class="t-badge"
+        >
+          <div
+            class="t-badge__inner t-badge--has-children t-badge--medium t-badge--circle"
+            style="right: 0px; top: 5px;"
+          >
+            1
+          </div>
+          
+          <svg
+            class="t-icon t-icon-app"
+            fill="none"
+            height="1em"
+            viewBox="0 0 16 16"
+            width="1em"
+          >
+            <path
+              d="M11.25 1.75a3 3 0 100 6 3 3 0 000-6zm-2 3a2 2 0 114 0 2 2 0 01-4 0zM2 3a1 1 0 011-1h3.5a1 1 0 011 1v3.5a1 1 0 01-1 1H3a1 1 0 01-1-1V3zm1 0v3.5h3.5V3H3zM2 9.5a1 1 0 011-1h3.5a1 1 0 011 1V13a1 1 0 01-1 1H3a1 1 0 01-1-1V9.5zm1 0V13h3.5V9.5H3zM8.5 9.5a1 1 0 011-1H13a1 1 0 011 1V13a1 1 0 01-1 1H9.5a1 1 0 01-1-1V9.5zm1 3.5H13V9.5H9.5V13z"
+              fill="currentColor"
+              fill-opacity="0.9"
+            />
+          </svg>
+          
+        </div>
+      </div>
+      <!--v-if-->
+    </div>
+    <transition-stub>
+      <!--v-if-->
+    </transition-stub>
+  </div>
+  <div
+    aria-label="TabBar"
+    aria-selected="false"
+    class="t-tab-bar-item t-no-border"
+    name="label_2"
+    role="tab"
+    text="标签二"
+  >
+    <div
+      class="t-tab-bar-item__content"
+    >
+      <div
+        class="t-tab-bar-item__icon"
+      >
+        <div
+          class="t-badge"
+        >
+          <div
+            class="t-badge__inner t-badge--has-children t-badge--medium t-badge--circle"
+            style="right: 0px; top: 5px;"
+          >
+            1
+          </div>
+          
+          <svg
+            class="t-icon t-icon-app"
+            fill="none"
+            height="1em"
+            viewBox="0 0 16 16"
+            width="1em"
+          >
+            <path
+              d="M11.25 1.75a3 3 0 100 6 3 3 0 000-6zm-2 3a2 2 0 114 0 2 2 0 01-4 0zM2 3a1 1 0 011-1h3.5a1 1 0 011 1v3.5a1 1 0 01-1 1H3a1 1 0 01-1-1V3zm1 0v3.5h3.5V3H3zM2 9.5a1 1 0 011-1h3.5a1 1 0 011 1V13a1 1 0 01-1 1H3a1 1 0 01-1-1V9.5zm1 0V13h3.5V9.5H3zM8.5 9.5a1 1 0 011-1H13a1 1 0 011 1V13a1 1 0 01-1 1H9.5a1 1 0 01-1-1V9.5zm1 3.5H13V9.5H9.5V13z"
+              fill="currentColor"
+              fill-opacity="0.9"
+            />
+          </svg>
+          
+        </div>
+      </div>
+      <!--v-if-->
+    </div>
+    <transition-stub>
+      <!--v-if-->
+    </transition-stub>
+  </div>
+  <div
+    aria-haspopup="true"
+    aria-label="TabBar"
+    aria-selected="false"
+    class="t-tab-bar-item t-no-border"
+    name="label_3"
+    role="tab"
+    text="此处展开"
+  >
+    <div
+      class="t-tab-bar-item__content"
+    >
+      <div
+        class="t-tab-bar-item__icon"
+      >
+        <div
+          class="t-badge"
+        >
+          <div
+            class="t-badge__inner t-badge--has-children t-badge--medium t-badge--circle"
+            style="right: 0px; top: 5px;"
+          >
+            1
+          </div>
+          
+          <svg
+            class="t-icon t-icon-app"
+            fill="none"
+            height="1em"
+            viewBox="0 0 16 16"
+            width="1em"
+          >
+            <path
+              d="M11.25 1.75a3 3 0 100 6 3 3 0 000-6zm-2 3a2 2 0 114 0 2 2 0 01-4 0zM2 3a1 1 0 011-1h3.5a1 1 0 011 1v3.5a1 1 0 01-1 1H3a1 1 0 01-1-1V3zm1 0v3.5h3.5V3H3zM2 9.5a1 1 0 011-1h3.5a1 1 0 011 1V13a1 1 0 01-1 1H3a1 1 0 01-1-1V9.5zm1 0V13h3.5V9.5H3zM8.5 9.5a1 1 0 011-1H13a1 1 0 011 1V13a1 1 0 01-1 1H9.5a1 1 0 01-1-1V9.5zm1 3.5H13V9.5H9.5V13z"
+              fill="currentColor"
+              fill-opacity="0.9"
+            />
+          </svg>
+          
+        </div>
+      </div>
+      <!--v-if-->
+    </div>
+    <transition-stub>
+      <!--v-if-->
+    </transition-stub>
+  </div>
+  
+  
+</div>
+`;
diff --git a/src/tab-bar/__test__/index.test.jsx b/src/tab-bar/__test__/index.test.jsx
new file mode 100644
index 00000000..ed5468cb
--- /dev/null
+++ b/src/tab-bar/__test__/index.test.jsx
@@ -0,0 +1,149 @@
+import { describe, it, expect, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+import TabBar from '../tab-bar.vue'
+import TabBarItem from '../tab-bar-item.vue'
+import { nextTick, ref } from 'vue';
+import { AppIcon as TIconApp } from 'tdesign-icons-vue-next';
+
+const list = [
+  {
+    name: 'label_1',
+    text: '标签一',
+    icon: undefined,
+    value: '1'
+  },
+  {
+    name: 'label_2',
+    text: '标签二',
+    icon: undefined,
+    value: '2'
+  },
+  {
+    name: 'label_3',
+    text: '此处展开',
+    icon: undefined,
+    value: '3',
+    subTabBar: [
+      {
+        name: 'spread_1',
+        text: '展开项一',
+        icon: undefined,
+        value: '3-1',
+      },
+      {
+        name: 'spread_2',
+        text: '展开项二',
+        icon: undefined,
+        value: '3-2',
+      },
+      {
+        name: 'spread_3',
+        text: '展开项三',
+        icon: undefined,
+        value: '3-3',
+      },
+    ],
+  },
+];
+
+describe('TabBar', () => {
+  describe('props', () => {
+    it('bordered', async () => {
+      const wrapper = mount(TabBar, {
+        shallow: true
+      })
+      expect(wrapper.classes())
+        .toContain('t-tab-bar--bordered')
+
+      await wrapper.setProps({ 'bordered': false})
+      expect(wrapper.classes())
+        .not.toContain('t-tab-bar--bordered')
+    })
+
+    it('fixed', async () => {
+      const wrapper = mount(TabBar, {
+        shallow: true
+      })
+      expect(wrapper.classes())
+        .toContain('t-tab-bar--fixed')
+
+      await wrapper.setProps({ 'fixed': false})
+      expect(wrapper.classes())
+        .not.toContain('t-tab-bar--fixed')
+    })
+
+    it('defaultValue', async () => {
+      const wrapper = mount({
+        render: () => (
+          <TabBar defaultValue={"2"}>
+            {
+              list.map((item) => {
+                return <TabBarItem {...item}>{item.text}</TabBarItem>
+              })
+            }
+          </TabBar>
+        )
+      })
+
+      expect(wrapper.findAll('.t-tab-bar-item')).toHaveLength(list.length)
+      expect(wrapper.find('[aria-selected="true"]').attributes('text')).toEqual('标签二')
+    })
+
+    it ('snapshot', () => {
+      const icon = () => <TIconApp />
+      const wrapper = mount({
+        render: () => (
+          <TabBar>
+            {
+              list.map((item) => {
+                return <TabBarItem {...item} icon={icon} badgeProps={{count: 1}} />
+              })
+            }
+          </TabBar>
+        )
+      })
+      expect(wrapper.element).toMatchSnapshot();
+    })
+  })
+
+  describe('events', () => {
+    it ('click to change', async () => {
+      const onChange = vi.fn()
+      const value = ref('1')
+      const wrapper = mount({
+        render: () => (
+          <TabBar
+            v-model={value.value}
+            onChange={onChange}
+          >
+            {
+              list.map((item) => {
+                return <TabBarItem {...item} />
+              })
+            }
+          </TabBar>
+        )
+      })
+
+      // 从 bar1 切到 bar2
+      const bar2 = wrapper.find('[name="label_2"] > .t-tab-bar-item__content')
+      expect(onChange).toHaveBeenCalledTimes(0)
+      await bar2.trigger('click')
+      expect(onChange).toHaveBeenCalledTimes(1)
+      expect(onChange).toHaveBeenLastCalledWith('2')
+
+      // 从 bar2 切到 bar3
+      const bar3 = wrapper.find('[name="label_3"] > .t-tab-bar-item__content')
+      await bar3.trigger('click')
+      expect(onChange).toHaveBeenCalledTimes(2)
+      expect(onChange).toHaveBeenLastCalledWith(['3'])
+
+      // 点击 spread
+      const bar3_1 = wrapper.find('[name="label_3"] .t-tab-bar-item__spread-item')
+      await bar3_1.trigger('click')
+      expect(onChange).toHaveBeenCalledTimes(3)
+      expect(onChange).toHaveBeenLastCalledWith(['3', '3-1'])
+      expect(wrapper.find('[name="label_3"]').find('.t-tab-bar-item__spread-item').exists()).toBe(false)
+    })
+  })
+})