186 lines
13 KiB
TypeScript
186 lines
13 KiB
TypeScript
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||
import DetailGeneratorWorkspace from './DetailGeneratorWorkspace'
|
||
import { newDetailDocument } from './client'
|
||
import { DETAIL_MODEL_DEFAULTS, type DetailConfig, type DetailJob, type DetailProject } from '@/lib/detail-generator/types'
|
||
|
||
jest.mock('./DetailCanvas', () => ({ __esModule: true, default: ({ screen: preview, onPendingTextChange, onUnsavedEdit }: any) => <div data-testid="detail-canvas">预览<button type="button" onClick={() => { onPendingTextChange?.(`${preview.id}-body`, '画布新文案\n保存后保持换行。'); onUnsavedEdit?.() }}>模拟原生画布输入</button></div> }))
|
||
jest.mock('next/navigation', () => {
|
||
const router = { push: jest.fn() }
|
||
return { useRouter: () => router }
|
||
})
|
||
|
||
const config: DetailConfig = {
|
||
access: true, configured: true, brands: ['品牌甲'], brandProfiles: [],
|
||
defaults: DETAIL_MODEL_DEFAULTS,
|
||
models: { analysis: ['gemini-2.5-flash'], copy: ['gemini-2.5-flash'], image: ['nano-banana-2'] },
|
||
}
|
||
const createProject = (): DetailProject => ({
|
||
id: 'project-a', ownerId: 'superadmin-a', revision: 3,
|
||
createdAt: '2026-10-12T00:00:00Z', updatedAt: '2026-10-12T00:00:00Z',
|
||
document: { ...newDetailDocument(), name: '座椅项目', product: { ...newDetailDocument().product, name: '橡木座椅', facts: '材质:橡木', assetIds: ['product-image'] }, brand: { ...newDetailDocument().brand, name: '品牌甲' } },
|
||
})
|
||
const createJob = (status: DetailJob['status']): DetailJob => ({ id: 'job-a', projectId: 'project-a', ownerId: 'superadmin-a', kind: 'generate', status, payload: {}, checkpoint: {}, progress: 0, error: null, requestId: null, createdAt: '2026-10-12T00:00:00Z', updatedAt: '2026-10-12T00:00:00Z' })
|
||
function response(body: unknown, status = 200) { return { ok: status >= 200 && status < 300, status, json: async () => body } as Response }
|
||
|
||
beforeEach(() => {
|
||
window.history.replaceState(null, '', '/assets/detail-generator'); jest.clearAllMocks()
|
||
jest.spyOn(window.history, 'back').mockImplementation(() => undefined)
|
||
jest.spyOn(window.history, 'forward').mockImplementation(() => undefined)
|
||
})
|
||
afterEach(() => jest.restoreAllMocks())
|
||
|
||
async function openProject(project: DetailProject, jobs: DetailJob[] = [], additional?: (url: string, init: RequestInit) => Response | undefined, overrideConfig = config) {
|
||
const fetchMock = jest.fn(async (url: string, init: RequestInit = {}) => {
|
||
const overridden = additional?.(String(url), init)
|
||
if (overridden) return overridden
|
||
if (url === '/api/detail-generator') return response({ config: overrideConfig, projects: [project] })
|
||
if (url === '/api/detail-generator/projects/project-a') return response({ project, assets: [], jobs })
|
||
throw new Error(`Unexpected request ${url}`)
|
||
})
|
||
global.fetch = fetchMock as typeof fetch
|
||
render(<DetailGeneratorWorkspace />)
|
||
fireEvent.click(await screen.findByRole('button', { name: /座椅项目/ }))
|
||
await waitFor(() => expect(screen.getByLabelText('项目名称')).toHaveValue('座椅项目'))
|
||
return fetchMock
|
||
}
|
||
|
||
test('模型未配置时仍可准备资料,不能发起模型调用', async () => {
|
||
const fetchMock = await openProject(createProject(), [], undefined, { ...config, configured: false })
|
||
expect(screen.getByText('模型服务尚未配置。可先准备项目资料。')).toBeInTheDocument()
|
||
expect(screen.getByRole('button', { name: '分析卖点与大纲' })).toBeDisabled()
|
||
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false)
|
||
})
|
||
|
||
test('保存遇到版本冲突时保留用户未保存的产品信息', async () => {
|
||
const fetchMock = await openProject(createProject(), [], (_url, init) => init.method === 'PATCH' ? response({ error: '项目已被更新,请重新打开后合并修改' }, 409) : undefined)
|
||
fireEvent.change(screen.getByLabelText('已确认的产品信息'), { target: { value: '新增真实参数:座高 45cm' } })
|
||
fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||
await screen.findByText('项目已被更新,请重新打开后合并修改')
|
||
expect(screen.getByLabelText('已确认的产品信息')).toHaveValue('新增真实参数:座高 45cm')
|
||
expect(JSON.parse(String(fetchMock.mock.calls.find(([, init]) => init?.method === 'PATCH')?.[1]?.body)).revision).toBe(3)
|
||
})
|
||
|
||
test('上传携带版本号并采用服务端返回项目,不再重复覆盖文档', async () => {
|
||
const project = createProject()
|
||
const asset = { id: 'new-image', name: '产品.png', mimeType: 'image/png', kind: 'product' }
|
||
const next = { ...project, revision: 4, document: { ...project.document, product: { ...project.document.product, assetIds: [...project.document.product.assetIds, asset.id] } } }
|
||
const fetchMock = await openProject(project, [], (url, init) => url.endsWith('/assets') && init.method === 'POST' ? response({ asset, project: next }) : undefined)
|
||
fireEvent.change(screen.getByLabelText('上传产品图'), { target: { files: [new File(['pixels'], '产品.png', { type: 'image/png' })] } })
|
||
await screen.findByAltText('产品.png')
|
||
const uploadCall = fetchMock.mock.calls.find(([url]) => String(url).endsWith('/assets'))
|
||
expect((uploadCall?.[1]?.body as FormData).get('revision')).toBe('3')
|
||
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'PATCH')).toBe(false)
|
||
})
|
||
|
||
test('选择已保存品牌草案加载调性并保留本项目私有标志', async () => {
|
||
const project = createProject()
|
||
project.document.brand.logoAssetId = 'own-logo'
|
||
await openProject(project, [], undefined, { ...config, brandProfiles: [{ id: 'brand-b', revision: 2, profile: { ...newDetailDocument().brand, name: '品牌乙', tone: '自然、温暖', primaryColor: '#335533' } }] })
|
||
fireEvent.change(screen.getByLabelText('品牌', { exact: true }), { target: { value: '品牌乙' } })
|
||
expect(screen.getByLabelText('调性')).toHaveValue('自然、温暖')
|
||
expect(screen.getByLabelText('品牌色')).toHaveValue('#335533')
|
||
expect(within(screen.getByRole('region', { name: '品牌标志' })).getByRole('button', { name: '移除素材' })).toBeInTheDocument()
|
||
})
|
||
|
||
test('分析完成后必须确认大纲才能生成,确认后分别调用审批与生成接口', async () => {
|
||
const project = createProject()
|
||
project.document.analysis = { sellingPoints: ['橡木材质'], warnings: [], screens: [{ id: 'screen-a', title: '橡木座椅', body: '自然质感', imagePrompt: '浅色环境中的橡木座椅,不生成文字' }] }
|
||
const approved = { ...project, revision: 4, document: { ...project.document, approvedAt: '2026-10-12T01:00:00Z' } }
|
||
const fetchMock = jest.fn(async (url: string, init: RequestInit = {}) => {
|
||
if (url === '/api/detail-generator') return response({ config, projects: [project] })
|
||
if (url.endsWith('/approve')) return response({ project: approved })
|
||
if (url.endsWith('/generate')) return response({ job: createJob('queued') })
|
||
return response({ project, assets: [], jobs: [] })
|
||
})
|
||
global.fetch = fetchMock as typeof fetch
|
||
render(<DetailGeneratorWorkspace />)
|
||
fireEvent.click(await screen.findByRole('button', { name: /座椅项目/ }))
|
||
const confirm = await screen.findByRole('button', { name: '确认大纲并生成' })
|
||
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false)
|
||
fireEvent.click(confirm)
|
||
await waitFor(() => expect(fetchMock.mock.calls.some(([url]) => url.endsWith('/generate'))).toBe(true))
|
||
const approval = fetchMock.mock.calls.find(([url]) => url.endsWith('/approve'))
|
||
expect(JSON.parse(String(approval?.[1]?.body)).revision).toBe(3)
|
||
const generation = fetchMock.mock.calls.find(([url]) => url.endsWith('/generate'))
|
||
expect(JSON.parse(String(generation?.[1]?.body)).idempotencyKey).toEqual(expect.any(String))
|
||
})
|
||
|
||
test('结果未知时停止生成与导出操作,不自动重新请求模型', async () => {
|
||
const project = createProject()
|
||
project.document.approvedAt = '2026-10-12T01:00:00Z'
|
||
project.document.analysis = { sellingPoints: ['橡木'], warnings: [], screens: [{ id: 'screen-a', title: '橡木', body: '', imagePrompt: '橡木座椅' }] }
|
||
project.document.screens = [{ id: 'screen-a', title: '橡木', body: '', imagePrompt: '橡木座椅', width: 750, height: 1000, backgroundColor: '#ffffff', texts: [], image: { assetId: 'product-image', x: 0, y: 0, width: 750, height: 1000 } }]
|
||
const unknown = { ...createJob('unknown'), requestId: 'provider-request-a' }
|
||
const fetchMock = jest.fn(async (url: string, _init?: RequestInit) => url === '/api/detail-generator' ? response({ config, projects: [project] }) : response({ project, assets: [], jobs: [unknown] }))
|
||
global.fetch = fetchMock as typeof fetch
|
||
render(<DetailGeneratorWorkspace />)
|
||
fireEvent.click(await screen.findByRole('button', { name: /座椅项目/ }))
|
||
await screen.findByText('模型结果待确认')
|
||
expect(screen.getByRole('button', { name: '重新生成此屏' })).toBeDisabled()
|
||
expect(screen.getByRole('button', { name: '导出' })).toBeDisabled()
|
||
expect(screen.getByText('请求编号:provider-request-a')).toBeInTheDocument()
|
||
expect(fetchMock.mock.calls.every(([, init]) => !init || (init as RequestInit).method !== 'POST')).toBe(true)
|
||
})
|
||
|
||
test('生成部分失败时可明确继续剩余屏,完整导出保持禁用', async () => {
|
||
const project = createProject()
|
||
project.document.approvedAt = '2026-10-12T01:00:00Z'
|
||
project.document.analysis = { sellingPoints: ['橡木'], warnings: [], screens: [
|
||
{ id: 'screen-a', title: '橡木', body: '', imagePrompt: '橡木座椅' },
|
||
{ id: 'screen-b', title: '结构', body: '', imagePrompt: '座椅结构' },
|
||
] }
|
||
const template = { title: '橡木', body: '', imagePrompt: '橡木座椅', width: 750, height: 1000, backgroundColor: '#ffffff', texts: [] }
|
||
project.document.screens = [
|
||
{ ...template, id: 'screen-a', image: { assetId: 'product-image', x: 0, y: 0, width: 750, height: 1000 } },
|
||
{ ...template, id: 'screen-b' },
|
||
]
|
||
global.fetch = jest.fn(async (url: string) => url === '/api/detail-generator' ? response({ config, projects: [project] }) : response({ project, assets: [], jobs: [{ ...createJob('failed'), error: '第二屏生成失败' }] })) as typeof fetch
|
||
render(<DetailGeneratorWorkspace />)
|
||
fireEvent.click(await screen.findByRole('button', { name: /座椅项目/ }))
|
||
expect(await screen.findByRole('button', { name: '继续生成剩余屏' })).toBeEnabled()
|
||
expect(screen.getByRole('button', { name: '导出' })).toBeDisabled()
|
||
})
|
||
|
||
test('编辑中权限失效后,返回素材库仍显示可取消的未保存确认', async () => {
|
||
await openProject(createProject(), [], (_url, init) => init.method === 'PATCH' ? response({ error: '此功能暂仅向超级管理员开放' }, 403) : undefined)
|
||
fireEvent.change(screen.getByLabelText('已确认的产品信息'), { target: { value: '我未保存的真实资料' } })
|
||
fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||
await screen.findByRole('heading', { name: '详情页生成暂不可用' })
|
||
fireEvent.click(screen.getByRole('link', { name: '素材资产库' }))
|
||
expect(await screen.findByRole('alertdialog', { name: '离开此页面?' })).toBeInTheDocument()
|
||
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }))
|
||
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
|
||
expect(screen.getByRole('link', { name: '素材资产库' })).toBeInTheDocument()
|
||
expect(window.location.pathname).toBe('/assets/detail-generator')
|
||
})
|
||
|
||
test('保存前合并尚未退出原生画布编辑的最新文字,不能保存旧 React 文案', async () => {
|
||
const project = createProject()
|
||
project.document.approvedAt = '2026-10-12T01:00:00Z'
|
||
project.document.analysis = { sellingPoints: ['橡木'], warnings: [], screens: [{ id: 'screen-a', title: '橡木', body: '旧文案', imagePrompt: '座椅' }] }
|
||
project.document.screens = [{
|
||
id: 'screen-a', title: '橡木', body: '旧文案', imagePrompt: '座椅', width: 750, height: 1200, backgroundColor: '#ffffff', image: { assetId: 'product-image', x: 0, y: 350, width: 750, height: 750 },
|
||
texts: [
|
||
{ id: 'screen-a-title', text: '橡木', x: 40, y: 58, width: 670, fontSize: 48, lineHeight: 1.25, color: '#101828', fontFamily: 'Noto Sans SC', align: 'left' },
|
||
{ id: 'screen-a-body', text: '旧文案', x: 40, y: 160, width: 670, fontSize: 28, lineHeight: 1.5, color: '#101828', fontFamily: 'Noto Sans SC', align: 'left' },
|
||
],
|
||
}]
|
||
let savedBody = ''
|
||
global.fetch = jest.fn(async (url: string, init: RequestInit = {}) => {
|
||
if (url === '/api/detail-generator') return response({ config, projects: [project] })
|
||
if (url.endsWith('/screens/screen-a')) {
|
||
const body = JSON.parse(String(init.body))
|
||
savedBody = body.screen.body
|
||
return response({ project: { ...project, revision: 4, document: { ...project.document, screens: [body.screen] } } })
|
||
}
|
||
return response({ project, assets: [], jobs: [] })
|
||
}) as typeof fetch
|
||
render(<DetailGeneratorWorkspace />)
|
||
fireEvent.click(await screen.findByRole('button', { name: /座椅项目/ }))
|
||
fireEvent.click(await screen.findByRole('button', { name: '模拟原生画布输入' }))
|
||
expect(screen.getByLabelText('正文')).toHaveValue('旧文案')
|
||
fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
|
||
await waitFor(() => expect(savedBody).toBe('画布新文案\n保存后保持换行。'))
|
||
await waitFor(() => expect(screen.getByLabelText('正文')).toHaveValue('画布新文案\n保存后保持换行。'))
|
||
})
|