1399 lines
42 KiB
TypeScript
1399 lines
42 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
|
import { beforeEach, expect, test, vi } from 'vitest';
|
|
|
|
import type {
|
|
JumpHopRuntimeRunSnapshotResponse,
|
|
JumpHopWorkProfileResponse,
|
|
} from '../../../packages/shared/src/contracts/jumpHop';
|
|
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
|
import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard';
|
|
import {
|
|
JUMP_HOP_THREE_CAMERA_UP_Y,
|
|
JumpHopRuntimeShell,
|
|
getJumpHopThreeProjectedY,
|
|
} from './JumpHopRuntimeShell';
|
|
|
|
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
|
useResolvedAssetReadUrl: vi.fn((source: string | null | undefined) => ({
|
|
resolvedUrl: source?.trim() ?? '',
|
|
isResolving: false,
|
|
shouldResolve: Boolean(source?.trim().startsWith('/generated-')),
|
|
})),
|
|
}));
|
|
|
|
vi.mock('../../services/jump-hop/useJumpHopLeaderboard', () => ({
|
|
useJumpHopLeaderboard: vi.fn(),
|
|
}));
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(useJumpHopLeaderboard).mockReturnValue({
|
|
leaderboard: null,
|
|
isLoading: false,
|
|
error: null,
|
|
refresh: vi.fn(),
|
|
});
|
|
});
|
|
|
|
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);
|
|
}
|
|
|
|
test('跳一跳运行态松手时只提交长按蓄力值', async () => {
|
|
vi.useFakeTimers();
|
|
const onJump = vi.fn().mockResolvedValue(undefined);
|
|
const run = buildRun();
|
|
|
|
render(
|
|
<JumpHopRuntimeShell
|
|
profile={buildProfile()}
|
|
run={run}
|
|
onJump={onJump}
|
|
onRestart={() => {}}
|
|
/>,
|
|
);
|
|
|
|
const stage = screen.getByTestId('jump-hop-stage');
|
|
await act(async () => {
|
|
dispatchPointerEvent(stage, 'pointerdown', {
|
|
pointerId: 1,
|
|
clientX: 180,
|
|
clientY: 420,
|
|
});
|
|
});
|
|
await act(async () => {
|
|
dispatchPointerEvent(stage, 'pointermove', {
|
|
pointerId: 1,
|
|
clientX: 132,
|
|
clientY: 478,
|
|
});
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(360);
|
|
});
|
|
|
|
await act(async () => {
|
|
dispatchPointerEvent(stage, 'pointerup', {
|
|
pointerId: 1,
|
|
clientX: 132,
|
|
clientY: 478,
|
|
});
|
|
});
|
|
|
|
expect(onJump).toHaveBeenCalledTimes(1);
|
|
const jumpPayload = onJump.mock.calls[0]?.[0];
|
|
expect(jumpPayload?.dragVectorX).toBeUndefined();
|
|
expect(jumpPayload?.dragVectorY).toBeUndefined();
|
|
expect(jumpPayload?.dragDistance).toBeGreaterThanOrEqual(360);
|
|
expect(jumpPayload?.dragDistance).toBeLessThanOrEqual(380);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('跳一跳运行态手指移动不改变提交方向', async () => {
|
|
vi.useFakeTimers();
|
|
const onJump = vi.fn().mockResolvedValue(undefined);
|
|
const run = buildRun();
|
|
|
|
render(
|
|
<JumpHopRuntimeShell
|
|
profile={buildProfile()}
|
|
run={run}
|
|
onJump={onJump}
|
|
onRestart={() => {}}
|
|
/>,
|
|
);
|
|
|
|
const stage = screen.getByTestId('jump-hop-stage');
|
|
await act(async () => {
|
|
dispatchPointerEvent(stage, 'pointerdown', {
|
|
pointerId: 1,
|
|
clientX: 40,
|
|
clientY: 40,
|
|
});
|
|
});
|
|
await act(async () => {
|
|
dispatchPointerEvent(stage, 'pointermove', {
|
|
pointerId: 1,
|
|
clientX: 10,
|
|
clientY: 20,
|
|
});
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(240);
|
|
});
|
|
await act(async () => {
|
|
dispatchPointerEvent(stage, 'pointerup', {
|
|
pointerId: 1,
|
|
clientX: 10,
|
|
clientY: 20,
|
|
});
|
|
});
|
|
|
|
const jumpPayload = onJump.mock.calls[0]?.[0];
|
|
expect(jumpPayload?.dragVectorX).toBeUndefined();
|
|
expect(jumpPayload?.dragVectorY).toBeUndefined();
|
|
expect(jumpPayload?.dragDistance).toBeGreaterThanOrEqual(240);
|
|
expect(jumpPayload?.dragDistance).toBeLessThanOrEqual(260);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('跳一跳运行态长按蓄力不会超过后端上限', async () => {
|
|
vi.useFakeTimers();
|
|
const onJump = vi.fn().mockResolvedValue(undefined);
|
|
const baseRun = buildRun();
|
|
const run: JumpHopRuntimeRunSnapshotResponse = {
|
|
...baseRun,
|
|
path: {
|
|
...baseRun.path,
|
|
scoring: {
|
|
...baseRun.path.scoring,
|
|
maxChargeMs: 300,
|
|
},
|
|
},
|
|
};
|
|
|
|
render(
|
|
<JumpHopRuntimeShell
|
|
profile={buildProfile()}
|
|
run={run}
|
|
onJump={onJump}
|
|
onRestart={() => {}}
|
|
/>,
|
|
);
|
|
|
|
const stage = screen.getByTestId('jump-hop-stage');
|
|
await act(async () => {
|
|
dispatchPointerEvent(stage, 'pointerdown', {
|
|
pointerId: 1,
|
|
clientX: 40,
|
|
clientY: 40,
|
|
});
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(780);
|
|
});
|
|
await act(async () => {
|
|
dispatchPointerEvent(stage, 'pointerup', {
|
|
pointerId: 1,
|
|
clientX: 40,
|
|
clientY: 40,
|
|
});
|
|
});
|
|
|
|
expect(onJump.mock.calls[0]?.[0]?.dragDistance).toBe(300);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('跳一跳运行态不再显示旧圆弧蓄力条而是显示长按蓄力引导', async () => {
|
|
const onJump = vi.fn().mockResolvedValue(undefined);
|
|
|
|
render(
|
|
<JumpHopRuntimeShell
|
|
profile={buildProfile()}
|
|
run={buildRun()}
|
|
onJump={onJump}
|
|
onRestart={() => {}}
|
|
/>,
|
|
);
|
|
|
|
const stage = screen.getByTestId('jump-hop-stage');
|
|
await act(async () => {
|
|
dispatchPointerEvent(stage, 'pointerdown', {
|
|
pointerId: 1,
|
|
clientX: 180,
|
|
clientY: 420,
|
|
});
|
|
});
|
|
|
|
expect(screen.queryByText('起跳')).toBeNull();
|
|
expect(stage.querySelector('.jump-hop-runtime__charge-orbit')).toBeNull();
|
|
expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy();
|
|
expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeNull();
|
|
});
|
|
|
|
test('跳一跳蓄力时角色只做垂直压缩', async () => {
|
|
vi.useFakeTimers();
|
|
render(
|
|
<JumpHopRuntimeShell
|
|
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
|
run={buildRun()}
|
|
onJump={vi.fn().mockResolvedValue(undefined)}
|
|
onRestart={() => {}}
|
|
/>,
|
|
);
|
|
|
|
const stage = screen.getByTestId('jump-hop-stage');
|
|
await act(async () => {
|
|
dispatchPointerEvent(stage, 'pointerdown', {
|
|
pointerId: 1,
|
|
clientX: 180,
|
|
clientY: 420,
|
|
});
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(180);
|
|
});
|
|
|
|
const character = screen.getByTestId('jump-hop-character-logo')
|
|
.parentElement as HTMLElement;
|
|
const stretchTransform = character.style.getPropertyValue(
|
|
'--jump-hop-character-stretch-transform',
|
|
);
|
|
const styleText = Array.from(document.querySelectorAll('style'))
|
|
.map((style) => style.textContent ?? '')
|
|
.join('\n');
|
|
|
|
expect(stretchTransform).toMatch(/^scale\((?<x>[\d.]+), (?<y>[\d.]+)\)$/);
|
|
const scaleMatch = stretchTransform.match(
|
|
/^scale\((?<x>[\d.]+), (?<y>[\d.]+)\)$/,
|
|
);
|
|
const scaleX = Number(scaleMatch?.groups?.x ?? 1);
|
|
const scaleY = Number(scaleMatch?.groups?.y ?? 1);
|
|
expect(scaleX).toBeGreaterThan(1);
|
|
expect(scaleY).toBeLessThan(1);
|
|
expect(scaleY).toBeLessThan(scaleX);
|
|
expect(styleText).toContain('var(--jump-hop-character-stretch-transform)');
|
|
expect(styleText).not.toContain(
|
|
'scaleY(calc(1 - (var(--jump-hop-charge) * 0.16)))',
|
|
);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('跳一跳运行态游玩中只保留得分并隐藏常驻排行榜', () => {
|
|
const runtimeRequestOptions = {
|
|
runtimeGuestToken: 'runtime-guest-token',
|
|
};
|
|
|
|
render(
|
|
<JumpHopRuntimeShell
|
|
profile={buildProfile()}
|
|
run={buildRun()}
|
|
runtimeRequestOptions={runtimeRequestOptions}
|
|
onJump={vi.fn().mockResolvedValue(undefined)}
|
|
onRestart={() => {}}
|
|
/>,
|
|
);
|
|
|
|
expect(useJumpHopLeaderboard).not.toHaveBeenCalled();
|
|
expect(screen.getByTestId('jump-hop-three-scene')).toBeTruthy();
|
|
expect(screen.queryByTestId('jump-hop-runtime-leaderboard')).toBeNull();
|
|
expect(screen.queryByRole('button', { name: /重开/ })).toBeNull();
|
|
expect(screen.queryByText('进行中')).toBeNull();
|
|
expect(screen.queryByText('00:00')).toBeNull();
|
|
expect(screen.queryByRole('button', { name: /^起跳$/ })).toBeNull();
|
|
});
|
|
|
|
test('跳一跳运行态背景和游戏舞台覆盖全部界面且 HUD 使用独立主题按钮和拼图顶部样式', () => {
|
|
const backButtonAsset = {
|
|
assetId: 'jump-hop-back-button',
|
|
imageSrc: '/generated-jump-hop-assets/jump-hop-profile-test/back-button/image.png',
|
|
imageObjectKey:
|
|
'generated-jump-hop-assets/jump-hop-profile-test/back-button/image.png',
|
|
assetObjectId: 'asset-back-button',
|
|
generationProvider: 'vector-engine-gpt-image-2',
|
|
prompt: '主题返回按钮',
|
|
width: 1024,
|
|
height: 1024,
|
|
} satisfies NonNullable<JumpHopWorkProfileResponse['backButtonAsset']>;
|
|
|
|
render(
|
|
<JumpHopRuntimeShell
|
|
profile={buildProfile({ backButtonAsset })}
|
|
run={buildRun()}
|
|
onJump={vi.fn().mockResolvedValue(undefined)}
|
|
onRestart={() => {}}
|
|
/>,
|
|
);
|
|
|
|
const stage = screen.getByTestId('jump-hop-stage');
|
|
expect(stage.className).toContain('absolute');
|
|
expect(stage.className).toContain('inset-0');
|
|
expect(stage.className).toContain('h-full');
|
|
expect(stage.className).toContain('w-full');
|
|
expect(stage.className).not.toContain('rounded-[1.5rem]');
|
|
|
|
const backButton = screen.getByRole('button', { name: '返回' });
|
|
expect(backButton.className).toContain('pointer-events-auto');
|
|
expect(backButton.className).toContain('jump-hop-runtime__back-button');
|
|
expect(backButton.className).toContain('h-14');
|
|
expect(backButton.className).toContain('w-14');
|
|
expect(backButton.className).toContain('sm:h-[3.875rem]');
|
|
expect(backButton.className).toContain('sm:w-[3.875rem]');
|
|
expect(backButton.getAttribute('data-has-asset')).toBe('true');
|
|
expect(backButton.textContent).toBe('');
|
|
expect(
|
|
screen
|
|
.getByTestId('jump-hop-runtime-back-button-asset')
|
|
.getAttribute('src'),
|
|
).toBe(backButtonAsset.imageSrc);
|
|
|
|
const header = backButton.closest('header');
|
|
expect(header?.className).toContain('absolute');
|
|
expect(header?.className).toContain('top-0');
|
|
expect(header?.className).toContain('z-[130]');
|
|
expect(header?.querySelector('.puzzle-runtime-header-card')).toBeTruthy();
|
|
const titleCard = header?.querySelector('.puzzle-runtime-level-title-card');
|
|
expect(titleCard).toBeTruthy();
|
|
expect(titleCard?.className).toContain('jump-hop-runtime__score-title-card');
|
|
expect(screen.getByTestId('jump-hop-runtime-level-logo')).toBeTruthy();
|
|
expect(screen.getByText('得分')).toBeTruthy();
|
|
expect(screen.queryByText('跳一跳')).toBeNull();
|
|
|
|
const scoreCard = screen.getByTestId('jump-hop-score-card');
|
|
expect(scoreCard.className).toContain('puzzle-runtime-timer-card');
|
|
expect(scoreCard.className).toContain('puzzle-runtime-timer');
|
|
expect(scoreCard.className).toContain('jump-hop-runtime__score-value-card');
|
|
expect(scoreCard.className).toContain('justify-center');
|
|
expect(scoreCard.className).toContain('text-center');
|
|
});
|
|
|
|
test('跳一跳运行态失败后在弹窗中展示排行榜', () => {
|
|
const runtimeRequestOptions = {
|
|
runtimeGuestToken: 'runtime-guest-token',
|
|
};
|
|
vi.mocked(useJumpHopLeaderboard).mockReturnValue({
|
|
leaderboard: {
|
|
profileId: 'jump-hop-profile-test',
|
|
items: [
|
|
{
|
|
rank: 1,
|
|
playerId: 'user-secret-1',
|
|
displayName: '陶泥儿玩家',
|
|
successfulJumpCount: 8,
|
|
durationMs: 8123,
|
|
updatedAt: '2026-05-27T00:00:00Z',
|
|
},
|
|
],
|
|
viewerBest: null,
|
|
},
|
|
isLoading: false,
|
|
error: null,
|
|
refresh: vi.fn(),
|
|
});
|
|
|
|
render(
|
|
<JumpHopRuntimeShell
|
|
profile={buildProfile({ publicationStatus: 'published' })}
|
|
run={buildFailedRun()}
|
|
runtimeRequestOptions={runtimeRequestOptions}
|
|
onJump={vi.fn().mockResolvedValue(undefined)}
|
|
onRestart={() => {}}
|
|
/>,
|
|
);
|
|
|
|
expect(useJumpHopLeaderboard).toHaveBeenCalledWith(
|
|
'jump-hop-profile-test',
|
|
runtimeRequestOptions,
|
|
);
|
|
expect(screen.getByRole('dialog', { name: '失败' })).toBeTruthy();
|
|
const leaderboard = screen.getByTestId('jump-hop-runtime-leaderboard');
|
|
expect(leaderboard).toBeTruthy();
|
|
expect(within(leaderboard).getByText('陶泥儿玩家')).toBeTruthy();
|
|
expect(within(leaderboard).queryByText('user-secret-1')).toBeNull();
|
|
expect(within(leaderboard).getByText('8 跳')).toBeTruthy();
|
|
expect(within(leaderboard).getByText('00:08')).toBeTruthy();
|
|
});
|
|
|
|
test('跳一跳草稿运行失败后不请求公开排行榜', () => {
|
|
render(
|
|
<JumpHopRuntimeShell
|
|
profile={buildProfile({ publicationStatus: 'draft' })}
|
|
run={buildFailedRun()}
|
|
onJump={vi.fn().mockResolvedValue(undefined)}
|
|
onRestart={() => {}}
|
|
/>,
|
|
);
|
|
|
|
expect(useJumpHopLeaderboard).not.toHaveBeenCalled();
|
|
expect(screen.getByRole('dialog', { name: '失败' })).toBeTruthy();
|
|
expect(screen.queryByTestId('jump-hop-runtime-leaderboard')).toBeNull();
|
|
});
|
|
|
|
test('跳一跳 Three.js 地板层位于 DOM 角色层下方', () => {
|
|
render(
|
|
<JumpHopRuntimeShell
|
|
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
|
run={buildRun()}
|
|
onJump={vi.fn().mockResolvedValue(undefined)}
|
|
onRestart={() => {}}
|
|
/>,
|
|
);
|
|
|
|
const threeScene = screen.getByTestId('jump-hop-three-scene');
|
|
const firstPlatform = screen.getAllByTestId('jump-hop-tile-image')[0]
|
|
?.parentElement?.parentElement as HTMLElement | undefined;
|
|
|
|
expect(threeScene.style.zIndex).toBe('42');
|
|
expect(Number(threeScene.style.zIndex)).toBeGreaterThan(
|
|
Number(firstPlatform?.style.zIndex ?? 0),
|
|
);
|
|
});
|
|
|
|
test('跳一跳 Three.js 平台层和 DOM 角色层保持同向屏幕坐标', () => {
|
|
expect(JUMP_HOP_THREE_CAMERA_UP_Y).toBe(1);
|
|
expect(getJumpHopThreeProjectedY(360, 568)).toBeLessThan(284);
|
|
expect(getJumpHopThreeProjectedY(200, 568)).toBeGreaterThan(284);
|
|
});
|
|
|
|
test('跳一跳蓄力时隐藏落点辅助标识但保留蓄力引导', async () => {
|
|
const onJump = vi.fn().mockResolvedValue(undefined);
|
|
|
|
render(
|
|
<JumpHopRuntimeShell
|
|
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
|
run={buildRun()}
|
|
onJump={onJump}
|
|
onRestart={() => {}}
|
|
/>,
|
|
);
|
|
|
|
const stage = screen.getByTestId('jump-hop-stage');
|
|
await act(async () => {
|
|
dispatchPointerEvent(stage, 'pointerdown', {
|
|
pointerId: 1,
|
|
clientX: 180,
|
|
clientY: 420,
|
|
});
|
|
});
|
|
|
|
await act(async () => {
|
|
dispatchPointerEvent(stage, 'pointermove', {
|
|
pointerId: 1,
|
|
clientX: 148,
|
|
clientY: 454,
|
|
});
|
|
});
|
|
|
|
expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull();
|
|
expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
dispatchPointerEvent(stage, 'pointermove', {
|
|
pointerId: 1,
|
|
clientX: 112,
|
|
clientY: 492,
|
|
});
|
|
});
|
|
|
|
expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull();
|
|
expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy();
|
|
expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeNull();
|
|
});
|
|
|
|
test('跳一跳运行态直接渲染生成的地板贴图切片图片', () => {
|
|
render(
|
|
<JumpHopRuntimeShell
|
|
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
|
run={buildRun()}
|
|
onJump={vi.fn().mockResolvedValue(undefined)}
|
|
onRestart={() => {}}
|
|
/>,
|
|
);
|
|
|
|
const tileImages = screen.getAllByTestId('jump-hop-tile-image');
|
|
expect(tileImages).toHaveLength(3);
|
|
expect(document.querySelector('.jump-hop-runtime__fallback-tile')).toBeNull();
|
|
const generatedReadUrlCalls = vi
|
|
.mocked(useResolvedAssetReadUrl)
|
|
.mock.calls.filter(([source]) =>
|
|
source?.includes('/generated-jump-hop-assets/'),
|
|
);
|
|
expect(generatedReadUrlCalls.length).toBeGreaterThanOrEqual(3);
|
|
for (const [, options] of generatedReadUrlCalls) {
|
|
expect(options).toEqual(
|
|
expect.objectContaining({
|
|
refreshKey: expect.stringMatching(/^asset-object-/),
|
|
}),
|
|
);
|
|
}
|
|
|
|
for (const image of tileImages) {
|
|
expect(image.getAttribute('src')).toContain(
|
|
'/generated-jump-hop-assets/jump-hop-profile-test/tile-',
|
|
);
|
|
fireEvent.load(image);
|
|
expect(image.getAttribute('data-loaded')).toBe('true');
|
|
}
|
|
});
|
|
|
|
test('跳一跳运行态提前预加载下一屏地块且不在真实图片加载前露出原型方块', () => {
|
|
render(
|
|
<JumpHopRuntimeShell
|
|
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
|
run={buildRunWithExtraPreviewPlatform()}
|
|
onJump={vi.fn().mockResolvedValue(undefined)}
|
|
onRestart={() => {}}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getAllByTestId('jump-hop-tile-image')).toHaveLength(3);
|
|
expect(document.querySelector('.jump-hop-runtime__fallback-tile')).toBeNull();
|
|
const preloadImages = screen.getAllByTestId('jump-hop-tile-preload-image');
|
|
expect(preloadImages.length).toBeGreaterThan(0);
|
|
expect(preloadImages[0]?.getAttribute('src')).toContain(
|
|
'/generated-jump-hop-assets/jump-hop-profile-test/tile-',
|
|
);
|
|
});
|
|
|
|
test('跳一跳新 UV 地板资源会解析六张面贴图而不是复用单张图', () => {
|
|
render(
|
|
<JumpHopRuntimeShell
|
|
profile={buildProfile({ tileAssets: buildTileAssets({ withFaceAssets: true }) })}
|
|
run={buildRun()}
|
|
onJump={vi.fn().mockResolvedValue(undefined)}
|
|
onRestart={() => {}}
|
|
/>,
|
|
);
|
|
|
|
const preloadImages = screen.getAllByTestId('jump-hop-tile-preload-image');
|
|
const faceImageSources = preloadImages
|
|
.map((image) => image.getAttribute('src') ?? '')
|
|
.filter((source) =>
|
|
source.includes('/generated-jump-hop-assets/jump-hop-profile-test/tile-'),
|
|
);
|
|
|
|
const firstTileMatch = faceImageSources[0]?.match(/tile-(\d{2})-/);
|
|
const firstTileNumber = firstTileMatch?.[1];
|
|
expect(firstTileNumber).toBeTruthy();
|
|
expect(faceImageSources).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining(`/tile-${firstTileNumber}-top/image.png`),
|
|
expect.stringContaining(`/tile-${firstTileNumber}-front/image.png`),
|
|
expect.stringContaining(`/tile-${firstTileNumber}-right/image.png`),
|
|
expect.stringContaining(`/tile-${firstTileNumber}-back/image.png`),
|
|
expect.stringContaining(`/tile-${firstTileNumber}-left/image.png`),
|
|
expect.stringContaining(`/tile-${firstTileNumber}-bottom/image.png`),
|
|
]),
|
|
);
|
|
const frontSource = `/tile-${firstTileNumber}-front/image.png`;
|
|
const frontRefreshKey = `asset-object-${firstTileNumber}-front`;
|
|
expect(
|
|
vi
|
|
.mocked(useResolvedAssetReadUrl)
|
|
.mock.calls.some(
|
|
([source, options]) =>
|
|
source?.includes(frontSource) && options?.refreshKey === frontRefreshKey,
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
test('跳一跳运行态首块地块落在中下方并且后续两块向中央和上方展开', () => {
|
|
render(
|
|
<JumpHopRuntimeShell
|
|
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
|
run={buildRun()}
|
|
onJump={vi.fn().mockResolvedValue(undefined)}
|
|
onRestart={() => {}}
|
|
/>,
|
|
);
|
|
|
|
const tileImages = screen.getAllByTestId('jump-hop-tile-image');
|
|
expect(tileImages).toHaveLength(3);
|
|
const first = tileImages[0]?.parentElement?.parentElement as HTMLElement | undefined;
|
|
const second = tileImages[1]?.parentElement?.parentElement as HTMLElement | undefined;
|
|
const third = tileImages[2]?.parentElement?.parentElement as HTMLElement | undefined;
|
|
expect(first?.style.top).toBe('64%');
|
|
expect(second?.style.top).toBe('47%');
|
|
expect(third?.style.top).toBe('30%');
|
|
});
|
|
|
|
test('跳一跳运行态用固定基准宽高和深度 scale 表达地块尺寸', () => {
|
|
render(
|
|
<JumpHopRuntimeShell
|
|
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
|
run={buildRun()}
|
|
onJump={vi.fn().mockResolvedValue(undefined)}
|
|
onRestart={() => {}}
|
|
/>,
|
|
);
|
|
|
|
const firstTile = screen.getAllByTestId('jump-hop-tile-image')[0]
|
|
?.parentElement?.parentElement as HTMLElement | undefined;
|
|
|
|
expect(firstTile?.style.width).toBe('116px');
|
|
expect(firstTile?.style.height).toBe('96px');
|
|
expect(firstTile?.style.getPropertyValue('--jump-hop-platform-scale')).toBe(
|
|
'1.08',
|
|
);
|
|
});
|
|
|
|
test('跳一跳运行态使用陶泥儿透明 logo 作为角色形象', () => {
|
|
render(
|
|
<JumpHopRuntimeShell
|
|
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
|
run={buildRun()}
|
|
onJump={vi.fn().mockResolvedValue(undefined)}
|
|
onRestart={() => {}}
|
|
/>,
|
|
);
|
|
|
|
const logo = screen.getByTestId('jump-hop-character-logo');
|
|
expect(logo.getAttribute('src')).toBe(
|
|
'/branding/jump-hop-taonier-character.png',
|
|
);
|
|
expect(
|
|
screen.queryByTestId('jump-hop-character-fallback-shape'),
|
|
).toBeNull();
|
|
});
|
|
|
|
test('跳一跳蓄力和计时刷新不会重建三维画布宿主', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
render(
|
|
<JumpHopRuntimeShell
|
|
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
|
run={buildRun()}
|
|
onJump={vi.fn().mockResolvedValue(undefined)}
|
|
onRestart={() => {}}
|
|
/>,
|
|
);
|
|
|
|
const stage = screen.getByTestId('jump-hop-stage');
|
|
const canvas = screen.getByTestId('jump-hop-three-canvas');
|
|
|
|
await act(async () => {
|
|
dispatchPointerEvent(stage, 'pointerdown', {
|
|
pointerId: 1,
|
|
clientX: 180,
|
|
clientY: 420,
|
|
});
|
|
});
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(520);
|
|
});
|
|
await act(async () => {
|
|
dispatchPointerEvent(stage, 'pointermove', {
|
|
pointerId: 1,
|
|
clientX: 160,
|
|
clientY: 460,
|
|
});
|
|
});
|
|
|
|
expect(screen.getByTestId('jump-hop-three-canvas')).toBe(canvas);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('跳一跳后端回包较慢时角色停在目标点等待推进', async () => {
|
|
vi.useFakeTimers();
|
|
const onJump = vi.fn().mockResolvedValue(undefined);
|
|
const initialRun = buildRun();
|
|
const nextRun: JumpHopRuntimeRunSnapshotResponse = {
|
|
...buildRun(),
|
|
currentPlatformIndex: 1,
|
|
successfulJumpCount: 1,
|
|
score: 1,
|
|
lastJump: {
|
|
chargeMs: 420,
|
|
jumpDistance: 1.68,
|
|
targetPlatformIndex: 1,
|
|
landedX: 0.93,
|
|
landedY: 1.4,
|
|
result: 'hit',
|
|
},
|
|
};
|
|
const { rerender } = render(
|
|
<JumpHopRuntimeShell
|
|
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
|
run={initialRun}
|
|
onJump={onJump}
|
|
onRestart={() => {}}
|
|
/>,
|
|
);
|
|
|
|
const stage = screen.getByTestId('jump-hop-stage');
|
|
await act(async () => {
|
|
dispatchPointerEvent(stage, 'pointerdown', {
|
|
pointerId: 1,
|
|
clientX: 180,
|
|
clientY: 420,
|
|
});
|
|
});
|
|
await act(async () => {
|
|
dispatchPointerEvent(stage, 'pointermove', {
|
|
pointerId: 1,
|
|
clientX: 132,
|
|
clientY: 478,
|
|
});
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(420);
|
|
});
|
|
await act(async () => {
|
|
dispatchPointerEvent(stage, 'pointerup', {
|
|
pointerId: 1,
|
|
clientX: 132,
|
|
clientY: 478,
|
|
});
|
|
});
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(580);
|
|
});
|
|
|
|
const character = screen.getByTestId('jump-hop-character-logo')
|
|
.parentElement as HTMLElement;
|
|
expect(stage.getAttribute('data-jump-animating')).toBe('true');
|
|
expect(stage.getAttribute('data-platform-advancing')).toBe('false');
|
|
expect(Number.parseFloat(character.style.left)).not.toBeCloseTo(50, 2);
|
|
expect(character.style.getPropertyValue('--jump-hop-flight-from-x')).not.toBe(
|
|
'0px',
|
|
);
|
|
expect(character.style.getPropertyValue('--jump-hop-flight-from-y')).not.toBe(
|
|
'0px',
|
|
);
|
|
|
|
rerender(
|
|
<JumpHopRuntimeShell
|
|
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
|
run={nextRun}
|
|
onJump={onJump}
|
|
onRestart={() => {}}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe(
|
|
'false',
|
|
);
|
|
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe(
|
|
'true',
|
|
);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('跳一跳成功落点偏移后下一跳视觉仍朝下一块地块方向', async () => {
|
|
vi.useFakeTimers();
|
|
const onJump = vi.fn().mockResolvedValue(undefined);
|
|
const run: JumpHopRuntimeRunSnapshotResponse = {
|
|
...buildRun(),
|
|
currentPlatformIndex: 1,
|
|
successfulJumpCount: 1,
|
|
score: 1,
|
|
lastJump: {
|
|
chargeMs: 300,
|
|
jumpDistance: 1.0,
|
|
targetPlatformIndex: 1,
|
|
landedX: 0,
|
|
landedY: 1.2,
|
|
result: 'hit',
|
|
},
|
|
};
|
|
|
|
render(
|
|
<JumpHopRuntimeShell
|
|
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
|
run={run}
|
|
onJump={onJump}
|
|
onRestart={() => {}}
|
|
/>,
|
|
);
|
|
|
|
const character = screen.getByTestId('jump-hop-character-logo')
|
|
.parentElement as HTMLElement;
|
|
const initialLeft = Number.parseFloat(character.style.left);
|
|
const initialTop = Number.parseFloat(character.style.top);
|
|
const stage = screen.getByTestId('jump-hop-stage');
|
|
|
|
await act(async () => {
|
|
dispatchPointerEvent(stage, 'pointerdown', {
|
|
pointerId: 1,
|
|
clientX: 180,
|
|
clientY: 420,
|
|
});
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(120);
|
|
});
|
|
await act(async () => {
|
|
dispatchPointerEvent(stage, 'pointerup', {
|
|
pointerId: 1,
|
|
clientX: 180,
|
|
clientY: 420,
|
|
});
|
|
});
|
|
|
|
expect(onJump).toHaveBeenCalledTimes(1);
|
|
expect(Number.parseFloat(character.style.left)).toBeLessThan(initialLeft);
|
|
expect(Number.parseFloat(character.style.top)).toBeLessThan(initialTop);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('跳一跳松手后先播放飞行动画再切换到下一块地块', async () => {
|
|
vi.useFakeTimers();
|
|
const onJump = vi.fn().mockResolvedValue(undefined);
|
|
const initialRun = buildRun();
|
|
const nextRun: JumpHopRuntimeRunSnapshotResponse = {
|
|
...buildRun(),
|
|
currentPlatformIndex: 1,
|
|
successfulJumpCount: 1,
|
|
score: 1,
|
|
lastJump: {
|
|
chargeMs: 420,
|
|
jumpDistance: 1.68,
|
|
targetPlatformIndex: 1,
|
|
landedX: 0.93,
|
|
landedY: 1.4,
|
|
result: 'hit',
|
|
},
|
|
};
|
|
const runAfterSecondJump: JumpHopRuntimeRunSnapshotResponse = {
|
|
...buildRunWithExtraPreviewPlatform(),
|
|
currentPlatformIndex: 2,
|
|
successfulJumpCount: 2,
|
|
score: 2,
|
|
lastJump: {
|
|
chargeMs: 360,
|
|
jumpDistance: 1.44,
|
|
targetPlatformIndex: 2,
|
|
landedX: -0.2,
|
|
landedY: 2.4,
|
|
result: 'hit',
|
|
},
|
|
};
|
|
const { rerender } = render(
|
|
<JumpHopRuntimeShell
|
|
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
|
run={initialRun}
|
|
onJump={onJump}
|
|
onRestart={() => {}}
|
|
/>,
|
|
);
|
|
|
|
const stage = screen.getByTestId('jump-hop-stage');
|
|
await act(async () => {
|
|
dispatchPointerEvent(stage, 'pointerdown', {
|
|
pointerId: 1,
|
|
clientX: 180,
|
|
clientY: 420,
|
|
});
|
|
});
|
|
await act(async () => {
|
|
dispatchPointerEvent(stage, 'pointermove', {
|
|
pointerId: 1,
|
|
clientX: 132,
|
|
clientY: 478,
|
|
});
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(420);
|
|
});
|
|
await act(async () => {
|
|
dispatchPointerEvent(stage, 'pointerup', {
|
|
pointerId: 1,
|
|
clientX: 132,
|
|
clientY: 478,
|
|
});
|
|
});
|
|
|
|
expect(onJump).toHaveBeenCalledTimes(1);
|
|
expect(stage.getAttribute('data-jump-animating')).toBe('true');
|
|
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe(
|
|
'64%',
|
|
);
|
|
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe(
|
|
'true',
|
|
);
|
|
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe(
|
|
'p0',
|
|
);
|
|
|
|
rerender(
|
|
<JumpHopRuntimeShell
|
|
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
|
run={nextRun}
|
|
onJump={onJump}
|
|
onRestart={() => {}}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe(
|
|
'true',
|
|
);
|
|
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe(
|
|
'64%',
|
|
);
|
|
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe(
|
|
'true',
|
|
);
|
|
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe(
|
|
'p0',
|
|
);
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(580);
|
|
});
|
|
|
|
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe(
|
|
'false',
|
|
);
|
|
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe(
|
|
'true',
|
|
);
|
|
const landedCharacter = screen.getByTestId('jump-hop-character-logo')
|
|
.parentElement as HTMLElement;
|
|
expect(landedCharacter.getAttribute('data-landing-recoil')).toBe('true');
|
|
expect(Number.parseFloat(landedCharacter.style.left)).not.toBeCloseTo(50, 1);
|
|
expect(Number.parseFloat(landedCharacter.style.top)).not.toBeCloseTo(75, 1);
|
|
expect(landedCharacter.style.getPropertyValue('--jump-hop-recoil-x')).not.toBe(
|
|
'0px',
|
|
);
|
|
expect(landedCharacter.style.getPropertyValue('--jump-hop-recoil-y')).not.toBe(
|
|
'0px',
|
|
);
|
|
const cameraLayer = screen.getByTestId('jump-hop-camera-layer');
|
|
expect(cameraLayer.getAttribute('data-platform-advancing')).toBe('true');
|
|
expect(cameraLayer.style.getPropertyValue('--jump-hop-camera-zoom')).toBe(
|
|
'1.3',
|
|
);
|
|
expect(cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-y')).toBe(
|
|
'-17%',
|
|
);
|
|
expect(
|
|
Number.parseFloat(
|
|
cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-x'),
|
|
),
|
|
).toBeCloseTo(8.96, 2);
|
|
const styleText = Array.from(document.querySelectorAll('style'))
|
|
.map((style) => style.textContent ?? '')
|
|
.join('\n');
|
|
expect(styleText).toContain('@keyframes jump-hop-character-recoil');
|
|
expect(styleText).not.toContain('@keyframes jump-hop-platform-exit-drift');
|
|
expect(styleText).toContain('scale(var(--jump-hop-camera-zoom, 1))');
|
|
expect(styleText).toMatch(
|
|
/data-platform-advancing='true'\]\s+\.jump-hop-runtime__platform[\s\S]*transform 1440ms cubic-bezier/,
|
|
);
|
|
expect(document.querySelector('.jump-hop-runtime__fallback-tile')).toBeNull();
|
|
const advancingCharacterRule = styleText.match(
|
|
/\.jump-hop-runtime__stage\[data-platform-advancing='true'\]\s+\.jump-hop-runtime__character\s*\{(?<body>[\s\S]*?)\}/,
|
|
)?.groups?.body;
|
|
expect(advancingCharacterRule).toContain('transform 120ms ease');
|
|
expect(advancingCharacterRule).toContain('opacity 160ms ease');
|
|
expect(advancingCharacterRule).not.toContain('left');
|
|
expect(advancingCharacterRule).not.toContain('top');
|
|
expect(screen.getByTestId('jump-hop-three-scene').parentElement).toBe(
|
|
cameraLayer,
|
|
);
|
|
expect(
|
|
screen
|
|
.getByTestId('jump-hop-stage')
|
|
.querySelector("[data-advance-state='settling']"),
|
|
).toBeNull();
|
|
expect(
|
|
screen
|
|
.getByTestId('jump-hop-stage')
|
|
.querySelector("[data-advance-state='entering']"),
|
|
).toBeNull();
|
|
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe(
|
|
'p0',
|
|
);
|
|
expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe(
|
|
'p1',
|
|
);
|
|
expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.top).toBe(
|
|
'64%',
|
|
);
|
|
expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.getPropertyValue('--jump-hop-platform-scale')).toBe(
|
|
'1.08',
|
|
);
|
|
expect(screen.getAllByTestId('jump-hop-tile-image')[2]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe(
|
|
'p2',
|
|
);
|
|
expect(screen.getAllByTestId('jump-hop-tile-image')[2]?.parentElement?.parentElement?.style.top).toBe(
|
|
'47%',
|
|
);
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(720);
|
|
});
|
|
|
|
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe(
|
|
'true',
|
|
);
|
|
expect(
|
|
(
|
|
screen.getByTestId('jump-hop-character-logo')
|
|
.parentElement as HTMLElement
|
|
).getAttribute('data-landing-recoil'),
|
|
).toBe('false');
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(660);
|
|
});
|
|
|
|
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe(
|
|
'true',
|
|
);
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(100);
|
|
});
|
|
|
|
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe(
|
|
'false',
|
|
);
|
|
expect(screen.getByTestId('jump-hop-camera-layer').getAttribute('data-platform-advancing')).toBe(
|
|
'false',
|
|
);
|
|
const retainedOldPlatform = screen
|
|
.getByTestId('jump-hop-stage')
|
|
.querySelector("[data-platform-id='p0']") as HTMLElement | null;
|
|
expect(retainedOldPlatform?.getAttribute('data-advance-state')).toBe(
|
|
'exiting',
|
|
);
|
|
expect(retainedOldPlatform?.style.top).toBe('81%');
|
|
const currentPlatform = screen
|
|
.getByTestId('jump-hop-stage')
|
|
.querySelector("[data-platform-id='p1']") as HTMLElement | null;
|
|
expect(currentPlatform?.style.top).toBe('64%');
|
|
expect(currentPlatform?.getAttribute('data-current')).toBe(
|
|
'true',
|
|
);
|
|
expect(currentPlatform?.getAttribute('data-platform-id')).toBe(
|
|
'p1',
|
|
);
|
|
expect(
|
|
(
|
|
screen
|
|
.getByTestId('jump-hop-stage')
|
|
.querySelector("[data-platform-id='p2']") as HTMLElement | null
|
|
)?.style.top,
|
|
).toBe('47%');
|
|
|
|
await act(async () => {
|
|
dispatchPointerEvent(stage, 'pointerdown', {
|
|
pointerId: 2,
|
|
clientX: 180,
|
|
clientY: 420,
|
|
});
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(160);
|
|
});
|
|
await act(async () => {
|
|
dispatchPointerEvent(stage, 'pointerup', {
|
|
pointerId: 2,
|
|
clientX: 180,
|
|
clientY: 420,
|
|
});
|
|
});
|
|
|
|
expect(onJump).toHaveBeenCalledTimes(2);
|
|
|
|
rerender(
|
|
<JumpHopRuntimeShell
|
|
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
|
run={runAfterSecondJump}
|
|
onJump={onJump}
|
|
onRestart={() => {}}
|
|
/>,
|
|
);
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(580);
|
|
});
|
|
|
|
const movedOldPlatform = screen
|
|
.getByTestId('jump-hop-stage')
|
|
.querySelector("[data-platform-id='p0']") as HTMLElement | null;
|
|
if (movedOldPlatform) {
|
|
expect(Number.parseFloat(movedOldPlatform.style.top)).toBeGreaterThan(81);
|
|
}
|
|
expect(
|
|
(
|
|
screen
|
|
.getByTestId('jump-hop-stage')
|
|
.querySelector("[data-current='true']") as HTMLElement | null
|
|
)?.getAttribute('data-platform-id'),
|
|
).toBe('p2');
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
function buildRun(): JumpHopRuntimeRunSnapshotResponse {
|
|
return {
|
|
runId: 'jump-hop-run-test',
|
|
profileId: 'jump-hop-profile-test',
|
|
ownerUserId: 'user-test',
|
|
status: 'playing',
|
|
currentPlatformIndex: 0,
|
|
successfulJumpCount: 0,
|
|
durationMs: 0,
|
|
score: 0,
|
|
combo: 0,
|
|
path: {
|
|
seed: 'test',
|
|
difficulty: 'standard',
|
|
finishIndex: 4294967295,
|
|
cameraPreset: 'portrait-isometric-9x16',
|
|
scoring: {
|
|
chargeToDistanceRatio: 0.004,
|
|
maxChargeMs: 900,
|
|
hitBonus: 20,
|
|
perfectBonus: 60,
|
|
},
|
|
platforms: [
|
|
{
|
|
platformId: 'p0',
|
|
tileType: 'start',
|
|
x: 0,
|
|
y: 0,
|
|
width: 1,
|
|
height: 1,
|
|
landingRadius: 0.5,
|
|
perfectRadius: 0.2,
|
|
scoreValue: 1,
|
|
},
|
|
{
|
|
platformId: 'p1',
|
|
tileType: 'normal',
|
|
x: 0.8,
|
|
y: 1.2,
|
|
width: 1,
|
|
height: 1,
|
|
landingRadius: 0.5,
|
|
perfectRadius: 0.2,
|
|
scoreValue: 1,
|
|
},
|
|
{
|
|
platformId: 'p2',
|
|
tileType: 'target',
|
|
x: -0.2,
|
|
y: 2.4,
|
|
width: 1,
|
|
height: 1,
|
|
landingRadius: 0.5,
|
|
perfectRadius: 0.2,
|
|
scoreValue: 1,
|
|
},
|
|
],
|
|
},
|
|
lastJump: null,
|
|
startedAtMs: 1000,
|
|
finishedAtMs: null,
|
|
};
|
|
}
|
|
|
|
function buildFailedRun(): JumpHopRuntimeRunSnapshotResponse {
|
|
return {
|
|
...buildRun(),
|
|
status: 'failed',
|
|
successfulJumpCount: 8,
|
|
durationMs: 8123,
|
|
score: 8,
|
|
combo: 0,
|
|
lastJump: {
|
|
chargeMs: 420,
|
|
jumpDistance: 1.62,
|
|
targetPlatformIndex: 1,
|
|
landedX: 0,
|
|
landedY: 0,
|
|
result: 'miss',
|
|
},
|
|
finishedAtMs: 9123,
|
|
};
|
|
}
|
|
|
|
function buildRunWithExtraPreviewPlatform(): JumpHopRuntimeRunSnapshotResponse {
|
|
const run = buildRun();
|
|
return {
|
|
...run,
|
|
path: {
|
|
...run.path,
|
|
platforms: [
|
|
...run.path.platforms,
|
|
{
|
|
platformId: 'p3',
|
|
tileType: 'normal',
|
|
x: 0.5,
|
|
y: 3.6,
|
|
width: 1,
|
|
height: 1,
|
|
landingRadius: 0.5,
|
|
perfectRadius: 0.2,
|
|
scoreValue: 1,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildTileAssets(options: { withFaceAssets?: boolean } = {}) {
|
|
return Array.from({ length: 18 }, (_, index) => {
|
|
const tileNumber = String(index + 1).padStart(2, '0');
|
|
const atlasRow = Math.floor(index / 3) + 1;
|
|
const atlasCol = (index % 3) + 1;
|
|
const buildFaceAsset = (
|
|
face: keyof NonNullable<
|
|
JumpHopWorkProfileResponse['tileAssets'][number]['faceAssets']
|
|
>,
|
|
) => ({
|
|
face,
|
|
assetId: `asset-${tileNumber}-${face}`,
|
|
imageSrc: `/generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}-${face}/image.png`,
|
|
imageObjectKey: `generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}-${face}/image.png`,
|
|
assetObjectId: `asset-object-${tileNumber}-${face}`,
|
|
generationProvider: 'vector-engine',
|
|
prompt: `tile ${tileNumber} ${face}`,
|
|
width: 256,
|
|
height: 256,
|
|
sourceAtlasCell: `row-${atlasRow}-col-${atlasCol}/${face}`,
|
|
});
|
|
const faceAssets: NonNullable<
|
|
JumpHopWorkProfileResponse['tileAssets'][number]['faceAssets']
|
|
> = {
|
|
top: buildFaceAsset('top'),
|
|
front: buildFaceAsset('front'),
|
|
right: buildFaceAsset('right'),
|
|
back: buildFaceAsset('back'),
|
|
left: buildFaceAsset('left'),
|
|
bottom: buildFaceAsset('bottom'),
|
|
};
|
|
return {
|
|
tileType: index === 0 ? 'start' : 'normal',
|
|
tileId: `tile-${tileNumber}`,
|
|
imageSrc: `/generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}/image.png`,
|
|
imageObjectKey: `generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}/image.png`,
|
|
assetObjectId: `asset-object-${tileNumber}`,
|
|
sourceAtlasCell: `row-${atlasRow}-col-${atlasCol}`,
|
|
atlasRow,
|
|
atlasCol,
|
|
visualWidth: 256,
|
|
visualHeight: 256,
|
|
topSurfaceRadius: 42,
|
|
landingRadius: 34,
|
|
faceAssets: options.withFaceAssets ? faceAssets : undefined,
|
|
} satisfies JumpHopWorkProfileResponse['tileAssets'][number];
|
|
});
|
|
}
|
|
|
|
function buildProfile(options: {
|
|
tileAssets?: JumpHopWorkProfileResponse['tileAssets'];
|
|
coverComposite?: string | null;
|
|
coverImageSrc?: string | null;
|
|
backButtonAsset?: JumpHopWorkProfileResponse['backButtonAsset'];
|
|
publicationStatus?: JumpHopWorkProfileResponse['summary']['publicationStatus'];
|
|
} = {}): JumpHopWorkProfileResponse {
|
|
const characterAsset = {
|
|
assetId: 'builtin',
|
|
imageSrc: 'builtin://jump-hop/default-character',
|
|
imageObjectKey: '',
|
|
assetObjectId: 'builtin',
|
|
generationProvider: 'builtin-three',
|
|
prompt: '默认角色',
|
|
width: 0,
|
|
height: 0,
|
|
};
|
|
return {
|
|
summary: {
|
|
runtimeKind: 'jump-hop',
|
|
workId: 'jump-hop-profile-test',
|
|
profileId: 'jump-hop-profile-test',
|
|
ownerUserId: 'user-test',
|
|
sourceSessionId: 'jump-hop-session-test',
|
|
themeText: '测试',
|
|
workTitle: '测试',
|
|
workDescription: '测试',
|
|
themeTags: ['测试'],
|
|
difficulty: 'standard',
|
|
stylePreset: 'minimal-blocks',
|
|
coverImageSrc: options.coverImageSrc ?? null,
|
|
publicationStatus: options.publicationStatus ?? 'draft',
|
|
playCount: 0,
|
|
updatedAt: '2026-05-27T00:00:00Z',
|
|
publishedAt: null,
|
|
publishReady: true,
|
|
generationStatus: 'ready',
|
|
},
|
|
draft: {
|
|
templateId: 'jump-hop',
|
|
templateName: '跳一跳',
|
|
profileId: 'jump-hop-profile-test',
|
|
themeText: '测试',
|
|
workTitle: '测试',
|
|
workDescription: '测试',
|
|
themeTags: ['测试'],
|
|
difficulty: 'standard',
|
|
stylePreset: 'minimal-blocks',
|
|
defaultCharacter: {
|
|
characterId: 'jump-hop-default-runner',
|
|
displayName: '默认角色',
|
|
modelKind: 'builtin-three',
|
|
bodyColor: '#f59e0b',
|
|
accentColor: '#2563eb',
|
|
},
|
|
characterPrompt: '默认角色',
|
|
tilePrompt: '地块',
|
|
endMoodPrompt: null,
|
|
characterAsset,
|
|
tileAtlasAsset: characterAsset,
|
|
tileAssets: options.tileAssets ?? [],
|
|
path: buildRun().path,
|
|
coverComposite: options.coverComposite ?? null,
|
|
backButtonAsset: options.backButtonAsset ?? null,
|
|
generationStatus: 'ready',
|
|
},
|
|
path: buildRun().path,
|
|
defaultCharacter: {
|
|
characterId: 'jump-hop-default-runner',
|
|
displayName: '默认角色',
|
|
modelKind: 'builtin-three',
|
|
bodyColor: '#f59e0b',
|
|
accentColor: '#2563eb',
|
|
},
|
|
characterAsset,
|
|
tileAtlasAsset: characterAsset,
|
|
tileAssets: options.tileAssets ?? [],
|
|
backButtonAsset: options.backButtonAsset ?? null,
|
|
};
|
|
}
|
|
|
|
test('跳一跳运行态使用 image2 背景底图铺满舞台底层', () => {
|
|
const backgroundSource =
|
|
'/generated-jump-hop-assets/jump-hop-profile-test/background/image.png';
|
|
|
|
render(
|
|
<JumpHopRuntimeShell
|
|
profile={buildProfile({ coverComposite: backgroundSource })}
|
|
run={buildRun()}
|
|
onJump={vi.fn().mockResolvedValue(undefined)}
|
|
onRestart={() => {}}
|
|
/>,
|
|
);
|
|
|
|
const backgroundImage = screen.getByTestId('jump-hop-stage-background-image');
|
|
expect(backgroundImage.getAttribute('src')).toBe(backgroundSource);
|
|
const backdrop = document.querySelector('.jump-hop-runtime__scene-backdrop');
|
|
expect(backdrop?.getAttribute('data-has-background')).toBe('true');
|
|
expect(useResolvedAssetReadUrl).toHaveBeenCalledWith(
|
|
backgroundSource,
|
|
expect.objectContaining({
|
|
refreshKey: backgroundSource,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('跳一跳运行态忽略旧 cover composite 占位背景', () => {
|
|
render(
|
|
<JumpHopRuntimeShell
|
|
profile={buildProfile({
|
|
coverComposite:
|
|
'/generated-jump-hop-assets/jump-hop-profile-test/cover-composite.png',
|
|
})}
|
|
run={buildRun()}
|
|
onJump={vi.fn().mockResolvedValue(undefined)}
|
|
onRestart={() => {}}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByTestId('jump-hop-stage-background-image')).toBeNull();
|
|
const backdrop = document.querySelector('.jump-hop-runtime__scene-backdrop');
|
|
expect(backdrop?.getAttribute('data-has-background')).toBe('false');
|
|
});
|