/* @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( {}} />, ); 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( {}} />, ); 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( {}} />, ); 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( {}} />, ); 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( {}} />, ); 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\((?[\d.]+), (?[\d.]+)\)$/); const scaleMatch = stretchTransform.match( /^scale\((?[\d.]+), (?[\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( {}} />, ); 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; render( {}} />, ); 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( {}} />, ); 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( {}} />, ); expect(useJumpHopLeaderboard).not.toHaveBeenCalled(); expect(screen.getByRole('dialog', { name: '失败' })).toBeTruthy(); expect(screen.queryByTestId('jump-hop-runtime-leaderboard')).toBeNull(); }); test('跳一跳 Three.js 地板层位于 DOM 角色层下方', () => { render( {}} />, ); 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( {}} />, ); 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( {}} />, ); 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( {}} />, ); 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( {}} />, ); 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( {}} />, ); 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( {}} />, ); 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( {}} />, ); 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( {}} />, ); 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( {}} />, ); 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( {}} />, ); 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( {}} />, ); 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( {}} />, ); 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( {}} />, ); 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*\{(?[\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( {}} />, ); 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( {}} />, ); 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( {}} />, ); 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'); });