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); + }); + }); +});