Repository: Tencent/tdesign-mobile-vue
Pull request #320 (2022-08-28 12:38:51)

test(Loading): loading test
### 🤔 这个 PR 的性质是？

- [x] 测试用例

### 🔗 相关 Issue

https://github.com/Tencent/tdesign-mobile-vue/issues/271?target=loading

### 💡 需求背景和解决方案

loading 增加测试用例

### 📝 更新日志

- test(Loading): 增加测试用例

- [x] 本条 PR 不需要纳入 Changelog

### ☑️ 请求合并前的自查清单

⚠️ 请自检并全部**勾选全部选项**。⚠️

- [x] 文档已补充或无须补充
- [x] 代码演示已提供或无须提供
- [x] TypeScript 定义已补充或无须补充
- [x] Changelog 已提供或无须提供


github-actions[bot]: <a href="https://preview-pr320-tdesign-mobile-vue.surge.sh"><img height="96" alt="完成" src="https://user-images.githubusercontent.com/15634204/150816437-9f5bb788-cd67-4cbc-9897-b82d74e9aa65.png" /></a>
<!-- [工作流地址](https://github.com/Tencent/tdesign-mobile-vue/actions/runs/3050526572) -->
<!-- AUTO_PREVIEW_HOOK -->

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/src/_common b/src/_common
index d9ad437c..51955cca 160000
--- a/src/_common
+++ b/src/_common
@@ -1,1 +1,1 @@
-Subproject commit d9ad437c0ded605ac3699f2fa19b98337a4edb4a
+Subproject commit 51955ccaf67c8498080ea9fdd628af4b0421b717
diff --git a/src/count-down/count-down.vue b/src/count-down/count-down.vue
index 1891565b..7bab8bb9 100644
--- a/src/count-down/count-down.vue
+++ b/src/count-down/count-down.vue
@@ -29,11 +29,9 @@ export default defineComponent({
     ...CountDownProps,
   },
   setup(props) {
-    const { content, size = 'small', theme = 'default', ...other } = props || {};
-    //
-    const { time, showTimes } = useCountDown(other);
+    const { time, showTimes } = useCountDown(props);
     const internalInstance = getCurrentInstance();
-    const hasChinese = /.*[\u4e00-\u9fa5]+.*$/?.test?.(other?.format);
+    const hasChinese = /.*[\u4e00-\u9fa5]+.*$/?.test?.(props?.format);
     const contentLayout = computed(() => renderTNode(internalInstance, 'content'));
     // return
     return {
diff --git a/src/shared/useCountDown/index.ts b/src/shared/useCountDown/index.ts
index 5aea3542..ef4d2472 100644
--- a/src/shared/useCountDown/index.ts
+++ b/src/shared/useCountDown/index.ts
@@ -8,7 +8,7 @@ export function useCountDown(props: TdUseCountDownProps): TdUseCountDown {
   // state
   const fps = ref();
   const count = ref(Number(time));
-  const showTimes = reactive(getShowTimes(getRemainTimes(time), format));
+  const showTimes = reactive(getShowTimes(getRemainTimes(time), format, !!millisecond));
 
   // raf
   const { pause, resume } = useRafFn(
diff --git a/src/shared/useCountDown/utils.ts b/src/shared/useCountDown/utils.ts
index 5361bf8a..a2084aef 100644
--- a/src/shared/useCountDown/utils.ts
+++ b/src/shared/useCountDown/utils.ts
@@ -30,7 +30,17 @@ export const getRemainTimes = (time?: number): TimeData => {
  * @param num
  * @returns
  */
-export const fillZero = (num: number): string | number => (num >= 10 ? num : `0${num}`);
+export const fillZero = (num: number, isMillieconds = false): string | number => {
+  if (isMillieconds) {
+    if (num >= 100) {
+      return num;
+    }
+
+    return num >= 10 ? `0${num}` : `00${num}`;
+  }
+
+  return num >= 10 ? num : `0${num}`;
+};
 
 /**
  * getMark
@@ -45,8 +55,9 @@ export const getMark = (format: string, type: string): string => format?.split?.
  * @param time
  * @returns
  */
-export const getShowTimes = (times: TimeData, format: string): TdUseCountDownShowTimes => {
+export const getShowTimes = (times: TimeData, format: string, milliseconds = false): TdUseCountDownShowTimes => {
   format = (format || 'DD:HH:mm:ss')?.toUpperCase?.();
+  milliseconds && !format.includes(':SSS') && (format = format.concat(':SSS'));
   const showTimes: TdUseCountDownShowTimes = [];
   if (format?.indexOf('DD') > -1) {
     showTimes?.push({
@@ -75,7 +86,7 @@ export const getShowTimes = (times: TimeData, format: string): TdUseCountDownSho
   if (format?.indexOf('SSS') > -1) {
     showTimes?.push({
       mark: getMark(format, 'SSS'),
-      value: fillZero(times?.milliseconds),
+      value: fillZero(times?.milliseconds, true),
     });
   }
 
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/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/count-down/__test__/index.test.jsx b/src/count-down/__test__/index.test.jsx
new file mode 100644
index 00000000..4be3bebc
--- /dev/null
+++ b/src/count-down/__test__/index.test.jsx
@@ -0,0 +1,103 @@
+import { nextTick } from 'vue';
+import { describe, it, expect, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+import CountDown from '../count-down.vue';
+
+const sleep = (duration) =>
+  new Promise((resolve) =>
+    setTimeout(() => {
+      resolve();
+    }, duration),
+  );
+
+describe('countdown.vue', async () => {
+  describe('props', async () => {
+    it('time', async () => {
+      const wrapper = mount(<CountDown time={2000}></CountDown>);
+      expect(wrapper.text()).toContain('00:00:02');
+      await sleep(1000);
+      expect(wrapper.text()).toContain('00:00:01');
+      await sleep(500);
+      expect(wrapper.text()).toContain('00:00:00');
+      await sleep(500);
+      await nextTick();
+      expect(wrapper.text()).toContain('default');
+    });
+
+    it('content', async () => {
+      const wrapper = mount(<CountDown time={1000} content="ok"></CountDown>);
+      await sleep(1500);
+      await nextTick();
+      expect(wrapper.text()).toContain('ok');
+
+      await wrapper.setProps({ content: 'change' });
+      expect(wrapper.text()).toContain('change');
+    });
+
+    it('format', async () => {
+      const wrapper = mount(<CountDown time={100000} format="mm:ss"></CountDown>);
+      expect(wrapper.text()).not.toContain('00:01:40');
+      expect(wrapper.text()).toContain('01:40');
+
+      const wrapper2 = mount(<CountDown time={100000} format="ss"></CountDown>);
+      expect(wrapper2.text()).not.toContain(':40');
+      expect(wrapper2.text()).toContain('40');
+
+      const wrapper3 = mount(<CountDown time={100000} format="中文"></CountDown>);
+      expect(wrapper3.find('.t-countdown---split-with-unit').exists()).toBe(false);
+
+      const wrapper4 = mount(<CountDown></CountDown>);
+      expect(wrapper4).toBeTruthy();
+    });
+
+    it('millisecond', async () => {
+      const wrapper = mount(<CountDown time={100000} millisecond={true}></CountDown>);
+      expect(wrapper.text()).toContain('00:01:40:000');
+    });
+
+    it('autoStart', async () => {
+      const wrapper = mount(<CountDown time={100000} autoStart={false}></CountDown>);
+      await sleep(1000);
+      expect(wrapper.text()).toContain('00:01:40');
+    });
+
+    it('size', async () => {
+      const wrapper = mount(<CountDown time={100000}></CountDown>);
+      expect(wrapper.find('.t-countdown--small').exists()).toBe(true);
+    });
+
+    it('theme', async () => {
+      const wrapper = mount(<CountDown time={100000}></CountDown>);
+      expect(wrapper.find('.t-countdown--default').exists()).toBe(true);
+    });
+  });
+
+  describe('events', async () => {
+    it('change', async () => {
+      const onChange = vi.fn();
+      const wrapper = mount(<CountDown time={2000} onChange={onChange}></CountDown>);
+      await sleep(1000);
+      expect(onChange).toHaveBeenCalled();
+    });
+
+    it('finish', async () => {
+      const onFinish = vi.fn();
+      const wrapper = mount(<CountDown time={1000} onFinish={onFinish}></CountDown>);
+      await sleep(1500);
+      expect(onFinish).toHaveBeenCalled();
+    });
+  });
+
+  describe('slots', async () => {
+    it('content', async () => {
+      const temp = <div>测试</div>;
+      const wrapper = mount(<CountDown time={1000}></CountDown>, {
+        slots: {
+          content: temp,
+        },
+      });
+      await sleep(1500);
+      expect(wrapper.text()).toContain('测试');
+    });
+  });
+});
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/loading/__test__/__snapshots__/index.test.jsx.snap b/src/loading/__test__/__snapshots__/index.test.jsx.snap
new file mode 100644
index 00000000..6bc891a2
--- /dev/null
+++ b/src/loading/__test__/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,114 @@
+// Vitest Snapshot v1
+
+exports[`Loading.vue > props > :pause 1`] = `
+<div
+  class="t-loading"
+  style=""
+>
+  
+  
+  <svg
+    class="t-loading__gradient t-icon-loading"
+    height="1em"
+    style="animation-play-state: paused; animation-duration: 800ms;"
+    version="1.1"
+    viewBox="0 0 14 14"
+    width="1em"
+    xmlns="http://www.w3.org/2000/svg"
+  >
+    <foreignobject
+      height="12"
+      width="12"
+      x="1"
+      y="1"
+    >
+      <div
+        class="t-loading__gradient-conic"
+      />
+    </foreignobject>
+  </svg>
+  
+  <!--v-if-->
+  <!---->
+  
+</div>
+`;
+
+exports[`Loading.vue > props > :reverse 1`] = `
+<div
+  class="t-loading"
+  style=""
+>
+  
+  
+  <svg
+    class="t-loading__gradient t-icon-loading"
+    height="1em"
+    style="animation-direction: reverse; animation-duration: 800ms;"
+    version="1.1"
+    viewBox="0 0 14 14"
+    width="1em"
+    xmlns="http://www.w3.org/2000/svg"
+  >
+    <foreignobject
+      height="12"
+      width="12"
+      x="1"
+      y="1"
+    >
+      <div
+        class="t-loading__gradient-conic"
+      />
+    </foreignobject>
+  </svg>
+  
+  <!--v-if-->
+  <!---->
+  
+</div>
+`;
+
+exports[`spinner.vue > render > :text 1`] = `
+<span
+  class="t-loading__spinner"
+>
+  
+  <i
+    class="t-loading__spinner--line t-loading__spinner--line-1"
+  />
+  <i
+    class="t-loading__spinner--line t-loading__spinner--line-2"
+  />
+  <i
+    class="t-loading__spinner--line t-loading__spinner--line-3"
+  />
+  <i
+    class="t-loading__spinner--line t-loading__spinner--line-4"
+  />
+  <i
+    class="t-loading__spinner--line t-loading__spinner--line-5"
+  />
+  <i
+    class="t-loading__spinner--line t-loading__spinner--line-6"
+  />
+  <i
+    class="t-loading__spinner--line t-loading__spinner--line-7"
+  />
+  <i
+    class="t-loading__spinner--line t-loading__spinner--line-8"
+  />
+  <i
+    class="t-loading__spinner--line t-loading__spinner--line-9"
+  />
+  <i
+    class="t-loading__spinner--line t-loading__spinner--line-10"
+  />
+  <i
+    class="t-loading__spinner--line t-loading__spinner--line-11"
+  />
+  <i
+    class="t-loading__spinner--line t-loading__spinner--line-12"
+  />
+  
+</span>
+`;
diff --git a/src/loading/__test__/index.test.jsx b/src/loading/__test__/index.test.jsx
new file mode 100644
index 00000000..c3550cd7
--- /dev/null
+++ b/src/loading/__test__/index.test.jsx
@@ -0,0 +1,98 @@
+import { mount } from '@vue/test-utils';
+import { describe, it, expect } from 'vitest';
+import Loading from '../loading.vue';
+import Spinner from '../icon/spinner.vue';
+import { nextTick } from "vue";
+
+describe('Loading.vue', () => {
+  describe('props', () => {
+    beforeEach(() => {
+      vi.useFakeTimers()
+    })
+
+    afterEach(() => {
+      vi.restoreAllMocks()
+    })
+
+    it(':progress', () => {
+      const wrapper = mount(() => (
+          <Loading theme="bar" progress={0.5} />
+      ));
+
+      const shadow = wrapper.find('.t-loading__shadow');
+      expect(shadow.exists()).toBeTruthy();
+    });
+
+    it(':delay', async () => {
+      const wrapper = mount(() => (
+          <Loading delay={1000} />
+      ));
+      expect(wrapper.find('.t-icon-loading').exists()).not.toBeTruthy();
+      vi.runAllTimers();
+      await nextTick();
+      expect(wrapper.find('.t-icon-loading').exists()).toBeTruthy();
+    });
+
+    it(':error', () => {
+      const wrapper = mount(() => (
+          <Loading theme="error"/>
+      ));
+      const errorBlock = wrapper.find('.t-loading__text');
+      expect(errorBlock.element.innerHTML).toBe('加载失败');
+    });
+
+    it(':inheritColor', () => {
+      const wrapper = mount(Loading, {
+        props: {
+          inheritColor: true
+        },
+      });
+      expect(wrapper.vm.rootStyle).toBe('color: inherit')
+    });
+
+    it(':pause', () => {
+      const wrapper = mount(Loading, {
+        props: {
+          pause: true
+        },
+      });
+      expect(wrapper.element).toMatchSnapshot();
+      const iconWrapper = wrapper.find('.t-loading__gradient')
+      const iconWrapperStyle = iconWrapper.attributes().style;
+      expect(iconWrapperStyle).toContain('animation-play-state: paused')
+    });
+
+    it(':reverse', () => {
+      const wrapper = mount(() => (
+          <Loading reverse={true}/>
+      ));
+      expect(wrapper.element).toMatchSnapshot();
+      const iconWrapper = wrapper.find('.t-loading__gradient')
+      const iconWrapperStyle = iconWrapper.attributes().style;
+      expect(iconWrapperStyle).toContain('animation-direction: reverse')
+    });
+  })
+
+  describe('slots', () => {
+    it(':text', () => {
+      const customLoading = 'custom-loading-text'
+      const wrapper = mount(() => (
+          <Loading>{{ text: () => <p className={customLoading}>{customLoading}</p> }}</Loading>
+      ));
+      const text = wrapper.find('.t-loading__text');
+      expect(text.exists()).toBeTruthy();
+      expect(text.element.innerHTML).toBe(`<p class="${customLoading}">${customLoading}</p>`);
+    });
+  })
+})
+
+describe('spinner.vue', () => {
+  describe('render', () => {
+    it(':text', () => {
+      const wrapper = mount(() => (
+          <Spinner/>
+      ));
+      expect(wrapper.element).toMatchSnapshot();
+    });
+  })
+})