Replace uses of the legacy `gpt-image-2-all` model with `gpt-image-2` and standardize image workflows: no-reference generation uses POST /v1/images/generations, any-reference flows use POST /v1/images/edits with multipart `image` parts. Update SKILLs, generation scripts, decision logs, and docs to reflect the contract change and edits-vs-generations guidance. Apply corresponding changes across backend (api-server match3d/puzzle modules, openai image adapter, mappers, telemetry, spacetime client/module), frontend components and services (Match3D, Puzzle, CreativeImageInputPanel, runtime shells), and add new spritesheet/parser files and tests. Also add media/logo.png. These changes align repository code and documentation with the VectorEngine image API contract and update generation/upload handling (green-screen -> alpha processing, spritesheet handling, and related tests).
2311 lines
67 KiB
TypeScript
2311 lines
67 KiB
TypeScript
/* @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<typeof import('../../services/runtimeAudioFeedback')>();
|
|
return {
|
|
...actual,
|
|
playRuntimeMergeSound: runtimeAudioFeedback.playRuntimeMergeSound,
|
|
};
|
|
});
|
|
|
|
vi.mock('../../services/match3dSpritesheetParser', async (importOriginal) => {
|
|
const actual =
|
|
await importOriginal<typeof import('../../services/match3dSpritesheetParser')>();
|
|
return {
|
|
...actual,
|
|
loadMatch3DSpritesheetAssetRegions:
|
|
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions,
|
|
};
|
|
});
|
|
|
|
vi.mock('./Match3DPhysicsBoard', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('./Match3DPhysicsBoard')>();
|
|
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 <div data-testid="match3d-physics-board-fallback" />;
|
|
},
|
|
Match3DTrayPreviewBoard: () => (
|
|
<div data-testid="match3d-tray-model-board" />
|
|
),
|
|
};
|
|
});
|
|
|
|
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(
|
|
<Match3DRuntimeShell
|
|
run={currentRun}
|
|
generatedItemAssets={generatedItemAssets}
|
|
onBack={vi.fn()}
|
|
onRestart={vi.fn()}
|
|
onOptimisticRunChange={onOptimisticRunChange}
|
|
onClickItem={onClickItem}
|
|
/>,
|
|
);
|
|
});
|
|
const { rerender } = render(
|
|
<Match3DRuntimeShell
|
|
run={currentRun}
|
|
generatedItemAssets={generatedItemAssets}
|
|
onBack={vi.fn()}
|
|
onRestart={vi.fn()}
|
|
onOptimisticRunChange={onOptimisticRunChange}
|
|
onClickItem={onClickItem}
|
|
/>,
|
|
);
|
|
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(
|
|
<Match3DRuntimeShell
|
|
run={run}
|
|
levelName="水果抓大鹅"
|
|
onBack={vi.fn()}
|
|
onRestart={vi.fn()}
|
|
onOptimisticRunChange={vi.fn()}
|
|
onClickItem={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<Match3DRuntimeShell
|
|
run={run}
|
|
levelName="水果抓大鹅"
|
|
onBack={vi.fn()}
|
|
onRestart={onRestart}
|
|
onOptimisticRunChange={vi.fn()}
|
|
onClickItem={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<Match3DRuntimeShell
|
|
run={run}
|
|
hideBackButton
|
|
onBack={vi.fn()}
|
|
onRestart={vi.fn()}
|
|
onOptimisticRunChange={vi.fn()}
|
|
onClickItem={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<Match3DRuntimeShell
|
|
run={run}
|
|
onBack={vi.fn()}
|
|
onRestart={vi.fn()}
|
|
onOptimisticRunChange={onOptimisticRunChange}
|
|
onClickItem={onClickItem}
|
|
/>,
|
|
);
|
|
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(
|
|
<Match3DRuntimeShell
|
|
run={run}
|
|
onBack={vi.fn()}
|
|
onRestart={vi.fn()}
|
|
onOptimisticRunChange={onOptimisticRunChange}
|
|
onClickItem={onClickItem}
|
|
/>,
|
|
);
|
|
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(
|
|
<Match3DRuntimeShell
|
|
run={nextRun}
|
|
onBack={vi.fn()}
|
|
onRestart={vi.fn()}
|
|
onOptimisticRunChange={onOptimisticRunChange}
|
|
onClickItem={onClickItem}
|
|
/>,
|
|
);
|
|
});
|
|
const { rerender } = render(
|
|
<Match3DRuntimeShell
|
|
run={run}
|
|
onBack={vi.fn()}
|
|
onRestart={vi.fn()}
|
|
onOptimisticRunChange={onOptimisticRunChange}
|
|
onClickItem={onClickItem}
|
|
/>,
|
|
);
|
|
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(
|
|
<Match3DRuntimeShell
|
|
run={run}
|
|
generatedItemAssets={generatedItemAssets}
|
|
onBack={vi.fn()}
|
|
onRestart={vi.fn()}
|
|
onOptimisticRunChange={vi.fn()}
|
|
onClickItem={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
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<Response>((resolve) => {
|
|
resolveFetch = resolve;
|
|
}),
|
|
);
|
|
|
|
render(
|
|
<Match3DRuntimeShell
|
|
run={run}
|
|
generatedItemAssets={generatedItemAssets}
|
|
onBack={vi.fn()}
|
|
onRestart={vi.fn()}
|
|
onOptimisticRunChange={vi.fn()}
|
|
onClickItem={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<Match3DRuntimeShell
|
|
run={firstRun}
|
|
generatedItemAssets={generatedItemAssets}
|
|
onBack={vi.fn()}
|
|
onRestart={vi.fn()}
|
|
onOptimisticRunChange={vi.fn()}
|
|
onClickItem={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getAllByTestId('match3d-token-image')[0]?.getAttribute('src'),
|
|
).toBe('https://oss.example.com/match3d-view.png');
|
|
});
|
|
|
|
await act(async () => {
|
|
rerender(
|
|
<Match3DRuntimeShell
|
|
run={secondRun}
|
|
generatedItemAssets={generatedItemAssets}
|
|
onBack={vi.fn()}
|
|
onRestart={vi.fn()}
|
|
onOptimisticRunChange={vi.fn()}
|
|
onClickItem={vi.fn()}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
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(
|
|
<Match3DRuntimeShell
|
|
run={run}
|
|
generatedItemAssets={[]}
|
|
generatedBackgroundAsset={{
|
|
prompt: '果园纯背景',
|
|
imageSrc: null,
|
|
imageObjectKey:
|
|
'generated-match3d-assets/session/profile/background/task/background.png',
|
|
containerPrompt: '果园浅盘容器',
|
|
containerImageSrc: null,
|
|
containerImageObjectKey:
|
|
'generated-match3d-assets/session/profile/ui-container/task/container.png',
|
|
status: 'image_ready',
|
|
error: null,
|
|
}}
|
|
onBack={vi.fn()}
|
|
onRestart={vi.fn()}
|
|
onOptimisticRunChange={vi.fn()}
|
|
onClickItem={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<Match3DRuntimeShell
|
|
run={run}
|
|
generatedItemAssets={[]}
|
|
generatedBackgroundAsset={{
|
|
prompt: '果园纯背景',
|
|
imageSrc: '/match3d/background.png',
|
|
imageObjectKey: null,
|
|
uiSpritesheetPrompt: '果园UI',
|
|
uiSpritesheetImageSrc:
|
|
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
|
|
uiSpritesheetImageObjectKey: null,
|
|
status: 'image_ready',
|
|
error: null,
|
|
}}
|
|
onBack={vi.fn()}
|
|
onRestart={vi.fn()}
|
|
onOptimisticRunChange={vi.fn()}
|
|
onClickItem={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<Match3DRuntimeShell
|
|
run={run}
|
|
generatedItemAssets={[]}
|
|
generatedBackgroundAsset={{
|
|
prompt: '果园纯背景',
|
|
imageSrc: '/match3d/background.png',
|
|
imageObjectKey: null,
|
|
uiSpritesheetPrompt: '果园UI',
|
|
uiSpritesheetImageSrc:
|
|
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
|
|
uiSpritesheetImageObjectKey: null,
|
|
containerPrompt: '兼容UI素材',
|
|
containerImageSrc:
|
|
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
|
|
containerImageObjectKey: null,
|
|
status: 'image_ready',
|
|
error: null,
|
|
}}
|
|
onBack={vi.fn()}
|
|
onRestart={vi.fn()}
|
|
onOptimisticRunChange={vi.fn()}
|
|
onClickItem={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<Match3DRuntimeShell
|
|
run={nextRun}
|
|
generatedItemAssets={[
|
|
{
|
|
itemId: 'match3d-item-1',
|
|
itemName: '草莓',
|
|
imageSrc: null,
|
|
imageObjectKey: null,
|
|
imageViews: [],
|
|
modelSrc: null,
|
|
modelObjectKey: null,
|
|
status: 'image_ready',
|
|
},
|
|
]}
|
|
generatedBackgroundAsset={{
|
|
prompt: '果园纯背景',
|
|
imageSrc: '/match3d/background.png',
|
|
imageObjectKey: null,
|
|
itemSpritesheetPrompt: '果园物品',
|
|
itemSpritesheetImageSrc:
|
|
'/generated-match3d-assets/session/profile/item-spritesheet/items.png',
|
|
itemSpritesheetImageObjectKey: null,
|
|
status: 'image_ready',
|
|
error: null,
|
|
}}
|
|
onBack={vi.fn()}
|
|
onRestart={vi.fn()}
|
|
onOptimisticRunChange={vi.fn()}
|
|
onClickItem={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
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<string, number>();
|
|
const typeByVisualKey = new Map<string, Set<string>>();
|
|
|
|
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<string, number>();
|
|
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<string, number>();
|
|
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<string, Set<number>>();
|
|
|
|
for (const item of run.items) {
|
|
const radii = radiiByVisualKey.get(item.visualKey) ?? new Set<number>();
|
|
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);
|
|
});
|