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) =>
预览
}))
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(DETAIL_MODEL_DEFAULTS), 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()
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 dynamic = { ...config, defaults: { analysis: 'model-a', copy: 'model-c', image: 'model-i' }, models: { analysis: ['model-a'], copy: ['model-c'], image: ['model-i'] }, modelOptions: {
analysis: [{ value: 'model-a', label: 'vision-analysis', providerName: '上游甲' }], copy: [{ value: 'model-c', label: 'copy-text', providerName: '上游乙' }], image: [{ value: 'model-i', label: 'image-reference', providerName: '上游甲' }],
} }
global.fetch = jest.fn(async () => response({ config: dynamic, projects: [] })) as typeof fetch
render()
const analysis = await screen.findByLabelText('资料分析', { exact: true })
expect(analysis).toHaveValue('model-a')
expect(screen.getByLabelText('卖点文案', { exact: true })).toHaveValue('model-c')
expect(screen.getByLabelText('图片生成', { exact: true })).toHaveValue('model-i')
expect(within(analysis).getByRole('option', { name: 'vision-analysis · 上游甲' })).toBeInTheDocument()
})
test('用户可在管理员开放范围内自主更换模型,保留其他阶段选择', async () => {
const dynamic = { ...config, defaults: { analysis: 'model-a', copy: 'model-c', image: 'model-i' }, models: { analysis: ['model-a'], copy: ['model-c'], image: ['model-i', 'model-i-other'] }, modelOptions: {
analysis: [{ value: 'model-a', label: 'vision-analysis', providerName: '上游甲' }], copy: [{ value: 'model-c', label: 'copy-text', providerName: '上游乙' }], image: [{ value: 'model-i', label: 'image-default', providerName: '上游甲' }, { value: 'model-i-other', label: 'image-alternative', providerName: '上游乙' }],
} }
global.fetch = jest.fn(async () => response({ config: dynamic, projects: [] })) as typeof fetch
render()
const image = await screen.findByLabelText('图片生成', { exact: true })
expect(screen.getByText('模型选择', { selector: 'summary' })).toBeInTheDocument()
expect(within(image).getAllByRole('option').map((option) => (option as HTMLOptionElement).value)).toEqual(['model-i', 'model-i-other'])
expect(within(image).queryByRole('option', { name: 'copy-text · 上游乙' })).not.toBeInTheDocument()
fireEvent.change(image, { target: { value: 'model-i-other' } })
expect(image).toHaveValue('model-i-other')
expect(screen.getByLabelText('资料分析', { exact: true })).toHaveValue('model-a')
expect(screen.getByLabelText('卖点文案', { exact: true })).toHaveValue('model-c')
expect((global.fetch as jest.Mock).mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false)
})
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 fetchMock = jest.fn(async (url: string) => url === '/api/detail-generator' ? response({ config: { ...config, models: { analysis: [], copy: [], image: ['new-image'] } }, projects: [project] }) : response({ project, assets: [], jobs: [] }))
global.fetch = fetchMock as typeof fetch
render()
fireEvent.click(await screen.findByRole('button', { name: /座椅项目/ }))
expect(await screen.findByRole('button', { name: '重新生成此屏' })).toBeDisabled()
expect(screen.getByRole('button', { name: '导出' })).toBeEnabled()
expect(screen.getByLabelText('替换当前屏图片')).toBeEnabled()
expect(screen.getByText('项目模型当前不可用。可修改模型选择,已有内容仍可编辑和导出。')).toBeInTheDocument()
expect(fetchMock.mock.calls.some(([, init]: any) => init?.method === 'POST')).toBe(false)
})
test('已显式迁入且仍允许的 legacy 模型保留原选择并可执行分析', async () => {
await openProject(createProject(), [], undefined, { ...config, models: { analysis: ['model-a'], copy: ['model-a'], image: ['model-i'] }, legacyModels: { analysis: ['gemini-2.5-flash'], copy: ['gemini-2.5-flash'], image: ['nano-banana-2'] } })
expect(screen.getByLabelText('资料分析', { exact: true })).toHaveValue('gemini-2.5-flash')
expect(screen.getByRole('button', { name: '分析卖点与大纲' })).toBeEnabled()
})
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()
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 () => {
let project = createProject()
project.document.analysis = { sellingPoints: ['橡木材质'], warnings: [], screens: [{ id: 'screen-a', title: '橡木座椅', body: '自然质感', imagePrompt: '浅色环境中的橡木座椅,不生成文字' }] }
const fetchMock = jest.fn(async (url: string, init: RequestInit = {}) => {
if (url === '/api/detail-generator') return response({ config: { ...config, models: { ...config.models, image: ['nano-banana-2', 'model-new-image'] } }, projects: [project] })
if (init.method === 'PATCH') { project = { ...project, revision: 4, document: JSON.parse(String(init.body)).document }; return response({ project }) }
if (url.endsWith('/approve')) return response({ project: { ...project, revision: 5, document: { ...project.document, approvedAt: '2026-10-12T01:00:00Z' } } })
if (url.endsWith('/generate')) return response({ job: createJob('queued') })
return response({ project, assets: [], jobs: [] })
})
global.fetch = fetchMock as typeof fetch
render()
fireEvent.click(await screen.findByRole('button', { name: /座椅项目/ }))
await screen.findByRole('button', { name: '确认大纲并生成' })
fireEvent.change(await screen.findByLabelText('图片生成', { exact: true }), { target: { value: 'model-new-image' } })
fireEvent.click(screen.getByRole('button', { name: '确认大纲并生成' }))
await waitFor(() => expect(fetchMock.mock.calls.some(([url]) => url.endsWith('/generate'))).toBe(true))
const writes = fetchMock.mock.calls.filter((call): call is [string, RequestInit] => !!call[1] && (call[1].method === 'PATCH' || call[1].method === 'POST'))
expect(writes.map(([url]) => url)).toEqual(['/api/detail-generator/projects/project-a', '/api/detail-generator/projects/project-a/approve', '/api/detail-generator/projects/project-a/generate'])
const saveCall = writes.find(([, init]) => init.method === 'PATCH')
const approveCall = writes.find(([url]) => url.endsWith('/approve'))
expect(saveCall).toBeDefined()
expect(approveCall).toBeDefined()
if (!saveCall || !approveCall) throw new Error('模型选择必须先保存,再确认大纲')
expect(JSON.parse(String(saveCall[1].body)).document.models.image).toBe('model-new-image')
expect(JSON.parse(String(approveCall[1].body)).revision).toBe(4)
})
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()
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()
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()
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保存后保持换行。'))
})