/* @vitest-environment jsdom */ import { act, fireEvent, render, screen, within } from '@testing-library/react'; import { expect, test, vi } from 'vitest'; import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; import { AuthUiContext } from '../auth/AuthUiContext'; import { PuzzleRuntimeShell } from './PuzzleRuntimeShell'; vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({ useResolvedAssetReadUrl: () => ({ resolvedUrl: '', isResolving: false, shouldResolve: false, }), })); vi.mock('../ResolvedAssetImage', () => ({ ResolvedAssetImage: () => null, })); function createAuthValue() { return { user: null, canAccessProtectedData: false, openLoginModal: () => {}, requireAuth: (action: () => void) => action(), openSettingsModal: () => {}, openAccountModal: () => {}, logout: async () => {}, musicVolume: 0.42, setMusicVolume: vi.fn(), platformTheme: 'light' as const, setPlatformTheme: vi.fn(), isHydratingSettings: false, isPersistingSettings: false, settingsError: null, }; } function renderPuzzleRuntime( ui: React.ReactElement, authValue = createAuthValue(), ) { return render( {ui}, ); } const clearedRun: PuzzleRunSnapshot = { runId: 'run-1', entryProfileId: 'profile-1', clearedLevelCount: 1, currentLevelIndex: 1, currentGridSize: 3, playedProfileIds: ['profile-1'], previousLevelTags: ['奇幻'], recommendedNextProfileId: 'profile-2', leaderboardEntries: [ { rank: 1, nickname: '测试作者', elapsedMs: 12_340, isCurrentPlayer: true, }, { rank: 2, nickname: '星桥旅人', elapsedMs: 18_120, }, ], currentLevel: { runId: 'run-1', levelIndex: 1, gridSize: 3, profileId: 'profile-1', levelName: '潮雾拼图', authorDisplayName: '测试作者', themeTags: ['奇幻'], coverImageSrc: null, status: 'cleared', startedAtMs: 1000, clearedAtMs: 13_340, elapsedMs: 12_340, leaderboardEntries: [ { rank: 1, nickname: '测试作者', elapsedMs: 12_340, isCurrentPlayer: true, }, { rank: 2, nickname: '星桥旅人', elapsedMs: 18_120, }, ], board: { rows: 3, cols: 3, selectedPieceId: null, allTilesResolved: true, mergedGroups: [], pieces: Array.from({ length: 9 }, (_, index) => ({ pieceId: `piece-${index}`, correctRow: Math.floor(index / 3), correctCol: index % 3, currentRow: Math.floor(index / 3), currentCol: index % 3, mergedGroupId: null, })), }, }, }; test('通关后显示结算弹窗、排行榜和下一关按钮', () => { vi.useFakeTimers(); const onAdvanceNextLevel = vi.fn(); renderPuzzleRuntime( , ); expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull(); expect(screen.getByTestId('puzzle-clear-flash')).toBeTruthy(); act(() => { vi.advanceTimersByTime(1_400); }); const dialog = screen.getByRole('dialog', { name: '通关完成' }); expect(within(dialog).getAllByText('0:12.34').length).toBeGreaterThan(0); expect(within(dialog).getByText('排行榜')).toBeTruthy(); expect(within(dialog).getByText('#1')).toBeTruthy(); expect(within(dialog).getByText('测试作者')).toBeTruthy(); fireEvent.click(within(dialog).getByRole('button', { name: '下一关' })); expect(onAdvanceNextLevel).toHaveBeenCalledTimes(1); vi.useRealTimers(); }); test('关闭通关弹窗后保留底部下一关入口', () => { vi.useFakeTimers(); renderPuzzleRuntime( , ); act(() => { vi.advanceTimersByTime(1_400); }); fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' })); expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull(); expect(screen.getByRole('button', { name: /下一关/u })).toBeTruthy(); vi.useRealTimers(); }); test('右上角设置按钮打开拼图设置并支持音量调节', () => { const authValue = createAuthValue(); renderPuzzleRuntime( , authValue, ); fireEvent.click(screen.getByRole('button', { name: '打开拼图设置' })); const dialog = screen.getByRole('dialog', { name: '拼图设置' }); const slider = within(dialog).getByRole('slider', { name: '拼图音乐音量' }); fireEvent.change(slider, { target: { value: '77' } }); expect(within(dialog).getByText('第 1 关')).toBeTruthy(); expect(authValue.setMusicVolume).toHaveBeenCalledWith(0.77); }); test('合并块按实际拼块外轮廓描边', () => { const mergedRun: PuzzleRunSnapshot = { ...clearedRun, currentLevel: { ...clearedRun.currentLevel!, status: 'playing', board: { ...clearedRun.currentLevel!.board, allTilesResolved: false, mergedGroups: [ { groupId: 'group-l', pieceIds: ['piece-0', 'piece-1', 'piece-3'], occupiedCells: [ { row: 0, col: 0 }, { row: 0, col: 1 }, { row: 1, col: 0 }, ], }, ], pieces: clearedRun.currentLevel!.board.pieces.map((piece) => ['piece-0', 'piece-1', 'piece-3'].includes(piece.pieceId) ? { ...piece, mergedGroupId: 'group-l' } : piece, ), }, }, }; const { container } = renderPuzzleRuntime( , ); const outlinedPieces = container.querySelectorAll( '[data-merged-piece-outline="true"]', ); expect(outlinedPieces).toHaveLength(3); expect(container.querySelector('.ring-2.ring-emerald-100\\/58')).toBeNull(); expect(outlinedPieces[0]?.className).toContain('border-r-0'); expect(outlinedPieces[0]?.className).toContain('border-b-0'); expect(outlinedPieces[0]?.className).toContain('rounded-tl-[0.85rem]'); expect(outlinedPieces[0]?.className).toContain('rounded-tr-none'); expect(outlinedPieces[0]?.className).toContain('rounded-bl-none'); expect(outlinedPieces[1]?.className).toContain('border-l-0'); expect(outlinedPieces[1]?.className).toContain('rounded-tr-[0.85rem]'); expect(outlinedPieces[2]?.className).toContain('border-t-0'); expect(outlinedPieces[2]?.className).toContain('rounded-bl-[0.85rem]'); });