Files
Genarrative/src/components/puzzle-result/PuzzleResultView.test.tsx
高物 548db78ca7 Update Match3D/image-generation docs & code
Adds/updates documentation, assets and implementation for Match3D and puzzle image generation workflows. Key changes: decision logs and pitfalls updated to prefer VectorEngine Gemini for Match3D material sheets and to require edits (multipart) for 1:1 container reference images; guidance added for when to use APIMart vs VectorEngine. .env.example clarified APIMart/Responses config. Many new public assets and PPT visuals added. Code changes across frontend and backend: updated shared contracts, server-rs match3d/puzzle/image-generation handlers, VectorEngine/OpenAI image generation clients, and multiple React components/tests to handle UI/background/container image signing, edits workflow, and puzzle UI background resolution. Added src/services/puzzle-runtime/puzzleUiBackgroundSource.ts and related test updates. Includes notes about multipart HTTP/1.1 requirement and test/verification commands in docs.
2026-05-14 20:34:45 +08:00

1150 lines
36 KiB
TypeScript

// @vitest-environment jsdom
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import * as puzzleWorksService from '../../services/puzzle-works';
import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient';
import { PuzzleResultView } from './PuzzleResultView';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
'data-testid': dataTestId,
}: {
src?: string | null;
alt?: string;
className?: string;
'data-testid'?: string;
}) => (
src ? (
<img
src={src}
alt={alt}
className={className}
data-testid={dataTestId}
/>
) : null
),
}));
vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({
puzzleAssetClient: {
listHistoryAssets: vi.fn(),
},
}));
vi.mock('../../services/puzzle-works', () => ({
updatePuzzleWork: vi.fn(),
}));
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: (src?: string | null) => ({
resolvedUrl: src
? `https://signed.example.com/${src.replace(/^\/+/u, '')}`
: '',
isResolving: false,
shouldResolve: Boolean(src?.trim().startsWith('/generated-')),
}),
}));
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
function createSession(
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
): PuzzleAgentSessionSnapshot {
const anchorPack = {
themePromise: {
key: 'themePromise',
label: '题材承诺',
value: '雨夜猫咪',
status: 'confirmed' as const,
},
visualSubject: {
key: 'visualSubject',
label: '画面主体',
value: '屋檐下的猫',
status: 'confirmed' as const,
},
visualMood: {
key: 'visualMood',
label: '视觉气质',
value: '温暖',
status: 'confirmed' as const,
},
compositionHooks: {
key: 'compositionHooks',
label: '拼图记忆点',
value: '雨滴与灯牌',
status: 'confirmed' as const,
},
tagsAndForbidden: {
key: 'tagsAndForbidden',
label: '标签与禁忌',
value: '猫咪、雨夜',
status: 'confirmed' as const,
},
};
const level = {
levelId: 'puzzle-level-1',
levelName: '雨夜猫街',
pictureDescription: '屋檐下的猫与暖灯街角。',
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/puzzle/candidate-1.png',
assetId: 'asset-1',
prompt: '雨夜猫咪',
actualPrompt: null,
sourceType: 'generated' as const,
selected: true,
},
],
selectedCandidateId: 'candidate-1',
coverImageSrc: '/puzzle/candidate-1.png',
coverAssetId: 'asset-1',
generationStatus: 'ready' as const,
};
const baseSession: PuzzleAgentSessionSnapshot = {
sessionId: 'puzzle-session-1',
currentTurn: 2,
progressPercent: 88,
stage: 'ready_to_publish',
anchorPack,
draft: {
workTitle: overrides.draft?.workTitle ?? '暖灯猫街作品',
workDescription:
overrides.draft?.workDescription ?? '一套雨夜猫街主题拼图。',
levelName: level.levelName,
summary: level.pictureDescription,
themeTags: overrides.draft?.themeTags ?? ['猫咪', '雨夜', '暖灯'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack,
candidates: level.candidates,
selectedCandidateId: level.selectedCandidateId,
coverImageSrc: level.coverImageSrc,
coverAssetId: level.coverAssetId,
generationStatus: 'ready',
levels: [level],
metadata: null,
...overrides.draft,
},
messages: [],
lastAssistantReply: null,
publishedProfileId: null,
suggestedActions: [],
resultPreview: null,
updatedAt: '2026-04-26T10:00:00.000Z',
};
const session = {
...baseSession,
resultPreview: {
draft: baseSession.draft!,
publishReady: true,
blockers: [],
qualityFindings: [],
},
...overrides,
} satisfies PuzzleAgentSessionSnapshot;
return session;
}
describe('PuzzleResultView', () => {
test('renders level list and work info tabs', () => {
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={() => {}}
/>,
);
expect(screen.getByRole('button', { name: '拼图关卡' })).toBeTruthy();
expect(screen.getByRole('button', { name: '作品信息' })).toBeTruthy();
expect(screen.getByRole('button', { name: '素材配置' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '音乐' })).toBeNull();
expect(screen.getByText('雨夜猫街')).toBeTruthy();
expect(screen.getByText('获得更多积分激励')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '作品信息' }));
expect(screen.getByLabelText('作品名称')).toHaveProperty(
'value',
'暖灯猫街作品',
);
expect(screen.getByLabelText('作品描述')).toHaveProperty(
'value',
'一套雨夜猫街主题拼图。',
);
});
test('result action bar restores draft trial entry', () => {
const onStartTestRun = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
workTitle: '暖灯猫街作品',
levels: [
expect.objectContaining({
levelId: 'puzzle-level-1',
levelName: '雨夜猫街',
}),
],
}),
);
});
test('auto saves work info and levels through one payload', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
item: {} as never,
});
render(
<PuzzleResultView
session={createSession()}
profileId="puzzle-profile-session-1"
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '作品信息' }));
fireEvent.change(screen.getByLabelText('作品名称'), {
target: { value: '暖灯猫街合集' },
});
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
workTitle: '暖灯猫街合集',
workDescription: '一套雨夜猫街主题拼图。',
levelName: '雨夜猫街',
summary: '一套雨夜猫街主题拼图。',
themeTags: ['猫咪', '雨夜', '暖灯'],
levels: expect.arrayContaining([
expect.objectContaining({
levelId: 'puzzle-level-1',
levelName: '雨夜猫街',
}),
]),
}),
);
});
test('opens an independent level detail dialog for generation and test play', () => {
const onExecuteAction = vi.fn();
const onStartTestRun = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
fireEvent.change(within(dialog).getByLabelText('关卡名称'), {
target: { value: '暖灯猫街' },
});
fireEvent.change(within(dialog).getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
fireEvent.click(
within(dialog).getByRole('button', { name: /重新生成画面/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_images',
levelId: 'puzzle-level-1',
promptText: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: undefined,
imageModel: 'gpt-image-2',
aiRedraw: true,
candidateCount: 1,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
summary: '一套雨夜猫街主题拼图。',
themeTags: ['猫咪', '雨夜', '暖灯'],
levelsJson: expect.any(String),
});
expect(screen.getByRole('progressbar', { name: '画面生成进度' })).toBeTruthy();
const generatePayload = onExecuteAction.mock.calls[0]![0];
expect(JSON.parse(generatePayload.levelsJson ?? '[]')).toEqual([
expect.objectContaining({
levelId: 'puzzle-level-1',
levelName: '暖灯猫街',
pictureDescription: '一只猫在雨夜灯牌下回头。',
generationStatus: 'generating',
}),
]);
expect(within(dialog).getByText('预计剩余 90 秒')).toBeTruthy();
expect(
within(dialog).queryByPlaceholderText('参考图链接或资产ID'),
).toBeNull();
const levelList = screen.getByLabelText('拼图关卡列表');
expect(within(levelList).getAllByText('生成中').length).toBeGreaterThan(0);
const levelNameInput = within(dialog).getByLabelText('关卡名称');
const formalImageTitle = within(dialog).getByText('画面图');
const pictureDescriptionInput = within(dialog).getByLabelText('画面描述');
expect(
levelNameInput.compareDocumentPosition(formalImageTitle) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
formalImageTitle.compareDocumentPosition(pictureDescriptionInput) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: /关卡测试/u }));
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
levelName: '暖灯猫街',
summary: '一套雨夜猫街主题拼图。',
levels: [
expect.objectContaining({
levelId: 'puzzle-level-1',
levelName: '暖灯猫街',
}),
],
}),
);
});
test('adds and deletes levels from the list', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
item: {} as never,
});
render(
<PuzzleResultView
session={createSession()}
profileId="puzzle-profile-session-1"
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /新增关卡/u }));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(
within(dialog).getByRole('button', { name: /生成画面/u }),
).toBeTruthy();
expect(within(dialog).getByText('消耗2泥点')).toBeTruthy();
expect(
within(dialog).getByText('等待时间可以制作更多关卡哦~'),
).toBeTruthy();
expect(within(dialog).getByText('画面图')).toBeTruthy();
expect(
within(dialog).queryByRole('button', { name: /关卡测试/u }),
).toBeNull();
fireEvent.click(screen.getByLabelText('关闭'));
expect(screen.getAllByText('第2关').length).toBeGreaterThan(0);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
levels: expect.arrayContaining([
expect.objectContaining({ levelId: 'puzzle-level-1' }),
expect.objectContaining({ levelName: '' }),
]),
}),
);
fireEvent.click(screen.getByLabelText('删除关卡 第2关'));
expect(screen.queryByText('第2关')).toBeNull();
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
levels: [
expect.objectContaining({
levelId: 'puzzle-level-1',
}),
],
}),
);
});
test('generates image for a newly added level with the current levels snapshot', () => {
vi.spyOn(Date, 'now').mockReturnValue(1_775_000_000_000);
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /新增关卡/u }));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
fireEvent.change(within(dialog).getByLabelText('画面描述'), {
target: { value: '新关卡里有一座发光钟楼。' },
});
fireEvent.click(within(dialog).getByRole('button', { name: /生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1775000000000-2',
promptText: '新关卡里有一座发光钟楼。',
referenceImageSrc: undefined,
imageModel: 'gpt-image-2',
aiRedraw: true,
candidateCount: 1,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
summary: '一套雨夜猫街主题拼图。',
themeTags: ['猫咪', '雨夜', '暖灯'],
levelsJson: expect.any(String),
});
const payload = onExecuteAction.mock.calls[0]![0];
expect(JSON.parse(payload.levelsJson ?? '[]')).toEqual([
expect.objectContaining({ levelId: 'puzzle-level-1' }),
expect.objectContaining({
levelId: 'puzzle-level-1775000000000-2',
levelName: '',
pictureDescription: '新关卡里有一座发光钟楼。',
generationStatus: 'generating',
}),
]);
});
test('keeps generation progress visible after closing and reopening level dialog', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByText('雨夜猫街'));
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
{ name: '确定' },
),
);
fireEvent.click(screen.getByLabelText('关闭'));
expect(
within(screen.getByLabelText('拼图关卡列表')).getAllByText('生成中')
.length,
).toBeGreaterThan(0);
fireEvent.click(screen.getByText('雨夜猫街'));
const reopenedDialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(
within(reopenedDialog).getByRole('progressbar', { name: '画面生成进度' }),
).toBeTruthy();
expect(within(reopenedDialog).getByText('预计剩余 90 秒')).toBeTruthy();
});
test('allows parallel draft editing while a level image is generating but blocks publish', () => {
const onExecuteAction = vi.fn();
const onStartTestRun = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByText('雨夜猫街'));
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
{ name: '确定' },
),
);
fireEvent.change(screen.getByLabelText('关卡名称'), {
target: { value: '继续编辑的猫街' },
});
fireEvent.click(screen.getByRole('button', { name: /关卡测试/u }));
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
levelName: '继续编辑的猫街',
}),
);
fireEvent.click(screen.getByLabelText('关闭'));
fireEvent.click(screen.getByRole('button', { name: /新增关卡/u }));
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
fireEvent.click(screen.getByLabelText('关闭'));
fireEvent.click(screen.getByRole('button', { name: /发布/u }));
const publishDialog = screen.getByRole('dialog', { name: '发布拼图作品' });
expect(within(publishDialog).getByText('还有关卡画面正在生成。')).toBeTruthy();
expect(
within(publishDialog).getByRole('button', { name: /发布到广场/u }),
).toHaveProperty('disabled', true);
});
test('publishes with work info and serialized levels', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /发布/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '发布拼图作品' })).getByRole(
'button',
{ name: /发布到广场/u },
),
);
expect(onExecuteAction).toHaveBeenCalledWith(
expect.objectContaining({
action: 'publish_puzzle_work',
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
levelName: '雨夜猫街',
summary: '一套雨夜猫街主题拼图。',
themeTags: ['猫咪', '雨夜', '暖灯'],
}),
);
const payload = onExecuteAction.mock.calls[0]![0];
expect(JSON.parse(payload.levelsJson)).toEqual([
expect.objectContaining({
levelId: 'puzzle-level-1',
levelName: '雨夜猫街',
}),
]);
});
test('keeps publish dialog open and shows backend publish error', () => {
const onExecuteAction = vi.fn();
const { rerender } = render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /发布/u }));
const dialog = screen.getByRole('dialog', { name: '发布拼图作品' });
fireEvent.click(
within(dialog).getByRole('button', { name: /发布到广场/u }),
);
rerender(
<PuzzleResultView
session={createSession()}
error="泥点余额不足"
isBusy={false}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
const publishDialog = screen.getByRole('dialog', {
name: '发布拼图作品',
});
expect(publishDialog).toBeTruthy();
expect(within(publishDialog).getByText('泥点余额不足')).toBeTruthy();
});
test('generates six tags after work title and description are filled', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession({
draft: {
...createSession().draft!,
workTitle: '雨夜猫街',
workDescription: '',
themeTags: [],
},
resultPreview: {
draft: createSession().draft!,
publishReady: false,
blockers: [
{
id: 'invalid-tag-count',
code: 'INVALID_TAG_COUNT',
message: '正式标签数量必须在 3 到 6 之间',
},
],
qualityFindings: [],
},
})}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '作品信息' }));
fireEvent.click(screen.getByRole('button', { name: 'AI生成作品标签' }));
expect(screen.getByText('请先填写作品名称和作品描述。')).toBeTruthy();
expect(onExecuteAction).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText('作品描述'), {
target: { value: '一套雨夜猫街主题拼图。' },
});
fireEvent.click(screen.getByRole('button', { name: 'AI生成作品标签' }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_puzzle_tags',
workTitle: '雨夜猫街',
workDescription: '一套雨夜猫街主题拼图。',
levelName: '雨夜猫街',
summary: '一套雨夜猫街主题拼图。',
themeTags: [],
levelsJson: expect.any(String),
});
});
test('renders UI background tab with saved prompt and runtime preview', () => {
const base = createSession();
const level = base.draft!.levels![0]!;
render(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
levels: [
{
...level,
uiBackgroundPrompt: '雨夜猫街竖屏拼图UI背景',
uiBackgroundImageSrc:
'/generated-puzzle-assets/session/ui/background.png',
uiBackgroundImageObjectKey:
'generated-puzzle-assets/session/ui/background.png',
},
],
},
})}
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
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.objectContaining({
levelId: 'puzzle-level-1',
uiBackgroundPrompt: '新拼图UI背景提示词',
}),
]);
});
test('素材配置隐藏背景音乐入口', () => {
const base = createSession();
const level = base.draft!.levels![0]!;
render(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
levels: [
{
...level,
backgroundMusic: {
taskId: 'music-task-1',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-1',
assetKind: 'puzzle_background_music',
audioSrc: '/generated-puzzle-assets/session/audio/music.mp3',
prompt: '',
title: '雨夜轻响',
updatedAt: '2026-05-12T10:00:00.000Z',
},
},
],
},
})}
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
expect(screen.queryByRole('button', { name: /重新生成音乐/u })).toBeNull();
expect(screen.queryByLabelText('拼图背景音乐')).toBeNull();
});
test('生成完成回包合并历史音乐和UI背景后试玩使用最新资源', () => {
const onStartTestRun = vi.fn();
const base = createSession();
const localLevel = {
...base.draft!.levels![0]!,
generationStatus: 'generating' as const,
uiBackgroundPrompt: '旧的UI背景提示词',
uiBackgroundImageSrc: 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',
backgroundMusic: {
taskId: 'music-task-fruit',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-fruit',
assetKind: 'puzzle_background_music',
audioSrc: '/generated-puzzle-assets/session/audio/fruit.mp3',
prompt: '',
title: '水果乐园',
updatedAt: '2026-05-14T10:00:00.000Z',
},
};
const { rerender } = render(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
levels: [localLevel],
},
})}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
rerender(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
coverImageSrc: incomingLevel.coverImageSrc,
coverAssetId: incomingLevel.coverAssetId,
generationStatus: 'ready',
levels: [incomingLevel],
},
updatedAt: '2026-05-14T10:00:00.000Z',
})}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
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',
backgroundMusic: expect.objectContaining({
audioSrc: '/generated-puzzle-assets/session/audio/fruit.mp3',
}),
}),
],
}),
);
});
test('auto saves UI background prompt edits through levels', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
item: {} as never,
});
render(
<PuzzleResultView
session={createSession()}
profileId="puzzle-profile-session-1"
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
target: { value: '新的自动保存UI背景提示词' },
});
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
levels: [
expect.objectContaining({
levelId: 'puzzle-level-1',
uiBackgroundPrompt: '新的自动保存UI背景提示词',
}),
],
}),
);
});
test('selects a history puzzle asset as reference image for the selected level', async () => {
const onExecuteAction = vi.fn();
vi.mocked(puzzleAssetClient.listHistoryAssets).mockResolvedValue([
{
assetObjectId: 'asset-history-1',
assetKind: 'puzzle_cover_image',
imageSrc: '/generated-puzzle-assets/history/image.png',
ownerUserId: 'user-1',
ownerLabel: '账号 user-1',
profileId: null,
entityId: 'puzzle-session-1',
createdAt: '2026-04-27T10:00:00.000Z',
updatedAt: '2026-04-27T10:00:00.000Z',
},
]);
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
const uploadInput = within(dialog).getByLabelText('上传参考图', {
selector: 'input',
});
expect(uploadInput.closest('.platform-subpanel')).toBeTruthy();
const historyButton = within(dialog).getByRole('button', {
name: '选择历史图片',
});
expect(within(historyButton).getByText('历史')).toBeTruthy();
fireEvent.click(historyButton);
const picker = await screen.findByRole('dialog', {
name: '选择历史图片',
});
fireEvent.click(
await within(picker).findByRole('button', { name: /账号 user-1/u }),
);
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '选择历史图片' })).toBeNull();
});
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenLastCalledWith({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1',
promptText: '屋檐下的猫与暖灯街角。',
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
imageModel: 'gpt-image-2',
aiRedraw: true,
candidateCount: 1,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
summary: '一套雨夜猫街主题拼图。',
themeTags: ['猫咪', '雨夜', '暖灯'],
levelsJson: expect.any(String),
});
});
test('uses the saved level picture reference when regenerating a level image', () => {
const onExecuteAction = vi.fn();
const session = createSession({
draft: {
...createSession().draft!,
levels: [
{
...createSession().draft!.levels![0]!,
pictureReference: '/generated-puzzle-assets/history/saved-reference.png',
},
],
},
});
render(
<PuzzleResultView
session={session}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByText('雨夜猫街'));
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_images',
referenceImageSrc: '/generated-puzzle-assets/history/saved-reference.png',
aiRedraw: true,
}),
);
expect(screen.queryByPlaceholderText('参考图链接或资产ID')).toBeNull();
});
test('passes the selected image model when regenerating a level image', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
fireEvent.click(within(dialog).getByRole('button', { name: '图片模型' }));
fireEvent.click(
within(dialog).getByRole('menuitemradio', { name: 'gpt-image-2' }),
);
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',
imageModel: 'gpt-image-2',
}),
);
});
test('shows creative agent draft edit bar and submits the current draft', () => {
const onSubmit = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={() => {}}
creativeDraftEdit={{
isBusy: false,
error: null,
onSubmit,
}}
/>,
);
fireEvent.change(screen.getByLabelText('智能修订拼图草稿'), {
target: { value: '把标题改得轻松一点' },
});
fireEvent.click(screen.getByRole('button', { name: '修改' }));
expect(onSubmit).toHaveBeenCalledWith({
instruction: '把标题改得轻松一点',
currentDraft: expect.objectContaining({
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
levels: [
expect.objectContaining({
levelId: 'puzzle-level-1',
pictureReference: null,
}),
],
}),
});
});
});