250 lines
6.9 KiB
TypeScript
250 lines
6.9 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 { PuzzleRuntimeShell } from './PuzzleRuntimeShell';
|
|
|
|
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
|
useResolvedAssetReadUrl: () => ({
|
|
resolvedUrl: '',
|
|
isResolving: false,
|
|
shouldResolve: false,
|
|
}),
|
|
}));
|
|
|
|
vi.mock('../ResolvedAssetImage', () => ({
|
|
ResolvedAssetImage: () => null,
|
|
}));
|
|
|
|
function createAuthValue() {
|
|
return {
|
|
user: null,
|
|
canAccessProtectedData: false,
|
|
openLoginModal: () => {},
|
|
requireAuth: (action: () => void) => action(),
|
|
openSettingsModal: () => {},
|
|
openAccountModal: () => {},
|
|
logout: async () => {},
|
|
musicVolume: 0.42,
|
|
setMusicVolume: vi.fn(),
|
|
platformTheme: 'light' as const,
|
|
setPlatformTheme: vi.fn(),
|
|
isHydratingSettings: false,
|
|
isPersistingSettings: false,
|
|
settingsError: null,
|
|
};
|
|
}
|
|
|
|
function renderPuzzleRuntime(
|
|
ui: React.ReactElement,
|
|
authValue = createAuthValue(),
|
|
) {
|
|
return render(
|
|
<AuthUiContext.Provider value={authValue}>{ui}</AuthUiContext.Provider>,
|
|
);
|
|
}
|
|
|
|
const clearedRun: PuzzleRunSnapshot = {
|
|
runId: 'run-1',
|
|
entryProfileId: 'profile-1',
|
|
clearedLevelCount: 1,
|
|
currentLevelIndex: 1,
|
|
currentGridSize: 3,
|
|
playedProfileIds: ['profile-1'],
|
|
previousLevelTags: ['奇幻'],
|
|
recommendedNextProfileId: 'profile-2',
|
|
leaderboardEntries: [
|
|
{
|
|
rank: 1,
|
|
nickname: '测试作者',
|
|
elapsedMs: 12_340,
|
|
isCurrentPlayer: true,
|
|
},
|
|
{
|
|
rank: 2,
|
|
nickname: '星桥旅人',
|
|
elapsedMs: 18_120,
|
|
},
|
|
],
|
|
currentLevel: {
|
|
runId: 'run-1',
|
|
levelIndex: 1,
|
|
gridSize: 3,
|
|
profileId: 'profile-1',
|
|
levelName: '潮雾拼图',
|
|
authorDisplayName: '测试作者',
|
|
themeTags: ['奇幻'],
|
|
coverImageSrc: null,
|
|
status: 'cleared',
|
|
startedAtMs: 1000,
|
|
clearedAtMs: 13_340,
|
|
elapsedMs: 12_340,
|
|
leaderboardEntries: [
|
|
{
|
|
rank: 1,
|
|
nickname: '测试作者',
|
|
elapsedMs: 12_340,
|
|
isCurrentPlayer: true,
|
|
},
|
|
{
|
|
rank: 2,
|
|
nickname: '星桥旅人',
|
|
elapsedMs: 18_120,
|
|
},
|
|
],
|
|
board: {
|
|
rows: 3,
|
|
cols: 3,
|
|
selectedPieceId: null,
|
|
allTilesResolved: true,
|
|
mergedGroups: [],
|
|
pieces: Array.from({ length: 9 }, (_, index) => ({
|
|
pieceId: `piece-${index}`,
|
|
correctRow: Math.floor(index / 3),
|
|
correctCol: index % 3,
|
|
currentRow: Math.floor(index / 3),
|
|
currentCol: index % 3,
|
|
mergedGroupId: null,
|
|
})),
|
|
},
|
|
},
|
|
};
|
|
|
|
test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
|
|
vi.useFakeTimers();
|
|
const onAdvanceNextLevel = vi.fn();
|
|
|
|
renderPuzzleRuntime(
|
|
<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('关闭通关弹窗后保留底部下一关入口', () => {
|
|
vi.useFakeTimers();
|
|
|
|
renderPuzzleRuntime(
|
|
<PuzzleRuntimeShell
|
|
run={clearedRun}
|
|
onBack={vi.fn()}
|
|
onSwapPieces={vi.fn()}
|
|
onDragPiece={vi.fn()}
|
|
onAdvanceNextLevel={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(1_400);
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
|
|
|
|
expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
|
|
expect(screen.getByRole('button', { name: /下一关/u })).toBeTruthy();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('右上角设置按钮打开拼图设置并支持音量调节', () => {
|
|
const authValue = createAuthValue();
|
|
|
|
renderPuzzleRuntime(
|
|
<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 mergedRun: PuzzleRunSnapshot = {
|
|
...clearedRun,
|
|
currentLevel: {
|
|
...clearedRun.currentLevel!,
|
|
status: 'playing',
|
|
board: {
|
|
...clearedRun.currentLevel!.board,
|
|
allTilesResolved: false,
|
|
mergedGroups: [
|
|
{
|
|
groupId: 'group-l',
|
|
pieceIds: ['piece-0', 'piece-1', 'piece-3'],
|
|
occupiedCells: [
|
|
{ row: 0, col: 0 },
|
|
{ row: 0, col: 1 },
|
|
{ row: 1, col: 0 },
|
|
],
|
|
},
|
|
],
|
|
pieces: clearedRun.currentLevel!.board.pieces.map((piece) =>
|
|
['piece-0', 'piece-1', 'piece-3'].includes(piece.pieceId)
|
|
? { ...piece, mergedGroupId: 'group-l' }
|
|
: piece,
|
|
),
|
|
},
|
|
},
|
|
};
|
|
|
|
const { container } = renderPuzzleRuntime(
|
|
<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(outlinedPieces[0]?.className).toContain('border-r-0');
|
|
expect(outlinedPieces[0]?.className).toContain('border-b-0');
|
|
expect(outlinedPieces[0]?.className).toContain('rounded-tl-[0.85rem]');
|
|
expect(outlinedPieces[0]?.className).toContain('rounded-tr-none');
|
|
expect(outlinedPieces[0]?.className).toContain('rounded-bl-none');
|
|
expect(outlinedPieces[1]?.className).toContain('border-l-0');
|
|
expect(outlinedPieces[1]?.className).toContain('rounded-tr-[0.85rem]');
|
|
expect(outlinedPieces[2]?.className).toContain('border-t-0');
|
|
expect(outlinedPieces[2]?.className).toContain('rounded-bl-[0.85rem]');
|
|
});
|