Files
Genarrative/src/components/puzzle-result/PuzzleResultView.test.tsx
高物 ae014ac881 Switch to VectorEngine gpt-image-2 and edits
Replace uses of the legacy `gpt-image-2-all` model with `gpt-image-2` and standardize image workflows: no-reference generation uses POST /v1/images/generations, any-reference flows use POST /v1/images/edits with multipart `image` parts. Update SKILLs, generation scripts, decision logs, and docs to reflect the contract change and edits-vs-generations guidance. Apply corresponding changes across backend (api-server match3d/puzzle modules, openai image adapter, mappers, telemetry, spacetime client/module), frontend components and services (Match3D, Puzzle, CreativeImageInputPanel, runtime shells), and add new spritesheet/parser files and tests. Also add media/logo.png. These changes align repository code and documentation with the VectorEngine image API contract and update generation/upload handling (green-screen -> alpha processing, spritesheet handling, and related tests).
2026-05-22 03:06:41 +08:00

1633 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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,
}),
],
}),
});
});
});