gongxiangp2/components/detail-generator/DetailGeneratorWorkspace.test.tsx

186 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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保存后保持换行。'))
})