补充 C4 引用页签与 C8 润色提醒的定向测试

- 新增 chatPromptPolish.test.tsx:覆盖提醒判据、草稿指纹、本机偏好读写与润色命令调用
- 覆盖主动润色回填、可重复润色覆盖结果、恢复原文回到最初原文
- 覆盖润色失败保留原文并在提醒面板内给出重试提示
- 覆盖长文本提交被提醒面板拦下、使用原文提交、关闭取消发送、AI 润色后发送
- 覆盖不再提醒偏好落本机 localStorage 后不再拦截,以及挂载时读取该偏好
- 覆盖短文本与命令不触发提醒
- resourceReferenceInput.test.tsx 新增:两个页签的独立搜索与筛选状态
- 新增:activeVersionId、版本回退、悬空绑定过滤与空态
- 新增:资源改名后 chip 与候选列表显示名刷新
- 新增:跨会话恢复草稿后光标落在文本末尾,引用追加在文本之后
This commit is contained in:
2026-09-10 14:38:59 +08:00
parent 89678173f5
commit b0d7121265
2 changed files with 663 additions and 1 deletions
@@ -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<string, unknown>,
) => Promise<unknown>;
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<ChatComposerDraft>({
text: initialText,
references: initialReferences,
});
return (
<form
onSubmit={(event) => {
event.preventDefault();
onSubmitDraft(draft);
}}
>
<ResourceReferenceInput
value={draft.text}
references={draft.references}
onChange={setDraft}
assets={[]}
projectPath="C:/project"
ariaLabel="创作想法"
/>
<button type="submit"></button>
</form>
);
}
function renderComposer({
initialText,
initialReferences,
onSubmitDraft = vi.fn(),
}: {
initialText: string;
initialReferences?: ChatReference[];
onSubmitDraft?: (draft: ChatComposerDraft) => void;
}) {
render(
<ControlledChatComposer
initialText={initialText}
initialReferences={initialReferences}
onSubmitDraft={onSubmitDraft}
/>,
);
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: [],
});
});
});
@@ -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(
<ResourceReferenceInput
value=""
references={[]}
onChange={vi.fn()}
assets={assets}
versions={[
iterationVersion('v1', ['hero', 'enemy']),
iterationVersion('v2', ['theme'], 'v1'),
]}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
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(
<ResourceReferenceInput
value=""
references={[]}
onChange={vi.fn()}
assets={assets}
activeVersionId="v1"
versions={[iterationVersion('v1', ['deleted-asset'])]}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
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(
<ResourceReferenceInput
value=""
references={[]}
onChange={vi.fn()}
assets={assets}
versions={[]}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
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(
<ResourceReferenceInput
value="用这个角色"
references={[resourceReferenceFromAsset(assets[0]!, 'asset-picker')]}
onChange={onChange}
assets={renamedAssets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
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(
<ResourceReferenceInput
value="上一个会话的草稿"
references={[]}
onChange={onChange}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
// 切换 / 重开会话:外部草稿被整体替换。
rerender(
<ResourceReferenceInput
value="恢复出来的草稿"
references={[]}
onChange={onChange}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
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');
});
});