/* @vitest-environment jsdom */ import { act, fireEvent, render, screen, waitFor, 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('../../services/puzzle-runtime/puzzleUiSpritesheetParser', async () => { const actual = await vi.importActual< typeof import('../../services/puzzle-runtime/puzzleUiSpritesheetParser') >('../../services/puzzle-runtime/puzzleUiSpritesheetParser'); return { ...actual, loadPuzzleUiSpritesheetLayout: vi.fn(async () => ({ width: 32, height: 24, regions: { back: { x: 3, y: 2, width: 4, height: 4 }, settings: { x: 24, y: 3, width: 5, height: 5 }, next: { x: 11, y: 11, width: 10, height: 5 }, hint: { x: 2, y: 20, width: 5, height: 3 }, reference: { x: 13, y: 19, width: 6, height: 4 }, freezeTime: { x: 25, y: 20, width: 5, height: 4 }, }, hitRegions: { back: { x: 4, y: 3, width: 2, height: 2 }, hint: { x: 3, y: 21, width: 3, height: 1 }, }, })), }; }); 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 originalRequestAnimationFrame = window.requestAnimationFrame; 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('缺少测试棋盘'); } const requestAnimationFrame = vi.fn(() => 1); Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: requestAnimationFrame, }); 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, }); }); expect(piece.style.transform).toBe('translate3d(30px, 30px, 0) scale(1.03)'); expect(piece.style.transition).toBe('none'); expect(requestAnimationFrame).not.toHaveBeenCalled(); 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' }), ); Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: originalRequestAnimationFrame, }); }); test('指针拖拽合并大块时按大块锚点提交拖拽', () => { 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, ), }, }, }; const { container } = 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, }); }); const mergedGroup = container.querySelector( '[data-merged-group-id="group-large"]', ) as HTMLElement | null; expect(mergedGroup?.style.transform).toBe( 'translate3d(150px, 150px, 0) scale(1.02)', ); expect(mergedGroup?.style.transition).toBe('none'); act(() => { dispatchPointerEvent(mergedPiece, 'pointerup', { pointerId: 12, clientX: 210, clientY: 210, }); }); expect(onDragPiece).toHaveBeenCalledTimes(1); expect(onDragPiece).toHaveBeenCalledWith({ pieceId: 'piece-0', targetRow: 2, targetCol: 2, }); }); 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('拖动拼图片时不显示已选择状态', () => { const playingRun: PuzzleRunSnapshot = { ...clearedRun, currentLevel: { ...clearedRun.currentLevel!, status: 'playing', startedAtMs: Date.now(), remainingMs: 300_000, timeLimitMs: 300_000, board: { ...clearedRun.currentLevel!.board, allTilesResolved: false, }, }, }; const { container } = 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 piece = container.querySelector( '[data-piece-id="piece-0"]', ) as HTMLElement | null; if (!piece) { throw new Error('缺少测试拼图片'); } act(() => { dispatchPointerEvent(piece, 'pointerdown', { pointerId: 14, clientX: 40, clientY: 40, }); }); expect(screen.getByText('已选择')).toBeTruthy(); act(() => { dispatchPointerEvent(piece, 'pointermove', { pointerId: 14, clientX: 70, clientY: 70, }); }); expect(screen.queryByText('已选择')).toBeNull(); expect(piece.className).not.toContain('puzzle-runtime-piece--selected'); }); 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(); const nextButton = within(dialog).getByRole('button', { name: '下一关' }); expect(nextButton.textContent).toContain('下一关'); expect(nextButton.querySelector('[data-puzzle-ui-sprite="next"]')).toBeNull(); fireEvent.click(nextButton); 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(); const levelLogo = screen.getByTestId( 'puzzle-runtime-level-logo', ) as HTMLImageElement; expect(levelLogo.getAttribute('src')).toContain('logo.png'); expect(levelLogo.closest('.puzzle-runtime-level-logo')).toBeTruthy(); expect(document.querySelector('.puzzle-runtime-level-mascot')).toBeNull(); expect(timer.closest('.puzzle-runtime-timer-card')).toBeTruthy(); expect( screen.getByText('潮雾拼图').closest('.puzzle-runtime-level-title-card'), ).toBeTruthy(); expect(timer.className).toContain('puzzle-runtime-timer'); expect(timer.className).toContain('text-lg'); expect(timer.className).not.toContain('text-2xl'); expect(hintButton.textContent).not.toContain('提示'); expect(referenceButton.textContent).not.toContain('原图'); expect(freezeButton.textContent).not.toContain('冻结'); expect(hintButton.parentElement?.className).toContain('grid-cols-3'); expect(hintButton.parentElement?.className).not.toContain( 'puzzle-runtime-toolbar', ); 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('运行态优先把关卡背景图渲染为舞台背景', async () => { const runWithLevelBackground: PuzzleRunSnapshot = { ...clearedRun, currentLevel: { ...clearedRun.currentLevel!, status: 'playing', coverImageSrc: '/generated-puzzle-assets/session/cover.png', uiBackgroundImageSrc: '/generated-puzzle-assets/session/ui/legacy.png', levelBackgroundImageSrc: '/generated-puzzle-assets/session/level-background/background.png', uiSpritesheetImageSrc: '/generated-puzzle-assets/session/ui-spritesheet/sheet.png', remainingMs: 300_000, timeLimitMs: 300_000, }, }; const { container } = renderPuzzleRuntime( , ); expect( container.querySelector( 'img[src="/generated-puzzle-assets/session/level-background/background.png"]', ), ).toBeTruthy(); expect( container.querySelector( 'img[src="/generated-puzzle-assets/session/ui/legacy.png"]', ), ).toBeNull(); await waitFor(() => { expect( screen .getByRole('button', { name: '提示' }) .querySelector('[data-puzzle-ui-sprite-hit-zone="hint"]'), ).toBeTruthy(); }); }); test('运行态用 UI spritesheet 原图检测矩形裁切返回设置下一关和道具按钮', async () => { const runWithSpritesheet: PuzzleRunSnapshot = { ...clearedRun, currentLevel: { ...clearedRun.currentLevel!, status: 'cleared', coverImageSrc: '/generated-puzzle-assets/session/cover.png', uiSpritesheetImageSrc: '/generated-puzzle-assets/session/ui-spritesheet/sheet.png', remainingMs: 120_000, timeLimitMs: 300_000, }, nextLevelMode: 'sameWork', nextLevelProfileId: 'profile-1', }; renderPuzzleRuntime( , ); await screen.findByRole('button', { name: '下一关' }); expect( screen .getByRole('button', { name: '返回上一页' }) .querySelector('[data-puzzle-ui-sprite="back"]'), ).toBeTruthy(); expect( screen.getByRole('button', { name: '返回上一页' }).className, ).toContain('puzzle-runtime-icon-button--sprite'); expect( screen.getByRole('button', { name: '返回上一页' }).className, ).toContain('puzzle-runtime-icon-button--precise-hit'); expect( screen .getByRole('button', { name: '返回上一页' }) .querySelector('[data-puzzle-ui-sprite-hit-zone="back"]'), ).toBeTruthy(); expect( screen.getByRole('button', { name: '返回上一页' }).className, ).not.toContain('rounded-full'); expect( screen .getByRole('button', { name: '打开拼图设置' }) .querySelector('[data-puzzle-ui-sprite="settings"]'), ).toBeTruthy(); expect( screen.getByRole('button', { name: '打开拼图设置' }).className, ).toContain('puzzle-runtime-icon-button--sprite'); expect( screen.getByRole('button', { name: '打开拼图设置' }).className, ).toContain('puzzle-runtime-icon-button--precise-hit'); expect( screen.getByRole('button', { name: '打开拼图设置' }).className, ).not.toContain('rounded-full'); const nextButton = screen.getByRole('button', { name: '下一关' }); expect(nextButton.dataset.puzzleUiSprite).toBe('next'); expect(nextButton.querySelector('[data-puzzle-ui-sprite="next"]')).toBeNull(); expect(nextButton.style.backgroundSize).toBe('320% 480%'); expect(nextButton.style.backgroundPosition).toBe('50% 57.89473684210527%'); expect(nextButton.className).not.toContain('puzzle-runtime-primary-button'); expect(nextButton.className).not.toContain('rounded-full'); expect(nextButton.className).not.toContain('px-5'); expect(nextButton.className).not.toContain('py-2.5'); expect(nextButton.textContent).toBe(''); expect( screen .getByRole('button', { name: '提示' }) .querySelector('[data-puzzle-ui-sprite="hint"]'), ).toBeTruthy(); const hintHitZone = screen .getByRole('button', { name: '提示' }) .querySelector('[data-puzzle-ui-sprite-hit-zone="hint"]') as HTMLElement | null; expect(hintHitZone).toBeTruthy(); expect(screen.getByRole('button', { name: '提示' }).className).toContain( 'puzzle-runtime-sprite-tool-button--precise-hit', ); expect(hintHitZone?.className).toContain( 'puzzle-runtime-ui-sprite-hit-zone', ); expect(hintHitZone?.style.left).toBe('20%'); expect(hintHitZone?.style.top).toBe('33.33333333333333%'); expect(hintHitZone?.style.width).toBe('60%'); expect(hintHitZone?.style.height).toBe('33.33333333333333%'); expect( screen .getByRole('button', { name: '提示' }) .querySelector('[data-puzzle-ui-sprite="hint"]')?.className, ).toContain('puzzle-runtime-bottom-ui-sprite'); expect( screen .getByRole('button', { name: '提示' }) .querySelector('[data-puzzle-ui-sprite="hint"]')?.className, ).not.toContain('w-full'); expect(screen.getByRole('button', { name: '提示' }).textContent).toBe(''); const referenceSprite = screen .getByRole('button', { name: '原图' }) .querySelector('[data-puzzle-ui-sprite="reference"]') as HTMLElement | null; expect(referenceSprite).toBeTruthy(); expect(referenceSprite?.style.backgroundSize).toBe('533.3333333333333% 600%'); expect(referenceSprite?.style.backgroundPosition).toBe('50% 95%'); expect(referenceSprite?.style.aspectRatio).toBe('6 / 4'); expect(referenceSprite?.className).toContain('puzzle-runtime-bottom-ui-sprite'); expect(referenceSprite?.className).not.toContain('w-full'); expect(screen.getByRole('button', { name: '原图' }).textContent).toBe(''); expect( screen .getByRole('button', { name: '冻结' }) .querySelector('[data-puzzle-ui-sprite="freezeTime"]'), ).toBeTruthy(); expect(screen.getByRole('button', { name: '冻结' }).textContent).toBe(''); }); 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('关闭通关弹窗后保留底部下一关入口', async () => { vi.useFakeTimers(); const onAdvanceNextLevel = vi.fn(); const runWithoutRecommendedNextProfile: PuzzleRunSnapshot = { ...clearedRun, recommendedNextProfileId: null, nextLevelMode: 'sameWork', nextLevelProfileId: 'profile-1', nextLevelId: 'puzzle-level-2', recommendedNextWorks: [], currentLevel: { ...clearedRun.currentLevel!, uiSpritesheetImageSrc: '/generated-puzzle-assets/session/ui-spritesheet/sheet.png', }, }; renderPuzzleRuntime( , ); await act(async () => {}); act(() => { vi.advanceTimersByTime(1_400); }); const dialog = screen.getByRole('dialog', { name: '通关完成' }); const dialogNextButton = within(dialog).getByRole('button', { name: '下一关', }); expect(dialogNextButton.dataset.puzzleUiSprite).toBe('next'); expect( dialogNextButton.querySelector('[data-puzzle-ui-sprite="next"]'), ).toBeNull(); expect(dialogNextButton.style.backgroundSize).toBe('320% 480%'); expect(dialogNextButton.style.backgroundPosition).toBe( '50% 57.89473684210527%', ); expect(dialogNextButton.className).not.toContain( 'puzzle-runtime-primary-button', ); expect(dialogNextButton.className).not.toContain('rounded-full'); expect(dialogNextButton.className).not.toContain('px-5'); expect(dialogNextButton.className).not.toContain('py-2.5'); expect(dialogNextButton.textContent).toBe(''); act(() => { 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('推荐页关闭通关弹窗后保留底部下一关入口且不叠加下一关素材图', async () => { vi.useFakeTimers(); const runWithoutRecommendedNextProfile: PuzzleRunSnapshot = { ...clearedRun, recommendedNextProfileId: null, nextLevelMode: 'sameWork', nextLevelProfileId: 'profile-1', nextLevelId: 'puzzle-level-2', recommendedNextWorks: [], currentLevel: { ...clearedRun.currentLevel!, uiSpritesheetImageSrc: '/generated-puzzle-assets/session/ui-spritesheet/sheet.png', }, }; renderPuzzleRuntime( , ); act(() => { vi.advanceTimersByTime(1_400); }); act(() => { fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' })); }); await act(async () => {}); expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull(); const nextButton = screen.getByRole('button', { name: /下一关/u }); expect(nextButton).toBeTruthy(); expect(nextButton.dataset.puzzleUiSprite).toBe('next'); expect(nextButton.querySelector('[data-puzzle-ui-sprite="next"]')).toBeNull(); expect(nextButton.textContent?.trim()).toBe(''); 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, ); const settingsButton = screen.getByRole('button', { name: '打开拼图设置' }); expect(settingsButton.querySelector('img')).toBeNull(); expect(settingsButton.querySelector('svg')).toBeTruthy(); fireEvent.click(settingsButton); 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('拼图设置面板展示进行中关卡的实时当前用时', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-05-15T08:00:10.000Z')); const startedAtMs = Date.now() - 8_500; const playingRun: PuzzleRunSnapshot = { ...clearedRun, clearedLevelCount: 0, currentLevel: { ...clearedRun.currentLevel!, status: 'playing', startedAtMs, clearedAtMs: null, elapsedMs: null, remainingMs: 291_500, board: { ...clearedRun.currentLevel!.board, allTilesResolved: false, }, }, }; renderPuzzleRuntime( , ); fireEvent.click(screen.getByRole('button', { name: '打开拼图设置' })); const dialog = screen.getByRole('dialog', { name: '拼图设置' }); expect(within(dialog).getByText('0:08.50')).toBeTruthy(); vi.useRealTimers(); }); 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('推荐页嵌入拼图通关结算使用页面级浮层避免卡片裁剪', () => { vi.useFakeTimers(); renderPuzzleRuntime(
, ); act(() => { vi.advanceTimersByTime(1_400); }); const dialog = screen.getByRole('dialog', { name: '通关完成' }); const overlay = dialog.closest('.puzzle-runtime-modal-overlay'); expect(overlay?.className).toContain('puzzle-runtime-modal-overlay--fixed'); expect(overlay?.closest('.platform-recommend-runtime-viewport')).toBeNull(); vi.useRealTimers(); }); 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.firstElementChild?.className).toContain( 'platform-theme--light', ); expect(container.querySelector('.puzzle-runtime-pill')).toBeTruthy(); }); test('拼图直达页在没有外层主题壳时也会自行补齐平台主题类', () => { const { container } = render( , ); expect(container.firstElementChild?.className).toContain( 'platform-theme--light', ); }); test('合并块不叠加可见轮廓和单块阴影', () => { const mergedRun: PuzzleRunSnapshot = { ...clearedRun, currentLevel: { ...clearedRun.currentLevel!, status: 'playing', startedAtMs: Date.now(), coverImageSrc: '/puzzle.png', 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(); const mergedGroupClipLayer = container.querySelector( '[data-merged-group-id="group-l"] [data-merged-group-clip="true"]', ) as SVGSVGElement | null; const mergedGroupClipPath = mergedGroupClipLayer?.querySelector('path'); const mergedPieceVisuals = container.querySelectorAll( '[data-merged-piece-visual="true"]', ); expect(mergedGroupClipLayer?.tagName.toLowerCase()).toBe('svg'); expect(mergedGroupClipLayer?.getAttribute('viewBox')).toBe('0 0 2 2'); expect(mergedGroupClipPath?.getAttribute('d')).toContain('Q 2 1 1.84 1'); expect(mergedGroupClipPath?.getAttribute('d')).toContain('Q 1 1 1 1.192'); expect(mergedPieceVisuals).toHaveLength(3); const mergedImageSlices = mergedGroupClipLayer?.querySelectorAll('image'); expect(mergedImageSlices).toHaveLength(3); expect(mergedImageSlices?.[0]?.getAttribute('href')).toBe('/puzzle.png'); expect(mergedImageSlices?.[0]?.getAttribute('width')).toBe('3'); expect(mergedImageSlices?.[0]?.getAttribute('height')).toBe('3'); for (const outlinedPiece of outlinedPieces) { const outlinedPieceElement = outlinedPiece as HTMLElement; expect(outlinedPieceElement.style.clipPath).toBe(''); expect(outlinedPieceElement.querySelector('.absolute.inset-0')).toBeNull(); 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(); } }); 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.192'); }); 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?.style.clipPath).toContain('url(#'); 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'); const originalViewer = screen.getByTestId('puzzle-original-viewer'); const board = screen.getByTestId('puzzle-board'); expect(originalViewer).toBeTruthy(); expect(originalViewer.parentElement).not.toBe(board); expect( within(originalViewer) .getByRole('img', { name: '潮雾拼图 原图' }) .getAttribute('src'), ).toBe('/puzzle.png'); expect(screen.queryByTestId('puzzle-original-overlay')).toBeNull(); expect(onPauseChange).toHaveBeenLastCalledWith(true); fireEvent.click(screen.getByRole('button', { name: '关闭原图' })); expect(screen.queryByTestId('puzzle-original-viewer')).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(); });