// @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, resourceCanvasGenerationSourceId, } from '../src/features/resource-canvas/resourceCanvasGenerationModel'; import { ResourceCanvasGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasGenerationPanelView'; 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); }); test('无源生成的稳定源标识挂在 operationId 上', () => { expect(resourceCanvasGenerationSourceId(' op-1 ')).toBe('create:op-1'); }); }); 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('失败后锁定原请求并用同一 operationId 与幂等键重试', async () => { const user = userEvent.setup(); const onSubmit = vi .fn<(input: unknown) => Promise>() .mockRejectedValueOnce(new Error('result-unknown: 测试网络中断')) .mockResolvedValueOnce(undefined); render( undefined} />, ); 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((await screen.findByRole('alert')).textContent).toContain( 'result-unknown: 测试网络中断', ); expect((prompt as HTMLTextAreaElement).disabled).toBe(true); expect((assetName as HTMLInputElement).disabled).toBe(true); await user.click(screen.getByRole('button', { name: '使用原请求重试' })); await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2)); expect(onSubmit.mock.calls[0]?.[0]).toEqual(onSubmit.mock.calls[1]?.[0]); expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({ kind: 'video', prompt: '一段片头动画,镜头缓慢推进', assetName: '新视频', }); }); 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 () => undefined); const onSubmit = vi .fn<(input: unknown) => Promise>() .mockRejectedValueOnce(new Error('result-unknown: 测试网络中断')) .mockResolvedValueOnce(undefined); render( undefined} />, ); const prompt = screen.getByLabelText('生成提示词') as HTMLTextAreaElement; await user.type(prompt, '一段片头动画'); await user.click(screen.getByRole('button', { name: '生成视频' })); expect(await screen.findByRole('alert')).not.toBeNull(); // 锁定输入 = 提交的提示词不可能漂移,所以复用同一 operationId 是安全的。 expect(prompt.disabled).toBe(true); const polishButton = screen.getByRole('button', { name: 'AI 润色' }); expect((polishButton as HTMLButtonElement).disabled).toBe(true); await user.click(screen.getByRole('button', { name: '使用原请求重试' })); await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2)); expect(onSubmit.mock.calls[1]?.[0]).toEqual(onSubmit.mock.calls[0]?.[0]); }); });