950 lines
39 KiB
TypeScript
950 lines
39 KiB
TypeScript
import { act, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||
import userEvent from '@testing-library/user-event'
|
||
|
||
import ProductPackageUploader, {
|
||
selectTaskDetailFilesWithinLimits,
|
||
} from '@/components/upload/ProductPackageUploader'
|
||
import { PerformanceSurveyBrowserImageError } from '@/lib/performance-survey-image-error'
|
||
import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy'
|
||
|
||
const mockChunkUpload = jest.fn()
|
||
const mockPrepareTaskDetailImagePreview = jest.fn()
|
||
const mockPrepareTaskDetailSelectionThumbnail = jest.fn()
|
||
const mockPollTaskDetailUploadJob = jest.fn()
|
||
|
||
jest.mock('@/lib/chunk-upload', () => ({
|
||
chunkUpload: (...args: unknown[]) => mockChunkUpload(...args),
|
||
}))
|
||
|
||
jest.mock('@/lib/task-detail-image-browser', () => ({
|
||
prepareTaskDetailImagePreview: (...args: unknown[]) => mockPrepareTaskDetailImagePreview(...args),
|
||
prepareTaskDetailSelectionThumbnail: (...args: unknown[]) => mockPrepareTaskDetailSelectionThumbnail(...args),
|
||
}))
|
||
|
||
jest.mock('@/lib/task-detail-upload-status', () => ({
|
||
pollTaskDetailUploadJob: (...args: unknown[]) => mockPollTaskDetailUploadJob(...args),
|
||
TaskDetailUploadJobError: class TaskDetailUploadJobError extends Error {
|
||
jobStatus = 'failed'
|
||
},
|
||
}))
|
||
|
||
jest.mock('@/components/upload/DetailImageSorter', () => ({
|
||
__esModule: true,
|
||
default: () => <div data-testid="detail-image-sorter" />,
|
||
}))
|
||
|
||
jest.mock('@/components/upload/UploadForm', () => ({
|
||
__esModule: true,
|
||
default: ({
|
||
onProductModelChange,
|
||
onCategoryChange,
|
||
}: {
|
||
onProductModelChange: (value: string) => void
|
||
onCategoryChange: (value: string) => void
|
||
}) => (
|
||
<div data-testid="upload-form">
|
||
<button type="button" onClick={() => onProductModelChange('SPU-TEST')}>设置测试 SPU</button>
|
||
<button type="button" onClick={() => onCategoryChange('测试店铺')}>设置测试店铺</button>
|
||
</div>
|
||
),
|
||
}))
|
||
|
||
jest.mock('@/components/upload/category-handlers', () => ({
|
||
addCategory: jest.fn(),
|
||
addScene: jest.fn(),
|
||
}))
|
||
|
||
const defaultProps = {
|
||
onSuccess: jest.fn(),
|
||
onCancel: jest.fn(),
|
||
categories: [],
|
||
scenes: [],
|
||
platforms: [],
|
||
productCategories: [],
|
||
onRefreshCategories: jest.fn().mockResolvedValue(undefined),
|
||
onRefreshScenes: jest.fn().mockResolvedValue(undefined),
|
||
}
|
||
|
||
function createDeferred<T>() {
|
||
let resolve!: (value: T | PromiseLike<T>) => void
|
||
let reject!: (reason?: unknown) => void
|
||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||
resolve = resolvePromise
|
||
reject = rejectPromise
|
||
})
|
||
return { promise, resolve, reject }
|
||
}
|
||
|
||
function usersResponse() {
|
||
return {
|
||
ok: true,
|
||
json: async () => [{ id: 'requester-1', username: 'operator-a', displayName: '运营甲' }],
|
||
}
|
||
}
|
||
|
||
async function fillRequiredTaskFields(
|
||
user: ReturnType<typeof userEvent.setup>,
|
||
files: File | File[],
|
||
) {
|
||
const requesterInput = await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
|
||
await user.click(requesterInput)
|
||
await user.type(requesterInput, '运营甲')
|
||
await user.click(screen.getByRole('option', { name: /运营甲/ }))
|
||
await user.click(screen.getByRole('button', { name: '设置测试 SPU' }))
|
||
await user.click(screen.getByRole('button', { name: '设置测试店铺' }))
|
||
await user.selectOptions(screen.getByLabelText(/出图方式/), '实拍')
|
||
await user.upload(screen.getByLabelText('主图'), files)
|
||
}
|
||
|
||
const capabilityError = () => new PerformanceSurveyBrowserImageError(
|
||
'BROWSER_DECODE_UNAVAILABLE',
|
||
'当前 Edge 环境无法解码这张合法图片',
|
||
)
|
||
|
||
function thumbnailResult(sourceName: string) {
|
||
return {
|
||
file: new File(['thumbnail'], `${sourceName}-thumbnail.webp`, { type: 'image/webp' }),
|
||
mimeType: 'image/webp' as const,
|
||
width: 320,
|
||
height: 240,
|
||
kind: 'preview' as const,
|
||
sourceName,
|
||
sourceIndex: 0,
|
||
sliceIndex: 0,
|
||
sliceCount: 1,
|
||
}
|
||
}
|
||
|
||
function installUploadFlowFetch(completeResponse?: Promise<unknown>) {
|
||
const fetchMock = jest.fn((input: RequestInfo | URL) => {
|
||
const url = String(input)
|
||
if (url === '/api/users/list') return Promise.resolve(usersResponse())
|
||
if (url === '/api/materials/upload/product-package/init') {
|
||
return Promise.resolve({ ok: true, json: async () => ({ data: { materialId: 'material-1' } }) })
|
||
}
|
||
if (url === '/api/materials/upload/product-package/complete' && completeResponse) {
|
||
return completeResponse
|
||
}
|
||
if (url === '/api/materials/upload/product-package/abort') {
|
||
return Promise.resolve({ ok: true, json: async () => ({ success: true }) })
|
||
}
|
||
return Promise.reject(new Error(`测试未处理请求:${url}`))
|
||
})
|
||
global.fetch = fetchMock as unknown as typeof fetch
|
||
return fetchMock
|
||
}
|
||
|
||
describe('ProductPackageUploader', () => {
|
||
const originalFetch = global.fetch
|
||
|
||
beforeEach(() => {
|
||
jest.clearAllMocks()
|
||
mockChunkUpload.mockResolvedValue({ uploadId: 'upload-original-1' })
|
||
mockPrepareTaskDetailSelectionThumbnail.mockImplementation((file: File) => (
|
||
Promise.resolve(thumbnailResult(file.name))
|
||
))
|
||
mockPollTaskDetailUploadJob.mockResolvedValue({
|
||
status: 'completed',
|
||
progress: 100,
|
||
stage: '处理完成',
|
||
})
|
||
global.fetch = jest.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => [
|
||
{
|
||
id: 'requester-1',
|
||
username: 'operator-a',
|
||
displayName: '运营甲',
|
||
},
|
||
],
|
||
}) as jest.Mock
|
||
|
||
let objectUrlIndex = 0
|
||
Object.defineProperty(URL, 'createObjectURL', {
|
||
configurable: true,
|
||
writable: true,
|
||
value: jest.fn(() => `blob:mock-${++objectUrlIndex}`),
|
||
})
|
||
|
||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||
configurable: true,
|
||
writable: true,
|
||
value: jest.fn(),
|
||
})
|
||
})
|
||
|
||
afterEach(() => {
|
||
jest.restoreAllMocks()
|
||
})
|
||
|
||
afterAll(() => {
|
||
global.fetch = originalFetch
|
||
})
|
||
|
||
test('任务-详情源文件选择框支持 psb/tif/tiff', async () => {
|
||
const user = userEvent.setup()
|
||
render(<ProductPackageUploader {...defaultProps} />)
|
||
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
|
||
|
||
const input = screen.getByLabelText(/PSD源文件/) as HTMLInputElement
|
||
expect(input.accept).toBe('.psd,.psb,.tif,.tiff,.zip,.rar,.7z')
|
||
|
||
const files = [
|
||
new File(['a'], 'sample.psb', { type: 'application/octet-stream' }),
|
||
new File(['b'], 'sample.tif', { type: 'image/tiff' }),
|
||
new File(['c'], 'sample.tiff', { type: 'image/tiff' }),
|
||
]
|
||
|
||
await user.upload(input, files)
|
||
|
||
expect(screen.getByText('sample.psb')).toBeInTheDocument()
|
||
expect(screen.getByText('sample.tif')).toBeInTheDocument()
|
||
expect(screen.getByText('sample.tiff')).toBeInTheDocument()
|
||
})
|
||
|
||
test('空的任务-详情源文件会被直接拦截并提示', async () => {
|
||
const user = userEvent.setup()
|
||
render(<ProductPackageUploader {...defaultProps} />)
|
||
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
|
||
|
||
const input = screen.getByLabelText(/PSD源文件/) as HTMLInputElement
|
||
const emptyFile = new File([], 'empty.psd', { type: 'application/octet-stream' })
|
||
|
||
await user.upload(input, emptyFile)
|
||
|
||
expect(screen.getByText('以下文件为空,无法上传:empty.psd')).toBeInTheDocument()
|
||
expect(screen.queryByText('empty.psd')).not.toBeInTheDocument()
|
||
})
|
||
|
||
test('应提供任务详情出图方式选择项,且默认为必填', async () => {
|
||
render(<ProductPackageUploader {...defaultProps} />)
|
||
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
|
||
|
||
const select = screen.getByLabelText(/出图方式/) as HTMLSelectElement
|
||
expect(select.value).toBe('')
|
||
expect(select).toBeRequired()
|
||
expect(screen.getByRole('option', { name: '请选择出图方式' })).toBeInTheDocument()
|
||
expect(screen.getByRole('option', { name: '实拍' })).toBeInTheDocument()
|
||
expect(screen.getByRole('option', { name: '渲染' })).toBeInTheDocument()
|
||
expect(screen.getByRole('option', { name: 'AI' })).toBeInTheDocument()
|
||
})
|
||
|
||
test('应提供必填的需求运营搜索框并允许选择人员', async () => {
|
||
const user = userEvent.setup()
|
||
render(<ProductPackageUploader {...defaultProps} />)
|
||
|
||
const input = await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
|
||
await user.click(input)
|
||
await user.type(input, '运营甲')
|
||
await user.click(screen.getByRole('option', { name: /运营甲/ }))
|
||
|
||
expect(input).toHaveValue('运营甲 (operator-a)')
|
||
expect(screen.getByText('记录发起本次出图需求的运营人员,与素材上传人可以不同。')).toBeInTheDocument()
|
||
})
|
||
|
||
test('未选择需求运营时提交应被前端拦截', async () => {
|
||
const user = userEvent.setup()
|
||
render(<ProductPackageUploader {...defaultProps} />)
|
||
|
||
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
|
||
await user.click(screen.getByRole('button', { name: '提交' }))
|
||
|
||
expect(screen.getByText('请选择需求运营人员')).toBeInTheDocument()
|
||
expect(global.fetch).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
test('文件经过主图上传框内部按钮时应保持拖拽高亮', async () => {
|
||
render(<ProductPackageUploader {...defaultProps} />)
|
||
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
|
||
|
||
const input = screen.getByLabelText('主图') as HTMLInputElement
|
||
const dropZone = input.parentElement as HTMLDivElement
|
||
const addButton = dropZone.querySelector('button')
|
||
|
||
expect(addButton).not.toBeNull()
|
||
|
||
Object.defineProperty(dropZone, 'getBoundingClientRect', {
|
||
configurable: true,
|
||
value: () => ({
|
||
x: 0,
|
||
y: 0,
|
||
top: 0,
|
||
right: 100,
|
||
bottom: 100,
|
||
left: 0,
|
||
width: 100,
|
||
height: 100,
|
||
toJSON: () => ({}),
|
||
}),
|
||
})
|
||
|
||
fireEvent.dragEnter(dropZone, {
|
||
clientX: 20,
|
||
clientY: 20,
|
||
dataTransfer: { files: [], types: ['Files'] },
|
||
})
|
||
expect(dropZone).toHaveClass('border-blue-500')
|
||
|
||
const internalDragLeave = createEvent.dragLeave(dropZone)
|
||
Object.defineProperties(internalDragLeave, {
|
||
clientX: { value: 50 },
|
||
clientY: { value: 50 },
|
||
relatedTarget: { value: addButton },
|
||
dataTransfer: { value: { files: [], types: ['Files'] } },
|
||
})
|
||
fireEvent(dropZone, internalDragLeave)
|
||
expect(dropZone).toHaveClass('border-blue-500')
|
||
|
||
const outerDragLeave = createEvent.dragLeave(dropZone)
|
||
Object.defineProperties(outerDragLeave, {
|
||
clientX: { value: 120 },
|
||
clientY: { value: 50 },
|
||
relatedTarget: { value: document.body },
|
||
dataTransfer: { value: { files: [], types: ['Files'] } },
|
||
})
|
||
fireEvent(dropZone, outerDragLeave)
|
||
expect(dropZone).not.toHaveClass('border-blue-500')
|
||
})
|
||
|
||
test('任务图片选择框只允许浏览器处理链明确支持的格式', async () => {
|
||
render(<ProductPackageUploader {...defaultProps} />)
|
||
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
|
||
|
||
expect((screen.getByLabelText('主图') as HTMLInputElement).accept)
|
||
.toBe('image/jpeg,image/png,image/gif,image/webp')
|
||
expect((screen.getByLabelText('SKU图') as HTMLInputElement).accept)
|
||
.toBe('image/jpeg,image/png,image/gif,image/webp')
|
||
expect((screen.getByLabelText('详情图') as HTMLInputElement).accept)
|
||
.toBe('image/jpeg,image/png,image/gif,image/webp')
|
||
})
|
||
|
||
test('选图门禁逐个保留合格文件,并覆盖单组、总图片、总条目和总大小限制', () => {
|
||
const file = (name: string, size: number) => {
|
||
const value = new File(['x'], name, { type: 'image/jpeg' })
|
||
Object.defineProperty(value, 'size', { configurable: true, value: size })
|
||
return value
|
||
}
|
||
const emptySnapshot = () => ({ main: [], sku: [], detail: [], psd: [] })
|
||
const baseLimits = {
|
||
maxImagesPerGroup: 10,
|
||
maxTotalImageCount: 10,
|
||
maxPsdFileCount: 10,
|
||
maxTotalSourceFileCount: 10,
|
||
maxTotalSourceBytes: 100,
|
||
maxSinglePsdBytes: 100,
|
||
maxTotalPsdBytes: 100,
|
||
}
|
||
|
||
const groupResult = selectTaskDetailFilesWithinLimits(
|
||
'main',
|
||
[file('a.jpg', 1), file('b.jpg', 1)],
|
||
emptySnapshot(),
|
||
{ ...baseLimits, maxImagesPerGroup: 1 },
|
||
)
|
||
expect(groupResult.acceptedFiles.map((item) => item.name)).toEqual(['a.jpg'])
|
||
expect(groupResult.errors.join('')).toContain('主图最多选择 1 张')
|
||
|
||
const psdGroupResult = selectTaskDetailFilesWithinLimits(
|
||
'psd',
|
||
[file('source-a.psd', 1), file('source-b.psd', 1)],
|
||
emptySnapshot(),
|
||
{ ...baseLimits, maxPsdFileCount: 1 },
|
||
)
|
||
expect(psdGroupResult.acceptedFiles.map((item) => item.name)).toEqual(['source-a.psd'])
|
||
expect(psdGroupResult.errors.join('')).toContain('PSD 源文件最多选择 1 个')
|
||
|
||
const totalImageResult = selectTaskDetailFilesWithinLimits(
|
||
'detail',
|
||
[file('detail.jpg', 1)],
|
||
{ ...emptySnapshot(), main: [file('existing.jpg', 1)] },
|
||
{ ...baseLimits, maxTotalImageCount: 1 },
|
||
)
|
||
expect(totalImageResult.acceptedFiles).toHaveLength(0)
|
||
expect(totalImageResult.errors.join('')).toContain('本次图片合计最多 1 张')
|
||
|
||
const totalSourceResult = selectTaskDetailFilesWithinLimits(
|
||
'main',
|
||
[file('new.jpg', 1)],
|
||
{ ...emptySnapshot(), psd: [file('existing.psd', 1)] },
|
||
{ ...baseLimits, maxTotalSourceFileCount: 1 },
|
||
)
|
||
expect(totalSourceResult.acceptedFiles).toHaveLength(0)
|
||
expect(totalSourceResult.errors.join('')).toContain('原图和源文件合计最多 1 个')
|
||
|
||
const totalBytesResult = selectTaskDetailFilesWithinLimits(
|
||
'sku',
|
||
[file('fits.jpg', 1), file('too-much.jpg', 2)],
|
||
{ ...emptySnapshot(), main: [file('existing.jpg', 2)] },
|
||
{ ...baseLimits, maxTotalSourceBytes: 4 },
|
||
)
|
||
expect(totalBytesResult.acceptedFiles.map((item) => item.name)).toEqual(['fits.jpg'])
|
||
expect(totalBytesResult.errors.join('')).toContain('本次图片总大小最多 4B')
|
||
|
||
const largePsd = file('sku-sheet.psd', 80)
|
||
const psdBytesResult = selectTaskDetailFilesWithinLimits(
|
||
'psd',
|
||
[largePsd],
|
||
{ ...emptySnapshot(), main: [file('hero.jpg', 90)] },
|
||
{ ...baseLimits, maxTotalSourceBytes: 100, maxSinglePsdBytes: 100, maxTotalPsdBytes: 100 },
|
||
)
|
||
expect(psdBytesResult.acceptedFiles.map((item) => item.name)).toEqual(['sku-sheet.psd'])
|
||
expect(psdBytesResult.errors).toEqual([])
|
||
})
|
||
|
||
test('快速连续选图使用同步快照计数,超限文件不创建 Blob URL', async () => {
|
||
const mutableLimits = TASK_DETAIL_UPLOAD_LIMITS as unknown as { maxImagesPerGroup: number }
|
||
const originalLimit = mutableLimits.maxImagesPerGroup
|
||
mutableLimits.maxImagesPerGroup = 2
|
||
|
||
try {
|
||
render(<ProductPackageUploader {...defaultProps} />)
|
||
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
|
||
const input = screen.getByLabelText('主图') as HTMLInputElement
|
||
const first = new File(['first'], 'first.jpg', { type: 'image/jpeg' })
|
||
const second = new File(['second'], 'second.jpg', { type: 'image/jpeg' })
|
||
const third = new File(['third'], 'third.jpg', { type: 'image/jpeg' })
|
||
|
||
act(() => {
|
||
fireEvent.change(input, { target: { files: [first] } })
|
||
fireEvent.change(input, { target: { files: [second, third] } })
|
||
})
|
||
|
||
await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalledTimes(2))
|
||
expect(mockPrepareTaskDetailSelectionThumbnail).toHaveBeenCalledTimes(2)
|
||
expect(mockPrepareTaskDetailSelectionThumbnail.mock.calls.map(([file]) => (file as File).name))
|
||
.toEqual(['first.jpg', 'second.jpg'])
|
||
expect((URL.createObjectURL as jest.Mock).mock.calls.every(([file]) => (
|
||
file instanceof File && file.type === 'image/webp'
|
||
))).toBe(true)
|
||
expect((URL.createObjectURL as jest.Mock).mock.calls.some(([file]) => file === first || file === second || file === third))
|
||
.toBe(false)
|
||
expect(screen.getByRole('button', { name: '删除主图 first.jpg' })).toBeInTheDocument()
|
||
expect(screen.getByRole('button', { name: '删除主图 second.jpg' })).toBeInTheDocument()
|
||
expect(screen.queryByRole('button', { name: '删除主图 third.jpg' })).not.toBeInTheDocument()
|
||
expect(screen.getByRole('alert')).toHaveTextContent('主图最多选择 2 张')
|
||
expect(screen.getByRole('alert')).toHaveTextContent('请删除不需要的文件,或分批提交')
|
||
expect(screen.getByRole('alert')).toHaveTextContent('其余 1 张符合限制的文件已保留')
|
||
} finally {
|
||
mutableLimits.maxImagesPerGroup = originalLimit
|
||
}
|
||
})
|
||
|
||
test('多批选图共用单一缩略图队列,不会并行解码原图', async () => {
|
||
const firstThumbnail = createDeferred<ReturnType<typeof thumbnailResult>>()
|
||
const secondThumbnail = createDeferred<ReturnType<typeof thumbnailResult>>()
|
||
mockPrepareTaskDetailSelectionThumbnail
|
||
.mockImplementationOnce(() => firstThumbnail.promise)
|
||
.mockImplementationOnce(() => secondThumbnail.promise)
|
||
|
||
render(<ProductPackageUploader {...defaultProps} />)
|
||
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
|
||
const input = screen.getByLabelText('主图') as HTMLInputElement
|
||
const first = new File(['first'], 'queue-first.jpg', { type: 'image/jpeg' })
|
||
const second = new File(['second'], 'queue-second.jpg', { type: 'image/jpeg' })
|
||
|
||
fireEvent.change(input, { target: { files: [first] } })
|
||
fireEvent.change(input, { target: { files: [second] } })
|
||
|
||
await waitFor(() => expect(mockPrepareTaskDetailSelectionThumbnail).toHaveBeenCalledTimes(1))
|
||
expect(screen.getByRole('img', { name: 'queue-first.jpg:正在生成缩略图' })).toBeInTheDocument()
|
||
expect(screen.getByRole('img', { name: 'queue-second.jpg:正在生成缩略图' })).toBeInTheDocument()
|
||
|
||
await act(async () => {
|
||
firstThumbnail.resolve(thumbnailResult(first.name))
|
||
await Promise.resolve()
|
||
})
|
||
await waitFor(() => expect(mockPrepareTaskDetailSelectionThumbnail).toHaveBeenCalledTimes(2))
|
||
|
||
await act(async () => {
|
||
secondThumbnail.resolve(thumbnailResult(second.name))
|
||
await Promise.resolve()
|
||
})
|
||
await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalledTimes(2))
|
||
})
|
||
|
||
test('浏览器能力不足时显示文件占位,不把原始 File 退回 img', async () => {
|
||
const user = userEvent.setup()
|
||
mockPrepareTaskDetailSelectionThumbnail.mockRejectedValueOnce(capabilityError())
|
||
const { container } = render(<ProductPackageUploader {...defaultProps} />)
|
||
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
|
||
const source = new File(['source'], 'no-thumbnail.jpg', { type: 'image/jpeg' })
|
||
|
||
await user.upload(screen.getByLabelText('主图'), source)
|
||
|
||
expect(await screen.findByRole('img', { name: 'no-thumbnail.jpg:预览不可用' })).toBeInTheDocument()
|
||
expect(screen.getByText('浏览器无法生成缩略图')).toBeInTheDocument()
|
||
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
||
expect(container.querySelector('img')).toBeNull()
|
||
})
|
||
|
||
test('移除正在生成缩略图的文件会立即 abort,且不创建 Blob URL', async () => {
|
||
const user = userEvent.setup()
|
||
let thumbnailSignal: AbortSignal | undefined
|
||
mockPrepareTaskDetailSelectionThumbnail.mockImplementationOnce((
|
||
_file: File,
|
||
options?: { signal?: AbortSignal },
|
||
) => new Promise((_, reject) => {
|
||
thumbnailSignal = options?.signal
|
||
thumbnailSignal?.addEventListener('abort', () => reject(new DOMException('缩略图已取消', 'AbortError')), { once: true })
|
||
}))
|
||
|
||
render(<ProductPackageUploader {...defaultProps} />)
|
||
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
|
||
await user.upload(
|
||
screen.getByLabelText('主图'),
|
||
new File(['source'], 'remove-loading.jpg', { type: 'image/jpeg' }),
|
||
)
|
||
await waitFor(() => expect(thumbnailSignal).toBeDefined())
|
||
|
||
await user.click(screen.getByRole('button', { name: '删除主图 remove-loading.jpg' }))
|
||
|
||
expect(thumbnailSignal?.aborted).toBe(true)
|
||
expect(screen.queryByRole('button', { name: '删除主图 remove-loading.jpg' })).not.toBeInTheDocument()
|
||
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('组件卸载会 abort 正在生成的缩略图且不遗留 Blob URL', async () => {
|
||
const user = userEvent.setup()
|
||
let thumbnailSignal: AbortSignal | undefined
|
||
mockPrepareTaskDetailSelectionThumbnail.mockImplementationOnce((
|
||
_file: File,
|
||
options?: { signal?: AbortSignal },
|
||
) => new Promise((_, reject) => {
|
||
thumbnailSignal = options?.signal
|
||
thumbnailSignal?.addEventListener('abort', () => reject(new DOMException('缩略图已取消', 'AbortError')), { once: true })
|
||
}))
|
||
|
||
const { unmount } = render(<ProductPackageUploader {...defaultProps} />)
|
||
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
|
||
await user.upload(
|
||
screen.getByLabelText('主图'),
|
||
new File(['source'], 'unmount-loading.jpg', { type: 'image/jpeg' }),
|
||
)
|
||
await waitFor(() => expect(thumbnailSignal).toBeDefined())
|
||
|
||
unmount()
|
||
|
||
expect(thumbnailSignal?.aborted).toBe(true)
|
||
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
||
expect(URL.revokeObjectURL).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('开始提交会终止选图缩略图队列,再进入正式预览处理', async () => {
|
||
const user = userEvent.setup()
|
||
jest.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||
let thumbnailSignal: AbortSignal | undefined
|
||
mockPrepareTaskDetailSelectionThumbnail.mockImplementationOnce((
|
||
_file: File,
|
||
options?: { signal?: AbortSignal },
|
||
) => new Promise((_, reject) => {
|
||
thumbnailSignal = options?.signal
|
||
thumbnailSignal?.addEventListener('abort', () => reject(new DOMException('缩略图已取消', 'AbortError')), { once: true })
|
||
}))
|
||
mockPrepareTaskDetailImagePreview.mockRejectedValueOnce(capabilityError())
|
||
installUploadFlowFetch(Promise.resolve({
|
||
ok: true,
|
||
json: async () => ({ data: { jobId: 'job-after-thumbnail-stop' } }),
|
||
}))
|
||
|
||
render(<ProductPackageUploader {...defaultProps} />)
|
||
await fillRequiredTaskFields(
|
||
user,
|
||
new File(['source'], 'submit-stops-thumbnail.jpg', { type: 'image/jpeg' }),
|
||
)
|
||
await waitFor(() => expect(thumbnailSignal).toBeDefined())
|
||
|
||
await user.click(screen.getByRole('button', { name: '提交' }))
|
||
|
||
expect(thumbnailSignal?.aborted).toBe(true)
|
||
await waitFor(() => expect(mockPrepareTaskDetailImagePreview).toHaveBeenCalledTimes(1))
|
||
expect(screen.getByRole('img', { name: 'submit-stops-thumbnail.jpg:预览不可用' })).toBeInTheDocument()
|
||
})
|
||
|
||
test('已生成的缩略图在删除和卸载时只回收对应 Blob URL', async () => {
|
||
const user = userEvent.setup()
|
||
const firstRender = render(<ProductPackageUploader {...defaultProps} />)
|
||
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
|
||
await user.upload(
|
||
screen.getByLabelText('主图'),
|
||
new File(['first'], 'remove-ready.jpg', { type: 'image/jpeg' }),
|
||
)
|
||
await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalledTimes(1))
|
||
await user.click(screen.getByRole('button', { name: '删除主图 remove-ready.jpg' }))
|
||
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1)
|
||
expect(URL.revokeObjectURL).toHaveBeenLastCalledWith('blob:mock-1')
|
||
firstRender.unmount()
|
||
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1)
|
||
|
||
const secondRender = render(<ProductPackageUploader {...defaultProps} />)
|
||
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
|
||
await user.upload(
|
||
screen.getByLabelText('主图'),
|
||
new File(['second'], 'unmount-ready.jpg', { type: 'image/jpeg' }),
|
||
)
|
||
await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalledTimes(2))
|
||
secondRender.unmount()
|
||
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2)
|
||
expect(URL.revokeObjectURL).toHaveBeenLastCalledWith('blob:mock-2')
|
||
})
|
||
|
||
test('上传成功关闭前回收所有选图缩略图 Blob URL', async () => {
|
||
const user = userEvent.setup()
|
||
const onSuccess = jest.fn()
|
||
mockPrepareTaskDetailImagePreview.mockResolvedValueOnce([thumbnailResult('success.jpg')])
|
||
installUploadFlowFetch(Promise.resolve({
|
||
ok: true,
|
||
json: async () => ({ data: { jobId: 'job-success' } }),
|
||
}))
|
||
|
||
render(<ProductPackageUploader {...defaultProps} onSuccess={onSuccess} />)
|
||
await fillRequiredTaskFields(
|
||
user,
|
||
new File(['success'], 'success.jpg', { type: 'image/jpeg' }),
|
||
)
|
||
await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalledTimes(1))
|
||
|
||
await user.click(screen.getByRole('button', { name: '提交' }))
|
||
|
||
await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1), { timeout: 3_000 })
|
||
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1)
|
||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-1')
|
||
})
|
||
|
||
test('取消上传会 abort 正在运行的浏览器图片预处理', async () => {
|
||
const user = userEvent.setup()
|
||
jest.spyOn(console, 'error').mockImplementation(() => undefined)
|
||
const previewDeferred = createDeferred<never>()
|
||
let previewSignal: AbortSignal | undefined
|
||
mockPrepareTaskDetailImagePreview.mockImplementationOnce((
|
||
_file: File,
|
||
_group: string,
|
||
_onProgress: unknown,
|
||
options?: { signal?: AbortSignal },
|
||
) => {
|
||
previewSignal = options?.signal
|
||
previewSignal?.addEventListener('abort', () => {
|
||
previewDeferred.reject(new DOMException('图片处理已取消', 'AbortError'))
|
||
}, { once: true })
|
||
return previewDeferred.promise
|
||
})
|
||
const fetchMock = installUploadFlowFetch()
|
||
|
||
render(<ProductPackageUploader {...defaultProps} />)
|
||
await fillRequiredTaskFields(
|
||
user,
|
||
new File(['image'], 'cancel-processing.jpg', { type: 'image/jpeg' }),
|
||
)
|
||
await user.click(screen.getByRole('button', { name: '提交' }))
|
||
await waitFor(() => expect(previewSignal).toBeDefined())
|
||
|
||
await user.click(screen.getByRole('button', { name: '取消上传' }))
|
||
|
||
expect(previewSignal?.aborted).toBe(true)
|
||
expect(await screen.findByText('已取消上传')).toBeInTheDocument()
|
||
expect(mockChunkUpload).not.toHaveBeenCalled()
|
||
expect(fetchMock).toHaveBeenCalledWith(
|
||
'/api/materials/upload/product-package/abort',
|
||
expect.objectContaining({ method: 'POST' }),
|
||
)
|
||
})
|
||
|
||
test('Edge 浏览器预览处理失败时继续上传原图并显式请求服务器兜底', async () => {
|
||
const user = userEvent.setup()
|
||
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||
mockPrepareTaskDetailImagePreview.mockRejectedValueOnce(capabilityError())
|
||
;(global.fetch as jest.Mock)
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
json: async () => [{ id: 'requester-1', username: 'operator-a', displayName: '运营甲' }],
|
||
})
|
||
.mockResolvedValueOnce({ ok: true, json: async () => ({ data: { materialId: 'material-1' } }) })
|
||
.mockResolvedValueOnce({ ok: true, json: async () => ({ data: { jobId: 'job-1' } }) })
|
||
|
||
render(<ProductPackageUploader {...defaultProps} />)
|
||
await fillRequiredTaskFields(
|
||
user,
|
||
new File(['image'], 'edge-photo.jpg', { type: 'image/jpeg' }),
|
||
)
|
||
await user.click(screen.getByRole('button', { name: '提交' }))
|
||
|
||
await waitFor(() => expect(mockPollTaskDetailUploadJob).toHaveBeenCalledWith(
|
||
'job-1',
|
||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||
))
|
||
|
||
expect(mockChunkUpload).toHaveBeenCalledTimes(1)
|
||
expect(mockChunkUpload.mock.calls[0][0]).toMatchObject({ name: 'edge-photo.jpg' })
|
||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||
expect.stringContaining('edge-photo.jpg'),
|
||
'当前 Edge 环境无法解码这张合法图片',
|
||
)
|
||
|
||
const completeRequest = (global.fetch as jest.Mock).mock.calls.find(
|
||
([url]) => url === '/api/materials/upload/product-package/complete',
|
||
)
|
||
expect(completeRequest).toBeDefined()
|
||
expect(JSON.parse(completeRequest[1].body)).toMatchObject({
|
||
materialId: 'material-1',
|
||
allowServerPreviewFallback: true,
|
||
assets: {
|
||
main: [expect.objectContaining({
|
||
fileName: 'edge-photo.jpg',
|
||
uploadId: 'upload-original-1',
|
||
})],
|
||
},
|
||
})
|
||
})
|
||
|
||
test('任务图片超过 60M 像素时立即阻断,不上传原图也不创建后台任务', async () => {
|
||
const user = userEvent.setup()
|
||
jest.spyOn(console, 'error').mockImplementation(() => undefined)
|
||
mockPrepareTaskDetailImagePreview.mockRejectedValueOnce(
|
||
new PerformanceSurveyBrowserImageError(
|
||
'SOURCE_PIXEL_LIMIT',
|
||
'图片「70m.jpg」像素总量超过 60,000,000,请先压缩或切图',
|
||
),
|
||
)
|
||
const fetchMock = installUploadFlowFetch()
|
||
|
||
render(<ProductPackageUploader {...defaultProps} />)
|
||
await fillRequiredTaskFields(
|
||
user,
|
||
new File(['image'], '70m.jpg', { type: 'image/jpeg' }),
|
||
)
|
||
await user.click(screen.getByRole('button', { name: '提交' }))
|
||
|
||
expect(await screen.findByText('图片「70m.jpg」像素总量超过 60,000,000,请先压缩或切图')).toBeInTheDocument()
|
||
expect(mockChunkUpload).not.toHaveBeenCalled()
|
||
expect(mockPollTaskDetailUploadJob).not.toHaveBeenCalled()
|
||
expect(fetchMock.mock.calls.some(([url]) => url === '/api/materials/upload/product-package/complete')).toBe(false)
|
||
expect(fetchMock).toHaveBeenCalledWith(
|
||
'/api/materials/upload/product-package/abort',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: JSON.stringify({ materialId: 'material-1', uploadIds: [] }),
|
||
}),
|
||
)
|
||
})
|
||
|
||
test('并发首错后等待另一上传成功,再用完整 uploadId 列表统一回滚', async () => {
|
||
const user = userEvent.setup()
|
||
jest.spyOn(console, 'error').mockImplementation(() => undefined)
|
||
jest.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||
mockPrepareTaskDetailImagePreview.mockRejectedValue(capabilityError())
|
||
const uploadA = createDeferred<{ uploadId: string }>()
|
||
const uploadB = createDeferred<{ uploadId: string }>()
|
||
const xhrA = { abort: jest.fn() } as unknown as XMLHttpRequest
|
||
const xhrB = { abort: jest.fn() } as unknown as XMLHttpRequest
|
||
mockChunkUpload.mockImplementation((file: File, _metadata: unknown, options: {
|
||
onActiveXhrChange?: (xhrs: Set<XMLHttpRequest>) => void
|
||
}) => {
|
||
const isA = file.name === 'a.jpg'
|
||
options.onActiveXhrChange?.(new Set([isA ? xhrA : xhrB]))
|
||
return isA ? uploadA.promise : uploadB.promise
|
||
})
|
||
const fetchMock = installUploadFlowFetch()
|
||
|
||
render(<ProductPackageUploader {...defaultProps} />)
|
||
await fillRequiredTaskFields(user, [
|
||
new File(['a'], 'a.jpg', { type: 'image/jpeg' }),
|
||
new File(['b'], 'b.jpg', { type: 'image/jpeg' }),
|
||
])
|
||
await user.click(screen.getByRole('button', { name: '提交' }))
|
||
await waitFor(() => expect(mockChunkUpload).toHaveBeenCalledTimes(2))
|
||
|
||
await act(async () => {
|
||
uploadA.reject(new Error('A 上传失败'))
|
||
await Promise.resolve()
|
||
})
|
||
await waitFor(() => {
|
||
expect(xhrA.abort).toHaveBeenCalled()
|
||
expect(xhrB.abort).toHaveBeenCalled()
|
||
})
|
||
|
||
await act(async () => {
|
||
uploadB.resolve({ uploadId: 'upload-b' })
|
||
await Promise.resolve()
|
||
})
|
||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||
'/api/materials/upload/product-package/abort',
|
||
expect.objectContaining({
|
||
body: JSON.stringify({ materialId: 'material-1', uploadIds: ['upload-b'] }),
|
||
}),
|
||
))
|
||
expect(fetchMock.mock.calls.filter(([url]) => url === '/api/materials/upload/product-package/abort')).toHaveLength(1)
|
||
})
|
||
|
||
test('并发首错后另一上传也失败时停止重试并只执行一次素材回滚', async () => {
|
||
const user = userEvent.setup()
|
||
jest.spyOn(console, 'error').mockImplementation(() => undefined)
|
||
jest.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||
mockPrepareTaskDetailImagePreview.mockRejectedValue(capabilityError())
|
||
const uploadA = createDeferred<{ uploadId: string }>()
|
||
const uploadB = createDeferred<{ uploadId: string }>()
|
||
const xhrA = { abort: jest.fn() } as unknown as XMLHttpRequest
|
||
const xhrB = { abort: jest.fn() } as unknown as XMLHttpRequest
|
||
let bOptions: { isCancelled?: () => boolean } | undefined
|
||
mockChunkUpload.mockImplementation((file: File, _metadata: unknown, options: {
|
||
isCancelled?: () => boolean
|
||
onActiveXhrChange?: (xhrs: Set<XMLHttpRequest>) => void
|
||
}) => {
|
||
const isA = file.name === 'a.jpg'
|
||
if (!isA) bOptions = options
|
||
options.onActiveXhrChange?.(new Set([isA ? xhrA : xhrB]))
|
||
return isA ? uploadA.promise : uploadB.promise
|
||
})
|
||
const fetchMock = installUploadFlowFetch()
|
||
|
||
render(<ProductPackageUploader {...defaultProps} />)
|
||
await fillRequiredTaskFields(user, [
|
||
new File(['a'], 'a.jpg', { type: 'image/jpeg' }),
|
||
new File(['b'], 'b.jpg', { type: 'image/jpeg' }),
|
||
])
|
||
await user.click(screen.getByRole('button', { name: '提交' }))
|
||
await waitFor(() => expect(mockChunkUpload).toHaveBeenCalledTimes(2))
|
||
|
||
await act(async () => {
|
||
uploadA.reject(new Error('A 上传失败'))
|
||
await Promise.resolve()
|
||
})
|
||
await waitFor(() => expect(bOptions?.isCancelled?.()).toBe(true))
|
||
expect(xhrA.abort).toHaveBeenCalled()
|
||
expect(xhrB.abort).toHaveBeenCalled()
|
||
|
||
await act(async () => {
|
||
uploadB.reject(new Error('B 随后失败'))
|
||
await Promise.resolve()
|
||
})
|
||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||
'/api/materials/upload/product-package/abort',
|
||
expect.objectContaining({
|
||
body: JSON.stringify({ materialId: 'material-1', uploadIds: [] }),
|
||
}),
|
||
))
|
||
expect(fetchMock.mock.calls.filter(([url]) => url === '/api/materials/upload/product-package/abort')).toHaveLength(1)
|
||
})
|
||
|
||
test('complete 返回 202 前取消时标记后台已接单但不再启动轮询', async () => {
|
||
const user = userEvent.setup()
|
||
jest.spyOn(console, 'error').mockImplementation(() => undefined)
|
||
jest.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||
mockPrepareTaskDetailImagePreview.mockRejectedValueOnce(capabilityError())
|
||
const completeResponse = createDeferred<unknown>()
|
||
const fetchMock = installUploadFlowFetch(completeResponse.promise)
|
||
|
||
render(<ProductPackageUploader {...defaultProps} />)
|
||
await fillRequiredTaskFields(
|
||
user,
|
||
new File(['image'], 'cancel-before-202.jpg', { type: 'image/jpeg' }),
|
||
)
|
||
await user.click(screen.getByRole('button', { name: '提交' }))
|
||
await waitFor(() => expect(fetchMock.mock.calls.some(
|
||
([url]) => url === '/api/materials/upload/product-package/complete',
|
||
)).toBe(true))
|
||
|
||
await user.click(screen.getByRole('button', { name: '取消上传' }))
|
||
await act(async () => {
|
||
completeResponse.resolve({ ok: true, json: async () => ({ data: { jobId: 'job-after-cancel' } }) })
|
||
await Promise.resolve()
|
||
})
|
||
|
||
expect(await screen.findByText('已停止等待,后台任务仍在服务器处理中,可稍后查看素材状态')).toBeInTheDocument()
|
||
expect(mockPollTaskDetailUploadJob).not.toHaveBeenCalled()
|
||
expect(fetchMock.mock.calls.some(([url]) => url === '/api/materials/upload/product-package/abort')).toBe(false)
|
||
})
|
||
|
||
test('complete 返回 202 前卸载组件时不创建后台轮询', async () => {
|
||
const user = userEvent.setup()
|
||
jest.spyOn(console, 'error').mockImplementation(() => undefined)
|
||
jest.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||
mockPrepareTaskDetailImagePreview.mockRejectedValueOnce(capabilityError())
|
||
const completeResponse = createDeferred<unknown>()
|
||
const fetchMock = installUploadFlowFetch(completeResponse.promise)
|
||
|
||
const { unmount } = render(<ProductPackageUploader {...defaultProps} />)
|
||
await fillRequiredTaskFields(
|
||
user,
|
||
new File(['image'], 'unmount-before-202.jpg', { type: 'image/jpeg' }),
|
||
)
|
||
await user.click(screen.getByRole('button', { name: '提交' }))
|
||
await waitFor(() => expect(fetchMock.mock.calls.some(
|
||
([url]) => url === '/api/materials/upload/product-package/complete',
|
||
)).toBe(true))
|
||
|
||
unmount()
|
||
await act(async () => {
|
||
completeResponse.resolve({ ok: true, json: async () => ({ data: { jobId: 'job-after-unmount' } }) })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
})
|
||
|
||
expect(mockPollTaskDetailUploadJob).not.toHaveBeenCalled()
|
||
expect(fetchMock.mock.calls.some(([url]) => url === '/api/materials/upload/product-package/abort')).toBe(false)
|
||
})
|
||
|
||
test('浏览器生成的预览超过服务端总量前停止上传并回滚空素材', async () => {
|
||
const user = userEvent.setup()
|
||
jest.spyOn(console, 'error').mockImplementation(() => undefined)
|
||
const mutableLimits = TASK_DETAIL_UPLOAD_LIMITS as unknown as { maxTotalPreviewCount: number }
|
||
const originalLimit = mutableLimits.maxTotalPreviewCount
|
||
mutableLimits.maxTotalPreviewCount = 1
|
||
mockPrepareTaskDetailImagePreview.mockResolvedValueOnce([
|
||
{
|
||
file: new File(['preview-1'], 'preview-1.webp', { type: 'image/webp' }),
|
||
width: 800,
|
||
height: 600,
|
||
kind: 'preview',
|
||
sourceName: 'photo.jpg',
|
||
sourceIndex: 0,
|
||
sliceIndex: 0,
|
||
sliceCount: 1,
|
||
},
|
||
{
|
||
file: new File(['preview-2'], 'preview-2.webp', { type: 'image/webp' }),
|
||
width: 800,
|
||
height: 600,
|
||
kind: 'preview',
|
||
sourceName: 'photo.jpg',
|
||
sourceIndex: 0,
|
||
sliceIndex: 0,
|
||
sliceCount: 1,
|
||
},
|
||
])
|
||
;(global.fetch as jest.Mock)
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
json: async () => [{ id: 'requester-1', username: 'operator-a', displayName: '运营甲' }],
|
||
})
|
||
.mockResolvedValueOnce({ ok: true, json: async () => ({ data: { materialId: 'material-1' } }) })
|
||
.mockResolvedValueOnce({ ok: true, json: async () => ({ success: true }) })
|
||
|
||
try {
|
||
render(<ProductPackageUploader {...defaultProps} />)
|
||
const requesterInput = await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
|
||
await user.click(requesterInput)
|
||
await user.type(requesterInput, '运营甲')
|
||
await user.click(screen.getByRole('option', { name: /运营甲/ }))
|
||
await user.click(screen.getByRole('button', { name: '设置测试 SPU' }))
|
||
await user.click(screen.getByRole('button', { name: '设置测试店铺' }))
|
||
await user.selectOptions(screen.getByLabelText(/出图方式/), '实拍')
|
||
await user.upload(
|
||
screen.getByLabelText('主图'),
|
||
new File(['image'], 'photo.jpg', { type: 'image/jpeg' }),
|
||
)
|
||
await user.click(screen.getByRole('button', { name: '提交' }))
|
||
|
||
expect(await screen.findByText('本次图片生成的预览切片过多或过大,请减少详情长图数量后分批提交')).toBeInTheDocument()
|
||
expect(mockChunkUpload).not.toHaveBeenCalled()
|
||
expect(global.fetch).toHaveBeenCalledWith(
|
||
'/api/materials/upload/product-package/abort',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: JSON.stringify({ materialId: 'material-1', uploadIds: [] }),
|
||
}),
|
||
)
|
||
} finally {
|
||
mutableLimits.maxTotalPreviewCount = originalLimit
|
||
}
|
||
})
|
||
})
|