Files
Genarrative/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx
k88936 6981648796
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m56s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m48s
Project CI / Native shell tests (pull_request) Failing after 45s
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Frontend tests (pull_request) Failing after 1m52s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m42s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m57s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m1s
合并 origin/master:Supervisor 永久退役,项目对话收敛为 DirectProject 与 Design Agent
- 解决 refactor/split-direct-project 与 origin/master 在 App.tsx、立项策划聊天视图、Direct composer/引用输入区、styles.css、Rust direct user item 与 appSurface 用例上的冲突,按「Supervisor 永久退役」口径保留 DirectProject 独立聊天容器与 Design Agent 两条产品路径
- 采纳 master 的策划 Agent V1/V2 退役:删除 GDD 审批卡、策划输入卡、planningLane、planningSessionV2、planningSessionContract、规划展示适配与 Rust planning_*_v2 命令、模块、契约及对应用例,不保留兼容别名或双跑路径
- 把 master「折叠思考显示单行预览」的目的落到当前结构:新增共享表现 chat/components/AgentReasoning/AgentReasoning.tsx(折叠态单行纯文本预览 + 箭头、展开态安全 Markdown),DirectProject 回合与策划回合共用,删掉两处写死的 pre 折叠实现
- 把 master「策划入口可选模型 / 推理档」的目的接到当前策划输入盒:复用 ConversationModelSelect 与 ComposerReasoningEffortSelect,配置写回仍走客户端配置通道
- App.tsx 删除只服务退役 Supervisor / 策划 V2 的 state、ref、effect、回调与死参数,并删除两条读路径都退役后的 workspaceProjectKind;openWorkspace 的工程类型入参保留为未使用契约
- Rust 侧保留本分支 canonical→wire 投影、无审计 Direct 回合与 direct user item 严格校验,并入 master 的 prepare_new_web_project_at 前置复核
- 更新 ADR 与 shared-memory 决策记录:策划当前只有 Design Agent、两条路径的共享表现清单,以及本次合并的口径、代价与验证证据
- 验证:AGC 与仓库 typecheck、check:encoding、check:doc-index、git diff --check、改动文件 eslint 0 error;AGC vitest 168 个文件中除 5 个 jsdom localStorage 环境失败文件与本分支既有 resourceTagStatsRefresh 失败外全绿,appSurface 198 passed / 13 skipped;Rust 定向用例 direct_codex_user_item、skill_pack、sessions 全过(整套分片在本容器受 /sbin -> usr/bin 触发沙箱预检失败,与本合并无关)
2026-09-21 21:09:34 +08:00

516 lines
17 KiB
TypeScript

// @vitest-environment jsdom
// @vitest-environment-options {"url":"http://localhost"}
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useRef } 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 type { ResourceReferenceInputHandle } from '../src/features/project-workspace/ResourceReferenceInput';
import { ResourceReferenceInput } from '../src/features/project-workspace/ResourceReferenceInput';
import {
type ChatComposerDraft,
type ChatReference,
chatReferenceToContentPart,
hasMeaningfulDirectCodexContent,
resourceReferenceFromAsset,
} from '../src/features/project-workspace/resourceReferences';
import type { DirectCodexUserContentPart } from '../src/view/project-development/chat/generated/DirectCodexUserContentPart';
const storage = new Map<string, string>();
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: {
clear: () => storage.clear(),
getItem: (key: string) => storage.get(key) ?? null,
removeItem: (key: string) => storage.delete(key),
setItem: (key: string, value: string) => storage.set(key, String(value)),
},
});
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 ?? '';
}
/** 纯文本草稿的 canonical content。 */
function textContent(text: string): DirectCodexUserContentPart[] {
return text ? [{ type: 'input_text', text }] : [];
}
function ControlledChatComposer({
initialText,
initialReferences = [],
onSubmitDraft,
}: {
initialText: string;
initialReferences?: ChatReference[];
onSubmitDraft: (draft: ChatComposerDraft) => void;
}) {
const composerRef = useRef<ResourceReferenceInputHandle | null>(null);
const initialContent: DirectCodexUserContentPart[] = [
...textContent(initialText),
...initialReferences.map(chatReferenceToContentPart),
];
return (
<form
onSubmit={(event) => {
event.preventDefault();
const draft = composerRef.current?.getDraft();
const submittedContent = hasMeaningfulDirectCodexContent(
draft?.content ?? [],
)
? draft!.content
: initialContent;
onSubmitDraft({
content: submittedContent,
});
}}
>
<ResourceReferenceInput
ref={composerRef}
initialContent={initialContent}
onChange={() => {}}
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 longPrompt = '字'.repeat(CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH);
const content = textContent(longPrompt);
expect(
shouldRemindChatPromptPolish({
content,
prompt: longPrompt,
acknowledgedDraftKey: null,
reminderDisabled: false,
}),
).toBe(true);
expect(
shouldRemindChatPromptPolish({
content: textContent('短需求'),
prompt: '短需求',
acknowledgedDraftKey: null,
reminderDisabled: false,
}),
).toBe(false);
expect(
shouldRemindChatPromptPolish({
content,
prompt: longPrompt,
acknowledgedDraftKey: null,
reminderDisabled: true,
}),
).toBe(false);
expect(
shouldRemindChatPromptPolish({
content,
prompt: longPrompt,
acknowledgedDraftKey: chatPromptDraftKey(content),
reminderDisabled: false,
}),
).toBe(false);
const command = `/${'长'.repeat(60)}`;
expect(
shouldRemindChatPromptPolish({
content: textContent(command),
prompt: command,
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(textContent('需求'))).not.toBe(
chatPromptDraftKey([
...textContent('需求'),
chatReferenceToContentPart(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('回包与原文相同时聊天输入区也给提示,且不冒出「恢复原文」', async () => {
const user = userEvent.setup();
installPolishingInvoke('原本的需求');
renderComposer({ initialText: '原本的需求' });
await user.click(screen.getByRole('button', { name: 'AI 润色' }));
// 聊天输入区与资源侧共用 `usePromptPolish`:修好状态机还不够,共享输入区必须把
// notice 渲染出来,否则这三个宿主里「点了没反应」看起来还是按钮坏了。
expect(
await screen.findByText('AI 润色结果与原文相同,未做修改'),
).not.toBeNull();
expect(await composerText()).toBe('原本的需求');
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({
content: textContent(LONG_PROMPT),
});
});
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({
content: textContent('润色后的长需求'),
});
});
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({
content: textContent(LONG_PROMPT),
});
});
});
test('keeps the reminder open on Escape while the in-panel polish is in flight', async () => {
let resolvePolish: (value: string) => void = () => {};
installTauriInvoke(async (command) => {
if (command !== 'polish_local_project_prompt') return undefined;
return new Promise<string>((resolve) => {
resolvePolish = resolve;
});
});
const onSubmitDraft = renderComposer({ initialText: LONG_PROMPT });
fireEvent.click(sendButton());
fireEvent.click(
within(reminderPanel()).getByRole('button', { name: 'AI 润色' }),
);
await waitFor(() => {
expect(
within(reminderPanel())
.getByRole('button', { name: 'AI 润色' })
.getAttribute('aria-busy'),
).toBe('true');
});
// Escape 与「关闭」按钮、遮罩点击同一口径:在飞期间不许关面板,
// 否则润色回来还会在用户已经取消之后继续把这一轮提交出去。
fireEvent.keyDown(reminderPanel(), { key: 'Escape' });
expect(screen.queryByRole('dialog', { name: '发送前提醒' })).not.toBeNull();
expect(onSubmitDraft).not.toHaveBeenCalled();
// 在飞请求照旧走完:润色回填后把这一轮发出去。
resolvePolish('润色后的长需求');
await waitFor(() => {
expect(onSubmitDraft).toHaveBeenCalledWith({
content: textContent('润色后的长需求'),
});
});
});
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({
content: textContent(LONG_PROMPT),
});
});
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({
content: textContent(LONG_PROMPT),
});
});
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({
content: textContent('做个跳跃游戏'),
});
cleanup();
const commandSubmit = renderComposer({
initialText: `/${'命令'.repeat(40)}`,
});
fireEvent.click(sendButton());
expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull();
expect(commandSubmit).toHaveBeenCalledWith({
content: textContent(`/${'命令'.repeat(40)}`),
});
});
});