/** * EditMaterialModal 组件测试 * 测试素材编辑模态框的核心功能,包括音频字段支持 * @jest-environment jsdom */ import React from 'react' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import '@testing-library/jest-dom' // Mock next/image jest.mock('next/image', () => ({ __esModule: true, default: (props: any) => { // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text return }, })) // Mock fetch const mockFetch = jest.fn() global.fetch = mockFetch // Mock URL.createObjectURL global.URL.createObjectURL = jest.fn(() => 'blob:test-url') global.URL.revokeObjectURL = jest.fn() import EditMaterialModal from '../EditMaterialModal' describe('EditMaterialModal 组件', () => { const mockOnClose = jest.fn() const mockOnSuccess = jest.fn() const baseMaterial = { id: 'test-material-id', previewUrl: 'https://example.com/preview.jpg', productModel: 'Test Product', categoryName: 'Test Category', sceneName: 'Test Scene', materialType: '3DMAX', renderer: 'CR', authorName: 'Test Author', } const audioMaterial = { ...baseMaterial, materialType: '音频', audioDuration: '03:45', audioCategory: '背景音乐', audioScene: '产品展示', } beforeEach(() => { jest.clearAllMocks() // Mock API responses mockFetch.mockImplementation((url: string) => { if (url.includes('/api/template-categories')) { return Promise.resolve({ ok: true, json: () => Promise.resolve([ { fieldKey: 'categoryName', options: ['Category 1', 'Category 2'] }, { fieldKey: 'sceneName', options: ['Scene 1', 'Scene 2'] }, { fieldKey: 'audioCategory', options: ['背景音乐', '音效', '配音'] }, { fieldKey: 'audioScene', options: ['产品展示', '广告', '教程'] }, ]), }) } if (url.includes('/api/upload-templates')) { return Promise.resolve({ ok: true, json: () => Promise.resolve([]), }) } if (url.includes('/api/admin/upload-templates/template-1')) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ id: 'template-1', name: '测试模板', description: '测试模板描述', materialType: '3DMAX', fields: [ { id: 'field-1', key: 'customField', label: '自定义字段', type: 'text', required: false, }, ], }), }) } if (url.includes('/api/materials/update')) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }), }) } return Promise.resolve({ ok: false, json: () => Promise.resolve({ error: 'Not found' }), }) }) }) describe('基础渲染测试', () => { it('应该正确渲染模态框', () => { render( ) expect(screen.getByText('编辑素材')).toBeInTheDocument() expect(screen.getByText('预览图')).toBeInTheDocument() }) it('应该显示预览图', () => { render( ) const previewImage = screen.getByAltText('预览图') expect(previewImage).toBeInTheDocument() expect(previewImage).toHaveAttribute('src', baseMaterial.previewUrl) }) it('应该显示取消和保存按钮', () => { render( ) expect(screen.getByText('取消')).toBeInTheDocument() expect(screen.getByText('保存修改')).toBeInTheDocument() }) }) describe('音频字段支持测试', () => { it('应该正确初始化音频相关字段', async () => { render( ) // 等待组件初始化 await waitFor(() => { expect(mockFetch).toHaveBeenCalled() }) // 验证音频字段值被正确初始化(通过检查组件内部状态) // 由于字段是通过模板动态渲染的,我们验证 API 调用 expect(mockFetch).toHaveBeenCalledWith( '/api/template-categories', expect.any(Object) ) }) it('应该接受 audioDuration 字段', () => { const materialWithDuration = { ...baseMaterial, audioDuration: '05:30', } render( ) // 组件应该正常渲染,不抛出错误 expect(screen.getByText('编辑素材')).toBeInTheDocument() }) it('应该接受 audioCategory 字段', () => { const materialWithCategory = { ...baseMaterial, audioCategory: '背景音乐', } render( ) expect(screen.getByText('编辑素材')).toBeInTheDocument() }) it('应该接受 audioScene 字段', () => { const materialWithScene = { ...baseMaterial, audioScene: '产品展示', } render( ) expect(screen.getByText('编辑素材')).toBeInTheDocument() }) it('应该处理所有音频字段为 null 的情况', () => { const materialWithNullAudio = { ...baseMaterial, audioDuration: null, audioCategory: null, audioScene: null, } render( ) expect(screen.getByText('编辑素材')).toBeInTheDocument() }) it('应该处理音频字段为 undefined 的情况', () => { const materialWithUndefinedAudio = { ...baseMaterial, // 不设置音频字段,它们将是 undefined } render( ) expect(screen.getByText('编辑素材')).toBeInTheDocument() }) }) describe('交互测试', () => { it('点击取消按钮应该调用 onClose', () => { render( ) fireEvent.click(screen.getByText('取消')) expect(mockOnClose).toHaveBeenCalled() }) it('应该能够更换预览图', async () => { render( ) // 找到隐藏的文件输入 const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement expect(fileInput).toBeInTheDocument() // 模拟文件选择 const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' }) fireEvent.change(fileInput, { target: { files: [file] } }) // 验证 URL.createObjectURL 被调用 expect(global.URL.createObjectURL).toHaveBeenCalledWith(file) }) }) describe('表单提交测试', () => { it('提交表单应该调用更新 API', async () => { render( ) // 等待模板加载 await waitFor(() => { expect(mockFetch).toHaveBeenCalled() }) // 点击保存按钮 fireEvent.click(screen.getByText('保存修改')) // 验证 API 被调用 await waitFor(() => { const updateCalls = mockFetch.mock.calls.filter( (call: any[]) => call[0].includes('/api/materials/update') ) expect(updateCalls.length).toBeGreaterThan(0) }) }) it('有 templateId 时应优先从普通模板列表加载并提交模板字段', async () => { mockFetch.mockImplementation((url: string) => { if (url.includes('/api/template-categories')) { return Promise.resolve({ ok: true, json: () => Promise.resolve([]), }) } if (url.includes('/api/upload-templates')) { return Promise.resolve({ ok: true, json: () => Promise.resolve([ { id: 'template-1', name: '测试模板', description: '测试模板描述', materialType: '3DMAX', status: 'active', fields: [ { id: 'field-1', key: 'customField', label: '自定义字段', type: 'text', required: false, }, ], }, ]), }) } if (url.includes('/api/admin/upload-templates/template-1')) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ id: 'template-1', name: '测试模板', description: '测试模板描述', materialType: '3DMAX', fields: [ { id: 'field-1', key: 'customField', label: '自定义字段', type: 'text', required: false, }, ], }), }) } if (url.includes('/api/materials/update')) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }), }) } return Promise.resolve({ ok: false, json: () => Promise.resolve({ error: 'Not found' }), }) }) render( ) await waitFor(() => { expect(screen.getByLabelText('自定义字段')).toBeInTheDocument() }) expect(mockFetch).toHaveBeenCalledWith('/api/upload-templates') expect(mockFetch).not.toHaveBeenCalledWith('/api/admin/upload-templates/template-1') fireEvent.click(screen.getByText('保存修改')) await waitFor(() => { const updateCall = mockFetch.mock.calls.find( (call: any[]) => call[0].includes('/api/materials/update') ) expect(updateCall).toBeTruthy() const formData = updateCall?.[1]?.body as FormData expect(formData.get('templateId')).toBe('template-1') expect(formData.get('templateFields')).toContain('customField') expect(formData.get('templateFields')).toContain('已有自定义值') }) }) it('普通模板列表找不到模板时才回退请求管理员模板详情', async () => { render( ) await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith('/api/upload-templates') expect(mockFetch).toHaveBeenCalledWith('/api/admin/upload-templates/template-1') }) expect(screen.getByLabelText('自定义字段')).toBeInTheDocument() }) it('普通模板列表和管理员模板详情都不可用时应降级到基础字段编辑', async () => { mockFetch.mockImplementation((url: string) => { if (url.includes('/api/template-categories')) { return Promise.resolve({ ok: true, json: () => Promise.resolve([]), }) } if (url.includes('/api/upload-templates')) { return Promise.resolve({ ok: true, json: () => Promise.resolve([]), }) } if (url.includes('/api/admin/upload-templates/template-1')) { return Promise.resolve({ ok: false, status: 403, json: () => Promise.resolve({ error: '无权限' }), }) } if (url.includes('/api/materials/update')) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }), }) } return Promise.resolve({ ok: false, json: () => Promise.resolve({ error: 'Not found' }), }) }) render( ) expect(await screen.findByText('未找到对应的上传模板,请使用基础字段编辑')).toBeInTheDocument() expect(mockFetch).toHaveBeenCalledWith('/api/upload-templates') expect(mockFetch).toHaveBeenCalledWith('/api/admin/upload-templates/template-1') expect(screen.queryByLabelText('自定义字段')).not.toBeInTheDocument() }) it('保存接口返回无权限时应把后端错误提示展示给用户', async () => { mockFetch.mockImplementation((url: string) => { if (url.includes('/api/template-categories')) { return Promise.resolve({ ok: true, json: () => Promise.resolve([]), }) } if (url.includes('/api/upload-templates')) { return Promise.resolve({ ok: true, json: () => Promise.resolve([]), }) } if (url.includes('/api/materials/update')) { return Promise.resolve({ ok: false, status: 403, json: () => Promise.resolve({ error: '您没有权限编辑此素材' }), }) } return Promise.resolve({ ok: false, json: () => Promise.resolve({ error: 'Not found' }), }) }) render( ) await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith('/api/upload-templates') }) fireEvent.click(screen.getByText('保存修改')) expect(await screen.findByText('您没有权限编辑此素材')).toBeInTheDocument() expect(mockOnSuccess).not.toHaveBeenCalled() }) it('必填 checkbox 未勾选也应视为已填写并提交 false', async () => { mockFetch.mockImplementation((url: string) => { if (url.includes('/api/template-categories')) { return Promise.resolve({ ok: true, json: () => Promise.resolve([]), }) } if (url.includes('/api/upload-templates')) { return Promise.resolve({ ok: true, json: () => Promise.resolve([ { id: 'template-1', name: '勾选模板', description: null, materialType: '3DMAX', status: 'active', fields: [ { id: 'field-product', key: 'productModel', label: '产品型号', type: 'text', required: true, }, { id: 'field-checkbox', key: 'allowUse', label: '允许使用', type: 'checkbox', required: true, }, ], }, ]), }) } if (url.includes('/api/materials/update')) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }), }) } return Promise.resolve({ ok: false, json: () => Promise.resolve({ error: 'Not found' }), }) }) render( ) expect(await screen.findByLabelText('允许使用')).not.toBeChecked() fireEvent.click(screen.getByText('保存修改')) await waitFor(() => { const updateCall = mockFetch.mock.calls.find( (call: any[]) => call[0].includes('/api/materials/update') ) expect(updateCall).toBeTruthy() const formData = updateCall?.[1]?.body as FormData const payload = JSON.parse(String(formData.get('templateFields'))) expect(payload.allowUse).toBe(false) }) expect(screen.queryByText('请填写必填字段:允许使用')).not.toBeInTheDocument() }) it('条件不满足时应隐藏字段且不提交隐藏字段值', async () => { mockFetch.mockImplementation((url: string) => { if (url.includes('/api/template-categories')) { return Promise.resolve({ ok: true, json: () => Promise.resolve([]), }) } if (url.includes('/api/admin/upload-templates/template-1')) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ id: 'template-1', name: '条件模板', description: '测试条件显示', materialType: '3DMAX', fields: [ { id: 'field-product', key: 'productModel', label: '产品型号', type: 'text', required: true, }, { id: 'field-conditional', key: 'conditionalCode', label: '条件编码', type: 'text', required: true, conditions: JSON.stringify({ logic: 'AND', conditions: [{ fieldKey: 'productModel', operator: 'equals', value: '需要编码' }], }), }, ], }), }) } if (url.includes('/api/materials/update')) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }), }) } return Promise.resolve({ ok: false, json: () => Promise.resolve({ error: 'Not found' }), }) }) render( ) await waitFor(() => { expect(screen.getByLabelText('产品型号')).toBeInTheDocument() }) expect(screen.queryByLabelText('条件编码')).not.toBeInTheDocument() fireEvent.click(screen.getByText('保存修改')) await waitFor(() => { const updateCall = mockFetch.mock.calls.find( (call: any[]) => call[0].includes('/api/materials/update') ) expect(updateCall).toBeTruthy() const formData = updateCall?.[1]?.body as FormData const payload = JSON.parse(String(formData.get('templateFields'))) expect(payload.productModel).toBe('普通产品') expect(payload.conditionalCode).toBeUndefined() }) }) it('依赖字段变化后应显示条件字段并参与必填校验', async () => { mockFetch.mockImplementation((url: string) => { if (url.includes('/api/template-categories')) { return Promise.resolve({ ok: true, json: () => Promise.resolve([]), }) } if (url.includes('/api/admin/upload-templates/template-1')) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ id: 'template-1', name: '条件模板', description: '测试条件显示', materialType: '3DMAX', fields: [ { id: 'field-product', key: 'productModel', label: '产品型号', type: 'text', required: true, }, { id: 'field-conditional', key: 'conditionalCode', label: '条件编码', type: 'text', required: true, conditions: JSON.stringify({ logic: 'AND', conditions: [{ fieldKey: 'productModel', operator: 'equals', value: '需要编码' }], }), }, ], }), }) } if (url.includes('/api/materials/update')) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }), }) } return Promise.resolve({ ok: false, json: () => Promise.resolve({ error: 'Not found' }), }) }) render( ) const productInput = await screen.findByLabelText('产品型号') fireEvent.change(productInput, { target: { value: '需要编码' } }) await waitFor(() => { expect(screen.getByLabelText('条件编码')).toBeInTheDocument() }) fireEvent.click(screen.getByText('保存修改')) expect(await screen.findByText('请填写必填字段:条件编码')).toBeInTheDocument() expect( mockFetch.mock.calls.some((call: any[]) => call[0].includes('/api/materials/update')) ).toBe(false) }) }) describe('边界情况测试', () => { it('应该处理空字符串的音频字段', () => { const materialWithEmptyAudio = { ...baseMaterial, audioDuration: '', audioCategory: '', audioScene: '', } render( ) expect(screen.getByText('编辑素材')).toBeInTheDocument() }) it('应该处理所有可选字段都存在的情况', () => { const fullMaterial = { ...baseMaterial, brandName: 'Test Brand', seriesName: 'Test Series', skuCode: 'SKU001', materialTexture: 'Leather', renderTime: '2h', imageResolution: '4K', colorMode: 'RGB', templateId: 'template-1', platformName: 'Platform A', audioDuration: '03:45', audioCategory: '背景音乐', audioScene: '产品展示', } render( ) expect(screen.getByText('编辑素材')).toBeInTheDocument() }) }) }) describe('EditMaterialModal 类型安全测试', () => { it('material 接口应该包含音频字段', () => { // 这个测试验证 TypeScript 类型定义是否正确 // 如果类型定义不正确,编译时就会报错 const material: Parameters[0]['material'] = { id: 'test', previewUrl: 'url', productModel: 'model', categoryName: 'category', sceneName: 'scene', materialType: 'type', renderer: 'renderer', authorName: 'author', audioDuration: '03:00', audioCategory: 'music', audioScene: 'scene', } expect(material.audioDuration).toBe('03:00') expect(material.audioCategory).toBe('music') expect(material.audioScene).toBe('scene') }) it('音频字段应该是可选的', () => { const materialWithoutAudio: Parameters[0]['material'] = { id: 'test', previewUrl: 'url', productModel: 'model', categoryName: 'category', sceneName: 'scene', materialType: 'type', renderer: 'renderer', authorName: 'author', } expect(materialWithoutAudio.audioDuration).toBeUndefined() expect(materialWithoutAudio.audioCategory).toBeUndefined() expect(materialWithoutAudio.audioScene).toBeUndefined() }) })