Switch to VectorEngine gpt-image-2 and edits
Replace uses of the legacy `gpt-image-2-all` model with `gpt-image-2` and standardize image workflows: no-reference generation uses POST /v1/images/generations, any-reference flows use POST /v1/images/edits with multipart `image` parts. Update SKILLs, generation scripts, decision logs, and docs to reflect the contract change and edits-vs-generations guidance. Apply corresponding changes across backend (api-server match3d/puzzle modules, openai image adapter, mappers, telemetry, spacetime client/module), frontend components and services (Match3D, Puzzle, CreativeImageInputPanel, runtime shells), and add new spritesheet/parser files and tests. Also add media/logo.png. These changes align repository code and documentation with the VectorEngine image API contract and update generation/upload handling (green-screen -> alpha processing, spritesheet handling, and related tests).
This commit is contained in:
@@ -60,9 +60,25 @@ vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function stubReferenceImageUpload(dataUrl: string) {
|
||||
class MockFileReader {
|
||||
result: string | null = null;
|
||||
onload: null | (() => void) = null;
|
||||
onerror: null | (() => void) = null;
|
||||
|
||||
readAsDataURL() {
|
||||
this.result = dataUrl;
|
||||
this.onload?.();
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
|
||||
}
|
||||
|
||||
function createSession(
|
||||
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
|
||||
): PuzzleAgentSessionSnapshot {
|
||||
@@ -102,6 +118,17 @@ function createSession(
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '雨夜猫街',
|
||||
pictureDescription: '屋檐下的猫与暖灯街角。',
|
||||
pictureReference: null,
|
||||
uiBackgroundPrompt: null,
|
||||
uiBackgroundImageSrc: null,
|
||||
uiBackgroundImageObjectKey: null,
|
||||
levelSceneImageSrc: null,
|
||||
levelSceneImageObjectKey: null,
|
||||
uiSpritesheetImageSrc: null,
|
||||
uiSpritesheetImageObjectKey: null,
|
||||
levelBackgroundImageSrc: null,
|
||||
levelBackgroundImageObjectKey: null,
|
||||
backgroundMusic: null,
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-1',
|
||||
@@ -170,7 +197,7 @@ function openPuzzleLevelsTab() {
|
||||
}
|
||||
|
||||
describe('PuzzleResultView', () => {
|
||||
test('renders level list and work info tabs', () => {
|
||||
test('renders level list and work info tabs without asset config tab', () => {
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
@@ -182,18 +209,13 @@ describe('PuzzleResultView', () => {
|
||||
|
||||
const workInfoTab = screen.getByRole('button', { name: '作品信息' });
|
||||
const levelsTab = screen.getByRole('button', { name: '拼图关卡' });
|
||||
const assetsTab = screen.getByRole('button', { name: '素材配置' });
|
||||
expect(workInfoTab).toBeTruthy();
|
||||
expect(levelsTab).toBeTruthy();
|
||||
expect(assetsTab).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '素材配置' })).toBeNull();
|
||||
expect(
|
||||
workInfoTab.compareDocumentPosition(levelsTab) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
levelsTab.compareDocumentPosition(assetsTab) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '音乐' })).toBeNull();
|
||||
expect(screen.getByLabelText('作品名称')).toHaveProperty(
|
||||
'value',
|
||||
@@ -236,6 +258,62 @@ describe('PuzzleResultView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('level detail trial keeps the complete draft and only selects the target level', () => {
|
||||
const onStartTestRun = vi.fn();
|
||||
const base = createSession();
|
||||
const firstLevel = base.draft!.levels![0]!;
|
||||
const secondLevel = {
|
||||
...firstLevel,
|
||||
levelId: 'puzzle-level-2',
|
||||
levelName: '钟楼猫街',
|
||||
pictureDescription: '发光钟楼下的猫咪。',
|
||||
candidates: [
|
||||
{
|
||||
...firstLevel.candidates[0]!,
|
||||
candidateId: 'candidate-2',
|
||||
imageSrc: '/puzzle/candidate-2.png',
|
||||
assetId: 'asset-2',
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-2',
|
||||
coverImageSrc: '/puzzle/candidate-2.png',
|
||||
coverAssetId: 'asset-2',
|
||||
};
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession({
|
||||
draft: {
|
||||
...base.draft!,
|
||||
levels: [firstLevel, secondLevel],
|
||||
},
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onStartTestRun={onStartTestRun}
|
||||
/>,
|
||||
);
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('钟楼猫街'));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '关卡详情' })).getByRole(
|
||||
'button',
|
||||
{ name: '关卡测试' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(onStartTestRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
levels: [
|
||||
expect.objectContaining({ levelId: 'puzzle-level-1' }),
|
||||
expect.objectContaining({ levelId: 'puzzle-level-2' }),
|
||||
],
|
||||
}),
|
||||
{ levelId: 'puzzle-level-2' },
|
||||
);
|
||||
});
|
||||
|
||||
test('auto saves work info and levels through one payload', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
|
||||
@@ -294,6 +372,8 @@ describe('PuzzleResultView', () => {
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
expect(dialog.className).toContain('max-w-[56rem]');
|
||||
expect(dialog.querySelector('.puzzle-level-detail-list')).toBeTruthy();
|
||||
fireEvent.change(within(dialog).getByLabelText('关卡名称'), {
|
||||
target: { value: '暖灯猫街' },
|
||||
});
|
||||
@@ -316,6 +396,7 @@ describe('PuzzleResultView', () => {
|
||||
referenceImageSrc: undefined,
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
referenceImageSrcs: [],
|
||||
candidateCount: 1,
|
||||
shouldAutoNameLevel: false,
|
||||
workTitle: '暖灯猫街作品',
|
||||
@@ -334,7 +415,7 @@ describe('PuzzleResultView', () => {
|
||||
generationStatus: 'generating',
|
||||
}),
|
||||
]);
|
||||
expect(within(dialog).getByText('预计剩余 90 秒')).toBeTruthy();
|
||||
expect(within(dialog).getByText('预计剩余 270 秒')).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).queryByPlaceholderText('参考图链接或资产ID'),
|
||||
).toBeNull();
|
||||
@@ -343,7 +424,15 @@ describe('PuzzleResultView', () => {
|
||||
|
||||
const levelNameInput = within(dialog).getByLabelText('关卡名称');
|
||||
const formalImageTitle = within(dialog).getByText('画面图');
|
||||
const formalImageCard = formalImageTitle
|
||||
.closest('.creative-image-input-panel__image-field')
|
||||
?.querySelector('.puzzle-image-upload-card');
|
||||
const pictureDescriptionInput = within(dialog).getByLabelText('画面描述');
|
||||
expect(levelNameInput.closest('.platform-subpanel')).toBeNull();
|
||||
expect(formalImageTitle.closest('.platform-subpanel')).toBeNull();
|
||||
expect(pictureDescriptionInput.closest('.platform-subpanel')).toBeNull();
|
||||
expect(formalImageCard).toBeTruthy();
|
||||
expect(formalImageCard?.className).toContain('min-h-[');
|
||||
expect(
|
||||
levelNameInput.compareDocumentPosition(formalImageTitle) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
@@ -365,6 +454,7 @@ describe('PuzzleResultView', () => {
|
||||
}),
|
||||
],
|
||||
}),
|
||||
{ levelId: 'puzzle-level-1' },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -463,6 +553,7 @@ describe('PuzzleResultView', () => {
|
||||
referenceImageSrc: undefined,
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
referenceImageSrcs: [],
|
||||
candidateCount: 1,
|
||||
shouldAutoNameLevel: true,
|
||||
workTitle: '暖灯猫街作品',
|
||||
@@ -552,7 +643,7 @@ describe('PuzzleResultView', () => {
|
||||
expect(
|
||||
within(reopenedDialog).getByRole('progressbar', { name: '画面生成进度' }),
|
||||
).toBeTruthy();
|
||||
expect(within(reopenedDialog).getByText('预计剩余 90 秒')).toBeTruthy();
|
||||
expect(within(reopenedDialog).getByText('预计剩余 270 秒')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('allows parallel draft editing while a level image is generating but blocks publish', () => {
|
||||
@@ -586,6 +677,7 @@ describe('PuzzleResultView', () => {
|
||||
expect.objectContaining({
|
||||
levelName: '继续编辑的猫街',
|
||||
}),
|
||||
{ levelId: 'puzzle-level-1' },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('关闭'));
|
||||
@@ -602,7 +694,7 @@ describe('PuzzleResultView', () => {
|
||||
).toHaveProperty('disabled', true);
|
||||
});
|
||||
|
||||
test('keeps level controls enabled while regenerating the UI background', () => {
|
||||
test('asset config tab is removed and cannot trigger the legacy UI background action', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
@@ -614,76 +706,20 @@ describe('PuzzleResultView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
|
||||
target: { value: '雨夜猫街竖屏拼图UI背景' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: /确认消耗泥点/u })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_ui_background',
|
||||
promptText: '雨夜猫街竖屏拼图UI背景',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: /生成中/u }),
|
||||
).toHaveProperty('disabled', true);
|
||||
expect(screen.queryByRole('button', { name: '素材配置' })).toBeNull();
|
||||
expect(screen.queryByLabelText('拼图UI背景提示词')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /生成UI背景/u })).toBeNull();
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
const addLevelButton = screen.getByRole('button', { name: /新增关卡/u });
|
||||
expect(addLevelButton).toHaveProperty('disabled', false);
|
||||
fireEvent.click(addLevelButton);
|
||||
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('restores UI background generate button when background generation fails', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
const { rerender } = render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
isBusy={false}
|
||||
/>,
|
||||
expect(onExecuteAction).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_ui_background',
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
|
||||
target: { value: '雨夜猫街竖屏拼图UI背景' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: /确认消耗泥点/u })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /生成中/u })).toHaveProperty(
|
||||
'disabled',
|
||||
true,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
error="UI背景生成失败"
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
isBusy={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const generateButton = screen.getByRole('button', { name: /生成UI背景/u });
|
||||
expect(generateButton).toHaveProperty('disabled', false);
|
||||
expect(screen.queryByRole('button', { name: /生成中/u })).toBeNull();
|
||||
});
|
||||
|
||||
test('keeps the current level dialog open when another level generation completes', () => {
|
||||
@@ -899,7 +935,8 @@ describe('PuzzleResultView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('renders UI background tab with saved prompt and runtime preview', () => {
|
||||
test('preserves generated level asset bundle in test run draft', () => {
|
||||
const onStartTestRun = vi.fn();
|
||||
const base = createSession();
|
||||
const level = base.draft!.levels![0]!;
|
||||
|
||||
@@ -911,133 +948,53 @@ describe('PuzzleResultView', () => {
|
||||
levels: [
|
||||
{
|
||||
...level,
|
||||
uiBackgroundPrompt: '雨夜猫街竖屏拼图UI背景',
|
||||
uiBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/ui/background.png',
|
||||
uiBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/session/ui/background.png',
|
||||
levelSceneImageSrc:
|
||||
'/generated-puzzle-assets/session/level-scene.png',
|
||||
levelSceneImageObjectKey:
|
||||
'generated-puzzle-assets/session/level-scene.png',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet.png',
|
||||
uiSpritesheetImageObjectKey:
|
||||
'generated-puzzle-assets/session/ui-spritesheet.png',
|
||||
levelBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/level-background.png',
|
||||
levelBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/session/level-background.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onStartTestRun={onStartTestRun}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
|
||||
|
||||
expect(screen.getByAltText('拼图UI背景图').getAttribute('src')).toBe(
|
||||
'/generated-puzzle-assets/session/ui/background.png',
|
||||
);
|
||||
expect(screen.getByLabelText('拼图UI背景提示词')).toHaveProperty(
|
||||
'value',
|
||||
'雨夜猫街竖屏拼图UI背景',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '预览UI' }));
|
||||
const preview = screen.getByRole('dialog', { name: 'UI预览' });
|
||||
expect(
|
||||
within(preview)
|
||||
.getByTestId('puzzle-ui-runtime-preview-background')
|
||||
.getAttribute('src'),
|
||||
).toBe('/generated-puzzle-assets/session/ui/background.png');
|
||||
expect(within(preview).getByLabelText('拼图区边界')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('UI背景只有 objectKey 时草稿页仍显示生成图', () => {
|
||||
const base = createSession();
|
||||
const level = base.draft!.levels![0]!;
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession({
|
||||
draft: {
|
||||
...base.draft!,
|
||||
levels: [
|
||||
{
|
||||
...level,
|
||||
uiBackgroundPrompt: '雨夜猫街竖屏拼图UI背景',
|
||||
uiBackgroundImageSrc: null,
|
||||
uiBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/session/ui/background-object-key.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
|
||||
expect(screen.getByAltText('拼图UI背景图').getAttribute('src')).toBe(
|
||||
'/generated-puzzle-assets/session/ui/background-object-key.png',
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /重新生成/u })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('does not display local fallback as saved UI background prompt', () => {
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
|
||||
expect(screen.getByLabelText('拼图UI背景提示词')).toHaveProperty(
|
||||
'value',
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
test('generates UI background with edited prompt and current levels snapshot', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
|
||||
target: { value: '新拼图UI背景提示词' },
|
||||
});
|
||||
expect(screen.getByRole('button', { name: /生成UI背景.*2泥点/u })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
|
||||
const confirmDialog = screen.getByRole('dialog', {
|
||||
name: '确认消耗泥点',
|
||||
});
|
||||
expect(within(confirmDialog).getByText('消耗 2 泥点')).toBeTruthy();
|
||||
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确定' }));
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||||
action: 'generate_puzzle_ui_background',
|
||||
levelId: 'puzzle-level-1',
|
||||
promptText: '新拼图UI背景提示词',
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
levelsJson: expect.any(String),
|
||||
});
|
||||
const payload = onExecuteAction.mock.calls[0]![0];
|
||||
expect(JSON.parse(payload.levelsJson ?? '[]')).toEqual([
|
||||
expect(onStartTestRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
levelId: 'puzzle-level-1',
|
||||
uiBackgroundPrompt: '新拼图UI背景提示词',
|
||||
levels: [
|
||||
expect.objectContaining({
|
||||
levelSceneImageSrc:
|
||||
'/generated-puzzle-assets/session/level-scene.png',
|
||||
levelSceneImageObjectKey:
|
||||
'generated-puzzle-assets/session/level-scene.png',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet.png',
|
||||
uiSpritesheetImageObjectKey:
|
||||
'generated-puzzle-assets/session/ui-spritesheet.png',
|
||||
levelBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/level-background.png',
|
||||
levelBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/session/level-background.png',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
);
|
||||
});
|
||||
|
||||
test('素材配置隐藏背景音乐入口', () => {
|
||||
test('does not expose music or standalone UI asset controls', () => {
|
||||
const base = createSession();
|
||||
const level = base.draft!.levels![0]!;
|
||||
|
||||
@@ -1068,30 +1025,39 @@ describe('PuzzleResultView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
expect(screen.queryByRole('button', { name: '素材配置' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /重新生成音乐/u })).toBeNull();
|
||||
expect(screen.queryByLabelText('拼图背景音乐')).toBeNull();
|
||||
expect(screen.queryByLabelText('拼图UI背景提示词')).toBeNull();
|
||||
});
|
||||
|
||||
test('生成完成回包合并历史音乐和UI背景后试玩使用最新资源', () => {
|
||||
test('生成完成回包合并历史音乐和关卡资产后试玩使用最新资源', () => {
|
||||
const onStartTestRun = vi.fn();
|
||||
const base = createSession();
|
||||
const localLevel = {
|
||||
...base.draft!.levels![0]!,
|
||||
generationStatus: 'generating' as const,
|
||||
uiBackgroundPrompt: '旧的UI背景提示词',
|
||||
uiBackgroundImageSrc: null,
|
||||
levelSceneImageSrc: null,
|
||||
uiSpritesheetImageSrc: null,
|
||||
levelBackgroundImageSrc: null,
|
||||
backgroundMusic: null,
|
||||
};
|
||||
const incomingLevel = {
|
||||
...localLevel,
|
||||
generationStatus: 'ready' as const,
|
||||
uiBackgroundPrompt: '水果乐园UI背景',
|
||||
uiBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/ui/fruit-background.png',
|
||||
uiBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/session/ui/fruit-background.png',
|
||||
levelSceneImageSrc:
|
||||
'/generated-puzzle-assets/session/level-scene-fruit.png',
|
||||
levelSceneImageObjectKey:
|
||||
'generated-puzzle-assets/session/level-scene-fruit.png',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet-fruit.png',
|
||||
uiSpritesheetImageObjectKey:
|
||||
'generated-puzzle-assets/session/ui-spritesheet-fruit.png',
|
||||
levelBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/level-background-fruit.png',
|
||||
levelBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/session/level-background-fruit.png',
|
||||
backgroundMusic: {
|
||||
taskId: 'music-task-fruit',
|
||||
provider: 'vector-engine-suno',
|
||||
@@ -1135,20 +1101,18 @@ describe('PuzzleResultView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
expect(screen.getByAltText('拼图UI背景图').getAttribute('src')).toBe(
|
||||
'/generated-puzzle-assets/session/ui/fruit-background.png',
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
|
||||
|
||||
expect(onStartTestRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
levels: [
|
||||
expect.objectContaining({
|
||||
uiBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/ui/fruit-background.png',
|
||||
levelSceneImageSrc:
|
||||
'/generated-puzzle-assets/session/level-scene-fruit.png',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet-fruit.png',
|
||||
levelBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/level-background-fruit.png',
|
||||
backgroundMusic: expect.objectContaining({
|
||||
audioSrc: '/generated-puzzle-assets/session/audio/fruit.mp3',
|
||||
}),
|
||||
@@ -1158,24 +1122,42 @@ describe('PuzzleResultView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('auto saves UI background prompt edits through levels', async () => {
|
||||
test('auto saves generated level asset bundle through levels', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
|
||||
item: {} as never,
|
||||
});
|
||||
const base = createSession();
|
||||
const level = base.draft!.levels![0]!;
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
session={createSession({
|
||||
draft: {
|
||||
...base.draft!,
|
||||
levels: [
|
||||
{
|
||||
...level,
|
||||
levelSceneImageSrc:
|
||||
'/generated-puzzle-assets/session/level-scene.png',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet.png',
|
||||
levelBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/level-background.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
})}
|
||||
profileId="puzzle-profile-session-1"
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
|
||||
target: { value: '新的自动保存UI背景提示词' },
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
fireEvent.change(screen.getByLabelText('关卡名称'), {
|
||||
target: { value: '雨夜猫街新版' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
@@ -1188,7 +1170,13 @@ describe('PuzzleResultView', () => {
|
||||
levels: [
|
||||
expect.objectContaining({
|
||||
levelId: 'puzzle-level-1',
|
||||
uiBackgroundPrompt: '新的自动保存UI背景提示词',
|
||||
levelName: '雨夜猫街新版',
|
||||
levelSceneImageSrc:
|
||||
'/generated-puzzle-assets/session/level-scene.png',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet.png',
|
||||
levelBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/level-background.png',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -1222,10 +1210,11 @@ describe('PuzzleResultView', () => {
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
const uploadInput = within(dialog).getByLabelText('上传参考图', {
|
||||
const uploadInput = within(dialog).getAllByLabelText('上传参考图', {
|
||||
selector: 'input',
|
||||
});
|
||||
expect(uploadInput.closest('.platform-subpanel')).toBeTruthy();
|
||||
})[0]!;
|
||||
expect(uploadInput.closest('.platform-subpanel')).toBeNull();
|
||||
expect(uploadInput.closest('.puzzle-level-detail-list')).toBeTruthy();
|
||||
const historyButton = within(dialog).getByRole('button', {
|
||||
name: '选择历史图片',
|
||||
});
|
||||
@@ -1245,7 +1234,9 @@ describe('PuzzleResultView', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: '选择历史图片' })).toBeNull();
|
||||
});
|
||||
expect(screen.getByText('历史素材 · image.png')).toBeTruthy();
|
||||
expect(within(dialog).getByAltText('拼图参考图').getAttribute('src')).toBe(
|
||||
'/generated-puzzle-assets/history/image.png',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
|
||||
fireEvent.click(
|
||||
@@ -1261,6 +1252,7 @@ describe('PuzzleResultView', () => {
|
||||
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
referenceImageSrcs: [],
|
||||
candidateCount: 1,
|
||||
shouldAutoNameLevel: false,
|
||||
workTitle: '暖灯猫街作品',
|
||||
@@ -1461,6 +1453,110 @@ describe('PuzzleResultView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('level image editor submits uploaded image directly when AI redraw is off', async () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
const uploadedDataUrl = 'data:image/png;base64,level-upload';
|
||||
stubReferenceImageUpload(uploadedDataUrl);
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
const uploadInput = within(dialog).getAllByLabelText('上传参考图', {
|
||||
selector: 'input',
|
||||
})[0]!;
|
||||
fireEvent.change(uploadInput, {
|
||||
target: {
|
||||
files: [new File(['x'], 'level-upload.png', { type: 'image/png' })],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(dialog).getByRole('switch', { name: 'AI重绘' })).toBeTruthy();
|
||||
});
|
||||
expect(within(dialog).getByAltText('拼图参考图')).toHaveProperty(
|
||||
'src',
|
||||
uploadedDataUrl,
|
||||
);
|
||||
fireEvent.click(within(dialog).getByRole('switch', { name: 'AI重绘' }));
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /重新生成画面/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_images',
|
||||
levelId: 'puzzle-level-1',
|
||||
referenceImageSrc: uploadedDataUrl,
|
||||
referenceImageSrcs: [],
|
||||
aiRedraw: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('level image editor uploads prompt reference images from the description box', async () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
const uploadedDataUrl = 'data:image/png;base64,level-reference';
|
||||
stubReferenceImageUpload(uploadedDataUrl);
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
const referenceInputs = within(dialog).getAllByLabelText('上传参考图', {
|
||||
selector: 'input',
|
||||
});
|
||||
expect(referenceInputs.length).toBeGreaterThanOrEqual(2);
|
||||
fireEvent.change(referenceInputs[referenceInputs.length - 1]!, {
|
||||
target: {
|
||||
files: [new File(['x'], 'prompt-reference.png', { type: 'image/png' })],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(dialog).getByRole('button', {
|
||||
name: /预览参考图 prompt-reference\.png/u,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /重新生成画面/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_images',
|
||||
levelId: 'puzzle-level-1',
|
||||
referenceImageSrc: undefined,
|
||||
referenceImageSrcs: [uploadedDataUrl],
|
||||
aiRedraw: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('level image editor hides AI redraw controls when only the formal image is shown', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
@@ -1480,7 +1576,7 @@ describe('PuzzleResultView', () => {
|
||||
expect(within(dialog).getByLabelText('画面描述')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('UI background generator reuses common image input UI without sharing level image fields', () => {
|
||||
test('standalone UI background generator stays removed from the result page', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
@@ -1491,40 +1587,11 @@ describe('PuzzleResultView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
|
||||
expect(screen.getByText('UI背景预览')).toBeTruthy();
|
||||
expect(screen.getByLabelText('UI背景提示词')).toBeTruthy();
|
||||
expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull();
|
||||
expect(screen.queryByLabelText('上传拼图图片')).toBeNull();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('UI背景提示词'), {
|
||||
target: { value: '独立的草稿UI背景提示词' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: /确认消耗泥点/u })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_ui_background',
|
||||
promptText: '独立的草稿UI背景提示词',
|
||||
}),
|
||||
);
|
||||
const payload = onExecuteAction.mock.calls[0]![0];
|
||||
expect(payload).not.toHaveProperty('referenceImageSrc');
|
||||
expect(payload).not.toHaveProperty('aiRedraw');
|
||||
expect(JSON.parse(payload.levelsJson ?? '[]')).toEqual([
|
||||
expect.objectContaining({
|
||||
levelId: 'puzzle-level-1',
|
||||
pictureDescription: '屋檐下的猫与暖灯街角。',
|
||||
uiBackgroundPrompt: '独立的草稿UI背景提示词',
|
||||
}),
|
||||
]);
|
||||
expect(screen.queryByRole('button', { name: '素材配置' })).toBeNull();
|
||||
expect(screen.queryByText('UI背景预览')).toBeNull();
|
||||
expect(screen.queryByLabelText('UI背景提示词')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /生成UI背景/u })).toBeNull();
|
||||
expect(onExecuteAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('shows creative agent draft edit bar and submits the current draft', () => {
|
||||
|
||||
Reference in New Issue
Block a user