合并主分支

解决简单冲突
This commit is contained in:
2026-08-06 18:54:32 +08:00
55 changed files with 11232 additions and 270 deletions
@@ -1,6 +1,8 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import backgroundMusicPromptCanonicalizationCases from '../../../packages/shared/test-fixtures/background-music-prompt-canonicalization.json';
import {
completeEditorBackgroundMusicPrompt,
createEditorAsset,
createEditorAssetFolder,
createEditorProject,
@@ -13,6 +15,7 @@ import {
EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS,
EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES,
EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH,
EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS,
extractEditorUiDesignAssets,
generateEditorBackgroundMusic,
generateEditorCharacterAnimation,
@@ -32,6 +35,7 @@ import {
removeEditorImageBackground,
renameEditorProject,
saveEditorProjectLayout,
simplifyEditorBackgroundMusicPrompt,
snapEditorImageToPixelArt,
splitEditorIconSpritesheet,
submitEditorAssetShowcase,
@@ -46,6 +50,10 @@ const editorRetryOptionsExpectation = expect.objectContaining({
maxRetries: 0,
retryUnsafeMethods: false,
});
// 助手请求只带有界超时:额外的 transport retry 会把一次业务语义轮放大成多次上游调用。
const promptAssistRequestOptions = {
timeoutMs: EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS,
};
vi.mock('../apiClient', () => ({
requestJson: requestJsonMock,
@@ -1983,13 +1991,21 @@ describe('editorProjectClient', () => {
});
it('generates editor background music through the backend BFF', async () => {
const representativeComplexPromptCase =
backgroundMusicPromptCanonicalizationCases.find(
({ name }) => name === 'representative-complex',
);
if (!representativeComplexPromptCase) {
throw new Error('missing representative-complex prompt fixture');
}
const canonicalPrompt = representativeComplexPromptCase.prompt;
requestJsonMock.mockResolvedValueOnce({
audioSrc: '/generated-character-drafts/editor-audios/bgm.mp3',
width: 420,
height: 120,
sourceType: 'generated',
prompt: '森林冒险背景音乐',
actualPrompt: '森林冒险背景音乐',
prompt: canonicalPrompt,
actualPrompt: canonicalPrompt,
model: 'chirp-v4',
provider: 'VectorEngine',
taskId: 'music-task-1',
@@ -1997,7 +2013,7 @@ describe('editorProjectClient', () => {
});
const result = await generateEditorBackgroundMusic({
gptDescriptionPrompt: '森林冒险背景音乐',
gptDescriptionPrompt: canonicalPrompt,
makeInstrumental: true,
assetFolderId: 'project',
assetLabel: '游戏背景音乐 1',
@@ -2010,20 +2026,196 @@ describe('editorProjectClient', () => {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
gptDescriptionPrompt: '森林冒险背景音乐',
gptDescriptionPrompt: canonicalPrompt,
makeInstrumental: true,
assetFolderId: 'project',
assetLabel: '游戏背景音乐 1',
}),
}),
'生成游戏背景音乐失败',
expect.objectContaining({
{
timeoutMs: 1_200_000,
retry: editorRetryOptionsExpectation,
}),
},
);
});
it('forwards background music transport failures without configuring retries', async () => {
requestJsonMock.mockRejectedValueOnce(new Error('transport failed'));
await expect(
generateEditorBackgroundMusic({
gptDescriptionPrompt: '森林冒险背景音乐',
makeInstrumental: true,
}),
).rejects.toThrow('transport failed');
expect(requestJsonMock.mock.calls[0]?.[3]).toEqual({
timeoutMs: 1_200_000,
});
});
it('completes a background music prompt through the authenticated internal BFF', async () => {
requestJsonMock.mockResolvedValueOnce({
prompt: '轻快明亮的森林冒险背景音乐',
charCount: 13,
});
const result = await completeEditorBackgroundMusicPrompt({
currentPrompt: '森林冒险',
targetChars: 180,
maxChars: 200,
} as Parameters<typeof completeEditorBackgroundMusicPrompt>[0] & {
targetChars: number;
maxChars: number;
});
expect(result).toEqual({
prompt: '轻快明亮的森林冒险背景音乐',
charCount: 13,
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/audios/background-music/prompts/completions',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
currentPrompt: '森林冒险',
}),
signal: undefined,
},
'AI 补全背景音乐提示词失败',
promptAssistRequestOptions,
);
expect(EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS).toBe(180_000);
expect(requestJsonMock.mock.calls[0]).toHaveLength(4);
expect(requestJsonMock.mock.calls[0][3]).toEqual(promptAssistRequestOptions);
});
it('simplifies a background music prompt without forwarding client-controlled limits', async () => {
requestJsonMock.mockResolvedValueOnce({
prompt: '紧张推进的战斗背景音乐',
charCount: 11,
});
const result = await simplifyEditorBackgroundMusicPrompt({
currentPrompt: '紧张推进、铜管与鼓点交织的战斗背景音乐'.repeat(10),
targetChars: 170,
maxChars: 200,
model: 'client-must-not-control',
} as Parameters<typeof simplifyEditorBackgroundMusicPrompt>[0] & {
targetChars: number;
maxChars: number;
model: string;
});
expect(result).toEqual({
prompt: '紧张推进的战斗背景音乐',
charCount: 11,
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/audios/background-music/prompts/simplifications',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
currentPrompt: '紧张推进、铜管与鼓点交织的战斗背景音乐'.repeat(10),
}),
signal: undefined,
},
'简化背景音乐提示词失败',
promptAssistRequestOptions,
);
expect(requestJsonMock.mock.calls[0]).toHaveLength(4);
expect(requestJsonMock.mock.calls[0][3]).toEqual(promptAssistRequestOptions);
});
it.each([
[
'completion',
completeEditorBackgroundMusicPrompt,
'/api/editor/audios/background-music/prompts/completions',
'AI 补全背景音乐提示词失败',
],
[
'simplification',
simplifyEditorBackgroundMusicPrompt,
'/api/editor/audios/background-music/prompts/simplifications',
'简化背景音乐提示词失败',
],
] as const)(
'forwards the AbortSignal for background music prompt %s',
async (_label, requestPromptAssist, path, fallbackMessage) => {
const controller = new AbortController();
const abortReason = new Error('prompt assist cancelled');
requestJsonMock.mockImplementationOnce(
(_url: string, init: RequestInit) =>
new Promise((_resolve, reject) => {
init.signal?.addEventListener(
'abort',
() => reject(init.signal?.reason),
{ once: true },
);
}),
);
const request = requestPromptAssist(
{ currentPrompt: '森林冒险' },
{ signal: controller.signal },
);
controller.abort(abortReason);
await expect(request).rejects.toBe(abortReason);
expect(requestJsonMock).toHaveBeenCalledWith(
path,
expect.objectContaining({
signal: controller.signal,
body: JSON.stringify({ currentPrompt: '森林冒险' }),
}),
fallbackMessage,
promptAssistRequestOptions,
);
},
);
it.each([
[
'completion',
completeEditorBackgroundMusicPrompt,
'AI 补全背景音乐提示词失败',
],
[
'simplification',
simplifyEditorBackgroundMusicPrompt,
'简化背景音乐提示词失败',
],
] as const)(
'preserves the v1 API error from background music prompt %s',
async (_label, requestPromptAssist, fallbackMessage) => {
const apiError = Object.assign(new Error('登录状态已失效'), {
name: 'ApiClientError',
status: 401,
code: 'UNAUTHORIZED',
});
requestJsonMock.mockRejectedValueOnce(apiError);
await expect(
requestPromptAssist({ currentPrompt: '森林冒险' }),
).rejects.toBe(apiError);
expect(requestJsonMock).toHaveBeenCalledWith(
expect.stringMatching(
/^\/api\/editor\/audios\/background-music\/prompts\//,
),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ currentPrompt: '森林冒险' }),
}),
fallbackMessage,
promptAssistRequestOptions,
);
expect(requestJsonMock.mock.calls[0]).toHaveLength(4);
},
);
it('edits editor images through the backend BFF', async () => {
requestJsonMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,edited',