/* @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 { buildMergedGroupOutlinePath, resolveDraggedMergedGroupLayer, resolveDraggedPieceCellLayer, resolveDraggedPieceLayer, } from './puzzleRuntimeShape'; import { PuzzleRuntimeShell } from './PuzzleRuntimeShell'; vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({ useResolvedAssetReadUrl: (src: string | null) => ({ resolvedUrl: src ?? '', isResolving: false, shouldResolve: false, }), })); vi.mock('../ResolvedAssetImage', () => ({ ResolvedAssetImage: ({ src, alt, className, }: { src?: string | null; alt?: string; className?: string; }) => (src ? {alt} : null), })); function createAuthValue() { return { user: null, canAccessProtectedData: false, openLoginModal: () => {}, requireAuth: (action: () => void) => action(), openSettingsModal: () => {}, openAccountModal: () => {}, setCurrentUser: vi.fn(), 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}, ); } function dispatchPointerEvent( target: HTMLElement, type: string, options: { pointerId: number; clientX: number; clientY: number }, ) { const event = new Event(type, { bubbles: true, cancelable: true }); Object.assign(event, options); target.dispatchEvent(event); } 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, levelId: 'puzzle-level-1', gridSize: 3, profileId: 'profile-1', levelName: '潮雾拼图', authorDisplayName: '测试作者', themeTags: ['奇幻'], coverImageSrc: null, status: 'cleared', startedAtMs: 1000, clearedAtMs: 13_340, elapsedMs: 12_340, timeLimitMs: 300_000, remainingMs: 287_660, pausedAccumulatedMs: 0, pauseStartedAtMs: null, freezeAccumulatedMs: 0, freezeStartedAtMs: null, freezeUntilMs: null, 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('拼图界面不调用 mocap,也不渲染 mocap 光标或调试面板', () => { renderPuzzleRuntime( , ); expect(screen.queryByTestId('puzzle-mocap-debug')).toBeNull(); expect(screen.queryByTestId('puzzle-mocap-cursor')).toBeNull(); }); test('指针拖拽时会触发拖拽提交并在松开时落子', () => { const onDragPiece = vi.fn(); const playingRun: PuzzleRunSnapshot = { ...clearedRun, currentLevel: { ...clearedRun.currentLevel!, status: 'playing', startedAtMs: Date.now(), remainingMs: 300_000, timeLimitMs: 300_000, board: { ...clearedRun.currentLevel!.board, allTilesResolved: false, pieces: clearedRun.currentLevel!.board.pieces.map((piece) => piece.pieceId === 'piece-0' ? {...piece, currentRow: 0, currentCol: 0} : piece, ), }, }, }; const { container } = renderPuzzleRuntime( , ); const piece = container.querySelector('[data-piece-id="piece-0"]') as HTMLElement | null; if (!piece) { throw new Error('缺少测试拼图片'); } const board = container.querySelector('[data-testid="puzzle-board"]') as HTMLElement | null; if (!board) { throw new Error('缺少测试棋盘'); } board.getBoundingClientRect = () => ({ x: 0, y: 0, left: 0, top: 0, right: 300, bottom: 300, width: 300, height: 300, toJSON: () => ({}), } as DOMRect); act(() => { dispatchPointerEvent(piece, 'pointerdown', { pointerId: 11, clientX: 40, clientY: 40, }); }); act(() => { dispatchPointerEvent(piece, 'pointermove', { pointerId: 11, clientX: 70, clientY: 70, }); }); act(() => { dispatchPointerEvent(piece, 'pointermove', { pointerId: 11, clientX: 140, clientY: 140, }); }); act(() => { dispatchPointerEvent(piece, 'pointerup', { pointerId: 11, clientX: 140, clientY: 140, }); }); expect(onDragPiece).toHaveBeenCalledTimes(1); expect(onDragPiece).toHaveBeenCalledWith( expect.objectContaining({pieceId: 'piece-0'}), ); }); test('指针拖拽合并大块时按大块锚点提交拖拽', () => { const originalRequestAnimationFrame = window.requestAnimationFrame; const originalCancelAnimationFrame = window.cancelAnimationFrame; const onDragPiece = vi.fn(); const mergedRun: PuzzleRunSnapshot = { ...clearedRun, currentLevel: { ...clearedRun.currentLevel!, status: 'playing', startedAtMs: Date.now(), remainingMs: 300_000, timeLimitMs: 300_000, board: { ...clearedRun.currentLevel!.board, allTilesResolved: false, mergedGroups: [ { groupId: 'group-large', 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-large' } : piece, ), }, }, }; Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: vi.fn(() => 1), }); Object.defineProperty(window, 'cancelAnimationFrame', { configurable: true, value: vi.fn(), }); const { container, unmount } = renderPuzzleRuntime( , ); const board = container.querySelector( '[data-testid="puzzle-board"]', ) as HTMLElement | null; if (!board) { throw new Error('缺少测试棋盘'); } board.getBoundingClientRect = () => ({ x: 0, y: 0, left: 0, top: 0, right: 300, bottom: 300, width: 300, height: 300, toJSON: () => ({}), }) as DOMRect; const mergedPiece = container.querySelector( '[data-merged-piece-outline="true"]', ) as HTMLElement | null; if (!mergedPiece) { throw new Error('缺少测试合并拼图片'); } act(() => { dispatchPointerEvent(mergedPiece, 'pointerdown', { pointerId: 12, clientX: 60, clientY: 60, }); }); act(() => { dispatchPointerEvent(mergedPiece, 'pointermove', { pointerId: 12, clientX: 210, clientY: 210, }); }); act(() => { dispatchPointerEvent(mergedPiece, 'pointerup', { pointerId: 12, clientX: 210, clientY: 210, }); }); expect(onDragPiece).toHaveBeenCalledTimes(1); expect(onDragPiece).toHaveBeenCalledWith({ pieceId: 'piece-0', targetRow: 2, targetCol: 2, }); unmount(); Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: originalRequestAnimationFrame, }); Object.defineProperty(window, 'cancelAnimationFrame', { configurable: true, value: originalCancelAnimationFrame, }); }); test('拖拽合并大块时底层单格不显示选中色块', () => { const originalRequestAnimationFrame = window.requestAnimationFrame; const originalCancelAnimationFrame = window.cancelAnimationFrame; const mergedRun: PuzzleRunSnapshot = { ...clearedRun, currentLevel: { ...clearedRun.currentLevel!, status: 'playing', startedAtMs: Date.now(), remainingMs: 300_000, timeLimitMs: 300_000, board: { ...clearedRun.currentLevel!.board, allTilesResolved: false, mergedGroups: [ { groupId: 'group-large', 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-large' } : piece, ), }, }, }; Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: vi.fn(() => 1), }); Object.defineProperty(window, 'cancelAnimationFrame', { configurable: true, value: vi.fn(), }); const { container, unmount } = renderPuzzleRuntime( , ); const board = container.querySelector( '[data-testid="puzzle-board"]', ) as HTMLElement | null; if (!board) { throw new Error('缺少测试棋盘'); } board.getBoundingClientRect = () => ({ x: 0, y: 0, left: 0, top: 0, right: 300, bottom: 300, width: 300, height: 300, toJSON: () => ({}), }) as DOMRect; const mergedPiece = container.querySelector( '[data-merged-piece-outline="true"]', ) as HTMLElement | null; if (!mergedPiece) { throw new Error('缺少测试合并拼图片'); } act(() => { dispatchPointerEvent(mergedPiece, 'pointerdown', { pointerId: 13, clientX: 60, clientY: 60, }); }); act(() => { dispatchPointerEvent(mergedPiece, 'pointermove', { pointerId: 13, clientX: 210, clientY: 210, }); }); const basePiece = container.querySelector( '[data-piece-id="piece-0"]', ) as HTMLElement | null; expect(basePiece?.className).toContain('puzzle-runtime-piece--merged'); expect(basePiece?.className).not.toContain( 'puzzle-runtime-piece--selected', ); unmount(); Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: originalRequestAnimationFrame, }); Object.defineProperty(window, 'cancelAnimationFrame', { configurable: true, value: originalCancelAnimationFrame, }); }); 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('首次点击左上返回弹出作品改造引导,保存并退出后不再重复弹出', () => { const onBack = vi.fn(); const onRemodelWork = vi.fn(); window.localStorage.clear(); renderPuzzleRuntime( , ); fireEvent.click(screen.getByRole('button', { name: '返回上一页' })); const dialog = screen.getByRole('dialog', { name: /体验不佳?\s*试试改造功能!/u, }); expect(dialog).toBeTruthy(); expect(onBack).not.toHaveBeenCalled(); fireEvent.click(within(dialog).getByRole('button', { name: '保存并退出' })); expect(onBack).toHaveBeenCalledTimes(1); expect( window.localStorage.getItem( 'genarrative.puzzle-runtime.exit-remodel-prompt.v1:profile-1', ), ).toBe('1'); fireEvent.click(screen.getByRole('button', { name: '返回上一页' })); expect(screen.queryByRole('dialog')).toBeNull(); expect(onBack).toHaveBeenCalledTimes(2); expect(onRemodelWork).not.toHaveBeenCalled(); }); test('首次退出引导的作品改造按钮进入改造流程', () => { const onRemodelWork = vi.fn(); window.localStorage.clear(); renderPuzzleRuntime( , ); fireEvent.click(screen.getByRole('button', { name: '返回上一页' })); fireEvent.click(screen.getByRole('button', { name: '作品改造' })); expect(onRemodelWork).toHaveBeenCalledTimes(1); expect(onRemodelWork).toHaveBeenCalledWith('profile-remodel'); expect(screen.queryByRole('dialog')).toBeNull(); }); test('顶部不显示作者,关卡标题和倒计时更紧凑', () => { const runWithoutNext: PuzzleRunSnapshot = { ...clearedRun, recommendedNextProfileId: null, }; renderPuzzleRuntime( , ); const timer = screen.getByText('4:48'); const hintButton = screen.getByRole('button', { name: '提示' }); const referenceButton = screen.getByRole('button', { name: '原图' }); const freezeButton = screen.getByRole('button', { name: '冻结' }); expect(screen.queryByText('测试作者')).toBeNull(); expect(screen.getByText('第 1 关')).toBeTruthy(); expect(screen.getByText('潮雾拼图')).toBeTruthy(); expect(timer.className).toContain('puzzle-runtime-timer'); expect(timer.className).toContain('text-lg'); expect(timer.className).not.toContain('text-2xl'); expect(hintButton.className).toContain('h-16'); expect(referenceButton.className).toContain('h-16'); expect(freezeButton.className).toContain('h-16'); expect(screen.queryByText('等待下一关候选')).toBeNull(); }); test('运行态优先把关卡 UI 背景渲染为舞台背景', () => { const runWithUiBackground: PuzzleRunSnapshot = { ...clearedRun, currentLevel: { ...clearedRun.currentLevel!, status: 'playing', coverImageSrc: '/generated-puzzle-assets/session/cover.png', uiBackgroundImageSrc: '/generated-puzzle-assets/session/ui/background.png', remainingMs: 300_000, timeLimitMs: 300_000, }, }; const { container } = renderPuzzleRuntime( , ); const backgroundImage = container.querySelector( 'img[src="/generated-puzzle-assets/session/ui/background.png"]', ); expect(backgroundImage).toBeTruthy(); }); test('运行态在只有 UI 背景 objectKey 时仍渲染生成背景', () => { const runWithUiBackground: PuzzleRunSnapshot = { ...clearedRun, currentLevel: { ...clearedRun.currentLevel!, status: 'playing', coverImageSrc: '/generated-puzzle-assets/session/cover.png', uiBackgroundImageSrc: null, uiBackgroundImageObjectKey: 'generated-puzzle-assets/session/ui/background-object-key.png', remainingMs: 300_000, timeLimitMs: 300_000, }, }; const { container } = renderPuzzleRuntime( , ); const backgroundImage = container.querySelector( 'img[src="/generated-puzzle-assets/session/ui/background-object-key.png"]', ); expect(backgroundImage).toBeTruthy(); }); test('关闭通关弹窗后保留底部下一关入口', () => { vi.useFakeTimers(); const onAdvanceNextLevel = vi.fn(); const runWithoutRecommendedNextProfile: PuzzleRunSnapshot = { ...clearedRun, recommendedNextProfileId: null, nextLevelMode: 'sameWork', nextLevelProfileId: 'profile-1', nextLevelId: 'puzzle-level-2', recommendedNextWorks: [], }; renderPuzzleRuntime( , ); act(() => { vi.advanceTimersByTime(1_400); }); fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' })); expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull(); const nextButton = screen.getByRole('button', { name: /下一关/u }); expect(nextButton).toBeTruthy(); fireEvent.click(nextButton); expect(onAdvanceNextLevel).toHaveBeenCalledTimes(1); expect(onAdvanceNextLevel).toHaveBeenCalledWith({ profileId: 'profile-1', levelId: 'puzzle-level-2', }); vi.useRealTimers(); }); test('当前作品没有下一关时展示三个相似作品并可选择进入', () => { vi.useFakeTimers(); const onAdvanceNextLevel = vi.fn(); const similarWorksRun: PuzzleRunSnapshot = { ...clearedRun, recommendedNextProfileId: 'profile-similar-1', nextLevelMode: 'similarWorks', nextLevelProfileId: 'profile-similar-1', nextLevelId: null, recommendedNextWorks: [ { profileId: 'profile-similar-1', levelName: '雾海遗迹', authorDisplayName: '星桥旅人', themeTags: ['奇幻', '遗迹'], coverImageSrc: null, similarityScore: 0.91, }, { profileId: 'profile-similar-2', levelName: '风塔试炼', authorDisplayName: '晨风', themeTags: ['奇幻', '机关'], coverImageSrc: null, similarityScore: 0.84, }, { profileId: 'profile-similar-3', levelName: '月井秘路', authorDisplayName: '月井守望', themeTags: ['秘境', '魔法'], coverImageSrc: null, similarityScore: 0.79, }, ], }; renderPuzzleRuntime( , ); act(() => { vi.advanceTimersByTime(1_400); }); const dialog = screen.getByRole('dialog', { name: '通关完成' }); expect(within(dialog).getByText('雾海遗迹')).toBeTruthy(); expect(within(dialog).getByText('风塔试炼')).toBeTruthy(); expect(within(dialog).getByText('月井秘路')).toBeTruthy(); expect(within(dialog).queryByRole('button', { name: '下一关' })).toBeNull(); fireEvent.click(within(dialog).getByRole('button', { name: /风塔试炼/u })); expect(onAdvanceNextLevel).toHaveBeenCalledTimes(1); expect(onAdvanceNextLevel).toHaveBeenCalledWith({ profileId: 'profile-similar-2', }); vi.useRealTimers(); }); test('当前作品没有下一关时底部入口打开相似作品选择', () => { vi.useFakeTimers(); const similarWorksRun: PuzzleRunSnapshot = { ...clearedRun, recommendedNextProfileId: 'profile-similar-1', nextLevelMode: 'similarWorks', nextLevelProfileId: 'profile-similar-1', nextLevelId: null, recommendedNextWorks: [ { profileId: 'profile-similar-1', levelName: '雾海遗迹', authorDisplayName: '星桥旅人', themeTags: ['奇幻', '遗迹'], coverImageSrc: null, similarityScore: 0.91, }, ], }; renderPuzzleRuntime( , ); act(() => { vi.advanceTimersByTime(1_400); }); fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' })); fireEvent.click(screen.getByRole('button', { name: /换个作品/u })); expect(screen.getByRole('dialog', { name: '通关完成' })).toBeTruthy(); 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('推荐页嵌入拼图时隐藏返回和设置里的退出入口', () => { renderPuzzleRuntime( , ); const backButton = screen.getByRole('button', { name: '返回上一页' }); expect((backButton as HTMLButtonElement).disabled).toBe(true); expect(backButton.className).toContain('invisible'); fireEvent.click(screen.getByRole('button', { name: '打开拼图设置' })); const dialog = screen.getByRole('dialog', { name: '拼图设置' }); expect(within(dialog).getByRole('button', { name: '继续拼图' })).toBeTruthy(); expect( within(dialog).queryByRole('button', { name: '返回上一页' }), ).toBeNull(); expect(within(dialog).queryByText('保存并退出')).toBeNull(); }); test('拼图棋盘使用贴近移动端边缘的正方形舞台承载切块', () => { const { container } = renderPuzzleRuntime( , ); const board = screen.getByTestId('puzzle-board'); expect(board.className).toContain('puzzle-runtime-board'); expect(board.className).toContain('aspect-square'); expect(board.className).toContain('max-w-[min(99vw,calc(100vh_-_14rem))]'); expect(board.className).not.toContain('aspect-video'); expect(board.className).not.toContain('aspect-[9/16]'); expect(board.getAttribute('style')).toContain('grid-template-rows'); expect(container.querySelector('.min-h-\\[4\\.5rem\\]')).toBeNull(); }); test('拼图运行态主体使用主题语义类承接明暗主题', () => { const { container } = renderPuzzleRuntime( , ); expect(container.firstElementChild?.className).toContain( 'puzzle-runtime-shell', ); expect(container.querySelector('.puzzle-runtime-pill')).toBeTruthy(); }); test('合并块不叠加可见轮廓和单块阴影', () => { const mergedRun: PuzzleRunSnapshot = { ...clearedRun, currentLevel: { ...clearedRun.currentLevel!, status: 'playing', startedAtMs: Date.now(), 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( container.querySelector('[data-merged-group-outline="true"]'), ).toBeNull(); const outlineStroke = container.querySelector( '[data-merged-group-outline-stroke="true"]', ); expect(outlineStroke).toBeNull(); expect((outlinedPieces[0] as HTMLElement).style.clipPath).toBe(''); for (const outlinedPiece of outlinedPieces) { const outlinedPieceElement = outlinedPiece as HTMLElement; expect(outlinedPieceElement.className).not.toContain('bg-emerald-300/10'); expect(outlinedPieceElement.className).not.toContain('shadow-['); expect( outlinedPieceElement.querySelector('.absolute.inset-0.bg-black\\/8'), ).toBeNull(); } const clippedLayer = container.querySelector( '[style*="clip-path"]', ) as HTMLElement | null; expect(clippedLayer).toBeNull(); }); test('合并块轮廓路径为内凹角生成圆角曲线', () => { const outlinePath = buildMergedGroupOutlinePath({ rowSpan: 2, colSpan: 2, pieces: [ { localRow: 0, localCol: 0, }, { localRow: 0, localCol: 1, }, { localRow: 1, localCol: 0, }, ], }); expect(outlinePath).toContain('Q 2 1 1.84 1'); expect(outlinePath).toContain('Q 1 1 1 1.16'); }); test('基础单块不叠加边框圆角或图片蒙版', () => { const playingRun: PuzzleRunSnapshot = { ...clearedRun, currentLevel: { ...clearedRun.currentLevel!, status: 'playing', startedAtMs: Date.now(), coverImageSrc: '/puzzle.png', board: { ...clearedRun.currentLevel!.board, allTilesResolved: false, }, }, }; const { container } = renderPuzzleRuntime( , ); const basePiece = container.querySelector( '[data-piece-id="piece-0"]', ) as HTMLElement | null; expect(basePiece?.className).toContain('overflow-hidden'); expect(basePiece?.className).toContain('border-0'); expect(basePiece?.className).not.toContain('rounded-[0.85rem]'); expect(basePiece?.className).not.toContain('border-2'); expect(basePiece?.querySelector('.puzzle-runtime-piece-overlay')).toBeNull(); }); test('移动端点击拼图片时立即触发一次震动反馈', () => { const originalVibrate = navigator.vibrate; const originalRequestAnimationFrame = window.requestAnimationFrame; const originalCancelAnimationFrame = window.cancelAnimationFrame; const vibrate = vi.fn(); const playingRun: PuzzleRunSnapshot = { ...clearedRun, currentLevel: { ...clearedRun.currentLevel!, status: 'playing', startedAtMs: Date.now(), board: { ...clearedRun.currentLevel!.board, allTilesResolved: false, }, }, }; Object.defineProperty(navigator, 'vibrate', { configurable: true, value: vibrate, }); Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: vi.fn(() => 1), }); Object.defineProperty(window, 'cancelAnimationFrame', { configurable: true, value: vi.fn(), }); const { container, unmount } = renderPuzzleRuntime( , ); const piece = container.querySelector( '[data-piece-id="piece-0"]', ) as HTMLElement | null; if (!piece) { throw new Error('缺少测试拼图片'); } act(() => { dispatchPointerEvent(piece, 'pointerdown', { pointerId: 1, clientX: 100, clientY: 100, }); }); expect(vibrate).toHaveBeenCalledTimes(1); expect(vibrate).toHaveBeenCalledWith([12]); act(() => { dispatchPointerEvent(piece, 'pointermove', { pointerId: 1, clientX: 104, clientY: 104, }); }); expect(vibrate).toHaveBeenCalledTimes(1); act(() => { dispatchPointerEvent(piece, 'pointermove', { pointerId: 1, clientX: 112, clientY: 100, }); }); act(() => { dispatchPointerEvent(piece, 'pointermove', { pointerId: 1, clientX: 132, clientY: 100, }); }); expect(vibrate).toHaveBeenCalledTimes(1); unmount(); Object.defineProperty(navigator, 'vibrate', { configurable: true, value: originalVibrate, }); Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: originalRequestAnimationFrame, }); Object.defineProperty(window, 'cancelAnimationFrame', { configurable: true, value: originalCancelAnimationFrame, }); }); test('道具确认弹窗暂停时间,提示只演示不直接移动拼块', async () => { const onPauseChange = vi.fn(); const onUseProp = vi.fn().mockResolvedValue(clearedRun); const playingRun: PuzzleRunSnapshot = { ...clearedRun, currentLevel: { ...clearedRun.currentLevel!, status: 'playing', startedAtMs: Date.now(), remainingMs: 180_000, board: { ...clearedRun.currentLevel!.board, allTilesResolved: false, pieces: clearedRun.currentLevel!.board.pieces.map((piece, index) => { if (index === 0) { return { ...piece, currentRow: 2, currentCol: 2 }; } if (index === 8) { return { ...piece, currentRow: 0, currentCol: 0 }; } return piece; }), }, }, }; const { container } = renderPuzzleRuntime( , ); fireEvent.click(screen.getByRole('button', { name: '提示' })); expect(screen.getByRole('dialog', { name: '使用提示' })).toBeTruthy(); expect(screen.getByText('消耗 1 泥点')).toBeTruthy(); expect(onPauseChange).toHaveBeenLastCalledWith(true); await act(async () => { fireEvent.click(screen.getByRole('button', { name: '确定' })); }); expect(onUseProp).toHaveBeenCalledWith('hint'); expect(onPauseChange).toHaveBeenLastCalledWith(false); expect( (container.querySelector('[data-piece-cell-id="piece-0"]') as HTMLElement) .style.transform, ).toBe('translate(-66.66666666666666%, -66.66666666666666%) scale(1.03)'); }); test('道具使用失败时保留确认弹窗和暂停态', async () => { const onPauseChange = vi.fn(); const onUseProp = vi.fn().mockRejectedValue(new Error('泥点余额不足')); const playingRun: PuzzleRunSnapshot = { ...clearedRun, currentLevel: { ...clearedRun.currentLevel!, status: 'playing', startedAtMs: Date.now(), remainingMs: 180_000, board: { ...clearedRun.currentLevel!.board, allTilesResolved: false, }, }, }; renderPuzzleRuntime( , ); fireEvent.click(screen.getByRole('button', { name: '冻结' })); await act(async () => { fireEvent.click(screen.getByRole('button', { name: '确定' })); }); expect(screen.getByRole('dialog', { name: '冻结时间' })).toBeTruthy(); expect(screen.getByText('泥点余额不足')).toBeTruthy(); expect(onPauseChange).toHaveBeenLastCalledWith(true); }); test('冻结确认期间后端同步失败态时关闭确认窗并展示失败面板', async () => { const onUseProp = vi.fn().mockResolvedValue({ ...clearedRun, currentLevel: { ...clearedRun.currentLevel!, status: 'failed', elapsedMs: 180_000, remainingMs: 0, board: { ...clearedRun.currentLevel!.board, allTilesResolved: false, }, }, }); const playingRun: PuzzleRunSnapshot = { ...clearedRun, currentLevel: { ...clearedRun.currentLevel!, status: 'playing', startedAtMs: Date.now(), remainingMs: 180_000, board: { ...clearedRun.currentLevel!.board, allTilesResolved: false, }, }, }; const { rerender } = renderPuzzleRuntime( , ); fireEvent.click(screen.getByRole('button', { name: '冻结' })); await act(async () => { fireEvent.click(screen.getByRole('button', { name: '确定' })); }); rerender( , ); expect(screen.queryByRole('dialog', { name: '冻结时间' })).toBeNull(); expect(screen.getByRole('dialog', { name: '关卡失败' })).toBeTruthy(); expect(screen.queryByTestId('puzzle-freeze-effect')).toBeNull(); }); test('倒计时归零时通知父层同步失败态', () => { vi.useFakeTimers(); const onTimeExpired = vi.fn(); const playingRun: PuzzleRunSnapshot = { ...clearedRun, currentLevel: { ...clearedRun.currentLevel!, status: 'playing', startedAtMs: Date.now() - 181_000, timeLimitMs: 180_000, remainingMs: 0, board: { ...clearedRun.currentLevel!.board, allTilesResolved: false, }, }, }; renderPuzzleRuntime( , ); expect(screen.getByRole('dialog', { name: '关卡失败' })).toBeTruthy(); expect(onTimeExpired).toHaveBeenCalledTimes(1); vi.useRealTimers(); }); test('失败弹窗支持重开当前关和续时确认', async () => { const onRestartLevel = vi.fn(); const onUseProp = vi.fn().mockResolvedValue({ ...clearedRun, currentLevel: { ...clearedRun.currentLevel!, status: 'playing', remainingMs: 60_000, }, }); const failedRun: PuzzleRunSnapshot = { ...clearedRun, currentLevel: { ...clearedRun.currentLevel!, status: 'failed', elapsedMs: 180_000, remainingMs: 0, board: { ...clearedRun.currentLevel!.board, allTilesResolved: false, }, }, }; renderPuzzleRuntime( , ); const failedDialog = screen.getByRole('dialog', { name: '关卡失败' }); fireEvent.click(within(failedDialog).getByRole('button', { name: '重新开始' })); expect(onRestartLevel).toHaveBeenCalledTimes(1); fireEvent.click( within(failedDialog).getByRole('button', { name: '继续1分钟' }), ); expect(screen.getByRole('dialog', { name: '继续1分钟' })).toBeTruthy(); expect(screen.getByText('消耗 1 泥点')).toBeTruthy(); await act(async () => { fireEvent.click(screen.getByRole('button', { name: '确定' })); }); expect(onUseProp).toHaveBeenCalledWith('extendTime'); }); test('失败续时扣费失败时保留确认弹窗', async () => { const onUseProp = vi.fn().mockRejectedValue(new Error('泥点余额不足')); const failedRun: PuzzleRunSnapshot = { ...clearedRun, currentLevel: { ...clearedRun.currentLevel!, status: 'failed', elapsedMs: 180_000, remainingMs: 0, board: { ...clearedRun.currentLevel!.board, allTilesResolved: false, }, }, }; renderPuzzleRuntime( , ); fireEvent.click(screen.getByRole('button', { name: '继续1分钟' })); await act(async () => { fireEvent.click(screen.getByRole('button', { name: '确定' })); }); expect(screen.getByRole('dialog', { name: '继续1分钟' })).toBeTruthy(); expect(screen.getByText('泥点余额不足')).toBeTruthy(); }); test('查看原图开关打开覆盖层并在关闭后恢复计时', async () => { const onPauseChange = vi.fn(); const onUseProp = vi.fn().mockResolvedValue(clearedRun); const playingRun: PuzzleRunSnapshot = { ...clearedRun, currentLevel: { ...clearedRun.currentLevel!, status: 'playing', startedAtMs: Date.now(), remainingMs: 180_000, coverImageSrc: '/puzzle.png', board: { ...clearedRun.currentLevel!.board, allTilesResolved: false, }, }, }; renderPuzzleRuntime( , ); fireEvent.click(screen.getByRole('button', { name: '原图' })); await act(async () => { fireEvent.click(screen.getByRole('button', { name: '确定' })); }); expect(onUseProp).toHaveBeenCalledWith('reference'); expect(screen.getByTestId('puzzle-original-overlay')).toBeTruthy(); expect(onPauseChange).toHaveBeenLastCalledWith(true); fireEvent.click(screen.getByRole('button', { name: '原图' })); expect(screen.queryByTestId('puzzle-original-overlay')).toBeNull(); expect(onPauseChange).toHaveBeenLastCalledWith(false); }); test('拖拽层级辅助函数只提升当前被拖动对象', () => { expect(resolveDraggedPieceCellLayer('piece-0', 'piece-0', false)).toBe(80); expect( resolveDraggedPieceCellLayer('piece-0', 'piece-1', false), ).toBeUndefined(); expect( resolveDraggedPieceCellLayer('piece-0', 'piece-0', true), ).toBeUndefined(); expect(resolveDraggedPieceLayer('piece-0', 'piece-0', false)).toBe(81); expect(resolveDraggedPieceLayer('piece-0', null, false)).toBeUndefined(); expect(resolveDraggedPieceLayer('piece-0', 'piece-0', true)).toBeUndefined(); expect(resolveDraggedMergedGroupLayer('group-1', 'group-1')).toBe(90); expect(resolveDraggedMergedGroupLayer('group-1', 'group-2')).toBeUndefined(); });