Files
Genarrative/apps/ai-game-creator-shell/tests/usePromptPolish.test.tsx
suzmii 1e17d2c852
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m44s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m57s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m44s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m53s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m21s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m27s
Project CI / Frontend tests (pull_request) Successful in 7m4s
Project CI / AI game creator shell web tests (pull_request) Successful in 6m22s
Project CI / Native shell tests (pull_request) Successful in 10m13s
补齐画布验收第一批:润色回包提示、未提交草稿恢复与派生名预检
AI 润色回包与原文相同时给出明确提示,不再落下点了等于没点的「恢复原文」假入口
润色「与原文相同」的判据改为规范化之后要写回宿主的文本,回包被长度上限截回原文同样算没变化
共享聊天输入区显示润色提示,并让提示随用户手改提示词失效
快速编辑未提交草稿改按资源路径归属,收起面板或被旁路重投影后都不再静默丢掉
恢复入口按路径与本会话草稿对号,并区分「本会话未提交」与原生账本条目
账本读取失败时入口仍报出本会话草稿条数,「继续编辑」重开面板回填提示词与 @ 引用
派生资源名预检接入快速编辑与角色动画两条提交路径,早于正规化写盘
名称预检自身抛错时回写面板失败态,不再留下「点了没反应」的提交按钮
浮层关闭判定改在捕获阶段冻结,避免浮层内按钮卸载自己时整块面板被自己人收掉
恢复入口动作按钮收进统一容器,补移动端纵向排布与禁用态样式
空草稿表改工厂函数,跨语言对表用例按模块路径解析 Rust 源码
补回归用例:旁路重投影后的草稿、原样回包提示、提交前名称拦截、草稿表读写与跨卡隔离
同步 decision-log 与画布验收待办的进展说明
2026-09-20 17:36:02 +08:00

251 lines
8.6 KiB
TypeScript

// @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<string | null>;
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<string>((resolve) => {
resolvePolish = resolve;
}),
),
applyPrompt,
});
let pending: Promise<string | null> | 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<string>((resolve) => {
resolvePolish = resolve;
}),
applyPrompt,
});
let pending: Promise<string | null> | 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(['润色后的需求']);
});
});