合并主分支
解决简单冲突
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { setStoredAccessToken } from '../apiClient';
|
||||
import { generateEditorBackgroundMusic } from './editorProjectClient';
|
||||
|
||||
function createLocalStorageMock() {
|
||||
const store = new Map<string, string>();
|
||||
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return store.has(key) ? store.get(key)! : null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
removeItem(key: string) {
|
||||
store.delete(key);
|
||||
},
|
||||
clear() {
|
||||
store.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('editorProjectClient background music transport', () => {
|
||||
const fetchMock = vi.fn();
|
||||
const requestBody = {
|
||||
gptDescriptionPrompt: '森林冒险背景音乐',
|
||||
makeInstrumental: true as const,
|
||||
projectId: 'editor-project-1',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.stubGlobal('window', {
|
||||
dispatchEvent: vi.fn(),
|
||||
localStorage: createLocalStorageMock(),
|
||||
});
|
||||
setStoredAccessToken('background-music-test-token', { emit: false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function expectSingleBackgroundMusicPost() {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe('/api/editor/audios/background-music/generations');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(JSON.parse(String(init.body))).toEqual(requestBody);
|
||||
}
|
||||
|
||||
it.each([429, 503])(
|
||||
'does not retry the formal POST after an HTTP %i response',
|
||||
async (status) => {
|
||||
fetchMock.mockResolvedValueOnce(new Response('', { status }));
|
||||
|
||||
await expect(
|
||||
generateEditorBackgroundMusic(requestBody),
|
||||
).rejects.toMatchObject({ status });
|
||||
|
||||
expectSingleBackgroundMusicPost();
|
||||
},
|
||||
);
|
||||
|
||||
it('does not retry the formal POST after a transport error', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
|
||||
await expect(
|
||||
generateEditorBackgroundMusic(requestBody),
|
||||
).rejects.toBeInstanceOf(TypeError);
|
||||
|
||||
expectSingleBackgroundMusicPost();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,7 @@
|
||||
import type {
|
||||
BackgroundMusicPromptAssistRequest,
|
||||
BackgroundMusicPromptAssistResponse,
|
||||
} from '../../../packages/shared/src/contracts/editorAudio';
|
||||
import type { ExternalGenerationJobStatusRecord } from '../../../packages/shared/src/contracts/externalGeneration';
|
||||
import { requestJson } from '../apiClient';
|
||||
import { EDITOR_GENERATION_REQUEST_RETRY_OPTIONS } from './editorRetryOptions';
|
||||
@@ -34,6 +38,15 @@ const EDITOR_SOUND_EFFECT_GENERATION_API =
|
||||
'/api/editor/audios/sound-effects/generations';
|
||||
const EDITOR_BACKGROUND_MUSIC_GENERATION_API =
|
||||
'/api/editor/audios/background-music/generations';
|
||||
const EDITOR_BACKGROUND_MUSIC_PROMPT_COMPLETION_API =
|
||||
'/api/editor/audios/background-music/prompts/completions';
|
||||
const EDITOR_BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_API =
|
||||
'/api/editor/audios/background-music/prompts/simplifications';
|
||||
/**
|
||||
* 覆盖简化两个业务语义轮加单轮 transport retry 的服务端预算,同时为浏览器到 BFF 的
|
||||
* 悬挂连接提供有界退出;超时后走既有失败路径恢复 idle,不写回候选。
|
||||
*/
|
||||
export const EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS = 180_000;
|
||||
const EDITOR_GENERATION_PRICING_API = '/api/editor/generation-pricing';
|
||||
const EDITOR_IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview';
|
||||
const EDITOR_IMAGE_REFERENCE_LIMIT = 5;
|
||||
@@ -620,6 +633,10 @@ export type EditorBackgroundMusicGenerationInput = {
|
||||
assetLabel?: string | null;
|
||||
};
|
||||
|
||||
export type EditorBackgroundMusicPromptAssistOptions = {
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export type EditorAudioGenerationResult = {
|
||||
audioSrc: string;
|
||||
objectKey?: string | null;
|
||||
@@ -1487,6 +1504,51 @@ export async function generateEditorSoundEffect(
|
||||
);
|
||||
}
|
||||
|
||||
function requestEditorBackgroundMusicPromptAssist(
|
||||
path: string,
|
||||
input: BackgroundMusicPromptAssistRequest,
|
||||
fallbackMessage: string,
|
||||
options: EditorBackgroundMusicPromptAssistOptions,
|
||||
) {
|
||||
return requestJson<BackgroundMusicPromptAssistResponse>(
|
||||
path,
|
||||
{
|
||||
...jsonRequest('POST', {
|
||||
currentPrompt: input.currentPrompt,
|
||||
}),
|
||||
signal: options.signal,
|
||||
},
|
||||
fallbackMessage,
|
||||
{
|
||||
timeoutMs: EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function completeEditorBackgroundMusicPrompt(
|
||||
input: BackgroundMusicPromptAssistRequest,
|
||||
options: EditorBackgroundMusicPromptAssistOptions = {},
|
||||
) {
|
||||
return requestEditorBackgroundMusicPromptAssist(
|
||||
EDITOR_BACKGROUND_MUSIC_PROMPT_COMPLETION_API,
|
||||
input,
|
||||
'AI 补全背景音乐提示词失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function simplifyEditorBackgroundMusicPrompt(
|
||||
input: BackgroundMusicPromptAssistRequest,
|
||||
options: EditorBackgroundMusicPromptAssistOptions = {},
|
||||
) {
|
||||
return requestEditorBackgroundMusicPromptAssist(
|
||||
EDITOR_BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_API,
|
||||
input,
|
||||
'简化背景音乐提示词失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateEditorBackgroundMusic(
|
||||
input: EditorBackgroundMusicGenerationInput,
|
||||
) {
|
||||
@@ -1508,7 +1570,6 @@ export async function generateEditorBackgroundMusic(
|
||||
'生成游戏背景音乐失败',
|
||||
{
|
||||
timeoutMs: 1_200_000,
|
||||
retry: EDITOR_GENERATION_REQUEST_RETRY_OPTIONS,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user