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.
1150 lines
36 KiB
TypeScript
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,
|
|
}),
|
|
],
|
|
}),
|
|
});
|
|
});
|
|
});
|