/* @vitest-environment jsdom */
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient';
import { PuzzleAgentWorkspace } from './PuzzleAgentWorkspace';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ?
: null),
}));
vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({
puzzleAssetClient: {
listHistoryAssets: vi.fn(),
uploadReferenceImage: vi.fn(),
},
}));
const baseSession: PuzzleAgentSessionSnapshot = {
sessionId: 'puzzle-session-1',
currentTurn: 3,
progressPercent: 62,
stage: 'collecting_anchors',
anchorPack: {
themePromise: {
key: 'themePromise',
label: '题材承诺',
value: '雾港遗迹拼图',
status: 'confirmed',
},
visualSubject: {
key: 'visualSubject',
label: '画面主体',
value: '潮雾中的灯塔与断桥',
status: 'confirmed',
},
visualMood: {
key: 'visualMood',
label: '视觉气质',
value: '',
status: 'missing',
},
compositionHooks: {
key: 'compositionHooks',
label: '拼图记忆点',
value: '',
status: 'missing',
},
tagsAndForbidden: {
key: 'tagsAndForbidden',
label: '标签与禁忌',
value: '',
status: 'missing',
},
},
draft: null,
messages: [
{
id: 'message-1',
role: 'assistant',
kind: 'chat',
text: '旧会话消息不再渲染为聊天入口。',
createdAt: '2026-04-24T10:00:00.000Z',
},
],
lastAssistantReply: '旧会话消息不再渲染为聊天入口。',
publishedProfileId: null,
suggestedActions: [],
resultPreview: null,
updatedAt: '2026-04-24T10:00:00.000Z',
};
beforeEach(() => {
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
vi.mocked(puzzleAssetClient.uploadReferenceImage).mockImplementation(
async ({ file }) => ({
assetObjectId: `asset-reference-${file.name}`,
assetKind: 'puzzle_cover_image',
objectKey: `generated-puzzle-assets/reference/${file.name}`,
imageSrc: `/generated-puzzle-assets/reference/${file.name}`,
ownerUserId: 'user-1',
ownerLabel: '账号 user-1',
profileId: null,
entityId: null,
createdAt: '1713686400.000000Z',
updatedAt: '1713686400.000000Z',
}),
);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
vi.clearAllMocks();
});
function stubReferenceImageUpload(dataUrl: string, width = 512, height = 512) {
class MockFileReader {
result: string | null = null;
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
readAsDataURL() {
this.result = dataUrl;
this.onload?.();
}
}
class MockImage {
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
naturalWidth = width;
naturalHeight = height;
width = width;
height = height;
set src(_value: string) {
this.onload?.();
}
}
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
vi.stubGlobal('Image', MockImage as unknown as typeof Image);
}
function stubCanvas(dataUrl: string, drawImage = vi.fn()) {
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, 'createElement').mockImplementation((tagName) => {
if (tagName !== 'canvas') {
return originalCreateElement(tagName);
}
return {
width: 0,
height: 0,
getContext: () => ({
drawImage,
fillRect: vi.fn(),
fillStyle: '',
imageSmoothingEnabled: false,
imageSmoothingQuality: 'low',
}),
toDataURL: vi.fn(() => dataUrl),
} as unknown as HTMLCanvasElement;
});
return drawImage;
}
function confirmPuzzlePointCost() {
const confirmDialog = screen.getByRole('dialog', {
name: '确认消耗泥点',
});
expect(within(confirmDialog).getByText('消耗 2 泥点')).toBeTruthy();
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确定' }));
}
test('puzzle workspace submits the work form instead of agent chat', () => {
const onCreateFromForm = vi.fn();
render(
{}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
expect(screen.queryByLabelText('作品名称')).toBeNull();
expect(screen.queryByLabelText('作品描述')).toBeNull();
expect(screen.getByText('想做个什么玩法?')).toBeTruthy();
expect(screen.queryByText('try')).toBeNull();
expect(screen.queryByText('Template')).toBeNull();
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
expect(onCreateFromForm).not.toHaveBeenCalled();
confirmPuzzlePointCost();
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '一只猫在雨夜灯牌下回头。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
});
expect(screen.getByText('消耗2泥点')).toBeTruthy();
expect(screen.queryByRole('button', { name: '补充剩余设定' })).toBeNull();
expect(screen.queryByText('旧会话消息不再渲染为聊天入口。')).toBeNull();
});
test('puzzle workspace keeps the reference image upload as a primary panel', () => {
const onCreateFromForm = vi.fn();
const { container } = render(
{}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
const uploadInput = screen.getByLabelText('上传拼图图片', {
selector: 'input',
});
const uploadCard = uploadInput.closest('.puzzle-image-upload-card');
expect(uploadCard).not.toBeNull();
expect(uploadCard?.closest('.platform-subpanel')).toBeNull();
expect(container.querySelector('.puzzle-image-upload-card')).toBeTruthy();
expect(container.querySelector('.puzzle-creation-form-body')?.className).toContain(
'overflow-hidden',
);
expect(container.querySelector('.puzzle-image-field')?.className).toContain(
'flex-1',
);
expect(screen.getByText('拼图画面')).toBeTruthy();
expect(
screen.queryByText('若没有合适的图片可以通过填写画面描述生成画面'),
).toBeNull();
expect(
screen
.getByText('上传图片/填写画面描述')
.closest('.puzzle-image-upload-card'),
).toBeTruthy();
expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull();
expect(screen.queryByLabelText('拼图创作模板')).toBeNull();
expect(
(screen.getByLabelText('画面描述') as HTMLTextAreaElement).value,
).toBe('');
expect(
(screen.getByLabelText('画面描述') as HTMLTextAreaElement).placeholder,
).toBe('');
expect(screen.queryByText(/一只猫在雨夜灯牌下回头/u)).toBeNull();
expect(screen.getByLabelText('画面描述').className).toContain('h-[6rem]');
expect(uploadCard?.className).toContain('aspect-square');
expect(uploadCard?.className).toContain('h-full');
expect(
screen
.getByRole('button', { name: /生成拼图游戏草稿/u })
.parentElement?.className,
).toContain('justify-center');
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在阳光窗台上看着毛线球。' },
});
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
confirmPuzzlePointCost();
expect(onCreateFromForm).toHaveBeenCalledWith(
expect.objectContaining({
pictureDescription: '一只猫在阳光窗台上看着毛线球。',
}),
);
});
test('puzzle workspace selects a history image from the upload card', async () => {
const onCreateFromForm = 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(
{}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
const historyButton = screen.getByRole('button', { name: '选择历史图片' });
expect(historyButton.closest('.puzzle-image-upload-card')).toBeTruthy();
expect(historyButton.className).toContain('top-3');
expect(historyButton.className).toContain('right-3');
expect(historyButton.className).not.toContain('bottom-3');
expect(screen.getByText('历史').closest('.puzzle-image-upload-card')).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(screen.getByAltText('拼图图片')).toHaveProperty(
'src',
expect.stringContaining('/generated-puzzle-assets/history/image.png'),
);
expect(screen.getByLabelText('画面AI重绘要求(提示词)')).toBeTruthy();
fireEvent.change(screen.getByLabelText('画面AI重绘要求(提示词)'), {
target: { value: '保留历史图里的主体,改成晴天花园。' },
});
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
confirmPuzzlePointCost();
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '保留历史图里的主体,改成晴天花园。',
pictureDescription: '保留历史图里的主体,改成晴天花园。',
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: 'asset-history-1',
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
});
});
test('puzzle upload card stays light in light theme', () => {
const onCreateFromForm = vi.fn();
const { container } = render(
{}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
expect(container.querySelector('.puzzle-image-upload-card')).toBeTruthy();
const uploadLabel = screen.getByText('上传图片/填写画面描述');
expect(uploadLabel).toBeTruthy();
expect(uploadLabel.closest('.puzzle-image-upload-card')).toBeTruthy();
expect(uploadLabel.className).not.toContain('rounded-full');
expect(uploadLabel.className).not.toContain('bg-white/94');
expect(uploadLabel.className).not.toContain('border');
expect(screen.queryByText('AI重绘')).toBeNull();
expect(container.querySelector('.puzzle-image-upload-card')?.className).toContain(
'bg-white/90',
);
expect(container.querySelector('.puzzle-image-upload-card')?.className).not.toContain(
'bg-slate-950',
);
});
test('puzzle workspace falls back to compile action for restored sessions', () => {
const onExecuteAction = vi.fn();
const onCreateFromForm = vi.fn();
render(
{}}
onSubmitMessage={() => {}}
onExecuteAction={onExecuteAction}
onCreateFromForm={onCreateFromForm}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
confirmPuzzlePointCost();
expect(onCreateFromForm).not.toHaveBeenCalled();
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'compile_puzzle_draft',
pictureDescription: '潮雾中的灯塔与断桥',
promptText: '潮雾中的灯塔与断桥',
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
candidateCount: 1,
});
});
test('puzzle workspace switches the image model from the description box', () => {
const onCreateFromForm = vi.fn();
render(
{}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
fireEvent.click(screen.getByRole('button', { name: '图片模型' }));
expect(screen.queryByRole('menuitemradio', { name: '原模型' })).toBeNull();
fireEvent.click(screen.getByRole('menuitemradio', { name: 'nanobanana2' }));
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
confirmPuzzlePointCost();
expect(onCreateFromForm).toHaveBeenCalledWith(
expect.objectContaining({
imageModel: 'gemini-3.1-flash-image-preview',
}),
);
});
test('puzzle workspace restores form draft fields and autosaves edits', () => {
vi.useFakeTimers();
const onAutoSaveForm = vi.fn();
const formDraftSession: PuzzleAgentSessionSnapshot = {
...baseSession,
seedText: '画面描述:旧街灯牌下的猫。',
draft: {
workTitle: '旧街拼图',
workDescription: '旧街雨夜的拼图草稿。',
levelName: '旧街灯牌',
summary: '旧街雨夜的拼图草稿。',
themeTags: ['旧街', '雨夜', '猫'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack: baseSession.anchorPack,
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'idle',
levels: [
{
levelId: 'puzzle-level-1',
levelName: '旧街灯牌',
pictureDescription: '旧街灯牌下的猫。',
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'idle',
},
],
formDraft: {
pictureDescription: '旧街灯牌下的猫。',
},
},
};
render(
{}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onAutoSaveForm={onAutoSaveForm}
/>,
);
expect(
(screen.getByLabelText('画面描述') as HTMLTextAreaElement).value,
).toBe('旧街灯牌下的猫。');
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '旧街灯牌下的猫和发光雨伞。' },
});
act(() => {
vi.advanceTimersByTime(700);
});
expect(onAutoSaveForm).toHaveBeenCalledWith({
seedText: '旧街灯牌下的猫和发光雨伞。',
pictureDescription: '旧街灯牌下的猫和发光雨伞。',
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
});
});
test('puzzle workspace hides prompt and cost when AI redraw is off', async () => {
const onCreateFromForm = vi.fn();
const uploadedDataUrl = 'data:image/png;base64,uploaded-square';
stubReferenceImageUpload(uploadedDataUrl);
render(
{}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
const input = screen.getByLabelText('上传拼图图片', {
selector: 'input',
});
fireEvent.change(input, {
target: {
files: [new File(['x'], 'first-level.png', { type: 'image/png' })],
},
});
await waitFor(() => {
expect(screen.getByLabelText('画面AI重绘要求(提示词)')).toBeTruthy();
});
expect(screen.queryByText('first-level.png')).toBeNull();
const aiRedrawSwitch = screen.getByRole('switch', { name: 'AI重绘' });
expect((aiRedrawSwitch as HTMLInputElement).checked).toBe(true);
fireEvent.click(aiRedrawSwitch);
expect(screen.queryByLabelText('画面AI重绘要求(提示词)')).toBeNull();
expect(screen.queryByText('消耗2泥点')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: 'first-level.png',
pictureDescription: 'first-level.png',
referenceImageSrc: '/generated-puzzle-assets/reference/first-level.png',
referenceImageSrcs: [],
referenceImageAssetObjectId: 'asset-reference-first-level.png',
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: false,
});
});
test('puzzle workspace submits history image when AI redraw is off', async () => {
const onCreateFromForm = 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(
{}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '选择历史图片' }));
const picker = await screen.findByRole('dialog', {
name: '选择历史图片',
});
fireEvent.click(
await within(picker).findByRole('button', { name: /image\.png/u }),
);
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '选择历史图片' })).toBeNull();
});
const aiRedrawSwitch = screen.getByRole('switch', { name: 'AI重绘' });
fireEvent.click(aiRedrawSwitch);
expect(screen.queryByLabelText('画面AI重绘要求(提示词)')).toBeNull();
expect(screen.queryByText('消耗2泥点')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '历史素材 · image.png',
pictureDescription: '历史素材 · image.png',
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
referenceImageSrcs: [],
referenceImageAssetObjectId: 'asset-history-1',
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: false,
});
});
test('puzzle workspace submits uploaded reference image when AI redraw is on', async () => {
const onCreateFromForm = vi.fn();
const uploadedDataUrl = 'data:image/png;base64,uploaded-square';
stubReferenceImageUpload(uploadedDataUrl);
vi.mocked(puzzleAssetClient.uploadReferenceImage).mockResolvedValue({
assetObjectId: 'asset-reference-main-1',
assetKind: 'puzzle_cover_image',
objectKey: 'generated-puzzle-assets/reference/main-1.png',
imageSrc: '/generated-puzzle-assets/reference/main-1.png',
ownerUserId: 'user-1',
ownerLabel: '账号 user-1',
profileId: null,
entityId: null,
createdAt: '1713686400.000000Z',
updatedAt: '1713686400.000000Z',
});
render(
{}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
fireEvent.change(screen.getByLabelText('上传拼图图片', { selector: 'input' }), {
target: {
files: [new File(['x'], 'first-level.png', { type: 'image/png' })],
},
});
await waitFor(() => {
expect(screen.getByAltText('拼图图片')).toBeTruthy();
});
expect(puzzleAssetClient.uploadReferenceImage).toHaveBeenCalledWith({
file: expect.any(File),
});
fireEvent.change(screen.getByLabelText('画面AI重绘要求(提示词)'), {
target: { value: '保留上传画面的主体和构图,改成雨夜灯街。' },
});
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
confirmPuzzlePointCost();
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '保留上传画面的主体和构图,改成雨夜灯街。',
pictureDescription: '保留上传画面的主体和构图,改成雨夜灯街。',
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: 'asset-reference-main-1',
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
});
});
test('puzzle workspace uploads prompt references as asset object ids', async () => {
const onCreateFromForm = vi.fn();
const uploadedSources = [
'data:image/png;base64,reference-1',
'data:image/png;base64,reference-2',
];
let readIndex = 0;
stubReferenceImageUpload(uploadedSources[0] ?? 'data:image/png;base64,reference-1');
class MockFileReader {
result: string | null = null;
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
readAsDataURL() {
this.result = uploadedSources[readIndex] ?? uploadedSources[0] ?? '';
readIndex += 1;
this.onload?.();
}
}
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
vi.mocked(puzzleAssetClient.uploadReferenceImage)
.mockResolvedValueOnce({
assetObjectId: 'asset-reference-prompt-1',
assetKind: 'puzzle_cover_image',
objectKey: 'generated-puzzle-assets/reference/prompt-1.png',
imageSrc: '/generated-puzzle-assets/reference/prompt-1.png',
ownerUserId: 'user-1',
ownerLabel: '账号 user-1',
profileId: null,
entityId: null,
createdAt: '1713686400.000000Z',
updatedAt: '1713686400.000000Z',
})
.mockResolvedValueOnce({
assetObjectId: 'asset-reference-prompt-2',
assetKind: 'puzzle_cover_image',
objectKey: 'generated-puzzle-assets/reference/prompt-2.png',
imageSrc: '/generated-puzzle-assets/reference/prompt-2.png',
ownerUserId: 'user-1',
ownerLabel: '账号 user-1',
profileId: null,
entityId: null,
createdAt: '1713686400.000000Z',
updatedAt: '1713686400.000000Z',
});
render(
{}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
fireEvent.change(screen.getByLabelText('上传参考图', { selector: 'input' }), {
target: {
files: uploadedSources.map(
(_source, index) =>
new File(['x'], `reference-${index + 1}.png`, {
type: 'image/png',
}),
),
},
});
await waitFor(() => {
expect(screen.getAllByRole('button', { name: /预览参考图/u })).toHaveLength(
2,
);
});
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
confirmPuzzlePointCost();
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '一只猫在雨夜灯牌下回头。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [
'asset-reference-prompt-1',
'asset-reference-prompt-2',
],
imageModel: 'gpt-image-2',
aiRedraw: true,
});
});
test('puzzle workspace uploads prompt reference images from the description box', async () => {
const onCreateFromForm = vi.fn();
const uploadedSources = [
'data:image/png;base64,reference-1',
'data:image/png;base64,reference-2',
'data:image/png;base64,reference-3',
'data:image/png;base64,reference-4',
'data:image/png;base64,reference-5',
'data:image/png;base64,reference-6',
];
let readIndex = 0;
const firstUploadedSource = uploadedSources[0] || 'data:image/png;base64,reference-1';
stubReferenceImageUpload(firstUploadedSource);
class MockFileReader {
result: string | null = null;
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
readAsDataURL() {
this.result =
uploadedSources[Math.min(readIndex, uploadedSources.length - 1)] ||
firstUploadedSource;
readIndex += 1;
this.onload?.();
}
}
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
render(
{}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
fireEvent.change(screen.getByLabelText('上传参考图', { selector: 'input' }), {
target: {
files: uploadedSources.map(
(_source, index) =>
new File(['x'], `reference-${index + 1}.png`, {
type: 'image/png',
}),
),
},
});
await waitFor(() => {
expect(screen.getAllByRole('button', { name: /预览参考图/u })).toHaveLength(
5,
);
});
expect(screen.getByText('参考图最多上传 5 张。')).toBeTruthy();
fireEvent.click(
screen.getByRole('button', { name: /预览参考图 reference-1\.png/u }),
);
expect(
await screen.findByRole('dialog', { name: 'reference-1.png' }),
).toBeTruthy();
expect(screen.getByAltText('参考图预览')).toHaveProperty(
'src',
expect.stringContaining('reference-1'),
);
fireEvent.click(screen.getByRole('button', { name: '关闭参考图预览' }));
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
confirmPuzzlePointCost();
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '一只猫在雨夜灯牌下回头。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [
'asset-reference-reference-1.png',
'asset-reference-reference-2.png',
'asset-reference-reference-3.png',
'asset-reference-reference-4.png',
'asset-reference-reference-5.png',
],
imageModel: 'gpt-image-2',
aiRedraw: true,
});
});
test('puzzle workspace shows AI redraw switch only after upload', async () => {
const uploadedDataUrl = 'data:image/png;base64,uploaded-square';
stubReferenceImageUpload(uploadedDataUrl);
render(
{}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={() => {}}
/>,
);
expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull();
fireEvent.change(screen.getByLabelText('上传拼图图片', { selector: 'input' }), {
target: {
files: [new File(['x'], 'first-level.png', { type: 'image/png' })],
},
});
await waitFor(() => {
expect(screen.getByRole('switch', { name: 'AI重绘' })).toBeTruthy();
});
expect(
screen.getByRole('switch', { name: 'AI重绘' }).closest('.puzzle-image-upload-card'),
).toBeTruthy();
expect(screen.getByRole('button', { name: '移除拼图图片' })).toBeTruthy();
expect(screen.getByRole('button', { name: '移除拼图图片' }).className).toContain(
'left-3',
);
expect(screen.getByRole('button', { name: '选择历史图片' }).className).toContain(
'right-3',
);
expect(screen.queryByText('上传图片/填写画面描述')).toBeNull();
});
test('puzzle workspace confirms before removing uploaded image', async () => {
const uploadedDataUrl = 'data:image/png;base64,uploaded-square';
stubReferenceImageUpload(uploadedDataUrl);
render(
{}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={() => {}}
/>,
);
fireEvent.change(screen.getByLabelText('上传拼图图片', { selector: 'input' }), {
target: {
files: [new File(['x'], 'first-level.png', { type: 'image/png' })],
},
});
await waitFor(() => {
expect(screen.getByAltText('拼图图片')).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: '移除拼图图片' }));
expect(
screen.getByRole('dialog', { name: '移除拼图图片?' }),
).toBeTruthy();
expect(screen.getByAltText('拼图图片')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '取消' }));
expect(screen.queryByRole('dialog', { name: '移除拼图图片?' })).toBeNull();
expect(screen.getByAltText('拼图图片')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '移除拼图图片' }));
fireEvent.click(screen.getByRole('button', { name: '移除' }));
expect(screen.queryByAltText('拼图图片')).toBeNull();
expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull();
expect(screen.getByText('上传图片/填写画面描述')).toBeTruthy();
});
test('puzzle workspace opens crop tool for non-square uploads', async () => {
const sourceDataUrl = 'data:image/png;base64,wide-source';
const croppedDataUrl = 'data:image/jpeg;base64,Y3JvcHBlZC1zcXVhcmU=';
stubReferenceImageUpload(sourceDataUrl, 800, 600);
const drawImage = stubCanvas(croppedDataUrl);
render(
{}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={() => {}}
/>,
);
fireEvent.change(
screen.getByLabelText('上传拼图图片', { selector: 'input' }),
{
target: {
files: [new File(['x'], 'wide.png', { type: 'image/png' })],
},
},
);
await waitFor(() => {
expect(screen.getByRole('dialog', { name: '裁剪拼图图片' })).toBeTruthy();
});
expect(
screen.getByRole('button', { name: '拖拽右下角裁剪边界' }),
).toBeTruthy();
expect(screen.queryByText('缩放')).toBeNull();
expect(screen.queryByText('横向')).toBeNull();
expect(screen.queryByText('纵向')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '应用' }));
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '裁剪拼图图片' })).toBeNull();
});
expect(screen.queryByText('wide.png')).toBeNull();
expect(screen.getByAltText('拼图图片')).toBeTruthy();
expect(drawImage).toHaveBeenCalledWith(
expect.anything(),
100,
0,
600,
600,
0,
0,
600,
600,
);
});