1043 lines
29 KiB
TypeScript
1043 lines
29 KiB
TypeScript
/* @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: () => 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(
|
||
<AuthUiContext.Provider value={authValue}>{ui}</AuthUiContext.Provider>,
|
||
);
|
||
}
|
||
|
||
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('通关后显示结算弹窗、排行榜和下一关按钮', () => {
|
||
vi.useFakeTimers();
|
||
const onAdvanceNextLevel = vi.fn();
|
||
|
||
renderPuzzleRuntime(
|
||
<PuzzleRuntimeShell
|
||
run={clearedRun}
|
||
onBack={vi.fn()}
|
||
onSwapPieces={vi.fn()}
|
||
onDragPiece={vi.fn()}
|
||
onAdvanceNextLevel={onAdvanceNextLevel}
|
||
/>,
|
||
);
|
||
|
||
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(
|
||
<PuzzleRuntimeShell
|
||
run={clearedRun}
|
||
onBack={onBack}
|
||
onRemodelWork={onRemodelWork}
|
||
onSwapPieces={vi.fn()}
|
||
onDragPiece={vi.fn()}
|
||
onAdvanceNextLevel={vi.fn()}
|
||
/>,
|
||
);
|
||
|
||
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(
|
||
<PuzzleRuntimeShell
|
||
run={{
|
||
...clearedRun,
|
||
currentLevel: {
|
||
...clearedRun.currentLevel!,
|
||
profileId: 'profile-remodel',
|
||
},
|
||
}}
|
||
onBack={vi.fn()}
|
||
onRemodelWork={onRemodelWork}
|
||
onSwapPieces={vi.fn()}
|
||
onDragPiece={vi.fn()}
|
||
onAdvanceNextLevel={vi.fn()}
|
||
/>,
|
||
);
|
||
|
||
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(
|
||
<PuzzleRuntimeShell
|
||
run={runWithoutNext}
|
||
onBack={vi.fn()}
|
||
onSwapPieces={vi.fn()}
|
||
onDragPiece={vi.fn()}
|
||
onAdvanceNextLevel={vi.fn()}
|
||
/>,
|
||
);
|
||
|
||
const avatar = screen.getByText('测');
|
||
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(avatar.className).toContain('rounded-full');
|
||
expect(screen.getByText('测试作者')).toBeTruthy();
|
||
expect(timer.className).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('关闭通关弹窗后保留底部下一关入口', () => {
|
||
vi.useFakeTimers();
|
||
const onAdvanceNextLevel = vi.fn();
|
||
const runWithoutRecommendedNextProfile: PuzzleRunSnapshot = {
|
||
...clearedRun,
|
||
recommendedNextProfileId: null,
|
||
nextLevelMode: 'sameWork',
|
||
nextLevelProfileId: 'profile-1',
|
||
nextLevelId: 'puzzle-level-2',
|
||
recommendedNextWorks: [],
|
||
};
|
||
|
||
renderPuzzleRuntime(
|
||
<PuzzleRuntimeShell
|
||
run={runWithoutRecommendedNextProfile}
|
||
onBack={vi.fn()}
|
||
onSwapPieces={vi.fn()}
|
||
onDragPiece={vi.fn()}
|
||
onAdvanceNextLevel={onAdvanceNextLevel}
|
||
/>,
|
||
);
|
||
|
||
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(
|
||
<PuzzleRuntimeShell
|
||
run={similarWorksRun}
|
||
onBack={vi.fn()}
|
||
onSwapPieces={vi.fn()}
|
||
onDragPiece={vi.fn()}
|
||
onAdvanceNextLevel={onAdvanceNextLevel}
|
||
/>,
|
||
);
|
||
|
||
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(
|
||
<PuzzleRuntimeShell
|
||
run={similarWorksRun}
|
||
onBack={vi.fn()}
|
||
onSwapPieces={vi.fn()}
|
||
onDragPiece={vi.fn()}
|
||
onAdvanceNextLevel={vi.fn()}
|
||
/>,
|
||
);
|
||
|
||
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(
|
||
<PuzzleRuntimeShell
|
||
run={clearedRun}
|
||
onBack={vi.fn()}
|
||
onSwapPieces={vi.fn()}
|
||
onDragPiece={vi.fn()}
|
||
onAdvanceNextLevel={vi.fn()}
|
||
/>,
|
||
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 { container } = renderPuzzleRuntime(
|
||
<PuzzleRuntimeShell
|
||
run={{
|
||
...clearedRun,
|
||
currentLevel: {
|
||
...clearedRun.currentLevel!,
|
||
status: 'playing',
|
||
startedAtMs: Date.now(),
|
||
board: {
|
||
...clearedRun.currentLevel!.board,
|
||
allTilesResolved: false,
|
||
},
|
||
},
|
||
}}
|
||
onBack={vi.fn()}
|
||
onSwapPieces={vi.fn()}
|
||
onDragPiece={vi.fn()}
|
||
onAdvanceNextLevel={vi.fn()}
|
||
/>,
|
||
);
|
||
|
||
const board = screen.getByTestId('puzzle-board');
|
||
expect(board.className).toContain('aspect-square');
|
||
expect(board.className).toContain('max-w-[min(99vw,calc(100vh_-_16.5rem))]');
|
||
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 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(
|
||
<PuzzleRuntimeShell
|
||
run={mergedRun}
|
||
onBack={vi.fn()}
|
||
onSwapPieces={vi.fn()}
|
||
onDragPiece={vi.fn()}
|
||
onAdvanceNextLevel={vi.fn()}
|
||
/>,
|
||
);
|
||
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"]'),
|
||
).toBeTruthy();
|
||
const outlineStroke = container.querySelector(
|
||
'[data-merged-group-outline-stroke="true"]',
|
||
);
|
||
expect(outlineStroke).toBeTruthy();
|
||
expect(outlineStroke?.getAttribute('d')).toContain('Q 2 1 1.84 1');
|
||
expect(outlineStroke?.getAttribute('d')).toContain('Q 1 1 1 1.16');
|
||
expect((outlinedPieces[0] as HTMLElement).style.clipPath).toBe('');
|
||
const clippedLayer = container.querySelector(
|
||
'[style*="clip-path"]',
|
||
) as HTMLElement | null;
|
||
expect(clippedLayer?.style.clipPath).toContain('url(#');
|
||
});
|
||
|
||
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(
|
||
<PuzzleRuntimeShell
|
||
run={playingRun}
|
||
onBack={vi.fn()}
|
||
onSwapPieces={vi.fn()}
|
||
onDragPiece={vi.fn()}
|
||
onAdvanceNextLevel={vi.fn()}
|
||
/>,
|
||
);
|
||
|
||
const basePiece = container.querySelector(
|
||
'[data-piece-id="piece-0"]',
|
||
) as HTMLElement | null;
|
||
expect(basePiece?.className).toContain('overflow-hidden');
|
||
expect(basePiece?.className).toContain('rounded-[0.85rem]');
|
||
});
|
||
|
||
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(
|
||
<PuzzleRuntimeShell
|
||
run={playingRun}
|
||
onBack={vi.fn()}
|
||
onSwapPieces={vi.fn()}
|
||
onDragPiece={vi.fn()}
|
||
onAdvanceNextLevel={vi.fn()}
|
||
/>,
|
||
);
|
||
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(
|
||
<PuzzleRuntimeShell
|
||
run={playingRun}
|
||
onBack={vi.fn()}
|
||
onSwapPieces={vi.fn()}
|
||
onDragPiece={vi.fn()}
|
||
onAdvanceNextLevel={vi.fn()}
|
||
onPauseChange={onPauseChange}
|
||
onUseProp={onUseProp}
|
||
/>,
|
||
);
|
||
|
||
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(
|
||
<PuzzleRuntimeShell
|
||
run={playingRun}
|
||
onBack={vi.fn()}
|
||
onSwapPieces={vi.fn()}
|
||
onDragPiece={vi.fn()}
|
||
onAdvanceNextLevel={vi.fn()}
|
||
onPauseChange={onPauseChange}
|
||
onUseProp={onUseProp}
|
||
/>,
|
||
);
|
||
|
||
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(
|
||
<PuzzleRuntimeShell
|
||
run={playingRun}
|
||
onBack={vi.fn()}
|
||
onSwapPieces={vi.fn()}
|
||
onDragPiece={vi.fn()}
|
||
onAdvanceNextLevel={vi.fn()}
|
||
onUseProp={onUseProp}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '冻结' }));
|
||
await act(async () => {
|
||
fireEvent.click(screen.getByRole('button', { name: '确定' }));
|
||
});
|
||
|
||
rerender(
|
||
<AuthUiContext.Provider value={createAuthValue()}>
|
||
<PuzzleRuntimeShell
|
||
run={{
|
||
...playingRun,
|
||
currentLevel: {
|
||
...playingRun.currentLevel!,
|
||
status: 'failed',
|
||
elapsedMs: 180_000,
|
||
remainingMs: 0,
|
||
},
|
||
}}
|
||
onBack={vi.fn()}
|
||
onSwapPieces={vi.fn()}
|
||
onDragPiece={vi.fn()}
|
||
onAdvanceNextLevel={vi.fn()}
|
||
onUseProp={onUseProp}
|
||
/>
|
||
</AuthUiContext.Provider>,
|
||
);
|
||
|
||
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(
|
||
<PuzzleRuntimeShell
|
||
run={playingRun}
|
||
onBack={vi.fn()}
|
||
onSwapPieces={vi.fn()}
|
||
onDragPiece={vi.fn()}
|
||
onAdvanceNextLevel={vi.fn()}
|
||
onTimeExpired={onTimeExpired}
|
||
/>,
|
||
);
|
||
|
||
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(
|
||
<PuzzleRuntimeShell
|
||
run={failedRun}
|
||
onBack={vi.fn()}
|
||
onSwapPieces={vi.fn()}
|
||
onDragPiece={vi.fn()}
|
||
onAdvanceNextLevel={vi.fn()}
|
||
onRestartLevel={onRestartLevel}
|
||
onUseProp={onUseProp}
|
||
/>,
|
||
);
|
||
|
||
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(
|
||
<PuzzleRuntimeShell
|
||
run={failedRun}
|
||
onBack={vi.fn()}
|
||
onSwapPieces={vi.fn()}
|
||
onDragPiece={vi.fn()}
|
||
onAdvanceNextLevel={vi.fn()}
|
||
onUseProp={onUseProp}
|
||
/>,
|
||
);
|
||
|
||
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(
|
||
<PuzzleRuntimeShell
|
||
run={playingRun}
|
||
onBack={vi.fn()}
|
||
onSwapPieces={vi.fn()}
|
||
onDragPiece={vi.fn()}
|
||
onAdvanceNextLevel={vi.fn()}
|
||
onPauseChange={onPauseChange}
|
||
onUseProp={onUseProp}
|
||
/>,
|
||
);
|
||
|
||
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();
|
||
});
|