diff --git a/site/test-coverage.js b/site/test-coverage.js
index 8639b729e..bf90eec78 100644
--- a/site/test-coverage.js
+++ b/site/test-coverage.js
@@ -26,7 +26,7 @@ module.exports = {
guide: { statements: '3.46%', branches: '0%', functions: '0%', lines: '3.77%' },
hooks: { statements: '69.04%', branches: '34.32%', functions: '71.87%', lines: '70%' },
image: { statements: '97.72%', branches: '100%', functions: '92.3%', lines: '97.61%' },
- imageViewer: { statements: '8.47%', branches: '2.87%', functions: '0%', lines: '8.84%' },
+ imageViewer: { statements: '41.94%', branches: '21.58%', functions: '36.95%', lines: '43.8%' },
indexes: { statements: '95.65%', branches: '69.81%', functions: '100%', lines: '96.94%' },
input: { statements: '3.57%', branches: '0%', functions: '0%', lines: '3.7%' },
layout: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' },
@@ -55,7 +55,7 @@ module.exports = {
steps: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' },
sticky: { statements: '7.14%', branches: '0%', functions: '0%', lines: '7.27%' },
swipeCell: { statements: '4.42%', branches: '0%', functions: '0%', lines: '4.67%' },
- swiper: { statements: '3.77%', branches: '0.9%', functions: '1.4%', lines: '3.89%' },
+ swiper: { statements: '47.38%', branches: '18.55%', functions: '52.11%', lines: '49.35%' },
switch: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' },
tabBar: { statements: '10%', branches: '0%', functions: '0%', lines: '10.81%' },
table: { statements: '100%', branches: '90%', functions: '100%', lines: '100%' },
@@ -64,5 +64,5 @@ module.exports = {
textarea: { statements: '98.64%', branches: '95%', functions: '93.33%', lines: '100%' },
toast: { statements: '98.73%', branches: '100%', functions: '94.11%', lines: '98.66%' },
treeSelect: { statements: '5.4%', branches: '0%', functions: '0%', lines: '5.88%' },
- upload: { statements: '4.28%', branches: '0%', functions: '0%', lines: '4.47%' },
+ upload: { statements: '98.57%', branches: '100%', functions: '95.23%', lines: '98.5%' },
};
diff --git a/src/upload/__tests__/hooks.test.ts b/src/upload/__tests__/hooks.test.ts
new file mode 100644
index 000000000..2c9dbbd11
--- /dev/null
+++ b/src/upload/__tests__/hooks.test.ts
@@ -0,0 +1,307 @@
+import { renderHook, act } from '@test/utils';
+import { vi } from 'vitest';
+import useUpload from '../hooks/useUpload';
+import type { TdUploadProps, UploadFile } from '../type';
+
+// Mock 依赖模块
+vi.mock('../../_common/js/upload/main', () => ({
+ formatToUploadFile: vi.fn((files: File[]) =>
+ files.map((f) => ({ name: f.name, raw: f, status: 'waiting' } as UploadFile))
+ ),
+ getDisplayFiles: vi.fn(({ uploadValue, toUploadFiles, autoUpload }: any) =>
+ autoUpload ? [...uploadValue, ...toUploadFiles] : uploadValue
+ ),
+ getFilesAndErrors: vi.fn((fileValidateList: any[]) => {
+ const toFiles = fileValidateList.map((x) => x.file ?? x);
+ return { sizeLimitErrors: [], beforeUploadErrorFiles: [], toFiles };
+ }),
+ validateFile: vi.fn((args: any) => {
+ const { files } = args;
+ const fileValidateList = files.map((f: File) => ({
+ file: { name: f.name, raw: f, status: 'waiting' }
+ }));
+ return Promise.resolve({ files, fileValidateList });
+ }),
+ upload: vi.fn(() => {
+ const data = { files: [], response: { ok: true } };
+ return Promise.resolve({ status: 'success', data, list: [{ data }], failedFiles: [] });
+ }),
+}));
+
+vi.mock('../../_common/js/upload/utils', () => ({
+ getFileList: vi.fn((fl: FileList) => Array.from(fl)),
+ getFileUrlByFileRaw: vi.fn(async () => 'blob://mock-url'),
+}));
+
+function createFile(name = 'test.png', type = 'image/png') {
+ const blob = new Blob(['test content'], { type });
+ return new File([blob], name, { type });
+}
+
+describe('useUpload', () => {
+ describe('basic functionality', () => {
+ it('should initialize with default values', () => {
+ const props: TdUploadProps = {};
+ const { result } = renderHook(() => useUpload(props));
+
+ expect(result.current.uploadValue).toEqual([]);
+ expect(result.current.toUploadFiles).toEqual([]);
+ expect(result.current.displayFiles).toEqual([]);
+ expect(result.current.isUploading).toBe(false);
+ expect(result.current.disabled).toBeUndefined();
+ });
+
+ it('should handle disabled state', () => {
+ const props: TdUploadProps = { disabled: true };
+ const { result } = renderHook(() => useUpload(props));
+
+ expect(result.current.disabled).toBe(true);
+ });
+
+ it('should initialize with defaultFiles', () => {
+ const defaultFiles: UploadFile[] = [
+ { name: 'test.png', url: 'https://example.com/test.png', status: 'success' }
+ ];
+ const props: TdUploadProps = { defaultFiles };
+ const { result } = renderHook(() => useUpload(props));
+
+ expect(result.current.uploadValue).toEqual(defaultFiles);
+ });
+ });
+
+ describe('file operations', () => {
+ it('should handle file change', async () => {
+ const onSelectChange = vi.fn();
+ const props: TdUploadProps = { onSelectChange };
+ const { result } = renderHook(() => useUpload(props));
+
+ const files = [createFile('test.png')];
+
+ await act(async () => {
+ result.current.onFileChange(files);
+ });
+
+ expect(onSelectChange).toHaveBeenCalledWith(
+ files,
+ expect.objectContaining({ currentSelectedFiles: expect.any(Array) })
+ );
+ });
+
+ it('should not handle file change when disabled', async () => {
+ const onSelectChange = vi.fn();
+ const props: TdUploadProps = { disabled: true, onSelectChange };
+ const { result } = renderHook(() => useUpload(props));
+
+ const files = [createFile('test.png')];
+
+ await act(async () => {
+ result.current.onFileChange(files);
+ });
+
+ expect(onSelectChange).not.toHaveBeenCalled();
+ });
+
+ it('should handle file upload', async () => {
+ const onSuccess = vi.fn();
+ const props: TdUploadProps = {
+ autoUpload: true,
+ action: '//mock-api',
+ onSuccess
+ };
+ const { result } = renderHook(() => useUpload(props));
+
+ const uploadFiles: UploadFile[] = [
+ { name: 'test.png', raw: createFile('test.png'), status: 'waiting' }
+ ];
+
+ await act(async () => {
+ result.current.uploadFiles(uploadFiles);
+ });
+
+ expect(onSuccess).toHaveBeenCalled();
+ });
+
+ it('should handle file removal', () => {
+ const onRemove = vi.fn();
+ const files: UploadFile[] = [
+ { name: 'test.png', url: 'https://example.com/test.png', status: 'success' }
+ ];
+ const props: TdUploadProps = { files, onRemove };
+ const { result } = renderHook(() => useUpload(props));
+
+ const mockEvent = { stopPropagation: vi.fn() } as any;
+
+ act(() => {
+ result.current.onInnerRemove({
+ e: mockEvent,
+ file: files[0],
+ index: 0
+ });
+ });
+
+ expect(onRemove).toHaveBeenCalledWith(
+ expect.objectContaining({
+ file: files[0],
+ index: 0,
+ e: mockEvent
+ })
+ );
+ });
+
+ it('should handle upload cancellation', () => {
+ const props: TdUploadProps = { autoUpload: true };
+ const { result } = renderHook(() => useUpload(props));
+
+ act(() => {
+ result.current.cancelUpload();
+ });
+
+ expect(result.current.isUploading).toBe(false);
+ });
+ });
+
+ describe('validation', () => {
+ it('should validate file size limit', async () => {
+ const { validateFile } = await import('../../_common/js/upload/main');
+ vi.mocked(validateFile).mockResolvedValueOnce({
+ files: [],
+ fileValidateList: [{
+ file: { name: 'large.png', size: 2000000, status: 'waiting' },
+ validateResult: { type: 'FILE_OVER_SIZE_LIMIT' }
+ }]
+ });
+
+ const onValidate = vi.fn();
+ const props: TdUploadProps = {
+ sizeLimit: 1000,
+ onValidate
+ };
+ const { result } = renderHook(() => useUpload(props));
+
+ const files = [createFile('large.png')];
+
+ await act(async () => {
+ result.current.onFileChange(files);
+ });
+
+ // 验证会被调用,具体的验证逻辑在 main 模块中
+ expect(validateFile).toHaveBeenCalled();
+ });
+
+ it('should validate max file count', async () => {
+ const { validateFile } = await import('../../_common/js/upload/main');
+ vi.mocked(validateFile).mockResolvedValueOnce({
+ files: [],
+ lengthOverLimit: true,
+ fileValidateList: []
+ });
+
+ const onValidate = vi.fn();
+ const props: TdUploadProps = {
+ max: 1,
+ onValidate
+ };
+ const { result } = renderHook(() => useUpload(props));
+
+ const files = [createFile('test1.png'), createFile('test2.png')];
+
+ await act(async () => {
+ result.current.onFileChange(files);
+ });
+
+ expect(onValidate).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'FILES_OVER_LENGTH_LIMIT' })
+ );
+ });
+
+ it('should handle duplicate file names', async () => {
+ const { validateFile } = await import('../../_common/js/upload/main');
+ vi.mocked(validateFile).mockResolvedValueOnce({
+ files: [],
+ hasSameNameFile: true,
+ fileValidateList: []
+ });
+
+ const onValidate = vi.fn();
+ const props: TdUploadProps = {
+ allowUploadDuplicateFile: false,
+ onValidate
+ };
+ const { result } = renderHook(() => useUpload(props));
+
+ const files = [createFile('same.png'), createFile('same.png')];
+
+ await act(async () => {
+ result.current.onFileChange(files);
+ });
+
+ expect(onValidate).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'FILTER_FILE_SAME_NAME' })
+ );
+ });
+ });
+
+ describe('upload modes', () => {
+ it('should handle auto upload mode', async () => {
+ const onSuccess = vi.fn();
+ const props: TdUploadProps = {
+ autoUpload: true,
+ action: '//mock-api',
+ onSuccess
+ };
+ const { result } = renderHook(() => useUpload(props));
+
+ const files = [createFile('test.png')];
+
+ await act(async () => {
+ result.current.onFileChange(files);
+ });
+
+ // 在自动上传模式下,文件变更会触发上传
+ expect(onSuccess).toHaveBeenCalled();
+ });
+
+ it('should handle manual upload mode', async () => {
+ const onChange = vi.fn();
+ const props: TdUploadProps = {
+ autoUpload: false,
+ onChange
+ };
+ const { result } = renderHook(() => useUpload(props));
+
+ const files = [createFile('test.png')];
+
+ await act(async () => {
+ result.current.onFileChange(files);
+ });
+
+ // 在手动上传模式下,文件会被添加到列表但不会自动上传
+ expect(onChange).toHaveBeenCalled();
+ });
+ });
+
+ describe('progress handling', () => {
+ it('should update file upload progress', () => {
+ const props: TdUploadProps = { autoUpload: true };
+ const { result } = renderHook(() => useUpload(props));
+
+ const file: UploadFile = { name: 'test.png', raw: createFile('test.png'), status: 'progress' };
+
+ // 通过 onFileChange 来设置 toUploadFiles,这样才能正确初始化状态
+ act(async () => {
+ await result.current.onFileChange([createFile('test.png')]);
+ });
+
+ // 检查 uploadFilePercent 函数存在
+ expect(typeof result.current.uploadFilePercent).toBe('function');
+
+ // 测试进度更新函数调用不报错
+ act(() => {
+ result.current.uploadFilePercent({ file, percent: 50 });
+ });
+
+ // 由于 mock 的限制,我们主要测试函数能正常调用
+ expect(result.current.uploadFilePercent).toBeDefined();
+ });
+ });
+});
diff --git a/src/upload/__tests__/index.test.tsx b/src/upload/__tests__/index.test.tsx
new file mode 100644
index 000000000..bb3f38314
--- /dev/null
+++ b/src/upload/__tests__/index.test.tsx
@@ -0,0 +1,463 @@
+// 修复 TS:显式引入 vi 类型(不会影响 mock 的时序)
+import { vi } from 'vitest';
+import { describe, it, expect, render, fireEvent, waitFor } from '@test/utils';
+
+// 需要在 mock 之后再导入 React 和组件
+import React from 'react';
+import { AddIcon } from 'tdesign-icons-react';
+import type { UploadFile } from '../type';
+import Upload from '../Upload';
+
+// 将 mock 放到文件顶部,确保在导入被测组件之前生效
+vi.mock('../../_common/js/upload/main', () => {
+ const mockFormatToUploadFile = vi.fn((files: File[]) =>
+ files.map((f) => ({ name: f.name, raw: f, status: 'waiting' } as UploadFile))
+ );
+ const mockGetDisplayFiles = vi.fn(({ uploadValue, toUploadFiles, autoUpload }: any) =>
+ autoUpload ? [...uploadValue, ...toUploadFiles] : uploadValue
+ );
+ const mockGetFilesAndErrors = vi.fn((fileValidateList: any[]) => {
+ const toFiles = fileValidateList.map((x) => x.file ?? x);
+ return { sizeLimitErrors: [], beforeUploadErrorFiles: [], toFiles };
+ });
+ const mockValidateFile = vi.fn((args: any) => {
+ const { files, max, allowUploadDuplicateFile } = args;
+ const hasSameNameFile = !allowUploadDuplicateFile && files.length > 1 && files[0].name === files[1].name;
+ const lengthOverLimit = max > 0 && files.length > max;
+ const fileValidateList = files.map((f: File) => ({
+ file: { name: f.name, raw: f, status: 'waiting', size: f.size }
+ }));
+ return Promise.resolve({ files, hasSameNameFile, lengthOverLimit, fileValidateList });
+ });
+ const mockUpload = vi.fn((opts: any) => {
+ const { toUploadFiles } = opts;
+ const data = {
+ files: toUploadFiles.map((f: UploadFile) => ({
+ ...f,
+ status: 'success',
+ url: 'https://example.com/test.png'
+ })),
+ response: { ok: true },
+ };
+ return Promise.resolve({ status: 'success', data, list: [{ data }], failedFiles: [] });
+ });
+
+ return {
+ formatToUploadFile: mockFormatToUploadFile,
+ getDisplayFiles: mockGetDisplayFiles,
+ getFilesAndErrors: mockGetFilesAndErrors,
+ validateFile: mockValidateFile,
+ upload: mockUpload,
+ };
+});
+
+vi.mock('../../_common/js/upload/utils', () => ({
+ getFileList: vi.fn((fl: FileList) => Array.from(fl)),
+ getFileUrlByFileRaw: vi.fn(async () => 'blob://mock-url'),
+}));
+
+const prefix = 't';
+const name = `.${prefix}-upload`;
+
+// 创建测试文件的辅助函数
+function createFile(name = 'test.png', type = 'image/png', size = 1024) {
+ const blob = new Blob(['test content'], { type });
+ return new File([blob], name, { type, lastModified: Date.now() });
+}
+
+describe('Upload', () => {
+ describe('props', () => {
+ it(': accept', () => {
+ const { container } = render();
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ expect(input).toHaveAttribute('accept', 'image/*');
+ });
+
+ it(': multiple', () => {
+ const { container } = render();
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ expect(input).toHaveAttribute('multiple');
+ });
+
+ it(': disabled', () => {
+ const onClickUpload = vi.fn();
+ const { container } = render();
+ const addBtn = container.querySelector(`${name}__item--add`);
+ if (addBtn) {
+ fireEvent.click(addBtn);
+ expect(onClickUpload).not.toHaveBeenCalled();
+ }
+ });
+
+ it(': max controls add button visibility', () => {
+ const files: UploadFile[] = [
+ { name: 'test.png', url: 'https://example.com/test.png', status: 'success' }
+ ];
+ const { container } = render();
+ const addBtn = container.querySelector(`${name}__item--add`);
+ expect(addBtn).toBeNull();
+ });
+
+ it(': max allows add button when under limit', () => {
+ const { container } = render();
+ const addBtn = container.querySelector(`${name}__item--add`);
+ expect(addBtn).toBeTruthy();
+ });
+
+ it(': max=0 always shows add button', () => {
+ const { container } = render();
+ const addBtn = container.querySelector(`${name}__item--add`);
+ expect(addBtn).toBeTruthy();
+ });
+
+ it(': children as custom trigger', () => {
+ const onClickUpload = vi.fn();
+ const { getByText } = render(
+
+
+
+ );
+ fireEvent.click(getByText('自定义上传'));
+ expect(onClickUpload).toHaveBeenCalled();
+ });
+
+ it(': addContent custom add button', () => {
+ const { container } = render(添加文件} />);
+ expect(container.querySelector(`${name}__add-icon`)).toBeTruthy();
+ });
+
+ it(': files display uploaded files', () => {
+ const files: UploadFile[] = [
+ { name: 'test1.png', url: 'https://example.com/test1.png', status: 'success' },
+ { name: 'test2.png', url: 'https://example.com/test2.png', status: 'success' }
+ ];
+ const { container } = render();
+ const items = container.querySelectorAll(`${name}__item`);
+ expect(items.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it(': files without url do not render Image but keep delete button', () => {
+ const files: UploadFile[] = [
+ { name: 'no-url.png', status: 'success' } as any,
+ ];
+ const { container } = render();
+ const image = container.querySelector(`${name}__image`);
+ expect(image).toBeFalsy();
+ const delBtn = container.querySelector(`${name}__delete-btn`);
+ expect(delBtn).toBeTruthy();
+ });
+
+ it(': preview controls image preview', () => {
+ const files: UploadFile[] = [
+ { name: 'test.png', url: 'https://example.com/test.png', status: 'success' }
+ ];
+ const { container } = render();
+ const image = container.querySelector(`${name}__image`);
+ if (image) {
+ fireEvent.click(image);
+ // 当 preview=false 时,不应该显示 ImageViewer
+ expect(container.querySelector('.t-image-viewer')).toBeFalsy();
+ }
+ });
+
+ it(': imageProps passed to Image component', () => {
+ const files: UploadFile[] = [
+ { name: 'test.png', url: 'https://example.com/test.png', status: 'success' }
+ ];
+ const { container } = render(
+
+ );
+ const image = container.querySelector(`${name}__image`);
+ expect(image).toBeTruthy();
+ });
+ });
+
+ describe('events', () => {
+ it(': onClickUpload', () => {
+ const onClickUpload = vi.fn();
+ const { container } = render();
+ const addBtn = container.querySelector(`${name}__item--add`);
+ if (addBtn) {
+ fireEvent.click(addBtn);
+ expect(onClickUpload).toHaveBeenCalledWith(
+ expect.objectContaining({ e: expect.any(Object) })
+ );
+ }
+ });
+
+ it(': onSelectChange when files selected', async () => {
+ const onSelectChange = vi.fn();
+ const { container } = render();
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+
+ const file = createFile('test.png');
+ Object.defineProperty(input, 'files', {
+ value: [file],
+ writable: false,
+ });
+
+ fireEvent.change(input);
+
+ await waitFor(() => {
+ expect(onSelectChange).toHaveBeenCalledWith(
+ [file],
+ expect.objectContaining({ currentSelectedFiles: expect.any(Array) })
+ );
+ });
+ });
+
+ it(': onSuccess when upload succeeds', async () => {
+ const onSuccess = vi.fn();
+ const { container } = render();
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+
+ const file = createFile('test.png');
+ Object.defineProperty(input, 'files', {
+ value: [file],
+ writable: false,
+ });
+
+ fireEvent.change(input);
+
+ await waitFor(() => {
+ expect(onSuccess).toHaveBeenCalled();
+ });
+ });
+
+ it(': onValidate when validation fails', async () => {
+ const { validateFile } = await import('../../_common/js/upload/main');
+ vi.mocked(validateFile).mockResolvedValueOnce({
+ files: [],
+ lengthOverLimit: true,
+ fileValidateList: []
+ });
+
+ const onValidate = vi.fn();
+ const { container } = render();
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+
+ const files = [createFile('test1.png'), createFile('test2.png')];
+ Object.defineProperty(input, 'files', {
+ value: files,
+ writable: false,
+ });
+
+ fireEvent.change(input);
+
+ await waitFor(() => {
+ expect(onValidate).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'FILES_OVER_LENGTH_LIMIT' })
+ );
+ });
+ });
+
+ it(': allowUploadDuplicateFile=true does not trigger same-name validate branch', async () => {
+ const onValidate = vi.fn();
+ const { container } = render(
+
+ );
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+
+ const f1 = createFile('same.png');
+ const f2 = createFile('same.png');
+ Object.defineProperty(input, 'files', {
+ value: [f1, f2],
+ writable: false,
+ });
+ fireEvent.change(input);
+
+ await waitFor(() => {
+ const types = onValidate.mock.calls.map((c: any[]) => c[0]?.type);
+ expect(types).not.toContain('FILTER_FILE_SAME_NAME');
+ });
+ });
+
+ it(': autoUpload=false does not call onSuccess', async () => {
+ const onSelectChange = vi.fn();
+ const onChange = vi.fn();
+ const onSuccess = vi.fn();
+ const { container } = render(
+
+ );
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+
+ const file = createFile('manual.png');
+ Object.defineProperty(input, 'files', {
+ value: [file],
+ writable: false,
+ });
+ fireEvent.change(input);
+
+ await waitFor(() => {
+ expect(onSelectChange).toHaveBeenCalled();
+ expect(onChange).toHaveBeenCalled();
+ expect(onSuccess).not.toHaveBeenCalled();
+ });
+ });
+
+ it(': onPreview when image clicked', () => {
+ const onPreview = vi.fn();
+ const files: UploadFile[] = [
+ { name: 'test.png', url: 'https://example.com/test.png', status: 'success' }
+ ];
+ const { container } = render();
+ const image = container.querySelector(`${name}__image`);
+
+ if (image) {
+ fireEvent.click(image);
+ expect(onPreview).toHaveBeenCalledWith(
+ expect.objectContaining({
+ file: expect.any(Object),
+ index: 0,
+ e: expect.any(Object)
+ })
+ );
+ }
+ });
+
+ it(': onRemove when delete button clicked', () => {
+ const onRemove = vi.fn();
+ const files: UploadFile[] = [
+ { name: 'test.png', url: 'https://example.com/test.png', status: 'success' }
+ ];
+ const { container } = render();
+ const deleteBtn = container.querySelector(`${name}__delete-btn`);
+
+ if (deleteBtn) {
+ fireEvent.click(deleteBtn);
+ expect(onRemove).toHaveBeenCalledWith(
+ expect.objectContaining({
+ file: expect.any(Object),
+ index: 0,
+ e: expect.any(Object)
+ })
+ );
+ }
+ });
+
+ it(': onChange when files change', async () => {
+ const onChange = vi.fn();
+ const { container } = render();
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+
+ const file = createFile('test.png');
+ Object.defineProperty(input, 'files', {
+ value: [file],
+ writable: false,
+ });
+
+ fireEvent.change(input);
+
+ await waitFor(() => {
+ expect(onChange).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('status rendering', () => {
+ it(': progress status shows loading and percent', () => {
+ const files: UploadFile[] = [
+ {
+ name: 'test.png',
+ url: 'https://example.com/test.png',
+ status: 'progress',
+ percent: 50
+ }
+ ];
+ const { container, getByText } = render();
+
+ expect(container.querySelector(`${name}__progress-loading`)).toBeTruthy();
+ expect(getByText('50%')).toBeInTheDocument();
+ });
+
+ it(': progress status without percent shows uploading text branch', () => {
+ const files: UploadFile[] = [
+ {
+ name: 'no-percent.png',
+ url: 'https://example.com/no-percent.png',
+ status: 'progress'
+ }
+ ];
+ const { container } = render();
+ expect(container.querySelector(`${name}__progress-mask`)).toBeTruthy();
+ expect(container.querySelector(`${name}__progress-text`)).toBeTruthy();
+ });
+
+ it(': fail status shows error icon', () => {
+ const files: UploadFile[] = [
+ {
+ name: 'test.png',
+ url: 'https://example.com/test.png',
+ status: 'fail'
+ }
+ ];
+ const { container } = render();
+
+ expect(container.querySelector(`${name}__progress-mask`)).toBeTruthy();
+ // 失败时也应显示失败文案节点
+ expect(container.querySelector(`${name}__progress-text`)).toBeTruthy();
+ });
+
+ it(': success status shows no progress mask', () => {
+ const files: UploadFile[] = [
+ {
+ name: 'test.png',
+ url: 'https://example.com/test.png',
+ status: 'success'
+ }
+ ];
+ const { container } = render();
+
+ expect(container.querySelector(`${name}__progress-mask`)).toBeFalsy();
+ });
+ });
+
+ describe('slots', () => {
+ it(': default slot with children', () => {
+ const { getByText } = render(
+
+ 自定义上传区域
+
+ );
+ expect(getByText('自定义上传区域')).toBeInTheDocument();
+ });
+
+ it(': addContent slot', () => {
+ const { getByText } = render(
+ 点击上传} />
+ );
+ expect(getByText('点击上传')).toBeInTheDocument();
+ });
+ });
+
+ describe('interaction', () => {
+ it(': input click triggered by add button', () => {
+ const { container } = render();
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ const addBtn = container.querySelector(`${name}__item--add`);
+
+ const clickSpy = vi.spyOn(input, 'click');
+
+ if (addBtn) {
+ fireEvent.click(addBtn);
+ expect(clickSpy).toHaveBeenCalled();
+ }
+ });
+
+ it(': ImageViewer opens and closes', async () => {
+ const files: UploadFile[] = [
+ { name: 'test.png', url: 'https://example.com/test.png', status: 'success' }
+ ];
+ const { container } = render();
+ const image = container.querySelector(`${name}__image`);
+
+ if (image) {
+ fireEvent.click(image);
+
+ await waitFor(() => {
+ expect(container.querySelector('.t-image-viewer')).toBeTruthy();
+ });
+ }
+ });
+ });
+});
diff --git a/src/upload/__tests__/util.test.ts b/src/upload/__tests__/util.test.ts
new file mode 100644
index 000000000..e113ed12f
--- /dev/null
+++ b/src/upload/__tests__/util.test.ts
@@ -0,0 +1,218 @@
+import { describe, it, expect } from '@test/utils';
+import {
+ urlCreator,
+ removeProperty,
+ removePropertyAtArray,
+ getIndex,
+ getNewestUidFactory
+} from '../util';
+
+describe('Upload Utils', () => {
+ describe('urlCreator', () => {
+ it('should return URL creator function', () => {
+ const creator = urlCreator();
+ expect(typeof creator).toBe('function');
+ });
+
+ it('should return webkitURL when available', () => {
+ const mockWebkitURL = { createObjectURL: () => 'webkit-url' };
+ Object.defineProperty(window, 'webkitURL', {
+ value: mockWebkitURL,
+ writable: true
+ });
+
+ const creator = urlCreator();
+ expect(creator).toBe(mockWebkitURL);
+ });
+
+ it('should return URL when webkitURL not available', () => {
+ const mockURL = { createObjectURL: () => 'standard-url' };
+ Object.defineProperty(window, 'webkitURL', {
+ value: undefined,
+ writable: true
+ });
+ Object.defineProperty(window, 'URL', {
+ value: mockURL,
+ writable: true
+ });
+
+ const creator = urlCreator();
+ expect(creator).toBe(mockURL);
+ });
+ });
+
+ describe('removeProperty', () => {
+ it('should remove specified property from object', () => {
+ const obj = { a: 1, b: 2, c: 3 };
+ const result = removeProperty(obj, 'b');
+
+ expect(result).toEqual({ a: 1, c: 3 });
+ expect(result).not.toBe(obj); // 应该返回新对象
+ });
+
+ it('should handle non-existent property', () => {
+ const obj = { a: 1, b: 2 };
+ const result = removeProperty(obj, 'nonexistent');
+
+ expect(result).toEqual({ a: 1, b: 2 });
+ });
+
+ it('should handle undefined property', () => {
+ const obj = { a: 1, b: undefined };
+ const result = removeProperty(obj, 'b');
+
+ // 实现不会删除值为 undefined 的键
+ expect(result).toEqual({ a: 1, b: undefined });
+ expect(Object.prototype.hasOwnProperty.call(result, 'b')).toBe(true);
+ });
+
+ it('should preserve original object', () => {
+ const obj = { a: 1, b: 2 };
+ const result = removeProperty(obj, 'b');
+
+ expect(obj).toEqual({ a: 1, b: 2 }); // 原对象不变
+ expect(result).toEqual({ a: 1 });
+ });
+ });
+
+ describe('removePropertyAtArray', () => {
+ it('should remove property from all objects in array', () => {
+ const arr = [
+ { a: 1, b: 2 },
+ { a: 3, b: 4 },
+ { a: 5, b: 6 }
+ ];
+ const result = removePropertyAtArray(arr, 'b');
+
+ expect(result).toEqual([
+ { a: 1 },
+ { a: 3 },
+ { a: 5 }
+ ]);
+ });
+
+ it('should handle empty array', () => {
+ const arr: any[] = [];
+ const result = removePropertyAtArray(arr, 'b');
+
+ expect(result).toEqual([]);
+ });
+
+ it('should return new array instance', () => {
+ const arr = [{ a: 1, b: 2 }];
+ const result = removePropertyAtArray(arr, 'b');
+
+ expect(result).not.toBe(arr);
+ expect(result[0]).not.toBe(arr[0]);
+ });
+
+ it('should preserve original array', () => {
+ const arr = [{ a: 1, b: 2 }];
+ const result = removePropertyAtArray(arr, 'b');
+
+ expect(arr).toEqual([{ a: 1, b: 2 }]); // 原数组不变
+ expect(result).toEqual([{ a: 1 }]);
+ });
+ });
+
+ describe('getIndex', () => {
+ it('should return function that generates incremental index', () => {
+ const indexGenerator = getIndex();
+
+ expect(typeof indexGenerator).toBe('function');
+ expect(indexGenerator()).toBe(0);
+ expect(indexGenerator()).toBe(1);
+ expect(indexGenerator()).toBe(2);
+ });
+
+ it('should create independent index generators', () => {
+ const gen1 = getIndex();
+ const gen2 = getIndex();
+
+ expect(gen1()).toBe(0);
+ expect(gen2()).toBe(0);
+ expect(gen1()).toBe(1);
+ expect(gen2()).toBe(1);
+ });
+
+ it('should maintain state between calls', () => {
+ const indexGenerator = getIndex();
+
+ indexGenerator(); // 0
+ indexGenerator(); // 1
+
+ expect(indexGenerator()).toBe(2);
+ });
+ });
+
+ describe('getNewestUidFactory', () => {
+ it('should return function that generates unique IDs', () => {
+ const uidFactory = getNewestUidFactory();
+
+ expect(typeof uidFactory).toBe('function');
+
+ const uid1 = uidFactory();
+ const uid2 = uidFactory();
+
+ expect(typeof uid1).toBe('string');
+ expect(typeof uid2).toBe('string');
+ expect(uid1).not.toBe(uid2);
+ });
+
+ it('should generate IDs with correct format', () => {
+ const uidFactory = getNewestUidFactory();
+ const uid = uidFactory();
+
+ expect(uid).toMatch(/^td__upload__\d+_\d+__$/);
+ });
+
+ it('should include timestamp in ID', () => {
+ const beforeTime = Date.now();
+ const uidFactory = getNewestUidFactory();
+ const uid = uidFactory();
+ const afterTime = Date.now();
+
+ const timestampMatch = uid.match(/td__upload__(\d+)_\d+__/);
+ expect(timestampMatch).toBeTruthy();
+
+ if (timestampMatch) {
+ const timestamp = parseInt(timestampMatch[1], 10);
+ expect(timestamp).toBeGreaterThanOrEqual(beforeTime);
+ expect(timestamp).toBeLessThanOrEqual(afterTime);
+ }
+ });
+
+ it('should generate incremental index in ID', () => {
+ const uidFactory = getNewestUidFactory();
+
+ const uid1 = uidFactory();
+ const uid2 = uidFactory();
+
+ const index1Match = uid1.match(/td__upload__\d+_(\d+)__/);
+ const index2Match = uid2.match(/td__upload__\d+_(\d+)__/);
+
+ expect(index1Match).toBeTruthy();
+ expect(index2Match).toBeTruthy();
+
+ if (index1Match && index2Match) {
+ const index1 = parseInt(index1Match[1], 10);
+ const index2 = parseInt(index2Match[1], 10);
+ expect(index2).toBe(index1 + 1);
+ }
+ });
+
+ it('should create independent UID factories', () => {
+ const factory1 = getNewestUidFactory();
+ const factory2 = getNewestUidFactory();
+
+ // 先从第一个工厂生成一个 ID
+ factory1();
+
+ const uid1 = factory1(); // 这是第二个 ID,索引为 1
+ const uid2 = factory2(); // 这是第一个 ID,索引为 0
+
+ // 两个工厂生成的 ID 应该不同(因为索引不同)
+ expect(uid1).not.toBe(uid2);
+ });
+ });
+});