From b0d712126530684009e2ed5a0e5633135127ce6b Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 10 Sep 2026 14:38:59 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=85=20C4=20=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E9=A1=B5=E7=AD=BE=E4=B8=8E=20C8=20=E6=B6=A6=E8=89=B2=E6=8F=90?= =?UTF-8?q?=E9=86=92=E7=9A=84=E5=AE=9A=E5=90=91=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 chatPromptPolish.test.tsx:覆盖提醒判据、草稿指纹、本机偏好读写与润色命令调用 - 覆盖主动润色回填、可重复润色覆盖结果、恢复原文回到最初原文 - 覆盖润色失败保留原文并在提醒面板内给出重试提示 - 覆盖长文本提交被提醒面板拦下、使用原文提交、关闭取消发送、AI 润色后发送 - 覆盖不再提醒偏好落本机 localStorage 后不再拦截,以及挂载时读取该偏好 - 覆盖短文本与命令不触发提醒 - resourceReferenceInput.test.tsx 新增:两个页签的独立搜索与筛选状态 - 新增:activeVersionId、版本回退、悬空绑定过滤与空态 - 新增:资源改名后 chip 与候选列表显示名刷新 - 新增:跨会话恢复草稿后光标落在文本末尾,引用追加在文本之后 --- .../tests/chatPromptPolish.test.tsx | 429 ++++++++++++++++++ .../tests/resourceReferenceInput.test.tsx | 235 +++++++++- 2 files changed, 663 insertions(+), 1 deletion(-) create mode 100644 apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx diff --git a/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx b/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx new file mode 100644 index 000000000..5d9119429 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx @@ -0,0 +1,429 @@ +// @vitest-environment jsdom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { + CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH, + CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY, + chatPromptDraftKey, + readChatPromptPolishReminderDisabled, + requestChatPromptPolish, + shouldRemindChatPromptPolish, + writeChatPromptPolishReminderDisabled, +} from '../src/features/project-workspace/chatPromptPolish'; +import { ResourceReferenceInput } from '../src/features/project-workspace/ResourceReferenceInput'; +import { + type ChatComposerDraft, + type ChatReference, + resourceReferenceFromAsset, +} from '../src/features/project-workspace/resourceReferences'; + +type TauriInvoke = ( + command: string, + args?: Record, +) => Promise; + +const LONG_PROMPT = + '做一个像素风横版动作小游戏,包含三段跳跃关卡、三个 Boss 和可以收集的金币与道具。'; + +function installTauriInvoke(invoke: TauriInvoke) { + const mock = vi.fn(invoke); + ( + window as unknown as { + __TAURI__?: { core?: { invoke?: typeof mock } }; + } + ).__TAURI__ = { core: { invoke: mock } }; + return mock; +} + +function installPolishingInvoke(...results: string[]) { + const queue = [...results]; + return installTauriInvoke(async (command) => { + if (command !== 'polish_local_project_prompt') return undefined; + const next = queue.shift(); + if (next === undefined) { + throw new Error('fixture has no more polish results'); + } + return next; + }); +} + +// Lexical 的编辑器状态提交排在微任务里,读取输入区文本前先让 React 追平编辑器内容。 +async function settleComposer() { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +async function composerText() { + await settleComposer(); + return screen.getByLabelText('创作想法').textContent ?? ''; +} + +function ControlledChatComposer({ + initialText, + initialReferences = [], + onSubmitDraft, +}: { + initialText: string; + initialReferences?: ChatReference[]; + onSubmitDraft: (draft: ChatComposerDraft) => void; +}) { + const [draft, setDraft] = useState({ + text: initialText, + references: initialReferences, + }); + return ( +
{ + event.preventDefault(); + onSubmitDraft(draft); + }} + > + + + + ); +} + +function renderComposer({ + initialText, + initialReferences, + onSubmitDraft = vi.fn(), +}: { + initialText: string; + initialReferences?: ChatReference[]; + onSubmitDraft?: (draft: ChatComposerDraft) => void; +}) { + render( + , + ); + return onSubmitDraft; +} + +function sendButton() { + return screen.getByRole('button', { name: '发送' }); +} + +function reminderPanel() { + return screen.getByRole('dialog', { name: '发送前提醒' }); +} + +afterEach(() => { + cleanup(); + delete ( + window as unknown as { __TAURI__?: { core?: { invoke?: TauriInvoke } } } + ).__TAURI__; + window.localStorage.clear(); +}); + +describe('发送前提醒判据', () => { + test('only reminds for long plain prompts that were not acknowledged this round', () => { + const draft: ChatComposerDraft = { + text: '字'.repeat(CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH), + references: [], + }; + expect( + shouldRemindChatPromptPolish({ + draft, + acknowledgedDraftKey: null, + reminderDisabled: false, + }), + ).toBe(true); + expect( + shouldRemindChatPromptPolish({ + draft: { text: '短需求', references: [] }, + acknowledgedDraftKey: null, + reminderDisabled: false, + }), + ).toBe(false); + expect( + shouldRemindChatPromptPolish({ + draft, + acknowledgedDraftKey: null, + reminderDisabled: true, + }), + ).toBe(false); + expect( + shouldRemindChatPromptPolish({ + draft, + acknowledgedDraftKey: chatPromptDraftKey(draft), + reminderDisabled: false, + }), + ).toBe(false); + expect( + shouldRemindChatPromptPolish({ + draft: { text: `/${'长'.repeat(60)}`, references: [] }, + acknowledgedDraftKey: null, + reminderDisabled: false, + }), + ).toBe(false); + }); + + test('changes the draft key when the references change', () => { + const reference = resourceReferenceFromAsset( + { + id: 'hero', + kind: 'character', + mediaType: 'image/png', + localPath: 'assets/hero.png', + source: { kind: 'uploaded' }, + }, + 'asset-picker', + ); + expect( + chatPromptDraftKey({ text: '需求', references: [] }), + ).not.toBe(chatPromptDraftKey({ text: '需求', references: [reference] })); + }); + + test('persists the 不再提醒 preference on this machine only', () => { + expect(readChatPromptPolishReminderDisabled()).toBe(false); + writeChatPromptPolishReminderDisabled(true); + expect(window.localStorage.getItem(CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY)) + .toBe('true'); + expect(readChatPromptPolishReminderDisabled()).toBe(true); + writeChatPromptPolishReminderDisabled(false); + expect(window.localStorage.getItem(CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY)) + .toBeNull(); + expect(readChatPromptPolishReminderDisabled()).toBe(false); + }); +}); + +describe('requestChatPromptPolish', () => { + test('goes through the short-text Tauri command with trimmed input', async () => { + const invoke = installPolishingInvoke(' 润色后的需求 '); + await expect( + requestChatPromptPolish(' 原始需求 ', ' 项目上下文 '), + ).resolves.toBe('润色后的需求'); + expect(invoke).toHaveBeenCalledWith('polish_local_project_prompt', { + prompt: '原始需求', + context: '项目上下文', + }); + }); + + test('omits blank context and keeps failures recoverable', async () => { + const invoke = installPolishingInvoke('润色结果'); + await requestChatPromptPolish('原始需求', ' '); + expect(invoke).toHaveBeenCalledWith('polish_local_project_prompt', { + prompt: '原始需求', + context: null, + }); + + const failing = vi.fn(async () => { + throw new Error('platform llm unavailable'); + }); + installTauriInvoke(failing); + await expect(requestChatPromptPolish('原始需求')).resolves.toBeNull(); + await expect(requestChatPromptPolish(' ')).resolves.toBeNull(); + }); + + test('treats an empty model reply as a failure', async () => { + installPolishingInvoke(' '); + await expect(requestChatPromptPolish('原始需求')).resolves.toBeNull(); + }); + + test('returns null when the native bridge is unavailable', async () => { + delete ( + window as unknown as { __TAURI__?: { core?: { invoke?: TauriInvoke } } } + ).__TAURI__; + await expect(requestChatPromptPolish('原始需求')).resolves.toBeNull(); + }); +}); + +describe('聊天输入区 AI 润色与发送前提醒', () => { + test('fills back the polished result, can polish again, and always restores the first original', async () => { + const user = userEvent.setup(); + const invoke = installPolishingInvoke('第一版润色结果', '第二版润色结果'); + renderComposer({ initialText: '原本的需求' }); + + await user.click(screen.getByRole('button', { name: 'AI 润色' })); + expect(await composerText()).toBe('第一版润色结果'); + expect(invoke).toHaveBeenCalledWith('polish_local_project_prompt', { + prompt: '原本的需求', + context: 'C:/project', + }); + + await user.click(screen.getByRole('button', { name: 'AI 润色' })); + expect(await composerText()).toBe('第二版润色结果'); + expect(invoke.mock.calls.at(-1)?.[1]).toEqual({ + prompt: '第一版润色结果', + context: 'C:/project', + }); + + await user.click(screen.getByRole('button', { name: '恢复原文' })); + expect(await composerText()).toBe('原本的需求'); + expect(screen.queryByRole('button', { name: '恢复原文' })).toBeNull(); + }); + + test('keeps the original text and shows a retry hint when polishing fails', async () => { + const user = userEvent.setup(); + installTauriInvoke(async (command) => { + if (command !== 'polish_local_project_prompt') return undefined; + throw new Error('platform llm timeout'); + }); + renderComposer({ initialText: '原本的需求' }); + + await user.click(screen.getByRole('button', { name: 'AI 润色' })); + expect(await composerText()).toBe('原本的需求'); + expect(await screen.findByText('AI 润色失败,可重试')).not.toBeNull(); + expect(screen.queryByRole('button', { name: '恢复原文' })).toBeNull(); + }); + + test('holds a long prompt behind the reminder panel and sends the original on demand', async () => { + const onSubmitDraft = renderComposer({ initialText: LONG_PROMPT }); + + fireEvent.click(sendButton()); + expect(reminderPanel()).not.toBeNull(); + expect(onSubmitDraft).not.toHaveBeenCalled(); + + fireEvent.click( + within(reminderPanel()).getByRole('button', { name: '使用原文提交' }), + ); + await waitFor(() => { + expect(onSubmitDraft).toHaveBeenCalledWith({ + text: LONG_PROMPT, + references: [], + }); + }); + expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); + }); + + test('closing the reminder cancels the send and keeps the reminder armed', async () => { + const onSubmitDraft = renderComposer({ initialText: LONG_PROMPT }); + + fireEvent.click(sendButton()); + fireEvent.click( + within(reminderPanel()).getByRole('button', { name: '关闭' }), + ); + expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); + expect(onSubmitDraft).not.toHaveBeenCalled(); + + fireEvent.click(sendButton()); + expect(reminderPanel()).not.toBeNull(); + expect(onSubmitDraft).not.toHaveBeenCalled(); + }); + + test('polishes first and then submits the polished prompt', async () => { + installPolishingInvoke('润色后的长需求'); + const onSubmitDraft = renderComposer({ initialText: LONG_PROMPT }); + + fireEvent.click(sendButton()); + fireEvent.click( + within(reminderPanel()).getByRole('button', { name: 'AI 润色' }), + ); + await waitFor(() => { + expect(onSubmitDraft).toHaveBeenCalledWith({ + text: '润色后的长需求', + references: [], + }); + }); + expect(await composerText()).toBe('润色后的长需求'); + }); + + test('keeps the reminder open with the original text when the in-panel polish fails', async () => { + installTauriInvoke(async () => { + throw new Error('platform llm timeout'); + }); + const onSubmitDraft = renderComposer({ initialText: LONG_PROMPT }); + + fireEvent.click(sendButton()); + fireEvent.click( + within(reminderPanel()).getByRole('button', { name: 'AI 润色' }), + ); + + expect( + await screen.findByText('AI 润色失败,可重试或使用原文提交'), + ).not.toBeNull(); + expect(onSubmitDraft).not.toHaveBeenCalled(); + expect(await composerText()).toBe(LONG_PROMPT); + + fireEvent.click( + within(reminderPanel()).getByRole('button', { name: '使用原文提交' }), + ); + await waitFor(() => { + expect(onSubmitDraft).toHaveBeenCalledWith({ + text: LONG_PROMPT, + references: [], + }); + }); + }); + + test('persists 不再提醒 locally and stops holding later sends', async () => { + const onSubmitDraft = renderComposer({ initialText: LONG_PROMPT }); + + fireEvent.click(sendButton()); + fireEvent.click( + within(reminderPanel()).getByRole('checkbox', { name: '不再提醒' }), + ); + expect(window.localStorage.getItem(CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY)) + .toBe('true'); + + fireEvent.click( + within(reminderPanel()).getByRole('button', { name: '关闭' }), + ); + fireEvent.click(sendButton()); + await waitFor(() => { + expect(onSubmitDraft).toHaveBeenCalledWith({ + text: LONG_PROMPT, + references: [], + }); + }); + expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); + }); + + test('reads the stored 不再提醒 preference when the composer mounts', () => { + window.localStorage.setItem( + CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY, + 'true', + ); + const onSubmitDraft = renderComposer({ initialText: LONG_PROMPT }); + + fireEvent.click(sendButton()); + expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); + expect(onSubmitDraft).toHaveBeenCalledWith({ + text: LONG_PROMPT, + references: [], + }); + }); + + test('does not hold short prompts or slash commands', () => { + const shortSubmit = renderComposer({ initialText: '做个跳跃游戏' }); + fireEvent.click(sendButton()); + expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); + expect(shortSubmit).toHaveBeenCalledWith({ + text: '做个跳跃游戏', + references: [], + }); + + cleanup(); + const commandSubmit = renderComposer({ + initialText: `/${'命令'.repeat(40)}`, + }); + fireEvent.click(sendButton()); + expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); + expect(commandSubmit).toHaveBeenCalledWith({ + text: `/${'命令'.repeat(40)}`, + references: [], + }); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index c78f121a8..9bb477de5 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -4,7 +4,10 @@ import userEvent from '@testing-library/user-event'; import { StrictMode } from 'react'; import { afterEach, describe, expect, test, vi } from 'vitest'; -import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { + GameCreationAppAssetManifestEntry, + GameIterationVersion, +} from '../../../packages/shared/src/contracts/gameCreationApp'; import { LOCAL_GAME_PREVIEW_INSPECT_MESSAGE, parseLocalGamePreviewInspectMessage, @@ -13,13 +16,16 @@ import { ResourceReferenceInput } from '../src/features/project-workspace/Resour import { type ChatComposerDraft, type ChatReference, + currentIterationVersionAssets, dispatchResourceReferenceInsert, RESOURCE_REFERENCE_FILTERS, RESOURCE_REFERENCE_INSERT_EVENT, + RESOURCE_REFERENCE_SCOPES, resourceReferenceCategory, resourceReferenceFromAsset, resourceReferenceMatchesCategoryFilter, resourceReferenceMatchesQuery, + resolveActiveIterationVersion, } from '../src/features/project-workspace/resourceReferences'; function asset( @@ -37,6 +43,30 @@ function asset( }; } +function iterationVersion( + versionId: string, + resourceIds: string[], + parentVersionId: string | null = null, +): GameIterationVersion { + return { + versionId, + parentVersionId, + projectRevision: 1, + resourceBindings: resourceIds.map((resourceId, index) => ({ + slotId: `slot-${index}`, + resourceId, + })), + createdReason: 'initial', + createdAt: 1, + }; +} + +async function settleComposer() { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + const assets = [ asset('hero', 'character', 'image/png', 'assets/hero.png'), asset('enemy', 'character', 'image/png', 'assets/enemy.png'), @@ -273,4 +303,207 @@ describe('ResourceReferenceInput', () => { expect(onChange.mock.calls.at(-1)?.[0].references).toHaveLength(0); }); }); + + test('exposes the current-version and all-canvas scopes as the only two tabs', () => { + expect(RESOURCE_REFERENCE_SCOPES).toEqual([ + { id: 'current-version', label: '当前版本素材' }, + { id: 'all-canvas', label: '全部画布素材' }, + ]); + }); + + test('resolves the current version from activeVersionId or the newest manifest version', () => { + const versions = [ + iterationVersion('v1', ['hero']), + iterationVersion('v2', ['theme'], 'v1'), + ]; + expect(resolveActiveIterationVersion(versions, 'v1')?.versionId).toBe('v1'); + expect(resolveActiveIterationVersion(versions, null)?.versionId).toBe('v2'); + expect(resolveActiveIterationVersion(versions, undefined)?.versionId).toBe( + 'v2', + ); + expect(resolveActiveIterationVersion(versions, 'missing')).toBeNull(); + expect(resolveActiveIterationVersion([], null)).toBeNull(); + expect(resolveActiveIterationVersion(undefined, null)).toBeNull(); + }); + + test('derives current-version assets from bindings and drops dangling bindings', () => { + const versions = [iterationVersion('v1', ['hero', 'deleted-asset'])]; + expect( + currentIterationVersionAssets(assets, versions, 'v1').map( + (entry) => entry.id, + ), + ).toEqual(['hero']); + expect(currentIterationVersionAssets(assets, versions, 'missing')).toEqual( + [], + ); + expect(currentIterationVersionAssets(assets, [], null)).toEqual([]); + expect( + currentIterationVersionAssets(assets, [iterationVersion('v1', [])], 'v1'), + ).toEqual([]); + }); + + test('keeps an independent search and filter state per picker scope', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: '插入素材引用' })); + // 没传 activeVersionId 时回退到 manifest 最新版本 v2,只列出该版本绑定的资源。 + expect( + screen + .getByRole('tab', { name: '当前版本素材' }) + .getAttribute('aria-selected'), + ).toBe('true'); + expect(screen.getByRole('option', { name: /theme/u })).not.toBeNull(); + expect(screen.queryByRole('option', { name: /hero/u })).toBeNull(); + + await user.type(screen.getByLabelText('搜索当前版本素材'), 'the'); + expect(screen.getByRole('option', { name: /theme/u })).not.toBeNull(); + + await user.click(screen.getByRole('tab', { name: '全部画布素材' })); + // 另一个页签有自己的搜索与筛选,不受当前版本页签影响。 + const allCanvasSearch = screen.getByLabelText( + '搜索全部画布素材', + ) as HTMLInputElement; + expect(allCanvasSearch.value).toBe(''); + expect(screen.getByRole('option', { name: /hero/u })).not.toBeNull(); + expect(screen.getByRole('option', { name: /enemy/u })).not.toBeNull(); + + await user.type(allCanvasSearch, 'hero'); + expect(screen.queryByRole('option', { name: /enemy/u })).toBeNull(); + await user.click(screen.getByRole('button', { name: '音频' })); + expect(screen.queryByRole('option', { name: /hero/u })).toBeNull(); + expect(screen.getByText('没有匹配的素材')).not.toBeNull(); + + await user.click(screen.getByRole('tab', { name: '当前版本素材' })); + expect( + (screen.getByLabelText('搜索当前版本素材') as HTMLInputElement).value, + ).toBe('the'); + expect(screen.getByRole('option', { name: /theme/u })).not.toBeNull(); + }); + + test('shows an empty state when the active version has no bound resources', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: '插入素材引用' })); + // 当前版本没有可用素材时默认落到「全部画布素材」,切回当前版本页签是空态。 + await user.click(screen.getByRole('tab', { name: '当前版本素材' })); + expect(screen.getByText('当前版本还没有绑定素材')).not.toBeNull(); + expect(screen.queryByRole('option')).toBeNull(); + + await user.click(screen.getByRole('tab', { name: '全部画布素材' })); + expect(screen.getByRole('option', { name: /hero/u })).not.toBeNull(); + }); + + test('shows the current-version empty state when the project has no versions', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: '插入素材引用' })); + await user.click(screen.getByRole('tab', { name: '当前版本素材' })); + expect(screen.getByText('当前版本还没有绑定素材')).not.toBeNull(); + }); + + test('refreshes chip and candidate display names after a resource rename', async () => { + const user = userEvent.setup(); + const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const renamedAssets = [ + asset('hero', 'character', 'image/png', 'assets/hero-final.png'), + assets[1]!, + assets[2]!, + ]; + render( + , + ); + + await waitFor(() => { + expect( + document.querySelector('.resource-reference-chip-label')?.textContent, + ).toBe('hero-final'); + }); + await waitFor(() => { + expect(onChange.mock.calls.at(-1)?.[0].references[0]?.label).toBe( + 'hero-final', + ); + }); + + await user.click(screen.getByRole('button', { name: '插入素材引用' })); + expect(screen.getByRole('option', { name: /hero-final/u })).not.toBeNull(); + expect(screen.queryByRole('option', { name: /hero /u })).toBeNull(); + }); + + test('restores a cross-session draft with the caret at the end of the text', async () => { + const user = userEvent.setup(); + const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const { rerender } = render( + , + ); + + // 切换 / 重开会话:外部草稿被整体替换。 + rerender( + , + ); + + await user.click(screen.getByRole('button', { name: '插入素材引用' })); + await user.click(screen.getByRole('option', { name: /hero/u })); + await user.click(screen.getByRole('button', { name: '插入引用' })); + await settleComposer(); + + expect(onChange.mock.calls.at(-1)?.[0].text).toBe('恢复出来的草稿@hero'); + }); });