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).
1633 lines
51 KiB
TypeScript
1633 lines
51 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.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 {
|
||
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: '屋檐下的猫与暖灯街角。',
|
||
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',
|
||
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;
|
||
}
|
||
|
||
function openPuzzleLevelsTab() {
|
||
fireEvent.click(screen.getByRole('button', { name: '拼图关卡' }));
|
||
}
|
||
|
||
describe('PuzzleResultView', () => {
|
||
test('renders level list and work info tabs without asset config tab', () => {
|
||
render(
|
||
<PuzzleResultView
|
||
session={createSession()}
|
||
onBack={() => {}}
|
||
onExecuteAction={() => {}}
|
||
onStartTestRun={() => {}}
|
||
/>,
|
||
);
|
||
|
||
const workInfoTab = screen.getByRole('button', { name: '作品信息' });
|
||
const levelsTab = screen.getByRole('button', { name: '拼图关卡' });
|
||
expect(workInfoTab).toBeTruthy();
|
||
expect(levelsTab).toBeTruthy();
|
||
expect(screen.queryByRole('button', { name: '素材配置' })).toBeNull();
|
||
expect(
|
||
workInfoTab.compareDocumentPosition(levelsTab) &
|
||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||
).toBeTruthy();
|
||
expect(screen.queryByRole('button', { name: '音乐' })).toBeNull();
|
||
expect(screen.getByLabelText('作品名称')).toHaveProperty(
|
||
'value',
|
||
'暖灯猫街作品',
|
||
);
|
||
expect(screen.getByLabelText('作品描述')).toHaveProperty(
|
||
'value',
|
||
'一套雨夜猫街主题拼图。',
|
||
);
|
||
|
||
openPuzzleLevelsTab();
|
||
expect(screen.getByText('雨夜猫街')).toBeTruthy();
|
||
expect(screen.getByText('获得更多积分激励')).toBeTruthy();
|
||
});
|
||
|
||
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('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({
|
||
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}
|
||
/>,
|
||
);
|
||
|
||
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: '暖灯猫街' },
|
||
});
|
||
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,
|
||
referenceImageSrcs: [],
|
||
candidateCount: 1,
|
||
shouldAutoNameLevel: false,
|
||
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('预计剩余 270 秒')).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 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,
|
||
).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: '暖灯猫街',
|
||
}),
|
||
],
|
||
}),
|
||
{ levelId: 'puzzle-level-1' },
|
||
);
|
||
});
|
||
|
||
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={() => {}}
|
||
/>,
|
||
);
|
||
|
||
openPuzzleLevelsTab();
|
||
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).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}
|
||
/>,
|
||
);
|
||
|
||
openPuzzleLevelsTab();
|
||
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,
|
||
referenceImageSrcs: [],
|
||
candidateCount: 1,
|
||
shouldAutoNameLevel: true,
|
||
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('requests automatic level naming when generating an unnamed level image', () => {
|
||
vi.spyOn(Date, 'now').mockReturnValue(1_775_000_000_000);
|
||
const onExecuteAction = vi.fn();
|
||
|
||
render(
|
||
<PuzzleResultView
|
||
session={createSession()}
|
||
onBack={() => {}}
|
||
onExecuteAction={onExecuteAction}
|
||
/>,
|
||
);
|
||
|
||
openPuzzleLevelsTab();
|
||
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(
|
||
expect.objectContaining({
|
||
action: 'generate_puzzle_images',
|
||
levelId: 'puzzle-level-1775000000000-2',
|
||
promptText: '新关卡里有一座发光钟楼。',
|
||
shouldAutoNameLevel: true,
|
||
}),
|
||
);
|
||
});
|
||
|
||
test('keeps generation progress visible after closing and reopening level dialog', () => {
|
||
const onExecuteAction = vi.fn();
|
||
|
||
render(
|
||
<PuzzleResultView
|
||
session={createSession()}
|
||
onBack={() => {}}
|
||
onExecuteAction={onExecuteAction}
|
||
/>,
|
||
);
|
||
|
||
openPuzzleLevelsTab();
|
||
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('预计剩余 270 秒')).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}
|
||
/>,
|
||
);
|
||
|
||
openPuzzleLevelsTab();
|
||
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: '继续编辑的猫街',
|
||
}),
|
||
{ levelId: 'puzzle-level-1' },
|
||
);
|
||
|
||
fireEvent.click(screen.getByLabelText('关闭'));
|
||
openPuzzleLevelsTab();
|
||
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('asset config tab is removed and cannot trigger the legacy UI background action', () => {
|
||
const onExecuteAction = vi.fn();
|
||
|
||
render(
|
||
<PuzzleResultView
|
||
session={createSession()}
|
||
onBack={() => {}}
|
||
onExecuteAction={onExecuteAction}
|
||
isBusy={false}
|
||
/>,
|
||
);
|
||
|
||
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();
|
||
expect(onExecuteAction).not.toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
action: 'generate_puzzle_ui_background',
|
||
}),
|
||
);
|
||
});
|
||
|
||
test('keeps the current level dialog open when another level generation completes', () => {
|
||
const base = createSession();
|
||
const firstLevel = base.draft!.levels![0]!;
|
||
const generatingSecondLevel = {
|
||
...firstLevel,
|
||
levelId: 'puzzle-level-2',
|
||
levelName: '第二关',
|
||
pictureDescription: '第二关画面正在生成。',
|
||
candidates: [],
|
||
selectedCandidateId: null,
|
||
coverImageSrc: null,
|
||
coverAssetId: null,
|
||
generationStatus: 'generating' as const,
|
||
};
|
||
const localThirdLevel = {
|
||
...firstLevel,
|
||
levelId: 'puzzle-level-3',
|
||
levelName: '第三关',
|
||
pictureDescription: '第三关初稿。',
|
||
candidates: [],
|
||
selectedCandidateId: null,
|
||
coverImageSrc: null,
|
||
coverAssetId: null,
|
||
generationStatus: 'idle' as const,
|
||
};
|
||
const completedSecondLevel = {
|
||
...generatingSecondLevel,
|
||
candidates: [
|
||
{
|
||
candidateId: 'candidate-level-2',
|
||
imageSrc: '/puzzle/level-2.png',
|
||
assetId: 'asset-level-2',
|
||
prompt: '第二关画面',
|
||
actualPrompt: null,
|
||
sourceType: 'generated' as const,
|
||
selected: true,
|
||
},
|
||
],
|
||
selectedCandidateId: 'candidate-level-2',
|
||
coverImageSrc: '/puzzle/level-2.png',
|
||
coverAssetId: 'asset-level-2',
|
||
generationStatus: 'ready' as const,
|
||
};
|
||
|
||
const { rerender } = render(
|
||
<PuzzleResultView
|
||
session={createSession({
|
||
draft: {
|
||
...base.draft!,
|
||
levels: [firstLevel, generatingSecondLevel, localThirdLevel],
|
||
},
|
||
})}
|
||
onBack={() => {}}
|
||
onExecuteAction={() => {}}
|
||
/>,
|
||
);
|
||
|
||
openPuzzleLevelsTab();
|
||
fireEvent.click(screen.getByText('第三关'));
|
||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||
fireEvent.change(within(dialog).getByLabelText('画面描述'), {
|
||
target: { value: '正在编辑第三关的信息。' },
|
||
});
|
||
|
||
rerender(
|
||
<PuzzleResultView
|
||
session={createSession({
|
||
draft: {
|
||
...base.draft!,
|
||
levels: [firstLevel, completedSecondLevel],
|
||
},
|
||
updatedAt: '2026-05-14T10:00:00.000Z',
|
||
})}
|
||
onBack={() => {}}
|
||
onExecuteAction={() => {}}
|
||
/>,
|
||
);
|
||
|
||
const currentDialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||
expect(within(currentDialog).getByLabelText('关卡名称')).toHaveProperty(
|
||
'value',
|
||
'第三关',
|
||
);
|
||
expect(within(currentDialog).getByLabelText('画面描述')).toHaveProperty(
|
||
'value',
|
||
'正在编辑第三关的信息。',
|
||
);
|
||
expect(within(currentDialog).queryByDisplayValue('第二关')).toBeNull();
|
||
});
|
||
|
||
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('preserves generated level asset bundle in test run draft', () => {
|
||
const onStartTestRun = vi.fn();
|
||
const base = createSession();
|
||
const level = base.draft!.levels![0]!;
|
||
|
||
render(
|
||
<PuzzleResultView
|
||
session={createSession({
|
||
draft: {
|
||
...base.draft!,
|
||
levels: [
|
||
{
|
||
...level,
|
||
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: '试玩' }));
|
||
|
||
expect(onStartTestRun).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
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('does not expose music or standalone UI asset controls', () => {
|
||
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={() => {}}
|
||
/>,
|
||
);
|
||
|
||
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('生成完成回包合并历史音乐和关卡资产后试玩使用最新资源', () => {
|
||
const onStartTestRun = vi.fn();
|
||
const base = createSession();
|
||
const localLevel = {
|
||
...base.draft!.levels![0]!,
|
||
generationStatus: 'generating' as const,
|
||
levelSceneImageSrc: null,
|
||
uiSpritesheetImageSrc: null,
|
||
levelBackgroundImageSrc: null,
|
||
backgroundMusic: null,
|
||
};
|
||
const incomingLevel = {
|
||
...localLevel,
|
||
generationStatus: 'ready' as const,
|
||
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',
|
||
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(onStartTestRun).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
levels: [
|
||
expect.objectContaining({
|
||
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',
|
||
}),
|
||
}),
|
||
],
|
||
}),
|
||
);
|
||
});
|
||
|
||
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({
|
||
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={() => {}}
|
||
/>,
|
||
);
|
||
|
||
openPuzzleLevelsTab();
|
||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||
fireEvent.change(screen.getByLabelText('关卡名称'), {
|
||
target: { value: '雨夜猫街新版' },
|
||
});
|
||
|
||
await act(async () => {
|
||
await vi.runAllTimersAsync();
|
||
});
|
||
|
||
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenCalledWith(
|
||
'puzzle-profile-session-1',
|
||
expect.objectContaining({
|
||
levels: [
|
||
expect.objectContaining({
|
||
levelId: 'puzzle-level-1',
|
||
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',
|
||
}),
|
||
],
|
||
}),
|
||
);
|
||
});
|
||
|
||
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: '1713686400.000000Z',
|
||
updatedAt: '1713686400.000000Z',
|
||
},
|
||
]);
|
||
|
||
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]!;
|
||
expect(uploadInput.closest('.platform-subpanel')).toBeNull();
|
||
expect(uploadInput.closest('.puzzle-level-detail-list')).toBeTruthy();
|
||
const historyButton = within(dialog).getByRole('button', {
|
||
name: '选择历史图片',
|
||
});
|
||
expect(within(historyButton).getByText('历史')).toBeTruthy();
|
||
fireEvent.click(historyButton);
|
||
|
||
const picker = await screen.findByRole('dialog', {
|
||
name: '选择历史图片',
|
||
});
|
||
expect(await within(picker).findByText('image.png')).toBeTruthy();
|
||
expect(await within(picker).findByText(/2024\/04\/21/u)).toBeTruthy();
|
||
expect(within(picker).queryByText('账号 user-1')).toBeNull();
|
||
fireEvent.click(
|
||
await within(picker).findByRole('button', { name: /image\.png/u }),
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.queryByRole('dialog', { name: '选择历史图片' })).toBeNull();
|
||
});
|
||
expect(within(dialog).getByAltText('拼图参考图').getAttribute('src')).toBe(
|
||
'/generated-puzzle-assets/history/image.png',
|
||
);
|
||
|
||
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,
|
||
referenceImageSrcs: [],
|
||
candidateCount: 1,
|
||
shouldAutoNameLevel: false,
|
||
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}
|
||
/>,
|
||
);
|
||
|
||
openPuzzleLevelsTab();
|
||
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}
|
||
/>,
|
||
);
|
||
|
||
openPuzzleLevelsTab();
|
||
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('level image editor exposes entrance image editing controls without sharing UI background state', () => {
|
||
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}
|
||
/>,
|
||
);
|
||
|
||
openPuzzleLevelsTab();
|
||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||
|
||
expect(within(dialog).getByText('画面图')).toBeTruthy();
|
||
expect(within(dialog).getByLabelText('上传参考图')).toBeTruthy();
|
||
expect(within(dialog).getByRole('switch', { name: 'AI重绘' })).toHaveProperty(
|
||
'checked',
|
||
true,
|
||
);
|
||
expect(
|
||
within(dialog).getByRole('button', { name: '选择历史图片' }),
|
||
).toBeTruthy();
|
||
|
||
fireEvent.change(within(dialog).getByLabelText('画面AI重绘要求(提示词)'), {
|
||
target: { value: '只重绘第一关猫街画面' },
|
||
});
|
||
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',
|
||
promptText: '只重绘第一关猫街画面',
|
||
aiRedraw: true,
|
||
}),
|
||
);
|
||
expect(onExecuteAction).not.toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
action: 'generate_puzzle_ui_background',
|
||
}),
|
||
);
|
||
});
|
||
|
||
test('level image editor keeps AI redraw switch scoped to the level image action', () => {
|
||
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}
|
||
/>,
|
||
);
|
||
|
||
openPuzzleLevelsTab();
|
||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||
|
||
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',
|
||
aiRedraw: false,
|
||
}),
|
||
);
|
||
expect(onExecuteAction).not.toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
action: 'generate_puzzle_ui_background',
|
||
aiRedraw: false,
|
||
}),
|
||
);
|
||
});
|
||
|
||
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();
|
||
|
||
render(
|
||
<PuzzleResultView
|
||
session={createSession()}
|
||
onBack={() => {}}
|
||
onExecuteAction={onExecuteAction}
|
||
/>,
|
||
);
|
||
|
||
openPuzzleLevelsTab();
|
||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||
|
||
expect(within(dialog).queryByRole('switch', { name: 'AI重绘' })).toBeNull();
|
||
expect(within(dialog).getByLabelText('画面描述')).toBeTruthy();
|
||
});
|
||
|
||
test('standalone UI background generator stays removed from the result page', () => {
|
||
const onExecuteAction = vi.fn();
|
||
|
||
render(
|
||
<PuzzleResultView
|
||
session={createSession()}
|
||
onBack={() => {}}
|
||
onExecuteAction={onExecuteAction}
|
||
/>,
|
||
);
|
||
|
||
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', () => {
|
||
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,
|
||
}),
|
||
],
|
||
}),
|
||
});
|
||
});
|
||
});
|