// @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, }: { src?: string | null; alt?: string; className?: string; }) => (src ? {alt} : null), })); vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({ puzzleAssetClient: { listHistoryAssets: vi.fn(), }, })); vi.mock('../../services/puzzle-works', () => ({ updatePuzzleWork: vi.fn(), })); afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); }); function createSession( overrides: Partial = {}, ): PuzzleAgentSessionSnapshot { const baseSession: PuzzleAgentSessionSnapshot = { sessionId: 'puzzle-session-1', currentTurn: 2, progressPercent: 88, stage: 'ready_to_publish', anchorPack: { themePromise: { key: 'themePromise', label: '题材承诺', value: '雨夜猫咪', status: 'confirmed', }, visualSubject: { key: 'visualSubject', label: '画面主体', value: '屋檐下的猫', status: 'confirmed', }, visualMood: { key: 'visualMood', label: '视觉气质', value: '温暖', status: 'confirmed', }, compositionHooks: { key: 'compositionHooks', label: '拼图记忆点', value: '雨滴与灯牌', status: 'confirmed', }, tagsAndForbidden: { key: 'tagsAndForbidden', label: '标签与禁忌', value: '猫咪、雨夜', status: 'confirmed', }, }, draft: { levelName: '雨夜猫街', summary: '屋檐下的猫与暖灯街角。', themeTags: ['猫咪', '雨夜'], forbiddenDirectives: [], creatorIntent: null, anchorPack: { themePromise: { key: 'themePromise', label: '题材承诺', value: '雨夜猫咪', status: 'confirmed', }, visualSubject: { key: 'visualSubject', label: '画面主体', value: '屋檐下的猫', status: 'confirmed', }, visualMood: { key: 'visualMood', label: '视觉气质', value: '温暖', status: 'confirmed', }, compositionHooks: { key: 'compositionHooks', label: '拼图记忆点', value: '雨滴与灯牌', status: 'confirmed', }, tagsAndForbidden: { key: 'tagsAndForbidden', label: '标签与禁忌', value: '猫咪、雨夜', status: 'confirmed', }, }, candidates: [ { candidateId: 'candidate-1', imageSrc: '/puzzle/candidate-1.png', assetId: 'asset-1', prompt: '雨夜猫咪', actualPrompt: null, sourceType: 'generated', selected: true, }, ], selectedCandidateId: 'candidate-1', coverImageSrc: '/puzzle/candidate-1.png', coverAssetId: 'asset-1', generationStatus: 'ready', metadata: null, }, 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; } describe('PuzzleResultView', () => { test('auto saves renamed title to the puzzle work profile', async () => { vi.useFakeTimers(); vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({ item: {} as never, }); render( {}} onExecuteAction={() => {}} />, ); fireEvent.change(screen.getByDisplayValue('雨夜猫街'), { target: { value: '暖灯猫街' }, }); await act(async () => { await vi.runAllTimersAsync(); }); expect(puzzleWorksService.updatePuzzleWork).toHaveBeenCalledWith( 'puzzle-profile-session-1', expect.objectContaining({ levelName: '暖灯猫街', summary: '屋檐下的猫与暖灯街角。', themeTags: ['猫咪', '雨夜'], }), ); }); test('uses one ordered list without tabs or persistent publish validation', () => { render( {}} onExecuteAction={() => {}} onStartTestRun={() => {}} />, ); expect(screen.queryByRole('button', { name: '基本信息' })).toBeNull(); expect(screen.queryByRole('button', { name: '拼图图片' })).toBeNull(); const html = document.body.textContent ?? ''; expect(html.indexOf('关卡名称')).toBeLessThan(html.indexOf('画面预览')); expect(html.indexOf('画面预览')).toBeLessThan(html.indexOf('画面描述')); expect(html.indexOf('画面描述')).toBeLessThan(html.indexOf('重新生成画面')); expect(html.indexOf('重新生成画面')).toBeLessThan(html.indexOf('题材标签')); expect(screen.queryByText('作者预览')).toBeNull(); expect(screen.queryByText('发布校验')).toBeNull(); expect(screen.getByRole('button', { name: /作品测试/u })).toBeTruthy(); expect(screen.getByRole('button', { name: /发布/u })).toBeTruthy(); }); test('edits theme tags with chips instead of a persistent tag input', () => { vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({ item: {} as never, }); render( {}} onExecuteAction={() => {}} />, ); expect(screen.queryByLabelText('新题材标签')).toBeNull(); fireEvent.click(screen.getByLabelText('删除标签 猫咪')); expect(screen.queryByText('猫咪')).toBeNull(); expect(screen.getByText('雨夜')).toBeTruthy(); fireEvent.click(screen.getByLabelText('新增题材标签')); fireEvent.change(screen.getByLabelText('新题材标签'), { target: { value: '暖灯' }, }); fireEvent.click(screen.getByRole('button', { name: '添加' })); expect(screen.getByText('暖灯')).toBeTruthy(); expect(screen.queryByLabelText('新题材标签')).toBeNull(); }); test('shows blockers only after clicking publish and blocks publish action', () => { const onExecuteAction = vi.fn(); render( {}} onExecuteAction={onExecuteAction} />, ); expect(screen.queryByText('请先选择正式图')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: /发布/u })); const dialog = screen.getByRole('dialog', { name: '发布拼图作品' }); expect(within(dialog).getByText('请先选择正式图')).toBeTruthy(); fireEvent.click(within(dialog).getByRole('button', { name: '发布到广场' })); expect(onExecuteAction).not.toHaveBeenCalled(); }); test('starts work test from the current editable draft', () => { const onStartTestRun = vi.fn(); render( {}} onExecuteAction={() => {}} onStartTestRun={onStartTestRun} />, ); fireEvent.change(screen.getByDisplayValue('雨夜猫街'), { target: { value: '暖灯猫街' }, }); fireEvent.change(screen.getByLabelText('画面描述'), { target: { value: '一只猫在雨夜灯牌下回头。' }, }); fireEvent.click(screen.getByLabelText('新增题材标签')); fireEvent.change(screen.getByLabelText('新题材标签'), { target: { value: '暖灯' }, }); fireEvent.click(screen.getByRole('button', { name: '添加' })); fireEvent.click(screen.getByRole('button', { name: /作品测试/u })); expect(onStartTestRun).toHaveBeenCalledWith( expect.objectContaining({ levelName: '暖灯猫街', summary: '一只猫在雨夜灯牌下回头。', themeTags: ['猫咪', '雨夜', '暖灯'], }), ); }); test('auto saves edited picture description to the puzzle work profile', async () => { vi.useFakeTimers(); vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({ item: {} as never, }); render( {}} onExecuteAction={() => {}} />, ); fireEvent.change(screen.getByLabelText('画面描述'), { target: { value: '一只猫在雨夜灯牌下回头。' }, }); await act(async () => { await vi.runAllTimersAsync(); }); expect(puzzleWorksService.updatePuzzleWork).toHaveBeenCalledWith( 'puzzle-profile-session-1', expect.objectContaining({ summary: '一只猫在雨夜灯牌下回头。', }), ); }); test('requires at least three theme tags before publish can pass', () => { const onExecuteAction = vi.fn(); render( {}} onExecuteAction={onExecuteAction} />, ); fireEvent.click(screen.getByLabelText('删除标签 猫咪')); fireEvent.click(screen.getByRole('button', { name: /发布/u })); const dialog = screen.getByRole('dialog', { name: '发布拼图作品' }); expect( within(dialog).getByText('正式标签数量必须在 3 到 6 之间。'), ).toBeTruthy(); expect( ( within(dialog).getByRole('button', { name: '发布到广场', }) as HTMLButtonElement ).disabled, ).toBe(true); }); test('publishes with the edited picture description', () => { const onExecuteAction = vi.fn(); render( {}} onExecuteAction={onExecuteAction} />, ); fireEvent.change(screen.getByLabelText('画面描述'), { target: { value: '一只猫在雨夜灯牌下回头。' }, }); fireEvent.click(screen.getByRole('button', { name: /发布/u })); fireEvent.click( within(screen.getByRole('dialog', { name: '发布拼图作品' })).getByRole( 'button', { name: '发布到广场' }, ), ); expect(onExecuteAction).toHaveBeenCalledWith({ action: 'publish_puzzle_work', levelName: '雨夜猫街', summary: '一只猫在雨夜灯牌下回头。', themeTags: ['猫咪', '雨夜', '暖灯'], }); }); test('auto saves added and removed theme tags', async () => { vi.useFakeTimers(); vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({ item: {} as never, }); render( {}} onExecuteAction={() => {}} />, ); fireEvent.click(screen.getByLabelText('新增题材标签')); fireEvent.change(screen.getByLabelText('新题材标签'), { target: { value: '暖灯' }, }); fireEvent.click(screen.getByRole('button', { name: '添加' })); await act(async () => { await vi.runAllTimersAsync(); }); expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith( 'puzzle-profile-session-1', expect.objectContaining({ themeTags: ['猫咪', '雨夜', '暖灯'], }), ); fireEvent.click(screen.getByLabelText('删除标签 猫咪')); await act(async () => { await vi.runAllTimersAsync(); }); expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith( 'puzzle-profile-session-1', expect.objectContaining({ themeTags: ['雨夜', '暖灯'], }), ); }); test('generates one image from the picture description and replaces current image', () => { const onExecuteAction = vi.fn(); render( {}} onExecuteAction={onExecuteAction} />, ); expect(screen.getByText('画面描述')).toBeTruthy(); expect(screen.queryByText(/候选图/u)).toBeNull(); expect(screen.queryByText(/请生成一张适合正方形拼图关卡/u)).toBeNull(); fireEvent.change(screen.getByLabelText('画面描述'), { target: { value: '一只猫在雨夜灯牌下回头。' }, }); fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u })); expect(onExecuteAction).toHaveBeenCalledWith({ action: 'generate_puzzle_images', promptText: '一只猫在雨夜灯牌下回头。', referenceImageSrc: undefined, candidateCount: 1, }); }); test('selects a history puzzle asset as reference image for the next generation', 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: '2026-04-27T10:00:00.000Z', updatedAt: '2026-04-27T10:00:00.000Z', }, ]); render( {}} onExecuteAction={onExecuteAction} />, ); fireEvent.click(screen.getByLabelText('从历史拼图素材库选择')); const dialog = await screen.findByRole('dialog', { name: '选择历史拼图素材', }); fireEvent.click( await within(dialog).findByRole('button', { name: /账号 user-1/u }), ); await waitFor(() => { expect( screen.queryByRole('dialog', { name: '选择历史拼图素材' }), ).toBeNull(); }); fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u })); expect(onExecuteAction).toHaveBeenLastCalledWith({ action: 'generate_puzzle_images', promptText: '屋檐下的猫与暖灯街角。', referenceImageSrc: '/generated-puzzle-assets/history/image.png', candidateCount: 1, }); }); test('refreshes the current formal image when session cover image changes', async () => { const { rerender } = render( {}} onExecuteAction={() => {}} />, ); expect( screen.getByRole('img', { name: '雨夜猫街' }).getAttribute('src'), ).toBe('/puzzle/candidate-1.png'); rerender( {}} onExecuteAction={() => {}} />, ); await waitFor(() => { expect( screen.getByRole('img', { name: '雨夜猫街' }).getAttribute('src'), ).toBe('/puzzle/candidate-2.png'); }); }); test('prefers the selected latest candidate image when coverImageSrc lags behind', async () => { render( {}} onExecuteAction={() => {}} />, ); await waitFor(() => { expect( screen.getByRole('img', { name: '雨夜猫街' }).getAttribute('src'), ).toBe('/puzzle/candidate-2.png'); }); }); });