// @vitest-environment jsdom import { cleanup, render, screen, waitFor, within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, test, vi } from 'vitest'; import { isResourceCanvasGenerationAvailable, RESOURCE_CANVAS_GENERATION_OPTIONS, } from '../src/features/resource-canvas/resourceCanvasGenerationModel'; import { ResourceCanvasGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasGenerationPanelView'; import { typeGenerationPrompt } from './resourceGenerationPromptTestUtils'; type TauriInvoke = ( command: string, args?: Record, ) => Promise; function installTauriInvoke(invoke: TauriInvoke) { const mock = vi.fn(invoke); ( window as unknown as { __TAURI__?: { core?: { invoke?: typeof mock } }; } ).__TAURI__ = { core: { invoke: mock } }; return mock; } afterEach(() => { cleanup(); delete ( window as unknown as { __TAURI__?: { core?: { invoke?: TauriInvoke } } } ).__TAURI__; }); describe('resourceCanvasGenerationModel', () => { test('只放行本地契约支持无源生成的三类素材', () => { expect(RESOURCE_CANVAS_GENERATION_OPTIONS.map((item) => item.kind)).toEqual( ['video', 'sound-effect', 'background-music'], ); expect( RESOURCE_CANVAS_GENERATION_OPTIONS.map((item) => item.editKind), ).toEqual(['video', 'sound-effect', 'background-music']); }); test('没有客户端桥或项目未就绪时生成能力不成立', () => { expect( isResourceCanvasGenerationAvailable({ hasRuntimeInvoke: false, projectPath: '/tmp/project', projectId: 'project-1', }), ).toBe(false); expect( isResourceCanvasGenerationAvailable({ hasRuntimeInvoke: true, projectPath: ' ', projectId: 'project-1', }), ).toBe(false); expect( isResourceCanvasGenerationAvailable({ hasRuntimeInvoke: true, projectPath: '/tmp/project', projectId: '', }), ).toBe(false); expect( isResourceCanvasGenerationAvailable({ hasRuntimeInvoke: true, projectPath: '/tmp/project', projectId: 'project-1', }), ).toBe(true); }); }); describe('ResourceCanvasGenerationPanelView', () => { test('只呈现三类可无源生成的素材,也不展示图片专属选项或泥点价', () => { render( undefined} onClose={() => undefined} />, ); const panel = screen.getByRole('dialog', { name: '生成素材' }); for (const label of ['视频', '音效', '背景音乐']) { expect(within(panel).getByRole('button', { name: label })).not.toBeNull(); } expect(panel.textContent).not.toContain('泥点'); expect(panel.textContent).not.toContain('像素艺术'); expect(panel.textContent).not.toContain('图片'); }); test('点「生成」即同步入队并关闭面板:不等 IPC、不等排队、不等生成', async () => { const user = userEvent.setup(); const onSubmit = vi.fn(); const onClose = vi.fn(); render( , ); const prompt = screen.getByLabelText('生成提示词'); const assetName = screen.getByLabelText('素材名称'); expect((assetName as HTMLInputElement).value).toBe('新视频'); await user.type(prompt, '一段片头动画,镜头缓慢推进'); await user.click(screen.getByRole('button', { name: '生成视频' })); // 提交与关闭在同一个事件循环里发生:提交回调**同步**返回,面板不持有在途状态。 expect(onSubmit).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({ kind: 'video', prompt: '一段片头动画,镜头缓慢推进', assetName: '新视频', }); expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({ operationId: expect.any(String), idempotencyKey: expect.any(String), }); // 面板里不出现任何阶段文案与「后台运行并关闭」这类在途按钮。 expect(document.body.textContent).not.toMatch( /排队中。|正在生成。|提交中…|后台运行并关闭/, ); }); test('失败重开:带原失败原因与原请求身份,改回原提示词才能重试', async () => { const user = userEvent.setup(); const onSubmit = vi.fn(); const onClose = vi.fn(); const boundRequest = { operationId: '8f2d5b1c-3a4e-4d6f-9b0c-1d2e3f4a5b6c', idempotencyKey: '5c1e7a90-2b3d-4e5f-8a9b-0c1d2e3f4a5b', prompt: '一段片头动画,镜头缓慢推进', }; render( , ); expect(screen.getByRole('alert').textContent).toContain( 'result-unknown: 测试网络中断', ); const prompt = screen.getByLabelText('生成提示词') as HTMLTextAreaElement; expect(prompt.value).toBe(boundRequest.prompt); // 改了提示词:这次提交不再是原请求的重试(会另铸身份并另行计费),必须被挡住。 await typeGenerationPrompt(document.body, '改成另一段片头动画'); const retry = screen.getByRole('button', { name: '使用原请求重试', }) as HTMLButtonElement; expect(retry.disabled).toBe(true); expect(document.body.textContent).toContain('这次生成是「原请求重试」'); await user.click(retry); expect(onSubmit).not.toHaveBeenCalled(); // 改回原样:重试复用同一对 operationId / 幂等键,不产生第二次付费生成。 await typeGenerationPrompt(document.body, boundRequest.prompt); await user.click(screen.getByRole('button', { name: '使用原请求重试' })); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({ operationId: boundRequest.operationId, idempotencyKey: boundRequest.idempotencyKey, prompt: boundRequest.prompt, }); expect(onClose).toHaveBeenCalledTimes(1); }); test('切换类型后默认名称与提交文案跟随类型', async () => { const user = userEvent.setup(); render( undefined} onClose={() => undefined} />, ); await user.click(screen.getByRole('button', { name: '背景音乐' })); expect((screen.getByLabelText('素材名称') as HTMLInputElement).value).toBe( '新背景音乐', ); expect(screen.getByRole('button', { name: '生成背景音乐' })).not.toBeNull(); }); test('只放行一种类型时不再渲染类型选择器,标题与默认名称跟该类走', () => { render( undefined} onClose={() => undefined} />, ); const panel = screen.getByRole('dialog', { name: '生成背景音乐' }); // 一个只有一个选项的分段控件是噪音:单类型入口不渲染它。 expect(within(panel).queryByRole('button', { name: '视频' })).toBeNull(); expect(within(panel).queryByRole('button', { name: '音效' })).toBeNull(); expect( (within(panel).getByLabelText('素材名称') as HTMLInputElement).value, ).toBe('新背景音乐'); }); }); describe('生成素材弹窗的提示词润色', () => { test('润色走短文本通道,带上「这是素材生成提示词」的场景约束并回填', async () => { const user = userEvent.setup(); const invoke = installTauriInvoke(async (command) => { if (command !== 'polish_local_project_prompt') return undefined; return ' 润色后的音效描述 '; }); render( undefined} onClose={() => undefined} />, ); const prompt = screen.getByLabelText('生成提示词') as HTMLTextAreaElement; await user.type(prompt, '木门推开的声音'); const polishButton = screen.getByRole('button', { name: 'AI 润色' }); expect(polishButton.getAttribute('aria-busy')).toBe('false'); await user.click(polishButton); await waitFor(() => expect(prompt.value).toBe('润色后的音效描述')); expect(invoke).toHaveBeenCalledWith('polish_local_project_prompt', { prompt: '木门推开的声音', context: expect.stringContaining('这是素材生成提示词(音效)'), }); // 场景约束只讲这一句,不把提示词扩写成需求文档。 const [, args] = invoke.mock.calls[0] as [string, { context: string }]; expect(args.context).toContain('不要扩写成需求'); // 成功后有原文快照,可以一键回到原文。 await user.click(screen.getByRole('button', { name: '恢复原文' })); expect(prompt.value).toBe('木门推开的声音'); }); test('润色结果超上限先截断再回填,并在状态行说明', async () => { const user = userEvent.setup(); installTauriInvoke(async (command) => { if (command !== 'polish_local_project_prompt') return undefined; // 背景音乐上限 140,这里给 200 字。 return `润色后的背景音乐描述${'啊'.repeat(196)}`; }); render( undefined} onClose={() => undefined} />, ); const prompt = screen.getByLabelText('生成提示词') as HTMLTextAreaElement; await user.type(prompt, '轻快的八音盒'); await user.click(screen.getByRole('button', { name: 'AI 润色' })); await waitFor(() => expect(prompt.value.length).toBe(140)); expect(prompt.value.startsWith('润色后的背景音乐描述')).toBe(true); expect(screen.getByRole('status').textContent).toBe('已按长度上限截断'); }); test('润色失败保留原文并给出可重试提示', async () => { const user = userEvent.setup(); installTauriInvoke(async (command) => { if (command !== 'polish_local_project_prompt') return undefined; throw new Error('platform llm timeout'); }); render( undefined} onClose={() => undefined} />, ); const prompt = screen.getByLabelText('生成提示词') as HTMLTextAreaElement; await user.type(prompt, '一段片头动画,镜头缓慢推进'); await user.click(screen.getByRole('button', { name: 'AI 润色' })); expect(await screen.findByText('AI 润色失败,可重试')).not.toBeNull(); expect(prompt.value).toBe('一段片头动画,镜头缓慢推进'); expect(screen.queryByRole('button', { name: '恢复原文' })).toBeNull(); }); test('失败重开后输入不锁定:润色照常可用,改动提示词就挡住原请求重试', async () => { const user = userEvent.setup(); installTauriInvoke(async (command) => { if (command !== 'polish_local_project_prompt') return undefined; return ' 润色后的片头动画 '; }); const onSubmit = vi.fn(); const boundRequest = { operationId: '2b7c1d3e-4f5a-4b6c-8d9e-0f1a2b3c4d5e', idempotencyKey: '6c8d2e4f-5a6b-4c7d-9e0f-1a2b3c4d5e6f', prompt: '一段片头动画', }; render( undefined} />, ); /* 提交后面板已经关了,重开的是**占位**:这里既没有在途状态也没有「锁」,用户照常能改草稿。 挡住重复付费的判据不是「锁住输入」,而是「提示词一变就不再是原请求」。 */ const prompt = screen.getByLabelText('生成提示词') as HTMLTextAreaElement; expect(prompt.disabled).toBe(false); const polishButton = screen.getByRole('button', { name: 'AI 润色', }) as HTMLButtonElement; expect(polishButton.disabled).toBe(false); await user.click(polishButton); await waitFor(() => expect(prompt.value).toBe('润色后的片头动画')); const retry = screen.getByRole('button', { name: '使用原请求重试', }) as HTMLButtonElement; expect(retry.disabled).toBe(true); await user.click(retry); expect(onSubmit).not.toHaveBeenCalled(); expect(document.body.textContent).toContain('这次生成是「原请求重试」'); }); });