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