Files
Genarrative/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx
T
kdletters f7404a07ef 收口拼消消运行态等待层与结算层
提取 PuzzleClearRuntimeShell 内部 overlay 薄壳并统一等待层与结算层结构
补充等待层与结算弹层语义断言测试
2026-06-11 22:35:54 +08:00

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');
});