This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { useEffect } from 'react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
Match3DClickItemRequest,
|
||||
@@ -12,16 +12,42 @@ import {
|
||||
confirmLocalMatch3DClick,
|
||||
startLocalMatch3DRun,
|
||||
} from '../../services/match3d-runtime';
|
||||
import {
|
||||
MATCH3D_EXTRUDED_READABLE_SHAPES,
|
||||
createMatch3DThreeGeometry,
|
||||
} from './Match3DPhysicsBoard';
|
||||
import { resolveGeometryAsset } from './match3dVisualAssets';
|
||||
import { Match3DRuntimeShell } from './Match3DRuntimeShell';
|
||||
|
||||
vi.mock('./Match3DPhysicsBoard', () => ({
|
||||
Match3DPhysicsBoard: ({ onFallback }: { onFallback: () => void }) => {
|
||||
useEffect(() => {
|
||||
onFallback();
|
||||
}, [onFallback]);
|
||||
return <div data-testid="match3d-physics-board-fallback" />;
|
||||
},
|
||||
}));
|
||||
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" />;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (
|
||||
globalThis as typeof globalThis & {
|
||||
__MATCH3D_KEEP_3D_TEST_RENDER__?: boolean;
|
||||
}
|
||||
).__MATCH3D_KEEP_3D_TEST_RENDER__;
|
||||
});
|
||||
|
||||
function renderRuntime(run: Match3DRunSnapshot) {
|
||||
let currentRun = run;
|
||||
@@ -79,13 +105,204 @@ test('点击可见物品后先乐观入槽再等待确认', async () => {
|
||||
await waitFor(() => expect(onClickItem).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
test('后端形状视觉键不会被统一兜底成红色苹字', () => {
|
||||
test('3D 模式下备选栏使用共享模型预览层,避免挤占中心棋盘上下文', () => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
__MATCH3D_KEEP_3D_TEST_RENDER__?: boolean;
|
||||
}
|
||||
).__MATCH3D_KEEP_3D_TEST_RENDER__ = true;
|
||||
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,
|
||||
),
|
||||
};
|
||||
|
||||
renderRuntime(nextRun);
|
||||
|
||||
expect(screen.getByTestId('match3d-tray-model-board')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('本地试玩按消除次数生成类型并在 25 类封顶', () => {
|
||||
const smallRun = startLocalMatch3DRun(12);
|
||||
const largeRun = startLocalMatch3DRun(100);
|
||||
const countTypes = (run: Match3DRunSnapshot) =>
|
||||
new Set(run.items.map((item) => item.itemTypeId)).size;
|
||||
|
||||
expect(countTypes(smallRun)).toBe(12);
|
||||
expect(countTypes(largeRun)).toBe(25);
|
||||
expect(largeRun.items).toHaveLength(300);
|
||||
});
|
||||
|
||||
test('25 次以内生成不重复积木视觉签名', () => {
|
||||
const run = startLocalMatch3DRun(25);
|
||||
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(25);
|
||||
expect(visualKeys.size).toBe(25);
|
||||
expect(signatures.size).toBe(25);
|
||||
});
|
||||
|
||||
test('积木池覆盖参考图里的特殊件', () => {
|
||||
const shapes = new Set(
|
||||
startLocalMatch3DRun(25).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(15);
|
||||
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()]).toEqual(Array(15).fill(3));
|
||||
expect(
|
||||
[...typeByVisualKey.values()].every((itemTypeIds) => itemTypeIds.size === 1),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('25 次以内的随机抽取不会刷新重复物品', () => {
|
||||
for (const clearCount of [1, 12, 15, 24, 25]) {
|
||||
const run = startLocalMatch3DRun(clearCount);
|
||||
const visualKeys = new Set(run.items.map((item) => item.visualKey));
|
||||
|
||||
expect(visualKeys.size).toBe(clearCount);
|
||||
}
|
||||
});
|
||||
|
||||
test('25 类型局面按五档体积比例生成尺寸', () => {
|
||||
const run = startLocalMatch3DRun(25);
|
||||
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(5);
|
||||
expect(tierCounts.get('L')).toBe(8);
|
||||
expect(tierCounts.get('M')).toBe(7);
|
||||
expect(tierCounts.get('XS')).toBe(4);
|
||||
expect(tierCounts.get('S')).toBe(1);
|
||||
});
|
||||
|
||||
test('同一视觉模型在复用时保持唯一尺寸', () => {
|
||||
const run = startLocalMatch3DRun(30);
|
||||
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(25);
|
||||
expect([...radiiByVisualKey.values()].every((radii) => radii.size === 1)).toBe(true);
|
||||
});
|
||||
|
||||
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('积木视觉键不会被统一兜底成红色苹字', () => {
|
||||
const run = startLocalMatch3DRun(2);
|
||||
run.items = run.items.slice(0, 2).map((item, index) => ({
|
||||
...item,
|
||||
itemInstanceId: `shape-${index}`,
|
||||
itemTypeId: `shape-type-${index}`,
|
||||
visualKey: index === 0 ? 'red_circle' : 'yellow_triangle',
|
||||
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,
|
||||
@@ -93,23 +310,23 @@ test('后端形状视觉键不会被统一兜底成红色苹字', () => {
|
||||
}));
|
||||
renderRuntime(run);
|
||||
|
||||
expect(screen.getByTestId('match3d-visual-red_circle')).toBeTruthy();
|
||||
expect(screen.getByTestId('match3d-visual-yellow_triangle')).toBeTruthy();
|
||||
expect(screen.getByTestId('match3d-visual-block-red-2x4')).toBeTruthy();
|
||||
expect(screen.getByTestId('match3d-visual-block-blue-1x2')).toBeTruthy();
|
||||
expect(screen.queryAllByText('苹')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('水果题材视觉键也渲染为无文字纯色几何体', () => {
|
||||
test('积木视觉键渲染为无文字纯色图标', () => {
|
||||
const run = startLocalMatch3DRun(3);
|
||||
run.items = run.items.slice(0, 3).map((item, index) => ({
|
||||
...item,
|
||||
itemInstanceId: `fruit-${index}`,
|
||||
itemTypeId: `fruit-type-${index}`,
|
||||
itemInstanceId: `block-icon-${index}`,
|
||||
itemTypeId: `block-icon-type-${index}`,
|
||||
visualKey:
|
||||
index === 0
|
||||
? 'watermelon-green'
|
||||
? 'block-red-2x4'
|
||||
: index === 1
|
||||
? 'apple-red'
|
||||
: 'grape-purple',
|
||||
? '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,
|
||||
@@ -118,31 +335,31 @@ test('水果题材视觉键也渲染为无文字纯色几何体', () => {
|
||||
}));
|
||||
renderRuntime(run);
|
||||
|
||||
expect(screen.getByTestId('match3d-visual-watermelon-green')).toBeTruthy();
|
||||
expect(screen.getByTestId('match3d-visual-block-red-2x4')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByTestId('match3d-visual-apple-red').getAttribute('data-shape'),
|
||||
).toBe('heart');
|
||||
screen.getByTestId('match3d-visual-block-clear-ring').getAttribute('data-shape'),
|
||||
).toBe('ring');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('match3d-visual-grape-purple')
|
||||
.getByTestId('match3d-visual-block-mint-arch')
|
||||
.getAttribute('data-shape'),
|
||||
).toBe('star');
|
||||
).toBe('arch');
|
||||
expect(screen.queryByText('苹果')).toBeNull();
|
||||
expect(screen.queryByText('苹')).toBeNull();
|
||||
});
|
||||
|
||||
test('运行态支持梯形和平行四边形等差异化几何造型', () => {
|
||||
test('运行态支持长条、斜坡和圆柱等差异化积木造型', () => {
|
||||
const run = startLocalMatch3DRun(3);
|
||||
run.items = run.items.slice(0, 3).map((item, index) => ({
|
||||
...item,
|
||||
itemInstanceId: `geometry-${index}`,
|
||||
itemTypeId: `geometry-type-${index}`,
|
||||
itemInstanceId: `block-geometry-${index}`,
|
||||
itemTypeId: `block-geometry-type-${index}`,
|
||||
visualKey:
|
||||
index === 0
|
||||
? 'peach-pink'
|
||||
? 'block-black-1x8'
|
||||
: index === 1
|
||||
? 'banana-yellow'
|
||||
: 'orange_hexagon',
|
||||
? 'block-purple-slope-1x2'
|
||||
: 'block-green-cylinder',
|
||||
x: 0.35 + index * 0.15,
|
||||
y: 0.5,
|
||||
layer: index,
|
||||
@@ -151,18 +368,18 @@ test('运行态支持梯形和平行四边形等差异化几何造型', () => {
|
||||
renderRuntime(run);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('match3d-visual-peach-pink').getAttribute('data-shape'),
|
||||
).toBe('trapezoid');
|
||||
screen.getByTestId('match3d-visual-block-black-1x8').getAttribute('data-shape'),
|
||||
).toBe('brick');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('match3d-visual-banana-yellow')
|
||||
.getByTestId('match3d-visual-block-purple-slope-1x2')
|
||||
.getAttribute('data-shape'),
|
||||
).toBe('parallelogram');
|
||||
).toBe('slope');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('match3d-visual-orange_hexagon')
|
||||
.getByTestId('match3d-visual-block-green-cylinder')
|
||||
.getAttribute('data-shape'),
|
||||
).toBe('hexagon');
|
||||
).toBe('cylinder');
|
||||
});
|
||||
|
||||
test('异常旧坐标只做显示层收束,不让物品贴出圆形空间', () => {
|
||||
@@ -172,7 +389,7 @@ test('异常旧坐标只做显示层收束,不让物品贴出圆形空间', ()
|
||||
{
|
||||
...item,
|
||||
itemInstanceId: 'legacy-outside',
|
||||
visualKey: 'apple-red',
|
||||
visualKey: 'block-red-2x4',
|
||||
x: -0.4,
|
||||
y: 0.5,
|
||||
radius: 0.1,
|
||||
|
||||
Reference in New Issue
Block a user