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