// @vitest-environment jsdom import { act, cleanup, renderHook } from '@testing-library/react'; import { afterEach, describe, expect, test, vi } from 'vitest'; import { PROMPT_POLISH_UNCHANGED_NOTICE, usePromptPolish, } from '../src/features/project-workspace/usePromptPolish'; afterEach(cleanup); function renderPolish({ requestPolish, applyPrompt = vi.fn(), normalizeResult, readPrompt = () => '原始需求', }: { requestPolish: ( prompt: string, context: string | null, ) => Promise; applyPrompt?: (text: string) => void; normalizeResult?: (polished: string) => { text: string; notice?: string | null; }; readPrompt?: () => string; }) { return renderHook(() => usePromptPolish({ readPrompt, applyPrompt, requestPolish, normalizeResult, }), ); } describe('usePromptPolish', () => { test('注入的 requestPolish 拒绝时收敛成失败提示,不产生未处理拒绝', async () => { const applyPrompt = vi.fn(); const { result } = renderPolish({ requestPolish: vi.fn(async () => { throw new Error('platform llm unavailable'); }), applyPrompt, }); let returned: string | null = 'sentinel'; await act(async () => { returned = await result.current.polish(); }); // 默认 `requestChatPromptPolish` 自己吞掉失败;注入实现可以拒绝,此时必须 // 变成用户可见的失败提示,而不是把 promise 变成未处理拒绝、界面上什么都没有。 expect(returned).toBeNull(); expect(result.current.error).toBe('AI 润色失败,可重试'); expect(result.current.polishing).toBe(false); expect(applyPrompt).not.toHaveBeenCalled(); }); test('notice 文案覆盖本次失败,并且回填前的抛错同样被接住', async () => { const applyPrompt = vi.fn(() => { throw new Error('composer write failed'); }); const { result } = renderPolish({ requestPolish: async () => '润色结果', applyPrompt, }); await act(async () => { await result.current.polish({ failureMessage: '润色失败,可直接发送' }); }); expect(result.current.error).toBe('润色失败,可直接发送'); expect(result.current.polishing).toBe(false); }); test('reset 之后的在飞润色结果不再回填宿主草稿', async () => { let resolvePolish: (value: string) => void = () => {}; const applyPrompt = vi.fn(); const { result } = renderPolish({ requestPolish: vi.fn( () => new Promise((resolve) => { resolvePolish = resolve; }), ), applyPrompt, }); let pending: Promise | null = null; await act(async () => { pending = result.current.polish(); }); // 宿主清空草稿 / 换资源:在飞请求必须被作废,迟到的结果不许写进新草稿。 await act(async () => { result.current.reset(); }); await act(async () => { resolvePolish('迟到的润色结果'); await pending; }); expect(applyPrompt).not.toHaveBeenCalled(); expect(result.current.originalText).toBeNull(); expect(result.current.notice).toBeNull(); expect(result.current.error).toBeNull(); }); test('没有 reset 时在飞结果照旧回填(对照)', async () => { let resolvePolish: (value: string) => void = () => {}; const applyPrompt = vi.fn(); const { result } = renderPolish({ requestPolish: () => new Promise((resolve) => { resolvePolish = resolve; }), applyPrompt, }); let pending: Promise | null = null; await act(async () => { pending = result.current.polish(); }); await act(async () => { resolvePolish('润色后的需求'); await pending; }); expect(applyPrompt).toHaveBeenCalledWith('润色后的需求'); expect(result.current.originalText).toBe('原始需求'); }); test('成功回包与当前 prompt 逐字相同:给「未做修改」提示,不落快照也不回填', async () => { const applyPrompt = vi.fn(); const { result } = renderPolish({ requestPolish: async () => '原始需求', applyPrompt, }); let returned: string | null = null; await act(async () => { returned = await result.current.polish(); }); // 验收项 AGC-004:润色结果就是原文时必须说得清,不能让用户以为按钮没反应、 // 或者以为文案已经被改过。 expect(returned).toBe('原始需求'); expect(result.current.notice).toBe(PROMPT_POLISH_UNCHANGED_NOTICE); expect(result.current.notice).toBe('AI 润色结果与原文相同,未做修改'); expect(result.current.error).toBeNull(); expect(result.current.polishing).toBe(false); // 没有可回退的变化 ⇒ 不落原文快照,宿主也就不会渲染「恢复原文」这个假入口。 expect(result.current.originalText).toBeNull(); // 不回填:宿主侧的回填还牵着「提示词变了就重铸请求身份」这类副作用,这次什么都没变。 expect(applyPrompt).not.toHaveBeenCalled(); }); test('回包被上限截回原文:算「与原文相同」,提示改说截断原因', async () => { const applyPrompt = vi.fn(); const { result } = renderPolish({ // 原文正好顶到长度上限、模型又在原文后面追加了内容:切片后与原文逐字相同。 requestPolish: async () => '原始需求加上很长很长的尾巴', applyPrompt, normalizeResult: () => ({ text: '原始需求', notice: '已按长度上限截断' }), }); await act(async () => { await result.current.polish(); }); // 判据是「最终要写回宿主的文本」而不是回包字符串:写回去一字未变,就没有可回退的变化, // 不该为它落快照、渲染一枚点了等于没点的「恢复原文」;截断提示优先,说清这次为什么没变。 expect(result.current.notice).toBe('已按长度上限截断'); expect(result.current.notice).not.toBe(PROMPT_POLISH_UNCHANGED_NOTICE); expect(applyPrompt).not.toHaveBeenCalled(); expect(result.current.originalText).toBeNull(); }); test('规范化确实改动了内容:照旧回填并给规范化提示(对照)', async () => { const applyPrompt = vi.fn(); const { result } = renderPolish({ requestPolish: async () => '润色后的需求加上要被截掉的尾巴', applyPrompt, normalizeResult: () => ({ text: '润色后的需求', notice: '已按长度上限截断', }), }); await act(async () => { await result.current.polish(); }); expect(result.current.notice).toBe('已按长度上限截断'); expect(applyPrompt).toHaveBeenCalledWith('润色后的需求'); expect(result.current.originalText).toBe('原始需求'); }); test('clearNotice 收掉上一轮提示,快照与错误不受影响', async () => { const { result } = renderPolish({ requestPolish: async () => '润色后的需求', }); await act(async () => { await result.current.polish(); }); expect(result.current.originalText).toBe('原始需求'); await act(async () => { result.current.clearNotice(); }); // 用户手改提示词只作废「上一轮的结论」,不该顺手把「恢复原文」也取消掉。 expect(result.current.notice).toBeNull(); expect(result.current.originalText).toBe('原始需求'); }); test('先真润色再遇到「回包等于原文」:提示互不覆盖,快照与恢复入口都留着', async () => { let current = '原始需求'; const applied: string[] = []; // 第二次回包等于第一次回填后的当前文本。 const results = ['润色后的需求', '润色后的需求']; const { result } = renderHook(() => usePromptPolish({ readPrompt: () => current, applyPrompt: (text) => { current = text; applied.push(text); }, requestPolish: async () => results.shift() ?? null, }), ); await act(async () => { await result.current.polish(); }); expect(applied).toEqual(['润色后的需求']); expect(result.current.originalText).toBe('原始需求'); // 第二次回包就是当前文本:只换提示,宿主状态与原文快照都不动。 await act(async () => { await result.current.polish(); }); expect(result.current.notice).toBe('AI 润色结果与原文相同,未做修改'); expect(result.current.originalText).toBe('原始需求'); expect(applied).toEqual(['润色后的需求']); }); });