989 lines
31 KiB
TypeScript
989 lines
31 KiB
TypeScript
/* @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 ? <img src={src} alt={alt} className={className} /> : 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(
|
||
<PuzzleAgentWorkspace
|
||
session={null}
|
||
onBack={() => {}}
|
||
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(
|
||
<PuzzleAgentWorkspace
|
||
session={null}
|
||
onBack={() => {}}
|
||
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(
|
||
<PuzzleAgentWorkspace
|
||
session={null}
|
||
onBack={() => {}}
|
||
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(
|
||
<PuzzleAgentWorkspace
|
||
session={null}
|
||
onBack={() => {}}
|
||
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(
|
||
<PuzzleAgentWorkspace
|
||
session={baseSession}
|
||
onBack={() => {}}
|
||
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(
|
||
<PuzzleAgentWorkspace
|
||
session={null}
|
||
onBack={() => {}}
|
||
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(
|
||
<PuzzleAgentWorkspace
|
||
session={formDraftSession}
|
||
onBack={() => {}}
|
||
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(
|
||
<PuzzleAgentWorkspace
|
||
session={null}
|
||
onBack={() => {}}
|
||
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(
|
||
<PuzzleAgentWorkspace
|
||
session={null}
|
||
onBack={() => {}}
|
||
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(
|
||
<PuzzleAgentWorkspace
|
||
session={null}
|
||
onBack={() => {}}
|
||
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(
|
||
<PuzzleAgentWorkspace
|
||
session={null}
|
||
onBack={() => {}}
|
||
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(
|
||
<PuzzleAgentWorkspace
|
||
session={null}
|
||
onBack={() => {}}
|
||
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(
|
||
<PuzzleAgentWorkspace
|
||
session={null}
|
||
onBack={() => {}}
|
||
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(
|
||
<PuzzleAgentWorkspace
|
||
session={null}
|
||
onBack={() => {}}
|
||
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(
|
||
<PuzzleAgentWorkspace
|
||
session={null}
|
||
onBack={() => {}}
|
||
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,
|
||
);
|
||
});
|