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

266 lines
20 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(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(<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 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(<DetailGeneratorWorkspace />)
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(<DetailGeneratorWorkspace />)
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(<DetailGeneratorWorkspace />)
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(<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 () => {
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(<DetailGeneratorWorkspace />)
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(<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保存后保持换行。'))
})