/* @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 ? {alt} : 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, ); });