// @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 ? ( {alt} ) : 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 { 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} onExecuteAction={() => {}} />, ); openPuzzleLevelsTab(); fireEvent.click(screen.getByText('第三关')); const dialog = screen.getByRole('dialog', { name: '关卡详情' }); fireEvent.change(within(dialog).getByLabelText('画面描述'), { target: { value: '正在编辑第三关的信息。' }, }); rerender( {}} 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( {}} 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( {}} onExecuteAction={onExecuteAction} />, ); fireEvent.click(screen.getByRole('button', { name: /发布/u })); const dialog = screen.getByRole('dialog', { name: '发布拼图作品' }); fireEvent.click( within(dialog).getByRole('button', { name: /发布到广场/u }), ); rerender( {}} 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( {}} 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( {}} 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( {}} 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( {}} onExecuteAction={() => {}} onStartTestRun={onStartTestRun} />, ); rerender( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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, }), ], }), }); }); });