/* @vitest-environment jsdom */ import { act, fireEvent, render, screen, waitFor, within, } from '@testing-library/react'; import { useEffect } from 'react'; import { afterEach, expect, test, vi } from 'vitest'; import type { Match3DClickItemRequest, Match3DRunSnapshot, } from '../../../packages/shared/src/contracts/match3dRuntime'; import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks'; import { confirmLocalMatch3DClick, resolveLocalMatch3DItemTypeCount, startLocalMatch3DRun, } from '../../services/match3d-runtime'; import { applyMatch3DRendererCanvasLayout, buildMatch3DGeneratedAssetTypeMap, buildMatch3DPhysicsEntrySignature, buildMatch3DTrayModelSourceMap, createMatch3DCannonShape, createMatch3DThreeGeometry, MATCH3D_EXTRUDED_READABLE_SHAPES, MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE, MATCH3D_TRAY_MODEL_TARGET_SIZE, measureMatch3DItemPreviewDimension, resolveMatch3DBoardDepthPlan, resolveMatch3DBoundaryRadius, resolveMatch3DColliderBounds, resolveMatch3DPhysicsStabilityPlan, resolveMatch3DSpawnDelay, resolveMatch3DSpawnTimingPlan, resolveMatch3DSpawnVisualScale, resolveMatch3DSpawnY, resolveMatch3DStackTargetY, resolveMatch3DTrayPreviewReferenceDimension, resolveMatch3DTrayPreviewRotation, resolveMatch3DTrayPreviewScale, } from './Match3DPhysicsBoard'; import { MATCH3D_RENDER_ITEM_SCALE, resolveRenderableItemFrame, } from './match3dRuntimePresentation'; import { Match3DRuntimeShell } from './Match3DRuntimeShell'; import { resolveGeometryAsset } from './match3dVisualAssets'; const runtimeAudioFeedback = vi.hoisted(() => ({ playRuntimeMergeSound: vi.fn(), })); const match3dSpritesheetParser = vi.hoisted(() => ({ loadMatch3DSpritesheetAssetRegions: vi.fn(), })); vi.mock('../../services/runtimeAudioFeedback', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, playRuntimeMergeSound: runtimeAudioFeedback.playRuntimeMergeSound, }; }); vi.mock('../../services/match3dSpritesheetParser', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, loadMatch3DSpritesheetAssetRegions: match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions, }; }); vi.mock('./Match3DPhysicsBoard', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, Match3DPhysicsBoard: ({ onFallback }: { onFallback: () => void }) => { useEffect(() => { const shouldKeep3D = ( globalThis as typeof globalThis & { __MATCH3D_KEEP_3D_TEST_RENDER__?: boolean; } ).__MATCH3D_KEEP_3D_TEST_RENDER__ === true; if (!shouldKeep3D) { onFallback(); } }, [onFallback]); return
; }, Match3DTrayPreviewBoard: () => (
), }; }); afterEach(() => { delete ( globalThis as typeof globalThis & { __MATCH3D_KEEP_3D_TEST_RENDER__?: boolean; } ).__MATCH3D_KEEP_3D_TEST_RENDER__; runtimeAudioFeedback.playRuntimeMergeSound.mockReset(); match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockReset(); vi.restoreAllMocks(); }); function mockMatch3DBoardRect() { Object.defineProperty( screen.getByTestId('match3d-board'), 'getBoundingClientRect', { configurable: true, value: () => ({ bottom: 420, height: 320, left: 40, right: 360, top: 100, width: 320, x: 40, y: 100, toJSON: () => ({}), }), }, ); } function mockMatch3DPointerCapture(element: HTMLElement) { Object.defineProperty(element, 'setPointerCapture', { configurable: true, value: vi.fn(), }); Object.defineProperty(element, 'releasePointerCapture', { configurable: true, value: vi.fn(), }); } function toMatch3DBoardClientPoint(item: Match3DRunSnapshot['items'][number]) { const frame = resolveRenderableItemFrame(item); return { clientX: 40 + frame.x * 320, clientY: 100 + frame.y * 320, }; } function fireMatch3DBoardPointer( element: HTMLElement, type: 'pointerdown' | 'pointermove' | 'pointerup', point: { clientX: number; clientY: number }, pointerId: number, ) { const event = new MouseEvent(type, { bubbles: true, cancelable: true, clientX: point.clientX, clientY: point.clientY, }); Object.defineProperty(event, 'pointerId', { configurable: true, value: pointerId, }); fireEvent(element, event); } function renderRuntime( run: Match3DRunSnapshot, generatedItemAssets: Match3DGeneratedItemAsset[] = [], ) { let currentRun = run; let authorityRun = run; const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => { const result = await confirmLocalMatch3DClick(authorityRun, payload); authorityRun = result.run; return result; }); const onOptimisticRunChange = vi.fn((nextRun: Match3DRunSnapshot) => { currentRun = nextRun; rerender( , ); }); const { rerender } = render( , ); return { onClickItem, onOptimisticRunChange, }; } test('展示圆形空间和 7 格备选栏', () => { renderRuntime(startLocalMatch3DRun(4)); expect(screen.getByTestId('match3d-board')).toBeTruthy(); expect(screen.getAllByTestId('match3d-tray-slot')).toHaveLength(7); }); test('顶部 HUD 对齐拼图样式展示关卡名和倒计时', () => { const run = startLocalMatch3DRun(4); render( , ); expect(screen.getByText('第 1 关')).toBeTruthy(); expect(screen.getByText('水果抓大鹅')).toBeTruthy(); expect(screen.getByText('10:00')).toBeTruthy(); expect(screen.getByRole('button', { name: '打开抓大鹅设置' })).toBeTruthy(); expect(screen.queryByRole('button', { name: '重新开始' })).toBeNull(); }); test('抓大鹅右上角设置面板内置重新开始', () => { const run = startLocalMatch3DRun(4); const onRestart = vi.fn(); render( , ); fireEvent.click(screen.getByRole('button', { name: '打开抓大鹅设置' })); const dialog = screen.getByRole('dialog', { name: '抓大鹅设置' }); expect(within(dialog).getByText('水果抓大鹅')).toBeTruthy(); expect(within(dialog).getByText('已清除 0/12')).toBeTruthy(); fireEvent.click(within(dialog).getByRole('button', { name: '重新开始' })); expect(onRestart).toHaveBeenCalledTimes(1); expect(screen.queryByRole('dialog', { name: '抓大鹅设置' })).toBeNull(); }); test('推荐页抓大鹅运行态隐藏返回按钮和结算返回入口', () => { const run: Match3DRunSnapshot = { ...startLocalMatch3DRun(4), status: 'Won', }; render( , ); expect(screen.queryByRole('button', { name: '返回' })).toBeNull(); expect(screen.getByRole('button', { name: '再来一局' })).toBeTruthy(); }); test('显示层把可消除物整体半径放大 2 倍且保留相对比例', () => { const run = startLocalMatch3DRun(21); const firstItemByType = [ ...new Map(run.items.map((item) => [item.itemTypeId, item])).values(), ]; const smallItem = firstItemByType.reduce((smallest, item) => item.radius < smallest.radius ? item : smallest, ); const largeItem = firstItemByType.reduce((largest, item) => item.radius > largest.radius ? item : largest, ); const smallFrame = resolveRenderableItemFrame(smallItem); const largeFrame = resolveRenderableItemFrame(largeItem); expect(smallFrame.radius).toBeCloseTo( smallItem.radius * MATCH3D_RENDER_ITEM_SCALE, ); expect(largeFrame.radius / smallFrame.radius).toBeCloseTo( largeItem.radius / smallItem.radius, ); }); test('松手命中可见物品后只提交一次乐观入槽', async () => { const run = startLocalMatch3DRun(4); const clickableItem = run.items .filter((item) => item.clickable) .sort((left, right) => right.layer - left.layer)[0]; expect(clickableItem).toBeTruthy(); const { onClickItem, onOptimisticRunChange } = renderRuntime(run); const board = screen.getByTestId('match3d-board'); mockMatch3DBoardRect(); mockMatch3DPointerCapture(board); const point = toMatch3DBoardClientPoint(clickableItem!); fireMatch3DBoardPointer(board, 'pointerdown', point, 7); expect(onClickItem).not.toHaveBeenCalled(); await waitFor(() => { expect( screen .getByTestId(`match3d-item-${clickableItem!.itemInstanceId}`) .getAttribute('aria-pressed'), ).toBe('true'); }); fireMatch3DBoardPointer(board, 'pointerup', point, 7); expect(onOptimisticRunChange).toHaveBeenCalled(); await waitFor(() => expect(onClickItem).toHaveBeenCalledTimes(1)); await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalledTimes(2)); }); test('松手后的浏览器 click 事件不会重复提交同一个物品', async () => { const run = startLocalMatch3DRun(4); const clickableItem = run.items .filter((item) => item.clickable) .sort((left, right) => right.layer - left.layer)[0]; expect(clickableItem).toBeTruthy(); const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => { return confirmLocalMatch3DClick(run, payload); }); const onOptimisticRunChange = vi.fn(); render( , ); const board = screen.getByTestId('match3d-board'); mockMatch3DBoardRect(); mockMatch3DPointerCapture(board); const point = toMatch3DBoardClientPoint(clickableItem!); fireMatch3DBoardPointer(board, 'pointerdown', point, 7); fireMatch3DBoardPointer(board, 'pointerup', point, 7); fireEvent.click( screen.getByTestId(`match3d-item-${clickableItem!.itemInstanceId}`), ); expect(onClickItem).toHaveBeenCalledTimes(1); await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalledTimes(2)); }); test('拖动后按松手位置选择单个物品', async () => { const baseRun = startLocalMatch3DRun(2); const [firstItem, secondItem] = baseRun.items.slice(0, 2); expect(firstItem && secondItem).toBeTruthy(); const run: Match3DRunSnapshot = { ...baseRun, items: [ { ...firstItem!, clickable: true, itemInstanceId: 'press-start-item', x: 0.35, y: 0.5, layer: 1, radius: 0.06, }, { ...secondItem!, clickable: true, itemInstanceId: 'release-target-item', x: 0.68, y: 0.5, layer: 2, radius: 0.06, }, ...baseRun.items.slice(2).map((item) => ({ ...item, clickable: false, x: 0.5, y: 0.5, layer: 0, })), ], }; const { onClickItem, onOptimisticRunChange } = renderRuntime(run); const board = screen.getByTestId('match3d-board'); mockMatch3DBoardRect(); mockMatch3DPointerCapture(board); fireMatch3DBoardPointer( board, 'pointerdown', toMatch3DBoardClientPoint(run.items[0]!), 9, ); fireMatch3DBoardPointer( board, 'pointermove', toMatch3DBoardClientPoint(run.items[1]!), 9, ); await waitFor(() => { expect( screen .getByTestId('match3d-item-release-target-item') .getAttribute('aria-pressed'), ).toBe('true'); }); fireMatch3DBoardPointer( board, 'pointerup', toMatch3DBoardClientPoint(run.items[1]!), 9, ); await waitFor(() => expect(onClickItem).toHaveBeenCalledTimes(1)); expect(onClickItem.mock.calls[0]?.[0].itemInstanceId).toBe( 'release-target-item', ); await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalledTimes(2)); }); test('运行态按物品实例稳定使用生成 2D 视角素材', () => { const run = startLocalMatch3DRun(1); const selectedItem = run.items[0]!; const nextRun: Match3DRunSnapshot = { ...run, items: run.items.map((item, index) => index === 0 ? { ...item, state: 'InTray' as const, clickable: false, traySlotIndex: 0, } : item, ), traySlots: run.traySlots.map((slot) => slot.slotIndex === 0 ? { slotIndex: 0, itemInstanceId: selectedItem.itemInstanceId, itemTypeId: selectedItem.itemTypeId, visualKey: selectedItem.visualKey, } : slot, ), }; const generatedItemAssets: Match3DGeneratedItemAsset[] = [ { itemId: 'match3d-item-1', itemName: '草莓', imageSrc: null, imageObjectKey: null, imageViews: [1, 2, 3, 4, 5].map((viewIndex) => ({ viewId: `view-${viewIndex}`, viewIndex, imageSrc: `/match3d/strawberry-view-${viewIndex}.png`, imageObjectKey: null, })), modelSrc: null, modelObjectKey: null, status: 'image_ready', }, ]; renderRuntime(nextRun, generatedItemAssets); const trayImage = screen.getByTestId( 'match3d-tray-image', ) as HTMLImageElement; expect(trayImage.src).toContain('/match3d/strawberry-view-1.png'); }); test('运行态按生成素材的相对尺寸缩放场内和托盘图片', () => { const run = startLocalMatch3DRun(1); const selectedItem = run.items[0]!; const nextRun: Match3DRunSnapshot = { ...run, items: run.items.map((item, index) => index === 0 ? { ...item, state: 'InTray' as const, clickable: false, traySlotIndex: 0, } : item, ), traySlots: run.traySlots.map((slot) => slot.slotIndex === 0 ? { slotIndex: 0, itemInstanceId: selectedItem.itemInstanceId, itemTypeId: selectedItem.itemTypeId, visualKey: selectedItem.visualKey, } : slot, ), }; const generatedItemAssets: Match3DGeneratedItemAsset[] = [ { itemId: 'match3d-item-1', itemName: '糖果', itemSize: '小', imageSrc: '/match3d/candy.png', imageObjectKey: null, imageViews: [], modelSrc: null, modelObjectKey: null, status: 'image_ready', }, ]; renderRuntime(nextRun, generatedItemAssets); expect( screen .getAllByTestId('match3d-token-image') .every((image) => image.style.transform === 'scale(0.58)'), ).toBe(true); expect( (screen.getByTestId('match3d-tray-image') as HTMLElement).style.transform, ).toBe('scale(0.58)'); }); test('点击物品乐观插入到物品栏同类后面并后移后续物品', async () => { const baseRun = startLocalMatch3DRun(3); const [appleBoard, pearTray, appleTray] = baseRun.items.slice(0, 3); expect(appleBoard && pearTray && appleTray).toBeTruthy(); const run: Match3DRunSnapshot = { ...baseRun, items: [ { ...appleBoard!, itemInstanceId: 'apple-3', itemTypeId: 'apple', visualKey: 'block-red-2x4', clickable: true, state: 'InBoard', x: 0.5, y: 0.5, layer: 10, traySlotIndex: null, }, { ...pearTray!, itemInstanceId: 'apple-1', itemTypeId: 'apple', visualKey: 'block-red-2x4', clickable: false, state: 'InTray', traySlotIndex: 0, }, { ...appleTray!, itemInstanceId: 'apple-2', itemTypeId: 'apple', visualKey: 'block-red-2x4', clickable: false, state: 'InTray', traySlotIndex: 1, }, { ...baseRun.items[3]!, itemInstanceId: 'pear-1', itemTypeId: 'pear', visualKey: 'block-blue-1x2', clickable: false, state: 'InTray', traySlotIndex: 2, }, ...baseRun.items.slice(4).map((item) => ({ ...item, clickable: false, state: 'InBoard' as const, })), ], traySlots: baseRun.traySlots.map((slot) => { if (slot.slotIndex === 0) { return { slotIndex: 0, itemInstanceId: 'apple-1', itemTypeId: 'apple', visualKey: 'block-red-2x4', }; } if (slot.slotIndex === 1) { return { slotIndex: 1, itemInstanceId: 'apple-2', itemTypeId: 'apple', visualKey: 'block-red-2x4', }; } if (slot.slotIndex === 2) { return { slotIndex: 2, itemInstanceId: 'pear-1', itemTypeId: 'pear', visualKey: 'block-blue-1x2', }; } return { slotIndex: slot.slotIndex }; }), }; const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => ({ status: 'Accepted' as const, acceptedItemInstanceId: payload.itemInstanceId, clearedItemInstanceIds: [], run: { ...run, snapshotVersion: run.snapshotVersion + 1, }, })); const onOptimisticRunChange = vi.fn(); render( , ); const board = screen.getByTestId('match3d-board'); mockMatch3DBoardRect(); mockMatch3DPointerCapture(board); Object.defineProperty(screen.getAllByTestId('match3d-tray-slot')[3]!, 'getBoundingClientRect', { configurable: true, value: () => ({ bottom: 530, height: 56, left: 220, right: 276, top: 474, width: 56, x: 220, y: 474, toJSON: () => ({}), }), }); const point = toMatch3DBoardClientPoint(run.items[0]!); fireMatch3DBoardPointer(board, 'pointerdown', point, 14); fireMatch3DBoardPointer(board, 'pointerup', point, 14); await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalled()); const optimisticRun = onOptimisticRunChange.mock.calls[0]?.[0] as | Match3DRunSnapshot | undefined; expect(optimisticRun?.traySlots.map((slot) => slot.itemInstanceId ?? null)).toEqual([ 'apple-1', 'apple-2', 'apple-3', 'pear-1', null, null, null, ]); expect( optimisticRun?.items.find((item) => item.itemInstanceId === 'apple-3') ?.traySlotIndex, ).toBe(2); }); test('三消确认后物品栏播放合成动画并隐藏权威快照中已清除的槽位', async () => { const baseRun = startLocalMatch3DRun(1); const [first, second, third, fourth] = baseRun.items.slice(0, 4); expect(first && second && third).toBeTruthy(); const run: Match3DRunSnapshot = { ...baseRun, totalItemCount: 4, items: [ { ...first!, itemInstanceId: 'apple-1', itemTypeId: 'apple', visualKey: 'block-red-2x4', state: 'InTray', clickable: false, traySlotIndex: 0, }, { ...second!, itemInstanceId: 'apple-2', itemTypeId: 'apple', visualKey: 'block-red-2x4', state: 'InTray', clickable: false, traySlotIndex: 1, }, { ...third!, itemInstanceId: 'apple-3', itemTypeId: 'apple', visualKey: 'block-red-2x4', state: 'InBoard', clickable: true, x: 0.5, y: 0.5, layer: 10, traySlotIndex: null, }, { ...(fourth ?? third!), itemInstanceId: 'pear-1', itemTypeId: 'pear', visualKey: 'block-blue-1x2', state: 'InTray', clickable: false, traySlotIndex: 2, }, ], traySlots: baseRun.traySlots.map((slot) => { if (slot.slotIndex === 0) { return { slotIndex: 0, itemInstanceId: 'apple-1', itemTypeId: 'apple', visualKey: 'block-red-2x4', }; } if (slot.slotIndex === 1) { return { slotIndex: 1, itemInstanceId: 'apple-2', itemTypeId: 'apple', visualKey: 'block-red-2x4', }; } if (slot.slotIndex === 2) { return { slotIndex: 2, itemInstanceId: 'pear-1', itemTypeId: 'pear', visualKey: 'block-blue-1x2', }; } return { slotIndex: slot.slotIndex }; }), }; const acceptedRun: Match3DRunSnapshot = { ...run, snapshotVersion: run.snapshotVersion + 1, clearedItemCount: 3, items: run.items.map((item) => item.itemTypeId === 'apple' ? { ...item, state: 'Cleared' as const, clickable: false, traySlotIndex: null, } : { ...item, traySlotIndex: 0 }, ), traySlots: run.traySlots.map((slot) => slot.slotIndex === 0 ? { slotIndex: 0, itemInstanceId: 'pear-1', itemTypeId: 'pear', visualKey: 'block-blue-1x2', } : { slotIndex: slot.slotIndex }, ), }; const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => ({ status: 'Accepted' as const, acceptedItemInstanceId: payload.itemInstanceId, clearedItemInstanceIds: ['apple-1', 'apple-2', 'apple-3'], run: acceptedRun, })); const onOptimisticRunChange = vi.fn((nextRun: Match3DRunSnapshot) => { rerender( , ); }); const { rerender } = render( , ); const board = screen.getByTestId('match3d-board'); mockMatch3DBoardRect(); mockMatch3DPointerCapture(board); screen.getAllByTestId('match3d-tray-slot').forEach((slot, index) => { Object.defineProperty(slot, 'getBoundingClientRect', { configurable: true, value: () => ({ bottom: 530, height: 56, left: 52 + index * 58, right: 108 + index * 58, top: 474, width: 56, x: 52 + index * 58, y: 474, toJSON: () => ({}), }), }); }); const point = toMatch3DBoardClientPoint(run.items[2]!); fireMatch3DBoardPointer(board, 'pointerdown', point, 15); fireMatch3DBoardPointer(board, 'pointerup', point, 15); await waitFor(() => expect(screen.getByTestId('match3d-tray-clear-animation')).toBeTruthy(), ); expect(screen.getAllByTestId('match3d-tray-clear-token')).toHaveLength(3); expect(screen.getByTestId('match3d-merge-feedback')).toBeTruthy(); expect(screen.queryByTestId('match3d-merge-feedback')?.querySelector('svg')).toBeNull(); expect(runtimeAudioFeedback.playRuntimeMergeSound).toHaveBeenCalledTimes(1); const latestRun = onOptimisticRunChange.mock.calls.at(-1)?.[0] as | Match3DRunSnapshot | undefined; expect(latestRun?.traySlots.map((slot) => slot.itemInstanceId ?? null)).toEqual([ 'pear-1', null, null, null, null, null, null, ]); }); test('点击物品时播放飞入底部栏位动画并使用第一张物品视图', async () => { const run = startLocalMatch3DRun(1); const clickableItem = run.items.find((item) => item.clickable)!; const generatedItemAssets: Match3DGeneratedItemAsset[] = [ { itemId: 'match3d-item-1', itemName: '草莓', imageSrc: null, imageObjectKey: null, imageViews: [1, 2, 3, 4, 5].map((viewIndex) => ({ viewId: `view-${viewIndex}`, viewIndex, imageSrc: `/match3d/strawberry-view-${viewIndex}.png`, imageObjectKey: null, })), modelSrc: null, modelObjectKey: null, status: 'image_ready', }, ]; const { onClickItem, onOptimisticRunChange } = renderRuntime( run, generatedItemAssets, ); const board = screen.getByTestId('match3d-board'); mockMatch3DBoardRect(); mockMatch3DPointerCapture(board); Object.defineProperty( screen.getAllByTestId('match3d-tray-slot')[0]!, 'getBoundingClientRect', { configurable: true, value: () => ({ bottom: 530, height: 56, left: 52, right: 108, top: 474, width: 56, x: 52, y: 474, toJSON: () => ({}), }), }, ); await waitFor(() => { expect( screen.getAllByTestId('match3d-token-image')[0]?.getAttribute('src'), ).toContain('/match3d/strawberry-view-'); }); const point = toMatch3DBoardClientPoint(clickableItem); fireMatch3DBoardPointer(board, 'pointerdown', point, 12); fireMatch3DBoardPointer(board, 'pointerup', point, 12); await waitFor(() => expect(onClickItem).toHaveBeenCalledTimes(1)); const flyingImage = screen.getByTestId('match3d-flying-token-image'); expect(flyingImage.getAttribute('src')).toContain( '/match3d/strawberry-view-1.png', ); expect( screen .getByTestId('match3d-flying-token') .style.getPropertyValue('--match3d-fly-dy'), ).not.toBe('0px'); await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalledTimes(2)); }); test('直接点击物品按钮不会绕过松手位置判定', () => { const run = startLocalMatch3DRun(1); const clickableItem = run.items.find((item) => item.clickable)!; const generatedItemAssets: Match3DGeneratedItemAsset[] = [ { itemId: 'match3d-item-1', itemName: '草莓', imageSrc: '/match3d/strawberry-view-1.png', imageObjectKey: null, imageViews: [], modelSrc: null, modelObjectKey: null, status: 'image_ready', }, ]; const { onClickItem, onOptimisticRunChange } = renderRuntime( run, generatedItemAssets, ); fireEvent.click( screen.getByTestId(`match3d-item-${clickableItem.itemInstanceId}`), ); expect(onClickItem).not.toHaveBeenCalled(); expect(onOptimisticRunChange).not.toHaveBeenCalled(); expect(screen.queryByTestId('match3d-flying-token-image')).toBeNull(); }); test('3D WebGL 画布锁定 CSS 尺寸,避免高 DPR 手机上溢出中心棋盘', () => { const canvas = document.createElement('canvas'); applyMatch3DRendererCanvasLayout(canvas); expect(canvas.style.position).toBe('absolute'); expect(canvas.style.inset).toBe('0'); expect(canvas.style.width).toBe('100%'); expect(canvas.style.height).toBe('100%'); expect(canvas.style.display).toBe('block'); }); test('3D 物理条目签名随 run 和视觉资源变化,避免旧模型复用到新局', () => { const run = startLocalMatch3DRun(10); const item = run.items[0]!; const sameIdDifferentVisual = { ...item, visualKey: item.visualKey === 'block-red-2x4' ? 'block-blue-1x2' : 'block-red-2x4', }; expect(buildMatch3DPhysicsEntrySignature(run.runId, item)).not.toBe( buildMatch3DPhysicsEntrySignature(`${run.runId}-next`, item), ); expect(buildMatch3DPhysicsEntrySignature(run.runId, item)).not.toBe( buildMatch3DPhysicsEntrySignature(run.runId, sameIdDifferentVisual), ); }); test('生成模型按运行态类型编号映射,并兼容仅 modelObjectKey 的历史素材', () => { const run = startLocalMatch3DRun(3); run.items = run.items.slice(0, 3).map((item, index) => ({ ...item, itemInstanceId: `generated-model-${index}`, itemTypeId: index === 0 ? 'match3d-type-02' : index === 1 ? 'match3d-type-01' : 'match3d-type-03', visualKey: 'block-red-2x4', })); const generatedItemAssets: Match3DGeneratedItemAsset[] = [ { itemId: 'match3d-item-1', itemName: '草莓', status: 'model_ready', modelSrc: null, modelObjectKey: 'generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb', }, { itemId: 'match3d-item-2', itemName: '苹果', status: 'model_ready', modelSrc: '/generated-match3d-assets/session/profile/items/match3d-item-2-item/model/model.glb', modelObjectKey: null, }, ]; const boardMap = buildMatch3DGeneratedAssetTypeMap(run, generatedItemAssets); const trayMap = buildMatch3DTrayModelSourceMap( run.items, [], generatedItemAssets, ); expect(boardMap.get('match3d-type-01')?.modelSrc).toBe( generatedItemAssets[0]!.modelObjectKey, ); expect(boardMap.get('match3d-type-02')?.modelSrc).toBe( generatedItemAssets[1]!.modelSrc, ); expect(trayMap.get('match3d-type-01')).toBe( generatedItemAssets[0]!.modelObjectKey, ); expect(trayMap.get('match3d-type-02')).toBe(generatedItemAssets[1]!.modelSrc); }); test('运行态会先换签 generated 图片素材再渲染局内物品', async () => { const run = startLocalMatch3DRun(3); const generatedItemAssets: Match3DGeneratedItemAsset[] = [ { itemId: 'match3d-item-1', itemName: '草莓', imageSrc: null, imageObjectKey: null, imageViews: [1, 2, 3, 4, 5].map((viewIndex) => ({ viewId: `view-${viewIndex}`, viewIndex, imageSrc: null, imageObjectKey: `generated-match3d-assets/session/profile/items/match3d-item-1/views/view-${viewIndex}.png`, })), status: 'image_ready', modelSrc: null, modelObjectKey: null, }, ]; vi.spyOn(globalThis, 'fetch').mockImplementation(() => Promise.resolve( new Response( JSON.stringify({ read: { signedUrl: 'https://oss.example.com/match3d-view.png', expiresAt: new Date(Date.now() + 60_000).toISOString(), }, }), { status: 200, headers: { 'Content-Type': 'application/json' }, }, ), ), ); render( , ); await waitFor(() => { expect(screen.getAllByTestId('match3d-token-image').length).toBeGreaterThan( 0, ); }); expect( screen.getAllByTestId('match3d-token-image')[0]!.getAttribute('src'), ).toBe('https://oss.example.com/match3d-view.png'); }); test('generated 图片素材换签未完成前不显示默认积木', async () => { const run = startLocalMatch3DRun(1); const generatedItemAssets: Match3DGeneratedItemAsset[] = [ { itemId: 'match3d-item-1', itemName: '草莓', imageSrc: null, imageObjectKey: null, imageViews: [ { viewId: 'view-01', viewIndex: 1, imageSrc: null, imageObjectKey: 'generated-match3d-assets/session/profile/items/match3d-item-1/views/view-01.png', }, ], status: 'image_ready', modelSrc: null, modelObjectKey: null, }, ]; let resolveFetch: (response: Response) => void = (_response: Response) => { throw new Error('read-url fetch was not started'); }; vi.spyOn(globalThis, 'fetch').mockImplementation( () => new Promise((resolve) => { resolveFetch = resolve; }), ); render( , ); expect(screen.queryByTestId('match3d-token-image')).toBeNull(); expect(screen.queryAllByTestId(/^match3d-visual-/u)).toHaveLength(0); resolveFetch( new Response( JSON.stringify({ read: { signedUrl: 'https://oss.example.com/match3d-view.png', expiresAt: new Date(Date.now() + 60_000).toISOString(), }, }), { status: 200, headers: { 'Content-Type': 'application/json' }, }, ), ); await waitFor(() => { expect( screen.getAllByTestId('match3d-token-image')[0]?.getAttribute('src'), ).toBe('https://oss.example.com/match3d-view.png'); }); }); test('同一批 generated 图片素材在重启 run 时保留已解析地址', async () => { const firstRun = startLocalMatch3DRun(1); const secondRun: Match3DRunSnapshot = { ...startLocalMatch3DRun(1), profileId: firstRun.profileId, items: firstRun.items.map((item) => ({ ...item, itemInstanceId: `${item.itemInstanceId}-restart`, })), }; const generatedItemAssets: Match3DGeneratedItemAsset[] = [ { itemId: 'match3d-item-1', itemName: '草莓', imageSrc: null, imageObjectKey: null, imageViews: [ { viewId: 'view-01', viewIndex: 1, imageSrc: null, imageObjectKey: 'generated-match3d-assets/session/profile/items/match3d-item-1/views/view-01.png', }, ], status: 'image_ready', modelSrc: null, modelObjectKey: null, }, ]; vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response( JSON.stringify({ read: { signedUrl: 'https://oss.example.com/match3d-view.png', expiresAt: new Date(Date.now() + 60_000).toISOString(), }, }), { status: 200, headers: { 'Content-Type': 'application/json' }, }, ), ); const { rerender } = render( , ); await waitFor(() => { expect( screen.getAllByTestId('match3d-token-image')[0]?.getAttribute('src'), ).toBe('https://oss.example.com/match3d-view.png'); }); await act(async () => { rerender( , ); }); expect( screen.getAllByTestId('match3d-token-image')[0]?.getAttribute('src'), ).toBe('https://oss.example.com/match3d-view.png'); expect(screen.queryAllByTestId(/^match3d-visual-/u)).toHaveLength(0); }); test('运行态按 generated itemId 编号映射到后端 match3d-type 类型', async () => { const baseRun = startLocalMatch3DRun(3); const baseTypeIds = [ ...new Set(baseRun.items.map((item) => item.itemTypeId)), ]; const run: Match3DRunSnapshot = { ...baseRun, items: baseRun.items.map((item) => item.itemTypeId === baseTypeIds[0] ? { ...item, itemTypeId: 'match3d-type-01' } : item.itemTypeId === baseTypeIds[1] ? { ...item, itemTypeId: 'match3d-type-02' } : item, ), }; const typeOneItem = run.items.find( (item) => item.itemTypeId === 'match3d-type-01', ); expect(typeOneItem).toBeTruthy(); const generatedItemAssets: Match3DGeneratedItemAsset[] = [ { itemId: 'match3d-item-1', itemName: '樱桃', imageSrc: null, imageObjectKey: null, imageViews: [ { viewId: 'view-01', viewIndex: 1, imageSrc: '/match3d/cherry-view-01.png', imageObjectKey: null, }, ], status: 'image_ready', modelSrc: null, modelObjectKey: null, }, { itemId: 'match3d-item-2', itemName: '苹果', imageSrc: null, imageObjectKey: null, imageViews: [ { viewId: 'view-01', viewIndex: 1, imageSrc: '/match3d/apple-view-01.png', imageObjectKey: null, }, ], status: 'image_ready', modelSrc: null, modelObjectKey: null, }, ]; renderRuntime(run, generatedItemAssets); const token = screen.getByTestId( `match3d-item-${typeOneItem!.itemInstanceId}`, ); await waitFor(() => { expect(token.querySelector('img')?.getAttribute('src')).toContain( '/match3d/cherry-view-01.png', ); }); }); test('运行态会换签并渲染抓大鹅中心容器 UI 图', async () => { const run = startLocalMatch3DRun(3); const generatedItemAssets: Match3DGeneratedItemAsset[] = [ { itemId: 'match3d-item-1', itemName: '草莓', imageSrc: null, imageObjectKey: null, imageViews: [], status: 'image_ready', modelSrc: null, modelObjectKey: null, backgroundAsset: { prompt: '果园纯背景', imageSrc: null, imageObjectKey: null, containerPrompt: '果园浅盘容器', containerImageSrc: null, containerImageObjectKey: 'generated-match3d-assets/session/profile/ui-container/task/container.png', status: 'image_ready', error: null, }, }, ]; vi.spyOn(globalThis, 'fetch').mockImplementation(() => Promise.resolve( new Response( JSON.stringify({ read: { signedUrl: 'https://oss.example.com/match3d-container.png', expiresAt: new Date(Date.now() + 60_000).toISOString(), }, }), { status: 200, headers: { 'Content-Type': 'application/json' }, }, ), ), ); renderRuntime(run, generatedItemAssets); await waitFor(() => { expect( screen.getByTestId('match3d-container-image').getAttribute('src'), ).toBe('https://oss.example.com/match3d-container.png'); }); fireEvent.load(screen.getByTestId('match3d-container-image')); const containerImage = screen.getByTestId( 'match3d-container-image', ) as HTMLImageElement; expect(containerImage.className).toContain('w-[min(116vw,42rem)]'); expect(containerImage.className).toContain('h-auto'); expect(containerImage.className).toContain('left-1/2'); expect(containerImage.className).toContain('top-[54%]'); expect(containerImage.className).toContain('-translate-x-1/2'); expect(screen.getByTestId('match3d-board').className).toContain( 'bg-transparent', ); expect(screen.getByTestId('match3d-board').className).not.toContain( 'rounded-full', ); }); test('运行态没有生成容器时使用透明参考容器兜底', async () => { const run = startLocalMatch3DRun(3); renderRuntime(run, []); let containerImage!: HTMLImageElement; await waitFor(() => { containerImage = screen.getByTestId( 'match3d-container-image', ) as HTMLImageElement; expect(containerImage.getAttribute('src')).toBe( '/match3d-background-references/pot-fused-reference.png', ); }); fireEvent.load(containerImage); expect(screen.getByTestId('match3d-board').className).toContain( 'bg-transparent', ); expect(screen.getByTestId('match3d-board').className).not.toContain( 'rounded-full', ); }); test('容器图换签失败时使用透明参考容器兜底', async () => { const run = startLocalMatch3DRun(3); const generatedItemAssets: Match3DGeneratedItemAsset[] = [ { itemId: 'match3d-item-1', itemName: '草莓', imageSrc: null, imageObjectKey: null, imageViews: [], status: 'image_ready', modelSrc: null, modelObjectKey: null, backgroundAsset: { prompt: '果园纯背景', imageSrc: null, imageObjectKey: null, containerPrompt: '果园浅盘容器', containerImageSrc: null, containerImageObjectKey: 'generated-match3d-assets/session/profile/ui-container/failing-task/container.png', status: 'image_ready', error: null, }, }, ]; vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('read-url failed')); renderRuntime(run, generatedItemAssets); await waitFor(() => { expect(globalThis.fetch).toHaveBeenCalled(); }); await waitFor(() => { expect( screen.getByTestId('match3d-container-image').getAttribute('src'), ).toBe('/match3d-background-references/pot-fused-reference.png'); }); fireEvent.load(screen.getByTestId('match3d-container-image')); expect(screen.getByTestId('match3d-board').className).toContain( 'bg-transparent', ); expect(screen.getByTestId('match3d-board').className).not.toContain( 'rounded-full', ); }); test('运行态会从顶层 UI 资产加载背景和容器图', async () => { const run = startLocalMatch3DRun(3); vi.spyOn(globalThis, 'fetch').mockImplementation((input) => { const url = String(input); const signedUrl = url.includes('ui-container') ? 'https://oss.example.com/match3d-container.png' : 'https://oss.example.com/match3d-background.png'; return Promise.resolve( new Response( JSON.stringify({ read: { signedUrl, expiresAt: new Date(Date.now() + 60_000).toISOString(), }, }), { status: 200, headers: { 'Content-Type': 'application/json' }, }, ), ); }); render( , ); await waitFor(() => { expect( screen.getByTestId('match3d-background-image').getAttribute('src'), ).toBe('https://oss.example.com/match3d-background.png'); expect( screen.getByTestId('match3d-container-image').getAttribute('src'), ).toBe('https://oss.example.com/match3d-container.png'); }); fireEvent.load(screen.getByTestId('match3d-container-image')); expect(screen.getByTestId('match3d-board').className).toContain( 'bg-transparent', ); }); test('运行态从UI spritesheet裁切按钮并映射到原UI位置', async () => { const run = startLocalMatch3DRun(3); match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockResolvedValue( ['返回', '设置', '方格', '移出', '凑齐', '打乱'].map((label, index) => ({ label, x: index * 10, y: 0, width: 8, height: 8, sheetWidth: 64, sheetHeight: 64, imageSrc: `data:image/png;base64,${label}`, })), ); render( , ); await waitFor(() => { expect( screen.getByTestId('match3d-ui-sprite-back').getAttribute('src'), ).toBe('data:image/png;base64,返回'); expect( screen.getByTestId('match3d-ui-sprite-settings').getAttribute('src'), ).toBe('data:image/png;base64,设置'); expect( screen.getByTestId('match3d-ui-sprite-prop-remove').getAttribute('src'), ).toBe('data:image/png;base64,移出'); expect( screen.getByTestId('match3d-ui-sprite-prop-collect').getAttribute('src'), ).toBe('data:image/png;base64,凑齐'); expect( screen.getByTestId('match3d-ui-sprite-prop-shuffle').getAttribute('src'), ).toBe('data:image/png;base64,打乱'); }); expect( match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions, ).toHaveBeenCalledWith( expect.objectContaining({ labels: ['返回', '设置', '方格', '移出', '凑齐', '打乱'], maxRegions: 6, source: '/generated-match3d-assets/session/profile/ui-spritesheet/ui.png', }), ); }); test('运行态不把兼容写入的UI spritesheet当中心容器图', async () => { const run = startLocalMatch3DRun(3); match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockResolvedValue( [], ); render( , ); await waitFor(() => { expect( screen.getByTestId('match3d-container-image').getAttribute('src'), ).toBe('/match3d-background-references/pot-fused-reference.png'); }); }); test('运行态缺少imageViews时从物品spritesheet解析五视角图片', async () => { const run = startLocalMatch3DRun(1); const selectedItem = run.items[0]!; const nextRun: Match3DRunSnapshot = { ...run, items: run.items.map((item, index) => index === 0 ? { ...item, state: 'InTray' as const, clickable: false, traySlotIndex: 0, } : item, ), traySlots: run.traySlots.map((slot) => slot.slotIndex === 0 ? { slotIndex: 0, itemInstanceId: selectedItem.itemInstanceId, itemTypeId: selectedItem.itemTypeId, visualKey: selectedItem.visualKey, } : slot, ), }; match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockImplementation( async ({ source }: { source: string }) => { if (source.includes('item-spritesheet')) { return Array.from({ length: 100 }, (_, index) => ({ label: `素材${index + 1}`, x: index * 2, y: 0, width: 2, height: 2, sheetWidth: 200, sheetHeight: 2, imageSrc: `data:image/png;base64,item-${index + 1}`, })); } return []; }, ); render( , ); await waitFor(() => { expect( (screen.getByTestId('match3d-tray-image') as HTMLImageElement).src, ).toBe('data:image/png;base64,item-1'); }); }); test('运行态从任意素材读取作品级背景音乐并换签播放', async () => { const run = startLocalMatch3DRun(3); const playSpy = vi .spyOn(HTMLMediaElement.prototype, 'play') .mockResolvedValue(undefined); vi.spyOn(globalThis, 'fetch').mockImplementation((input) => { const url = String(input); const signedUrl = url.includes('legacyPublicPath') ? 'https://oss.example.com/match3d-music.mp3' : 'https://oss.example.com/match3d-view.png'; return Promise.resolve( new Response( JSON.stringify({ read: { signedUrl, expiresAt: new Date(Date.now() + 60_000).toISOString(), }, }), { status: 200, headers: { 'Content-Type': 'application/json' }, }, ), ); }); const generatedItemAssets: Match3DGeneratedItemAsset[] = [ { itemId: 'match3d-item-1', itemName: '草莓', imageSrc: '/match3d/strawberry.png', imageObjectKey: null, imageViews: [], status: 'image_ready', modelSrc: null, modelObjectKey: null, }, { itemId: 'match3d-item-2', itemName: '苹果', imageSrc: '/match3d/apple.png', imageObjectKey: null, imageViews: [], status: 'image_ready', modelSrc: null, modelObjectKey: null, backgroundMusic: { taskId: 'music-task-1', provider: 'vector-engine-suno', assetObjectId: 'asset-music-1', assetKind: 'match3d_background_music', audioSrc: '/generated-match3d-assets/audio/music.mp3', prompt: '', title: '果园轻舞', updatedAt: '2026-05-14T00:00:00.000Z', }, }, ]; renderRuntime(run, generatedItemAssets); await waitFor(() => { expect(screen.getByLabelText('抓大鹅背景音乐').getAttribute('src')).toBe( 'https://oss.example.com/match3d-music.mp3', ); }); await waitFor(() => expect(playSpy).toHaveBeenCalled()); }); test('本地试玩按难度档位生成类型并兼容历史硬核消除数', () => { const smallRun = startLocalMatch3DRun(12); const hardRun = startLocalMatch3DRun(20); const countTypes = (run: Match3DRunSnapshot) => new Set(run.items.map((item) => item.itemTypeId)).size; expect(resolveLocalMatch3DItemTypeCount(8)).toBe(3); expect(resolveLocalMatch3DItemTypeCount(12)).toBe(9); expect(resolveLocalMatch3DItemTypeCount(16)).toBe(15); expect(resolveLocalMatch3DItemTypeCount(20)).toBe(20); expect(resolveLocalMatch3DItemTypeCount(21)).toBe(20); expect(countTypes(smallRun)).toBe(9); expect(countTypes(hardRun)).toBe(20); expect(hardRun.clearCount).toBe(21); expect(hardRun.items).toHaveLength(63); }); test('硬核档位生成不重复积木视觉签名', () => { const run = startLocalMatch3DRun(21); const firstItemByType = new Map( run.items.map((item) => [item.itemTypeId, item]), ); const visualKeys = new Set( [...firstItemByType.values()].map((item) => item.visualKey), ); const signatures = new Set( [...firstItemByType.values()].map((item) => { const asset = resolveGeometryAsset(item.visualKey); return `${asset.shape}-${asset.fill}-${asset.studsX}x${asset.studsY}-${asset.heightScale}`; }), ); expect(firstItemByType.size).toBe(20); expect(visualKeys.size).toBe(20); expect(signatures.size).toBe(20); }); test('积木池覆盖参考图里的特殊件', () => { const shapes = new Set( startLocalMatch3DRun(21).items.map( (item) => resolveGeometryAsset(item.visualKey).shape, ), ); expect(shapes).toContain('brick'); expect(shapes).toContain('tile'); expect(shapes).toContain('slope'); expect(shapes).toContain('cylinder'); expect(shapes).toContain('ring'); expect(shapes).toContain('arch'); expect(shapes).toContain('cone'); }); test('3D 特殊积木件使用可辨认挤出轮廓而不是基础代理体', async () => { const three = await import('three'); for (const shape of MATCH3D_EXTRUDED_READABLE_SHAPES) { const geometry = createMatch3DThreeGeometry(three, shape, 1); expect(geometry.type).toBe('ExtrudeGeometry'); } }); test('进阶档位保持 15 种视觉模型并按三消组复用', () => { const run = startLocalMatch3DRun(16); const countByVisualKey = new Map(); const typeByVisualKey = new Map>(); for (const item of run.items) { countByVisualKey.set( item.visualKey, (countByVisualKey.get(item.visualKey) ?? 0) + 1, ); typeByVisualKey.set( item.visualKey, typeByVisualKey.get(item.visualKey) ?? new Set(), ); typeByVisualKey.get(item.visualKey)!.add(item.itemTypeId); } expect(countByVisualKey.size).toBe(15); expect( [...countByVisualKey.values()].sort((left, right) => left - right), ).toEqual([...Array(14).fill(3), 6]); expect( [...typeByVisualKey.values()].every( (itemTypeIds) => itemTypeIds.size === 1, ), ).toBe(true); }); test('随机抽取不会刷新重复物品', () => { for (const clearCount of [1, 8, 12, 16, 21]) { const run = startLocalMatch3DRun(clearCount); const visualKeys = new Set(run.items.map((item) => item.visualKey)); expect(visualKeys.size).toBe(resolveLocalMatch3DItemTypeCount(clearCount)); } }); test('硬核档位按五档体积比例生成尺寸', () => { const run = startLocalMatch3DRun(21); const radiusByVisualKey = new Map(); for (const item of run.items) { radiusByVisualKey.set(item.visualKey, item.radius); } const baseRadius = [...radiusByVisualKey.values()].find( (radius) => Math.abs(radius / 0.072 - 1) < 0.01, ); expect(baseRadius).toBeTruthy(); const tierCounts = new Map(); for (const radius of radiusByVisualKey.values()) { const relativeVolume = Math.pow(radius / baseRadius!, 3); const tier = relativeVolume >= 1.6 ? 'XL' : relativeVolume >= 1.25 ? 'L' : relativeVolume >= 0.65 && relativeVolume <= 0.85 ? 'XS' : relativeVolume <= 0.5 ? 'S' : 'M'; tierCounts.set(tier, (tierCounts.get(tier) ?? 0) + 1); } expect(tierCounts.get('XL')).toBe(4); expect(tierCounts.get('L')).toBe(6); expect(tierCounts.get('M')).toBe(6); expect(tierCounts.get('XS')).toBe(3); expect(tierCounts.get('S')).toBe(1); }); test('同一视觉模型在复用时保持唯一尺寸', () => { const run = startLocalMatch3DRun(21); const radiiByVisualKey = new Map>(); for (const item of run.items) { const radii = radiiByVisualKey.get(item.visualKey) ?? new Set(); radii.add(Math.round(item.radius * 10_000)); radiiByVisualKey.set(item.visualKey, radii); } expect(radiiByVisualKey.size).toBe(20); expect( [...radiiByVisualKey.values()].every((radii) => radii.size === 1), ).toBe(true); }); test('托盘 3D 预览保留场内模型的相对尺寸比例', async () => { const three = await import('three'); const run = startLocalMatch3DRun(21); const firstItemByType = [ ...new Map(run.items.map((item) => [item.itemTypeId, item])).values(), ]; const referenceDimension = resolveMatch3DTrayPreviewReferenceDimension( three, firstItemByType, ); const previewRatios = new Set( firstItemByType.map((item) => Math.round( (measureMatch3DItemPreviewDimension(three, item) / referenceDimension) * 1_000, ), ), ); expect(previewRatios.size).toBeGreaterThan(1); }); test('托盘 3D 预览放大模型并展示俯视 3/4 体积感', () => { expect(MATCH3D_TRAY_MODEL_TARGET_SIZE).toBeGreaterThanOrEqual(0.85); expect(MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE).toBeGreaterThanOrEqual(0.9); const brickRotation = resolveMatch3DTrayPreviewRotation('block-red-2x4'); const tileRotation = resolveMatch3DTrayPreviewRotation( 'block-lavender-tile-2x2', ); const slopeRotation = resolveMatch3DTrayPreviewRotation( 'block-purple-slope-1x2', ); expect(brickRotation.x).toBeLessThan(-0.28); expect(brickRotation.z).toBeGreaterThan(0.2); expect(brickRotation.y).toBeGreaterThan(0.6); expect(tileRotation.x).toBeLessThan(-0.25); expect(tileRotation.z).toBeGreaterThan(0.2); expect(slopeRotation.x).toBeLessThan(-0.3); expect(slopeRotation.z).toBeGreaterThan(0.22); }); test('托盘 3D 预览为小模型保留最低可读显示尺寸', () => { const smallDimension = 0.4; const referenceDimension = 1; const scale = resolveMatch3DTrayPreviewScale( smallDimension, referenceDimension, ); expect(scale * smallDimension).toBeGreaterThanOrEqual( MATCH3D_TRAY_MODEL_TARGET_SIZE * MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE, ); expect(scale).toBeGreaterThan( MATCH3D_TRAY_MODEL_TARGET_SIZE / referenceDimension, ); }); test('积木 3D 资源可以为本局类型创建几何体', async () => { const three = await import('three'); const run = startLocalMatch3DRun(15); const firstItemByType = new Map( run.items.map((item) => [item.itemTypeId, item]), ); expect(firstItemByType.size).toBe(15); for (const item of firstItemByType.values()) { const shape = resolveGeometryAsset(item.visualKey).shape; const geometry = createMatch3DThreeGeometry(three, shape, 1); expect(geometry).toBeTruthy(); } }); test('3D 物体碰撞体按同款视觉尺寸生成', async () => { const cannon = await import('cannon-es'); const longBrick = resolveGeometryAsset('block-black-1x8'); const tile = resolveGeometryAsset('block-lavender-tile-2x2'); const cylinder = resolveGeometryAsset('block-green-cylinder'); const radius = 1; const longBrickBounds = resolveMatch3DColliderBounds(longBrick, radius); const longBrickShape = createMatch3DCannonShape(cannon, longBrick, radius); expect(longBrickShape.type).toBe(cannon.Shape.types.BOX); expect( (longBrickShape as import('cannon-es').Box).halfExtents.x * 2, ).toBeCloseTo(longBrickBounds.width); expect( (longBrickShape as import('cannon-es').Box).halfExtents.z * 2, ).toBeCloseTo(longBrickBounds.depth); const tileBounds = resolveMatch3DColliderBounds(tile, radius); const tileShape = createMatch3DCannonShape(cannon, tile, radius); expect((tileShape as import('cannon-es').Box).halfExtents.y * 2).toBeCloseTo( tileBounds.height, ); const cylinderBounds = resolveMatch3DColliderBounds(cylinder, radius); const cylinderShape = createMatch3DCannonShape(cannon, cylinder, radius); expect(cylinderShape.type).toBe(cannon.Shape.types.CYLINDER); expect((cylinderShape as import('cannon-es').Cylinder).height).toBeCloseTo( cylinderBounds.height, ); }); test('中心场地 3D 纵深随物体总量增加并随消除进度回补', () => { const smallDepthPlan = resolveMatch3DBoardDepthPlan(30, 30); const largeDepthPlan = resolveMatch3DBoardDepthPlan(300, 300); const earlyBottomY = resolveMatch3DStackTargetY(300, 300, 0); const lateBottomY = resolveMatch3DStackTargetY(300, 60, 0); expect(largeDepthPlan.initialDepth).toBeGreaterThan( smallDepthPlan.initialDepth, ); expect(largeDepthPlan.layerCapacity).toBeLessThan( smallDepthPlan.layerCapacity, ); expect(largeDepthPlan.layerCount).toBeGreaterThan(smallDepthPlan.layerCount); expect(largeDepthPlan.surfaceY).toBeGreaterThan(largeDepthPlan.baseY); expect(lateBottomY).toBeGreaterThan(earlyBottomY); expect(lateBottomY).toBeLessThanOrEqual(largeDepthPlan.surfaceY); }); test('高数量 3D 局面使用更稳定的物理参数', () => { const smallPlan = resolveMatch3DPhysicsStabilityPlan(30); const largePlan = resolveMatch3DPhysicsStabilityPlan(300); expect(largePlan.contactFriction).toBeGreaterThan(smallPlan.contactFriction); expect(largePlan.contactRestitution).toBeLessThan( smallPlan.contactRestitution, ); expect(largePlan.linearDamping).toBeGreaterThan(smallPlan.linearDamping); expect(largePlan.angularDamping).toBeGreaterThan(smallPlan.angularDamping); expect(largePlan.solverIterations).toBeGreaterThan( smallPlan.solverIterations, ); expect(largePlan.maxHorizontalSpeed).toBeLessThan( smallPlan.maxHorizontalSpeed, ); }); test('3D 真实边界半径比视觉半径更保守,避免长条贴边穿出锅壁', () => { const longBrick = resolveGeometryAsset('block-black-1x8'); const radius = 1; const boundaryRadius = resolveMatch3DBoundaryRadius(longBrick, radius); const visualRadius = Math.hypot( resolveMatch3DColliderBounds(longBrick, radius).width / 2, resolveMatch3DColliderBounds(longBrick, radius).depth / 2, ); expect(boundaryRadius).toBeCloseTo(visualRadius); expect(boundaryRadius).toBeGreaterThan(2.4); }); test('100 次局面的新物体会按层级延迟生成并逐层回落', () => { const fastTimingPlan = resolveMatch3DSpawnTimingPlan(29); const smallDepthPlan = resolveMatch3DBoardDepthPlan(30, 30); const largeDepthPlan = resolveMatch3DBoardDepthPlan(300, 300); const smallTimingPlan = resolveMatch3DSpawnTimingPlan(30); const largeTimingPlan = resolveMatch3DSpawnTimingPlan(300); const bottomDelay = resolveMatch3DSpawnDelay(0, largeDepthPlan.layerCapacity); const middleDelay = resolveMatch3DSpawnDelay( 30, largeDepthPlan.layerCapacity, ); const topDelay = resolveMatch3DSpawnDelay(120, largeDepthPlan.layerCapacity); const dynamicCapacityDelay = resolveMatch3DSpawnDelay( 120, largeDepthPlan.layerCapacity, ); const defaultCapacityDelay = resolveMatch3DSpawnDelay( 120, smallDepthPlan.layerCapacity, ); expect(bottomDelay).toBe(0); expect(middleDelay).toBeGreaterThan(bottomDelay); expect(topDelay).toBeGreaterThan(middleDelay); expect(dynamicCapacityDelay).toBeGreaterThan(defaultCapacityDelay); expect(smallTimingPlan.frameSpawnLimit).toBeLessThan( fastTimingPlan.frameSpawnLimit, ); expect(smallTimingPlan.burstSize).toBeLessThan(fastTimingPlan.burstSize); expect(smallTimingPlan.layerDelayMs).toBeGreaterThan( fastTimingPlan.layerDelayMs, ); expect( resolveMatch3DSpawnDelay(29, smallDepthPlan.layerCapacity, smallTimingPlan), ).toBeGreaterThan(450); expect(largeTimingPlan.initialDelayMs).toBeGreaterThan( smallTimingPlan.initialDelayMs, ); expect(largeTimingPlan.frameSpawnLimit).toBeLessThan( smallTimingPlan.frameSpawnLimit, ); expect(largeTimingPlan.burstSize).toBeLessThanOrEqual(6); expect(largeTimingPlan.layerDelayMs).toBeGreaterThanOrEqual( smallTimingPlan.layerDelayMs, ); expect( resolveMatch3DSpawnDelay( 299, largeDepthPlan.layerCapacity, largeTimingPlan, ), ).toBeGreaterThan(5000); }); test('3D 新物体生成高度会避让同位置已有堆叠', () => { const plannedSpawnY = 2; const raisedSpawnY = resolveMatch3DSpawnY( plannedSpawnY, 0.8, 0.7, { x: 0.1, z: 0.1 }, [ { boundaryRadius: 0.7, colliderHeight: 0.9, x: 0.18, y: 2.4, z: 0.15, }, ], ); const unchangedSpawnY = resolveMatch3DSpawnY( plannedSpawnY, 0.8, 0.7, { x: 0.1, z: 0.1 }, [ { boundaryRadius: 0.7, colliderHeight: 0.9, x: 3, y: 4, z: 3, }, ], ); expect(raisedSpawnY).toBeGreaterThan(plannedSpawnY); expect(unchangedSpawnY).toBe(plannedSpawnY); }); test('3D 新物体生成动画只缩放可见模型并最终回到完整尺寸', () => { const startScale = resolveMatch3DSpawnVisualScale(0); const middleScale = resolveMatch3DSpawnVisualScale(0.5); const endScale = resolveMatch3DSpawnVisualScale(1); expect(startScale).toBeGreaterThan(0); expect(startScale).toBeLessThan(0.25); expect(middleScale).toBeGreaterThan(startScale); expect(middleScale).toBeLessThan(endScale); expect(endScale).toBe(1); }); test('积木视觉键不会被统一兜底成红色苹字', () => { const run = startLocalMatch3DRun(2); run.items = run.items.slice(0, 2).map((item, index) => ({ ...item, itemInstanceId: `block-${index}`, itemTypeId: `block-type-${index}`, visualKey: index === 0 ? 'block-red-2x4' : 'block-blue-1x2', x: 0.42 + index * 0.16, y: 0.5, layer: index, clickable: true, })); renderRuntime(run); expect(screen.getByTestId('match3d-visual-block-red-2x4')).toBeTruthy(); expect(screen.getByTestId('match3d-visual-block-blue-1x2')).toBeTruthy(); expect(screen.queryAllByText('苹')).toHaveLength(0); }); test('积木视觉键渲染为无文字纯色图标', () => { const run = startLocalMatch3DRun(3); run.items = run.items.slice(0, 3).map((item, index) => ({ ...item, itemInstanceId: `block-icon-${index}`, itemTypeId: `block-icon-type-${index}`, visualKey: index === 0 ? 'block-red-2x4' : index === 1 ? 'block-clear-ring' : 'block-mint-arch', x: 0.35 + index * 0.15, y: 0.5, radius: index === 0 ? 0.12 : index === 1 ? 0.09 : 0.07, layer: index, clickable: true, })); renderRuntime(run); expect(screen.getByTestId('match3d-visual-block-red-2x4')).toBeTruthy(); expect( screen .getByTestId('match3d-visual-block-clear-ring') .getAttribute('data-shape'), ).toBe('ring'); expect( screen .getByTestId('match3d-visual-block-mint-arch') .getAttribute('data-shape'), ).toBe('arch'); expect(screen.queryByText('苹果')).toBeNull(); expect(screen.queryByText('苹')).toBeNull(); }); test('运行态支持长条、斜坡和圆柱等差异化积木造型', () => { const run = startLocalMatch3DRun(3); run.items = run.items.slice(0, 3).map((item, index) => ({ ...item, itemInstanceId: `block-geometry-${index}`, itemTypeId: `block-geometry-type-${index}`, visualKey: index === 0 ? 'block-black-1x8' : index === 1 ? 'block-purple-slope-1x2' : 'block-green-cylinder', x: 0.35 + index * 0.15, y: 0.5, layer: index, clickable: true, })); renderRuntime(run); expect( screen .getByTestId('match3d-visual-block-black-1x8') .getAttribute('data-shape'), ).toBe('brick'); expect( screen .getByTestId('match3d-visual-block-purple-slope-1x2') .getAttribute('data-shape'), ).toBe('slope'); expect( screen .getByTestId('match3d-visual-block-green-cylinder') .getAttribute('data-shape'), ).toBe('cylinder'); }); test('异常旧坐标只做显示层收束,不让物品贴出圆形空间', () => { const run = startLocalMatch3DRun(1); const item = run.items[0]!; run.items = [ { ...item, itemInstanceId: 'legacy-outside', visualKey: 'block-red-2x4', x: -0.4, y: 0.5, radius: 0.1, clickable: true, }, ]; renderRuntime(run); const token = screen.getByTestId( 'match3d-item-legacy-outside', ) as HTMLElement; expect(parseFloat(token.style.left)).toBeGreaterThanOrEqual(0); expect(parseFloat(token.style.left)).toBeLessThanOrEqual(100); }); test('本地运行态物品围绕容器口中心生成,不贴边挤在局部', () => { const run = startLocalMatch3DRun(21); const boardItems = run.items.filter((item) => item.state === 'InBoard'); const meanX = boardItems.reduce((sum, item) => sum + item.x, 0) / boardItems.length; const meanY = boardItems.reduce((sum, item) => sum + item.y, 0) / boardItems.length; const distances = boardItems.map((item) => Math.hypot(item.x - 0.5, item.y - 0.5), ); const farItems = distances.filter((distance) => distance > 0.26); const quadrants = new Set( boardItems.map( (item) => `${item.x >= 0.5 ? 'r' : 'l'}-${item.y >= 0.5 ? 'b' : 't'}`, ), ); expect(Math.abs(meanX - 0.5)).toBeLessThan(0.035); expect(Math.abs(meanY - 0.5)).toBeLessThan(0.035); expect(Math.max(...distances)).toBeLessThan(0.4); expect(farItems.length).toBeGreaterThan(0); expect(quadrants.size).toBe(4); });