f7404a07ef
提取 PuzzleClearRuntimeShell 内部 overlay 薄壳并统一等待层与结算层结构 补充等待层与结算弹层语义断言测试
1252 lines
35 KiB
TypeScript
1252 lines
35 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import type { ImgHTMLAttributes } from 'react';
|
|
import { afterEach, expect, test, vi } from 'vitest';
|
|
|
|
import type {
|
|
PuzzleClearCardAsset,
|
|
PuzzleClearRuntimeSnapshotResponse,
|
|
PuzzleClearWorkProfileResponse,
|
|
} from '../../../packages/shared/src/contracts/puzzleClear';
|
|
import { PuzzleClearRuntimeShell } from './PuzzleClearRuntimeShell';
|
|
|
|
vi.mock('../ResolvedAssetImage', () => ({
|
|
ResolvedAssetImage: ({
|
|
src,
|
|
alt,
|
|
className,
|
|
fallbackSrc: _fallbackSrc,
|
|
...rest
|
|
}: {
|
|
src?: string | null;
|
|
alt?: string;
|
|
className?: string;
|
|
[key: string]: unknown;
|
|
}) =>
|
|
src ? (
|
|
<img
|
|
src={src}
|
|
alt={alt}
|
|
className={className}
|
|
{...(rest as ImgHTMLAttributes<HTMLImageElement>)}
|
|
/>
|
|
) : null,
|
|
}));
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
Object.defineProperty(document, 'elementFromPoint', {
|
|
configurable: true,
|
|
value: undefined,
|
|
});
|
|
});
|
|
|
|
function createCard(
|
|
groupIndex: number,
|
|
partX: number,
|
|
partY: number,
|
|
): PuzzleClearCardAsset {
|
|
return {
|
|
cardId: `card-${groupIndex}-${partX}-${partY}`,
|
|
groupId: `group-${groupIndex}`,
|
|
shape: '1x2',
|
|
orientation: 'horizontal',
|
|
partX,
|
|
partY,
|
|
imageSrc: `/cards/${groupIndex}-${partX}-${partY}.png`,
|
|
imageObjectKey: `cards/${groupIndex}-${partX}-${partY}.png`,
|
|
assetObjectId: `asset-${groupIndex}-${partX}-${partY}`,
|
|
sourceAtlasCell: `${groupIndex}:${partX}:${partY}`,
|
|
};
|
|
}
|
|
|
|
function createRun(
|
|
overrides: Partial<PuzzleClearRuntimeSnapshotResponse> = {},
|
|
): PuzzleClearRuntimeSnapshotResponse {
|
|
const cards = Array.from({ length: 9 }, (_, index) =>
|
|
createCard(index, index % 2, 0),
|
|
);
|
|
const board = {
|
|
rows: 3,
|
|
cols: 3,
|
|
cells: cards.map((card, index) => ({
|
|
row: Math.floor(index / 3),
|
|
col: index % 3,
|
|
card,
|
|
lockedGroupId: index < 2 ? 'group-0' : null,
|
|
})),
|
|
};
|
|
|
|
return {
|
|
runId: 'puzzle-clear-run-1',
|
|
profileId: 'puzzle-clear-profile-1',
|
|
ownerUserId: 'user-1',
|
|
status: 'playing',
|
|
levelIndex: 1,
|
|
clearsDone: 1,
|
|
targetClears: 5,
|
|
levelDurationSeconds: 600,
|
|
levelStartedAtMs: Date.now(),
|
|
board,
|
|
readyColumns: [
|
|
[createCard(10, 0, 0)],
|
|
[createCard(11, 0, 0)],
|
|
[createCard(12, 0, 0)],
|
|
],
|
|
startedAtMs: Date.now(),
|
|
finishedAtMs: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function cloneRunWithBoard(
|
|
run: PuzzleClearRuntimeSnapshotResponse,
|
|
board: PuzzleClearRuntimeSnapshotResponse['board'],
|
|
): PuzzleClearRuntimeSnapshotResponse {
|
|
return {
|
|
...run,
|
|
board: {
|
|
rows: board.rows,
|
|
cols: board.cols,
|
|
cells: board.cells.map((cell) => ({
|
|
row: cell.row,
|
|
col: cell.col,
|
|
lockedGroupId: cell.lockedGroupId,
|
|
card: cell.card ? { ...cell.card } : null,
|
|
})),
|
|
},
|
|
readyColumns: run.readyColumns.map((column) =>
|
|
column.map((card) => ({ ...card })),
|
|
),
|
|
};
|
|
}
|
|
|
|
function createProfile(): PuzzleClearWorkProfileResponse {
|
|
const atlasAsset = {
|
|
assetId: 'atlas-1',
|
|
imageSrc: '/atlas.png',
|
|
imageObjectKey: 'atlas.png',
|
|
assetObjectId: 'atlas-object',
|
|
generationProvider: 'gpt-image-2',
|
|
prompt: '星空糖果',
|
|
width: 4096,
|
|
height: 4096,
|
|
};
|
|
const draft = {
|
|
templateId: 'puzzle-clear',
|
|
templateName: '拼消消',
|
|
profileId: 'puzzle-clear-profile-1',
|
|
workTitle: '星空糖果',
|
|
workDescription: '',
|
|
themePrompt: '星空糖果',
|
|
boardBackgroundPrompt: '星空糖果中央底图',
|
|
generateBoardBackground: true,
|
|
boardBackgroundAsset: {
|
|
...atlasAsset,
|
|
assetId: 'board-bg-1',
|
|
imageSrc: '/board-bg.png',
|
|
imageObjectKey: 'board-bg.png',
|
|
assetObjectId: 'board-bg-object',
|
|
},
|
|
cardBackImageSrc: '/card-back.png',
|
|
atlasAsset,
|
|
patternGroups: [],
|
|
cardAssets: [],
|
|
generationStatus: 'ready' as const,
|
|
};
|
|
|
|
return {
|
|
summary: {
|
|
runtimeKind: 'puzzle-clear',
|
|
workId: 'puzzle-clear-work-1',
|
|
profileId: 'puzzle-clear-profile-1',
|
|
ownerUserId: 'user-1',
|
|
sourceSessionId: 'session-1',
|
|
workTitle: '星空糖果',
|
|
workDescription: '',
|
|
themePrompt: '星空糖果',
|
|
coverImageSrc: '/atlas.png',
|
|
publicationStatus: 'published',
|
|
playCount: 0,
|
|
updatedAt: '2026-05-30T00:00:00.000Z',
|
|
publishedAt: '2026-05-30T00:00:00.000Z',
|
|
publishReady: true,
|
|
generationStatus: 'ready',
|
|
},
|
|
draft,
|
|
boardBackgroundAsset: draft.boardBackgroundAsset,
|
|
atlasAsset,
|
|
patternGroups: [],
|
|
cardAssets: [],
|
|
};
|
|
}
|
|
|
|
function dispatchPointer(
|
|
target: HTMLElement,
|
|
type: string,
|
|
options: { pointerId?: number; clientX: number; clientY: number },
|
|
) {
|
|
if (type === 'pointerdown') {
|
|
fireEvent.pointerDown(target, { pointerId: 1, ...options });
|
|
return;
|
|
}
|
|
if (type === 'pointerup') {
|
|
fireEvent.pointerUp(target, { pointerId: 1, ...options });
|
|
}
|
|
}
|
|
|
|
function dispatchPointerWithClientPoint(
|
|
target: HTMLElement,
|
|
type: string,
|
|
options: { pointerId?: number; clientX: number; clientY: number },
|
|
) {
|
|
const event = new MouseEvent(type, {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
clientX: options.clientX,
|
|
clientY: options.clientY,
|
|
});
|
|
Object.defineProperty(event, 'pointerId', {
|
|
configurable: true,
|
|
value: options.pointerId ?? 1,
|
|
});
|
|
fireEvent(target, event);
|
|
}
|
|
|
|
test('开局时场地卡片带有翻牌动画状态', () => {
|
|
render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={createRun()}
|
|
onSwapCards={vi.fn()}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByLabelText('卡片 1-1').className).toContain(
|
|
'puzzle-clear-card--opening',
|
|
);
|
|
expect(screen.getAllByTestId('puzzle-clear-ready-card')).toHaveLength(3);
|
|
});
|
|
|
|
test('翻牌只在开局阶段出现,交换后的新卡不会重新翻转', async () => {
|
|
const initialRun = createRun();
|
|
const { rerender } = render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={initialRun}
|
|
onSwapCards={vi.fn()}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByLabelText('卡片 1-1').className).not.toContain(
|
|
'puzzle-clear-card--opening',
|
|
),
|
|
);
|
|
|
|
const swappedBoard = cloneRunWithBoard(initialRun, {
|
|
rows: initialRun.board.rows,
|
|
cols: initialRun.board.cols,
|
|
cells: initialRun.board.cells.map((cell) => {
|
|
if (cell.row === 0 && cell.col === 0) {
|
|
const source = initialRun.board.cells.find(
|
|
(item) => item.row === 0 && item.col === 1,
|
|
);
|
|
return {
|
|
...cell,
|
|
card: source?.card ? { ...source.card } : null,
|
|
};
|
|
}
|
|
if (cell.row === 0 && cell.col === 1) {
|
|
const source = initialRun.board.cells.find(
|
|
(item) => item.row === 0 && item.col === 0,
|
|
);
|
|
return {
|
|
...cell,
|
|
card: source?.card ? { ...source.card } : null,
|
|
};
|
|
}
|
|
return {
|
|
...cell,
|
|
card: cell.card ? { ...cell.card } : null,
|
|
};
|
|
}),
|
|
});
|
|
|
|
rerender(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={swappedBoard}
|
|
onSwapCards={vi.fn()}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByLabelText('卡片 1-2').className).not.toContain(
|
|
'puzzle-clear-card--opening',
|
|
);
|
|
});
|
|
|
|
test('未拿到 active run 时显示本地等待层薄壳', () => {
|
|
render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={null}
|
|
onSwapCards={vi.fn()}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const pendingOverlay = screen.getByTestId(
|
|
'puzzle-clear-runtime-pending-overlay',
|
|
);
|
|
expect(pendingOverlay.getAttribute('role')).toBe('status');
|
|
expect(pendingOverlay.textContent).toContain('等待开局');
|
|
});
|
|
|
|
test('指针拖拽到另一张卡片时提交后端交换动作', async () => {
|
|
const onSwapCards = vi.fn().mockResolvedValue(undefined);
|
|
|
|
render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={createRun()}
|
|
onSwapCards={onSwapCards}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const from = screen.getByLabelText('卡片 1-1');
|
|
const to = screen.getByLabelText('卡片 1-2');
|
|
|
|
dispatchPointer(from, 'pointerdown', { clientX: 12, clientY: 12 });
|
|
dispatchPointer(to, 'pointerup', { clientX: 96, clientY: 12 });
|
|
|
|
await waitFor(() =>
|
|
expect(onSwapCards).toHaveBeenCalledWith({
|
|
fromRow: 0,
|
|
fromCol: 0,
|
|
toRow: 0,
|
|
toCol: 1,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('指针捕获后按松手坐标命中目标卡片', async () => {
|
|
const onSwapCards = vi.fn().mockResolvedValue(undefined);
|
|
const run = createRun();
|
|
|
|
render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={run}
|
|
onSwapCards={onSwapCards}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const from = screen.getByLabelText('卡片 1-1');
|
|
const to = screen.getByLabelText('卡片 1-2');
|
|
Object.defineProperty(document, 'elementFromPoint', {
|
|
configurable: true,
|
|
value: vi.fn(() => to),
|
|
});
|
|
|
|
dispatchPointer(from, 'pointerdown', { clientX: 12, clientY: 12 });
|
|
dispatchPointer(from, 'pointerup', { clientX: 96, clientY: 12 });
|
|
|
|
await waitFor(() =>
|
|
expect(onSwapCards).toHaveBeenCalledWith({
|
|
fromRow: 0,
|
|
fromCol: 0,
|
|
toRow: 0,
|
|
toCol: 1,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('指针拖拽到空格时仍按落点提交移动动作', async () => {
|
|
const onSwapCards = vi.fn().mockResolvedValue(undefined);
|
|
const run = createRun();
|
|
const emptyTargetRun = cloneRunWithBoard(run, {
|
|
rows: run.board.rows,
|
|
cols: run.board.cols,
|
|
cells: run.board.cells.map((cell) =>
|
|
cell.row === 0 && cell.col === 1
|
|
? { ...cell, card: null, lockedGroupId: null }
|
|
: cell,
|
|
),
|
|
});
|
|
|
|
render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={emptyTargetRun}
|
|
onSwapCards={onSwapCards}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const from = screen.getByLabelText('卡片 1-1');
|
|
const to = screen.getByLabelText('卡片 1-2');
|
|
Object.defineProperty(document, 'elementFromPoint', {
|
|
configurable: true,
|
|
value: vi.fn(() => to),
|
|
});
|
|
|
|
expect(to.hasAttribute('disabled')).toBe(false);
|
|
|
|
dispatchPointer(from, 'pointerdown', { clientX: 12, clientY: 12 });
|
|
dispatchPointer(from, 'pointerup', { clientX: 96, clientY: 12 });
|
|
|
|
await waitFor(() =>
|
|
expect(onSwapCards).toHaveBeenCalledWith({
|
|
fromRow: 0,
|
|
fromCol: 0,
|
|
toRow: 0,
|
|
toCol: 1,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('空格目标会保留可点击交互并接住落位', async () => {
|
|
const onSwapCards = vi.fn().mockResolvedValue(undefined);
|
|
const run = createRun();
|
|
const emptyTargetRun = cloneRunWithBoard(run, {
|
|
rows: run.board.rows,
|
|
cols: run.board.cols,
|
|
cells: run.board.cells.map((cell) =>
|
|
cell.row === 0 && cell.col === 1
|
|
? { ...cell, card: null, lockedGroupId: null }
|
|
: cell,
|
|
),
|
|
});
|
|
|
|
render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={emptyTargetRun}
|
|
onSwapCards={onSwapCards}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const emptyTarget = screen.getByLabelText('卡片 1-2');
|
|
expect(emptyTarget.hasAttribute('disabled')).toBe(false);
|
|
expect(emptyTarget.getAttribute('draggable')).toBe('false');
|
|
|
|
fireEvent.pointerDown(screen.getByLabelText('卡片 1-1'), {
|
|
pointerId: 1,
|
|
clientX: 18,
|
|
clientY: 18,
|
|
});
|
|
fireEvent.pointerUp(emptyTarget, {
|
|
pointerId: 1,
|
|
clientX: 96,
|
|
clientY: 18,
|
|
});
|
|
|
|
await waitFor(() =>
|
|
expect(onSwapCards).toHaveBeenCalledWith({
|
|
fromRow: 0,
|
|
fromCol: 0,
|
|
toRow: 0,
|
|
toCol: 1,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('中央场地底图固定在棋盘底层且空格露出底图', () => {
|
|
const run = createRun();
|
|
const emptyTargetRun = cloneRunWithBoard(run, {
|
|
rows: run.board.rows,
|
|
cols: run.board.cols,
|
|
cells: run.board.cells.map((cell) =>
|
|
cell.row === 0 && cell.col === 1
|
|
? { ...cell, card: null, lockedGroupId: null }
|
|
: cell,
|
|
),
|
|
});
|
|
|
|
render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={emptyTargetRun}
|
|
onSwapCards={vi.fn()}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const board = screen.getByTestId('puzzle-clear-board');
|
|
const boardBackground = screen.getByTestId('puzzle-clear-board-background');
|
|
const emptyTarget = screen.getByLabelText('卡片 1-2');
|
|
|
|
expect(board.contains(boardBackground)).toBe(true);
|
|
expect(boardBackground.getAttribute('src')).toBe('/board-bg.png');
|
|
expect(boardBackground.className).toContain('absolute');
|
|
expect(boardBackground.className).toContain('object-cover');
|
|
expect(
|
|
Array.from(document.querySelectorAll<HTMLImageElement>('img[src="/board-bg.png"]')),
|
|
).toEqual([boardBackground]);
|
|
expect(emptyTarget.className).toContain('bg-transparent');
|
|
expect(emptyTarget.className).not.toContain('bg-white/78');
|
|
});
|
|
|
|
test('空格落位后仍会继续呈现补位后的棋盘,而不是保留真空洞', async () => {
|
|
const onSwapCards = vi.fn().mockResolvedValue(undefined);
|
|
const run = cloneRunWithBoard(createRun(), {
|
|
rows: 3,
|
|
cols: 3,
|
|
cells: createRun().board.cells.map((cell) => ({
|
|
...cell,
|
|
lockedGroupId: null,
|
|
})),
|
|
});
|
|
const emptyTargetRun = cloneRunWithBoard(run, {
|
|
rows: run.board.rows,
|
|
cols: run.board.cols,
|
|
cells: run.board.cells.map((cell) =>
|
|
cell.row === 0 && cell.col === 1
|
|
? { ...cell, card: null, lockedGroupId: null }
|
|
: cell,
|
|
),
|
|
});
|
|
|
|
const { rerender } = render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={emptyTargetRun}
|
|
onSwapCards={onSwapCards}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const updatedRun = cloneRunWithBoard(emptyTargetRun, {
|
|
rows: emptyTargetRun.board.rows,
|
|
cols: emptyTargetRun.board.cols,
|
|
cells: emptyTargetRun.board.cells.map((cell) => {
|
|
if (cell.row === 0 && cell.col === 0) {
|
|
return {
|
|
...cell,
|
|
card: createCard(20, 0, 0),
|
|
};
|
|
}
|
|
if (cell.row === 0 && cell.col === 1) {
|
|
return {
|
|
...cell,
|
|
card: createCard(21, 0, 0),
|
|
};
|
|
}
|
|
return {
|
|
...cell,
|
|
card: cell.card ? { ...cell.card } : null,
|
|
};
|
|
}),
|
|
});
|
|
|
|
rerender(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={updatedRun}
|
|
onSwapCards={onSwapCards}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByLabelText('卡片 1-2').querySelector('img')).toBeTruthy(),
|
|
);
|
|
expect(screen.getByLabelText('卡片 1-1').querySelector('img')).toBeTruthy();
|
|
});
|
|
|
|
test('已有卡片因重力下沉时目标格不被过渡状态隐藏成空位', async () => {
|
|
const previousRun = cloneRunWithBoard(createRun(), {
|
|
rows: 3,
|
|
cols: 3,
|
|
cells: [
|
|
{ row: 0, col: 0, card: createCard(30, 0, 0), lockedGroupId: null },
|
|
{ row: 0, col: 1, card: createCard(31, 0, 0), lockedGroupId: null },
|
|
{ row: 0, col: 2, card: createCard(32, 0, 0), lockedGroupId: null },
|
|
{ row: 1, col: 0, card: createCard(36, 0, 0), lockedGroupId: null },
|
|
{ row: 1, col: 1, card: createCard(33, 0, 0), lockedGroupId: null },
|
|
{ row: 1, col: 2, card: createCard(38, 0, 0), lockedGroupId: null },
|
|
{ row: 2, col: 0, card: createCard(34, 0, 0), lockedGroupId: null },
|
|
{ row: 2, col: 1, card: createCard(37, 0, 0), lockedGroupId: null },
|
|
{ row: 2, col: 2, card: createCard(35, 0, 0), lockedGroupId: null },
|
|
],
|
|
});
|
|
const nextRun = {
|
|
...cloneRunWithBoard(previousRun, {
|
|
rows: 3,
|
|
cols: 3,
|
|
cells: [
|
|
{ row: 0, col: 0, card: createCard(40, 0, 0), lockedGroupId: null },
|
|
{ row: 0, col: 1, card: createCard(41, 0, 0), lockedGroupId: null },
|
|
{ row: 0, col: 2, card: createCard(42, 0, 0), lockedGroupId: null },
|
|
{ row: 1, col: 0, card: createCard(30, 0, 0), lockedGroupId: null },
|
|
{ row: 1, col: 1, card: createCard(31, 0, 0), lockedGroupId: null },
|
|
{ row: 1, col: 2, card: createCard(32, 0, 0), lockedGroupId: null },
|
|
{ row: 2, col: 0, card: createCard(34, 0, 0), lockedGroupId: null },
|
|
{ row: 2, col: 1, card: createCard(33, 0, 0), lockedGroupId: null },
|
|
{ row: 2, col: 2, card: createCard(35, 0, 0), lockedGroupId: null },
|
|
],
|
|
}),
|
|
clearsDone: previousRun.clearsDone + 1,
|
|
};
|
|
|
|
const { rerender } = render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={previousRun}
|
|
onSwapCards={vi.fn()}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
rerender(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={nextRun}
|
|
onSwapCards={vi.fn()}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(
|
|
screen.getByTestId('puzzle-clear-board').querySelector(
|
|
'.puzzle-clear-transition-piece--drop',
|
|
),
|
|
).toBeTruthy(),
|
|
);
|
|
|
|
const bottomTarget = screen.getByLabelText('卡片 3-2');
|
|
expect(bottomTarget.querySelector('img')).toBeTruthy();
|
|
expect(bottomTarget.className).not.toContain(
|
|
'puzzle-clear-card--transition-hidden',
|
|
);
|
|
});
|
|
|
|
test('顶部准备区缺失卡背资源时回退到已有模板图', () => {
|
|
const profile = createProfile();
|
|
profile.draft.cardBackImageSrc = '/creation-type-references/puzzle-clear-card-back.webp';
|
|
|
|
render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={profile}
|
|
run={createRun()}
|
|
onSwapCards={vi.fn()}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getAllByTestId('puzzle-clear-ready-card')[0]?.getAttribute('src')).toBe(
|
|
'/creation-type-references/puzzle.webp',
|
|
);
|
|
});
|
|
|
|
test('点击两张卡片仍保留轻触交换手感', async () => {
|
|
const onSwapCards = vi.fn().mockResolvedValue(undefined);
|
|
|
|
render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={createRun()}
|
|
onSwapCards={onSwapCards}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByLabelText('卡片 1-1'));
|
|
fireEvent.click(screen.getByLabelText('卡片 2-1'));
|
|
|
|
await waitFor(() =>
|
|
expect(onSwapCards).toHaveBeenCalledWith({
|
|
fromRow: 0,
|
|
fromCol: 0,
|
|
toRow: 1,
|
|
toCol: 0,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('真实指针点击序列不会被 pointerdown 选中态打断', async () => {
|
|
const onSwapCards = vi.fn().mockResolvedValue(undefined);
|
|
|
|
render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={createRun()}
|
|
onSwapCards={onSwapCards}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const from = screen.getByLabelText('卡片 1-1');
|
|
const to = screen.getByLabelText('卡片 2-2');
|
|
|
|
dispatchPointer(from, 'pointerdown', { clientX: 12, clientY: 12 });
|
|
dispatchPointer(from, 'pointerup', { clientX: 12, clientY: 12 });
|
|
fireEvent.click(from);
|
|
dispatchPointer(to, 'pointerdown', { clientX: 96, clientY: 96 });
|
|
dispatchPointer(to, 'pointerup', { clientX: 96, clientY: 96 });
|
|
fireEvent.click(to);
|
|
|
|
await waitFor(() =>
|
|
expect(onSwapCards).toHaveBeenCalledWith({
|
|
fromRow: 0,
|
|
fromCol: 0,
|
|
toRow: 1,
|
|
toCol: 1,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('按住拖动时会出现跟随光标的拖拽浮层', () => {
|
|
render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={createRun()}
|
|
onSwapCards={vi.fn()}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const from = screen.getByLabelText('卡片 1-1');
|
|
Object.defineProperty(from, 'getBoundingClientRect', {
|
|
configurable: true,
|
|
value: () =>
|
|
({
|
|
left: 100,
|
|
top: 120,
|
|
width: 60,
|
|
height: 60,
|
|
}) as DOMRect,
|
|
});
|
|
const board = screen.getByTestId('puzzle-clear-board');
|
|
Object.defineProperty(board, 'getBoundingClientRect', {
|
|
configurable: true,
|
|
value: () =>
|
|
({
|
|
left: 100,
|
|
top: 120,
|
|
width: 180,
|
|
height: 180,
|
|
}) as DOMRect,
|
|
});
|
|
|
|
dispatchPointerWithClientPoint(from, 'pointerdown', {
|
|
pointerId: 1,
|
|
clientX: 118,
|
|
clientY: 136,
|
|
});
|
|
dispatchPointerWithClientPoint(from, 'pointermove', {
|
|
pointerId: 1,
|
|
clientX: 168,
|
|
clientY: 164,
|
|
});
|
|
|
|
const ghost = screen.getByTestId('puzzle-clear-drag-ghost');
|
|
expect(ghost.className).toContain('puzzle-clear-drag-ghost--dragging');
|
|
expect(ghost.getAttribute('style') ?? '').toContain('position: fixed');
|
|
expect(ghost.parentElement).toBe(document.body);
|
|
expect(ghost.getAttribute('style') ?? '').toContain('left: 150px');
|
|
expect(ghost.getAttribute('style') ?? '').toContain('top: 148px');
|
|
});
|
|
|
|
test('按住拖动时原位置会先空出来', () => {
|
|
render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={createRun()}
|
|
onSwapCards={vi.fn()}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const from = screen.getByLabelText('卡片 1-1');
|
|
|
|
fireEvent.pointerDown(from, { pointerId: 1, clientX: 18, clientY: 18 });
|
|
fireEvent.pointerMove(from, { pointerId: 1, clientX: 72, clientY: 20 });
|
|
|
|
expect(from.querySelector('img')).toBeNull();
|
|
expect(from.className).toContain('puzzle-clear-card--drag-origin-empty');
|
|
});
|
|
|
|
test('拖拽源位只保留空槽,不再显示原卡片底色', () => {
|
|
render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={createRun()}
|
|
onSwapCards={vi.fn()}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const from = screen.getByLabelText('卡片 1-1');
|
|
|
|
fireEvent.pointerDown(from, { pointerId: 1, clientX: 18, clientY: 18 });
|
|
fireEvent.pointerMove(from, { pointerId: 1, clientX: 72, clientY: 20 });
|
|
|
|
expect(from.className).toContain('puzzle-clear-card--ghost-origin');
|
|
expect(from.className).toContain('puzzle-clear-card--drag-origin-empty');
|
|
expect(from.querySelector('img')).toBeNull();
|
|
});
|
|
|
|
test('按住已拼接局部时会拿起整个拼接组', () => {
|
|
render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={createRun()}
|
|
onSwapCards={vi.fn()}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const from = screen.getByLabelText('卡片 1-1');
|
|
const neighbor = screen.getByLabelText('卡片 1-2');
|
|
Object.defineProperty(from, 'getBoundingClientRect', {
|
|
configurable: true,
|
|
value: () =>
|
|
({
|
|
left: 100,
|
|
top: 120,
|
|
width: 60,
|
|
height: 60,
|
|
}) as DOMRect,
|
|
});
|
|
Object.defineProperty(neighbor, 'getBoundingClientRect', {
|
|
configurable: true,
|
|
value: () =>
|
|
({
|
|
left: 160,
|
|
top: 120,
|
|
width: 60,
|
|
height: 60,
|
|
}) as DOMRect,
|
|
});
|
|
|
|
fireEvent.pointerDown(from, { pointerId: 1, clientX: 18, clientY: 18 });
|
|
fireEvent.pointerMove(from, { pointerId: 1, clientX: 72, clientY: 20 });
|
|
|
|
const ghost = screen.getByTestId('puzzle-clear-drag-ghost');
|
|
expect(ghost.querySelectorAll('img')).toHaveLength(2);
|
|
expect(ghost.querySelector('[data-testid="puzzle-clear-drag-group"]')).toBeTruthy();
|
|
expect(
|
|
ghost.querySelector('[data-testid="puzzle-clear-drag-group"]')?.className,
|
|
).toContain('puzzle-clear-drag-group');
|
|
expect(ghost.getAttribute('style') ?? '').toContain('width: 120px');
|
|
});
|
|
|
|
test('按住已拼接局部时组内其他格子也会一起进入空槽状态', () => {
|
|
render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={createRun()}
|
|
onSwapCards={vi.fn()}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const from = screen.getByLabelText('卡片 1-1');
|
|
const neighbor = screen.getByLabelText('卡片 1-2');
|
|
|
|
fireEvent.pointerDown(from, { pointerId: 1, clientX: 18, clientY: 18 });
|
|
fireEvent.pointerMove(from, { pointerId: 1, clientX: 72, clientY: 20 });
|
|
|
|
expect(from.className).toContain('puzzle-clear-card--drag-origin-empty');
|
|
expect(neighbor.className).toContain('puzzle-clear-card--drag-group-empty');
|
|
expect(from.querySelector('img')).toBeNull();
|
|
expect(neighbor.querySelector('img')).toBeNull();
|
|
});
|
|
|
|
test('放下后会播放替换落点动画', async () => {
|
|
const onSwapCards = vi.fn().mockResolvedValue(undefined);
|
|
|
|
render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={createRun()}
|
|
onSwapCards={onSwapCards}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const from = screen.getByLabelText('卡片 1-1');
|
|
const to = screen.getByLabelText('卡片 1-2');
|
|
|
|
fireEvent.pointerDown(from, { pointerId: 1, clientX: 18, clientY: 18 });
|
|
fireEvent.pointerMove(from, { pointerId: 1, clientX: 68, clientY: 20 });
|
|
fireEvent.pointerUp(to, { pointerId: 1, clientX: 112, clientY: 20 });
|
|
|
|
const ghost = screen.getByTestId('puzzle-clear-drag-ghost');
|
|
expect(ghost.className).toContain('puzzle-clear-drag-ghost--dropping');
|
|
await waitFor(() =>
|
|
expect(onSwapCards).toHaveBeenCalledWith({
|
|
fromRow: 0,
|
|
fromCol: 0,
|
|
toRow: 0,
|
|
toCol: 1,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('放下后被替换的卡片会快速飞回空位', async () => {
|
|
let resolveSwapPromise: () => void = () => {};
|
|
const onSwapCards = vi.fn().mockImplementation(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
resolveSwapPromise = resolve;
|
|
}),
|
|
);
|
|
|
|
render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={createRun()}
|
|
onSwapCards={onSwapCards}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const from = screen.getByLabelText('卡片 1-1');
|
|
const to = screen.getByLabelText('卡片 1-2');
|
|
|
|
fireEvent.pointerDown(from, { pointerId: 1, clientX: 18, clientY: 18 });
|
|
fireEvent.pointerMove(from, { pointerId: 1, clientX: 68, clientY: 20 });
|
|
fireEvent.pointerUp(to, { pointerId: 1, clientX: 112, clientY: 20 });
|
|
|
|
expect(screen.getByTestId('puzzle-clear-swap-flight')).toBeTruthy();
|
|
expect(screen.getByTestId('puzzle-clear-swap-flight').className).toContain(
|
|
'puzzle-clear-swap-flight--incoming',
|
|
);
|
|
expect(screen.getByTestId('puzzle-clear-swap-flight').getAttribute('style') ?? '').toContain(
|
|
'--puzzle-clear-flight-delta-x',
|
|
);
|
|
|
|
resolveSwapPromise();
|
|
await waitFor(() =>
|
|
expect(screen.queryByTestId('puzzle-clear-swap-flight')).toBeNull(),
|
|
);
|
|
});
|
|
|
|
test('消除成功时会播放消除和掉落过渡动画', async () => {
|
|
const onSwapCards = vi.fn().mockResolvedValue(undefined);
|
|
const run = createRun({
|
|
clearsDone: 2,
|
|
board: {
|
|
rows: 3,
|
|
cols: 3,
|
|
cells: [
|
|
{ row: 0, col: 0, card: createCard(0, 0, 0), lockedGroupId: null },
|
|
{ row: 0, col: 1, card: createCard(1, 0, 0), lockedGroupId: null },
|
|
{ row: 0, col: 2, card: createCard(2, 0, 0), lockedGroupId: null },
|
|
{ row: 1, col: 0, card: createCard(3, 0, 0), lockedGroupId: null },
|
|
{ row: 1, col: 1, card: createCard(4, 0, 0), lockedGroupId: null },
|
|
{ row: 1, col: 2, card: createCard(5, 0, 0), lockedGroupId: null },
|
|
{ row: 2, col: 0, card: createCard(6, 0, 0), lockedGroupId: null },
|
|
{ row: 2, col: 1, card: createCard(7, 0, 0), lockedGroupId: null },
|
|
{ row: 2, col: 2, card: createCard(8, 0, 0), lockedGroupId: null },
|
|
],
|
|
},
|
|
});
|
|
|
|
const { rerender } = render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={createRun()}
|
|
onSwapCards={onSwapCards}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
rerender(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={run}
|
|
onSwapCards={onSwapCards}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(
|
|
screen.getByTestId('puzzle-clear-board').querySelectorAll(
|
|
'.puzzle-clear-transition-piece--clear',
|
|
).length,
|
|
).toBeGreaterThan(0),
|
|
);
|
|
expect(
|
|
screen.getByTestId('puzzle-clear-board').querySelectorAll(
|
|
'.puzzle-clear-transition-piece--drop',
|
|
).length,
|
|
).toBeGreaterThan(0);
|
|
expect(
|
|
screen
|
|
.getByTestId('puzzle-clear-board')
|
|
.querySelector('.puzzle-clear-transition-piece--drop')
|
|
?.getAttribute('style') ?? '',
|
|
).toContain('--puzzle-clear-drop-delay: 520ms');
|
|
});
|
|
|
|
test('正确局部拼合但未消除时会高光提醒拼合组', async () => {
|
|
const previousRun = cloneRunWithBoard(createRun(), {
|
|
rows: 3,
|
|
cols: 3,
|
|
cells: [
|
|
{ row: 0, col: 0, card: createCard(40, 0, 0), lockedGroupId: null },
|
|
{ row: 0, col: 1, card: createCard(40, 1, 0), lockedGroupId: null },
|
|
{ row: 0, col: 2, card: createCard(42, 0, 0), lockedGroupId: null },
|
|
{ row: 1, col: 0, card: createCard(43, 0, 0), lockedGroupId: null },
|
|
{ row: 1, col: 1, card: createCard(44, 0, 0), lockedGroupId: null },
|
|
{ row: 1, col: 2, card: createCard(45, 0, 0), lockedGroupId: null },
|
|
{ row: 2, col: 0, card: createCard(46, 0, 0), lockedGroupId: null },
|
|
{ row: 2, col: 1, card: createCard(47, 0, 0), lockedGroupId: null },
|
|
{ row: 2, col: 2, card: createCard(48, 0, 0), lockedGroupId: null },
|
|
],
|
|
});
|
|
const nextRun = cloneRunWithBoard(previousRun, {
|
|
rows: 3,
|
|
cols: 3,
|
|
cells: previousRun.board.cells.map((cell) =>
|
|
cell.row === 0 && (cell.col === 0 || cell.col === 1)
|
|
? { ...cell, lockedGroupId: 'group-40' }
|
|
: cell,
|
|
),
|
|
});
|
|
|
|
const { rerender } = render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={previousRun}
|
|
onSwapCards={vi.fn()}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
rerender(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={nextRun}
|
|
onSwapCards={vi.fn()}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(
|
|
screen.getByTestId('puzzle-clear-locked-group-visual').className,
|
|
).toContain('puzzle-clear-locked-group-visual--highlight'),
|
|
);
|
|
});
|
|
|
|
test('完成拼接的局部会以连续组面板呈现,而不是单个绿框', () => {
|
|
render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={createRun()}
|
|
onSwapCards={vi.fn()}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const groupVisual = screen.getByTestId('puzzle-clear-locked-group-visual');
|
|
expect(groupVisual).toBeTruthy();
|
|
expect(groupVisual.className).toContain('puzzle-clear-locked-group-visual');
|
|
expect(groupVisual.className).not.toContain('ring-emerald');
|
|
expect(screen.getAllByTestId('puzzle-clear-locked-group-piece')).toHaveLength(2);
|
|
});
|
|
|
|
test('交换完成前拖拽浮层保持落下态,避免过早收尾', async () => {
|
|
let resolveSwapPromise: () => void = () => {};
|
|
const onSwapCards = vi.fn().mockImplementation(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
resolveSwapPromise = resolve;
|
|
}),
|
|
);
|
|
|
|
render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={createRun()}
|
|
onSwapCards={onSwapCards}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const from = screen.getByLabelText('卡片 1-1');
|
|
const to = screen.getByLabelText('卡片 1-2');
|
|
|
|
fireEvent.pointerDown(from, { pointerId: 1, clientX: 18, clientY: 18 });
|
|
fireEvent.pointerMove(from, { pointerId: 1, clientX: 68, clientY: 20 });
|
|
fireEvent.pointerUp(to, { pointerId: 1, clientX: 112, clientY: 20 });
|
|
|
|
expect(screen.getByTestId('puzzle-clear-drag-ghost').className).toContain(
|
|
'puzzle-clear-drag-ghost--dropping',
|
|
);
|
|
|
|
resolveSwapPromise();
|
|
await waitFor(() =>
|
|
expect(screen.queryByTestId('puzzle-clear-drag-ghost')).toBeNull(),
|
|
);
|
|
});
|
|
|
|
test('关卡结算弹层提供下一关和失败重试动作', () => {
|
|
const onNextLevel = vi.fn();
|
|
const onRetryLevel = vi.fn();
|
|
const { rerender } = render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={createRun({ status: 'level_cleared' })}
|
|
onSwapCards={vi.fn()}
|
|
onRetryLevel={onRetryLevel}
|
|
onNextLevel={onNextLevel}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByRole('dialog', { name: '本关完成' })).toBeTruthy();
|
|
fireEvent.click(screen.getByRole('button', { name: /下一关/u }));
|
|
expect(onNextLevel).toHaveBeenCalledTimes(1);
|
|
|
|
rerender(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={createRun({ status: 'level_failed' })}
|
|
onSwapCards={vi.fn()}
|
|
onRetryLevel={onRetryLevel}
|
|
onNextLevel={onNextLevel}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByRole('dialog', { name: '本关失败' })).toBeTruthy();
|
|
fireEvent.click(screen.getAllByRole('button', { name: '重试' }).at(-1)!);
|
|
expect(onRetryLevel).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test('runtime 不渲染规则说明类文案', () => {
|
|
render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={createRun()}
|
|
onSwapCards={vi.fn()}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText(/规则|玩法说明|拖动卡片|拼接完整/u)).toBeNull();
|
|
});
|
|
|
|
test('卡片图片禁用浏览器原生拖拽', () => {
|
|
const { container } = render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={createRun()}
|
|
onSwapCards={vi.fn()}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const boardImage = container.querySelector<HTMLImageElement>(
|
|
'img[src="/cards/0-0-0.png"]',
|
|
);
|
|
const readyImage = screen.getAllByTestId('puzzle-clear-ready-card')[0];
|
|
expect(boardImage).toBeTruthy();
|
|
expect(readyImage).toBeTruthy();
|
|
expect(boardImage?.getAttribute('draggable')).toBe('false');
|
|
expect(readyImage?.getAttribute('draggable')).toBe('false');
|
|
expect(fireEvent.dragStart(boardImage!)).toBe(false);
|
|
});
|
|
|
|
test('棋盘继承拼图模板的正方形触控面约束', () => {
|
|
const { container } = render(
|
|
<PuzzleClearRuntimeShell
|
|
profile={createProfile()}
|
|
run={createRun()}
|
|
onSwapCards={vi.fn()}
|
|
onRetryLevel={vi.fn()}
|
|
onNextLevel={vi.fn()}
|
|
onTimeUp={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const board = container.querySelector<HTMLElement>('[data-testid="puzzle-clear-board"]');
|
|
if (!board) {
|
|
throw new Error('puzzle-clear board was not rendered');
|
|
}
|
|
expect(board.className).toContain('aspect-square');
|
|
expect(board.className).toContain('touch-none');
|
|
expect(board.className).toContain('select-none');
|
|
expect(board.className).toContain('gap-[1.5px]');
|
|
expect(board.className).not.toContain('gap-1.5');
|
|
expect(board.className).not.toContain('h-full');
|
|
expect(board.className).toContain('relative');
|
|
});
|