Prune stale docs and update .hermes content
Delete a large set of outdated documentation (many files under docs/ and .hermes/plans/, including audits, design, prd, technical, planning, assets, and todos). Update and consolidate .hermes content: refresh shared-memory pages (decision-log, development-workflow, document-map, pitfalls, project-overview, team-conventions) and several skills/references under .hermes/skills. Also modify AGENTS.md, README.md, UI_CODING_STANDARD.md, docs/README.md and .encoding-check-ignore. Purpose: clean up stale planning/audit material and keep current hermes documentation and related top-level docs in sync.
This commit is contained in:
@@ -1,55 +1,52 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { useEffect } from 'react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
Match3DGeneratedItemAsset,
|
||||
} from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
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 {
|
||||
MATCH3D_RENDER_ITEM_SCALE,
|
||||
resolveRenderableItemFrame,
|
||||
} from './match3dRuntimePresentation';
|
||||
import {
|
||||
MATCH3D_EXTRUDED_READABLE_SHAPES,
|
||||
MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE,
|
||||
MATCH3D_TRAY_MODEL_TARGET_SIZE,
|
||||
applyMatch3DRendererCanvasLayout,
|
||||
buildMatch3DGeneratedAssetTypeMap,
|
||||
buildMatch3DPhysicsEntrySignature,
|
||||
buildMatch3DTrayModelSourceMap,
|
||||
createMatch3DCannonShape,
|
||||
createMatch3DThreeGeometry,
|
||||
MATCH3D_EXTRUDED_READABLE_SHAPES,
|
||||
MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE,
|
||||
MATCH3D_TRAY_MODEL_TARGET_SIZE,
|
||||
measureMatch3DItemPreviewDimension,
|
||||
resolveMatch3DColliderBounds,
|
||||
resolveMatch3DBoardDepthPlan,
|
||||
resolveMatch3DBoundaryRadius,
|
||||
resolveMatch3DColliderBounds,
|
||||
resolveMatch3DPhysicsStabilityPlan,
|
||||
resolveMatch3DSpawnTimingPlan,
|
||||
resolveMatch3DStackTargetY,
|
||||
resolveMatch3DSpawnDelay,
|
||||
resolveMatch3DSpawnTimingPlan,
|
||||
resolveMatch3DSpawnVisualScale,
|
||||
resolveMatch3DSpawnY,
|
||||
resolveMatch3DTrayPreviewRotation,
|
||||
resolveMatch3DStackTargetY,
|
||||
resolveMatch3DTrayPreviewReferenceDimension,
|
||||
resolveMatch3DTrayPreviewRotation,
|
||||
resolveMatch3DTrayPreviewScale,
|
||||
} from './Match3DPhysicsBoard';
|
||||
import { resolveGeometryAsset } from './match3dVisualAssets';
|
||||
import {
|
||||
MATCH3D_RENDER_ITEM_SCALE,
|
||||
resolveRenderableItemFrame,
|
||||
} from './match3dRuntimePresentation';
|
||||
import { Match3DRuntimeShell } from './Match3DRuntimeShell';
|
||||
import { resolveGeometryAsset } from './match3dVisualAssets';
|
||||
|
||||
vi.mock('./Match3DPhysicsBoard', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('./Match3DPhysicsBoard')>();
|
||||
const actual = await importOriginal<typeof import('./Match3DPhysicsBoard')>();
|
||||
return {
|
||||
...actual,
|
||||
Match3DPhysicsBoard: ({ onFallback }: { onFallback: () => void }) => {
|
||||
@@ -81,6 +78,65 @@ afterEach(() => {
|
||||
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[] = [],
|
||||
@@ -128,11 +184,49 @@ test('展示圆形空间和 7 格备选栏', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
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 firstItemByType = [
|
||||
...new Map(run.items.map((item) => [item.itemTypeId, item])).values(),
|
||||
];
|
||||
const smallItem = firstItemByType.reduce((smallest, item) =>
|
||||
item.radius < smallest.radius ? item : smallest,
|
||||
);
|
||||
@@ -151,18 +245,139 @@ test('显示层把可消除物整体半径放大 2 倍且保留相对比例', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('点击可见物品后先乐观入槽再等待确认', async () => {
|
||||
test('松手命中可见物品后只提交一次乐观入槽', async () => {
|
||||
const run = startLocalMatch3DRun(4);
|
||||
const clickableItem = run.items.find((item) => item.clickable);
|
||||
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(onOptimisticRunChange).toHaveBeenCalled();
|
||||
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));
|
||||
});
|
||||
|
||||
@@ -189,7 +404,7 @@ test('运行态按物品实例稳定使用生成 2D 视角素材', () => {
|
||||
itemTypeId: selectedItem.itemTypeId,
|
||||
visualKey: selectedItem.visualKey,
|
||||
}
|
||||
: slot,
|
||||
: slot,
|
||||
),
|
||||
};
|
||||
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
|
||||
@@ -212,8 +427,161 @@ test('运行态按物品实例稳定使用生成 2D 视角素材', () => {
|
||||
|
||||
renderRuntime(nextRun, generatedItemAssets);
|
||||
|
||||
const trayImage = screen.getByTestId('match3d-tray-image') as HTMLImageElement;
|
||||
expect(trayImage.src).toContain('/match3d/strawberry-view-');
|
||||
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 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 手机上溢出中心棋盘', () => {
|
||||
@@ -234,9 +602,7 @@ test('3D 物理条目签名随 run 和视觉资源变化,避免旧模型复用
|
||||
const sameIdDifferentVisual = {
|
||||
...item,
|
||||
visualKey:
|
||||
item.visualKey === 'block-red-2x4'
|
||||
? 'block-blue-1x2'
|
||||
: 'block-red-2x4',
|
||||
item.visualKey === 'block-red-2x4' ? 'block-blue-1x2' : 'block-red-2x4',
|
||||
};
|
||||
|
||||
expect(buildMatch3DPhysicsEntrySignature(run.runId, item)).not.toBe(
|
||||
@@ -279,10 +645,7 @@ test('生成模型按运行态类型编号映射,并兼容仅 modelObjectKey
|
||||
},
|
||||
];
|
||||
|
||||
const boardMap = buildMatch3DGeneratedAssetTypeMap(
|
||||
run,
|
||||
generatedItemAssets,
|
||||
);
|
||||
const boardMap = buildMatch3DGeneratedAssetTypeMap(run, generatedItemAssets);
|
||||
const trayMap = buildMatch3DTrayModelSourceMap(
|
||||
run.items,
|
||||
[],
|
||||
@@ -298,9 +661,7 @@ test('生成模型按运行态类型编号映射,并兼容仅 modelObjectKey
|
||||
expect(trayMap.get('match3d-type-01')).toBe(
|
||||
generatedItemAssets[0]!.modelObjectKey,
|
||||
);
|
||||
expect(trayMap.get('match3d-type-02')).toBe(
|
||||
generatedItemAssets[1]!.modelSrc,
|
||||
);
|
||||
expect(trayMap.get('match3d-type-02')).toBe(generatedItemAssets[1]!.modelSrc);
|
||||
});
|
||||
|
||||
test('运行态会先换签 generated 图片素材再渲染局内物品', async () => {
|
||||
@@ -315,8 +676,7 @@ test('运行态会先换签 generated 图片素材再渲染局内物品', async
|
||||
viewId: `view-${viewIndex}`,
|
||||
viewIndex,
|
||||
imageSrc: null,
|
||||
imageObjectKey:
|
||||
`generated-match3d-assets/session/profile/items/match3d-item-1/views/view-${viewIndex}.png`,
|
||||
imageObjectKey: `generated-match3d-assets/session/profile/items/match3d-item-1/views/view-${viewIndex}.png`,
|
||||
})),
|
||||
status: 'image_ready',
|
||||
modelSrc: null,
|
||||
@@ -352,23 +712,176 @@ test('运行态会先换签 generated 图片素材再渲染局内物品', async
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('match3d-token-image').length).toBeGreaterThan(0);
|
||||
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',
|
||||
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 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: 'match3d-type-01' }
|
||||
: item.itemTypeId === baseTypeIds[1]
|
||||
? {...item, itemTypeId: 'match3d-type-02'}
|
||||
? { ...item, itemTypeId: 'match3d-type-02' }
|
||||
: item,
|
||||
),
|
||||
};
|
||||
@@ -475,6 +988,13 @@ test('运行态会换签并渲染抓大鹅中心容器 UI 图', async () => {
|
||||
).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(96vw,28rem)]');
|
||||
expect(containerImage.className).toContain('h-auto');
|
||||
expect(containerImage.className).toContain('left-1/2');
|
||||
expect(containerImage.className).toContain('-translate-x-1/2');
|
||||
expect(screen.getByTestId('match3d-board').className).toContain(
|
||||
'bg-transparent',
|
||||
);
|
||||
@@ -706,12 +1226,10 @@ test('硬核档位生成不重复积木视觉签名', () => {
|
||||
[...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}`;
|
||||
},
|
||||
),
|
||||
[...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(21);
|
||||
@@ -721,8 +1239,8 @@ test('硬核档位生成不重复积木视觉签名', () => {
|
||||
|
||||
test('积木池覆盖参考图里的特殊件', () => {
|
||||
const shapes = new Set(
|
||||
startLocalMatch3DRun(21).items.map((item) =>
|
||||
resolveGeometryAsset(item.visualKey).shape,
|
||||
startLocalMatch3DRun(21).items.map(
|
||||
(item) => resolveGeometryAsset(item.visualKey).shape,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -755,17 +1273,21 @@ test('进阶档位保持 15 种视觉模型并按三消组复用', () => {
|
||||
item.visualKey,
|
||||
(countByVisualKey.get(item.visualKey) ?? 0) + 1,
|
||||
);
|
||||
typeByVisualKey.set(item.visualKey, typeByVisualKey.get(item.visualKey) ?? new Set());
|
||||
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),
|
||||
[...countByVisualKey.values()].sort((left, right) => left - right),
|
||||
).toEqual([...Array(14).fill(3), 6]);
|
||||
expect(
|
||||
[...typeByVisualKey.values()].every(
|
||||
(itemTypeIds) => itemTypeIds.size === 1,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -824,15 +1346,17 @@ test('同一视觉模型在复用时保持唯一尺寸', () => {
|
||||
}
|
||||
|
||||
expect(radiiByVisualKey.size).toBe(21);
|
||||
expect([...radiiByVisualKey.values()].every((radii) => radii.size === 1)).toBe(true);
|
||||
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 firstItemByType = [
|
||||
...new Map(run.items.map((item) => [item.itemTypeId, item])).values(),
|
||||
];
|
||||
const referenceDimension = resolveMatch3DTrayPreviewReferenceDimension(
|
||||
three,
|
||||
firstItemByType,
|
||||
@@ -840,8 +1364,7 @@ test('托盘 3D 预览保留场内模型的相对尺寸比例', async () => {
|
||||
const previewRatios = new Set(
|
||||
firstItemByType.map((item) =>
|
||||
Math.round(
|
||||
(measureMatch3DItemPreviewDimension(three, item) /
|
||||
referenceDimension) *
|
||||
(measureMatch3DItemPreviewDimension(three, item) / referenceDimension) *
|
||||
1_000,
|
||||
),
|
||||
),
|
||||
@@ -880,8 +1403,7 @@ test('托盘 3D 预览为小模型保留最低可读显示尺寸', () => {
|
||||
);
|
||||
|
||||
expect(scale * smallDimension).toBeGreaterThanOrEqual(
|
||||
MATCH3D_TRAY_MODEL_TARGET_SIZE *
|
||||
MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE,
|
||||
MATCH3D_TRAY_MODEL_TARGET_SIZE * MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE,
|
||||
);
|
||||
expect(scale).toBeGreaterThan(
|
||||
MATCH3D_TRAY_MODEL_TARGET_SIZE / referenceDimension,
|
||||
@@ -914,12 +1436,12 @@ test('3D 物体碰撞体按同款视觉尺寸生成', async () => {
|
||||
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,
|
||||
);
|
||||
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);
|
||||
@@ -930,9 +1452,9 @@ test('3D 物体碰撞体按同款视觉尺寸生成', async () => {
|
||||
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);
|
||||
expect((cylinderShape as import('cannon-es').Cylinder).height).toBeCloseTo(
|
||||
cylinderBounds.height,
|
||||
);
|
||||
});
|
||||
|
||||
test('中心场地 3D 纵深随物体总量增加并随消除进度回补', () => {
|
||||
@@ -947,9 +1469,7 @@ test('中心场地 3D 纵深随物体总量增加并随消除进度回补', () =
|
||||
expect(largeDepthPlan.layerCapacity).toBeLessThan(
|
||||
smallDepthPlan.layerCapacity,
|
||||
);
|
||||
expect(largeDepthPlan.layerCount).toBeGreaterThan(
|
||||
smallDepthPlan.layerCount,
|
||||
);
|
||||
expect(largeDepthPlan.layerCount).toBeGreaterThan(smallDepthPlan.layerCount);
|
||||
expect(largeDepthPlan.surfaceY).toBeGreaterThan(largeDepthPlan.baseY);
|
||||
expect(lateBottomY).toBeGreaterThan(earlyBottomY);
|
||||
expect(lateBottomY).toBeLessThanOrEqual(largeDepthPlan.surfaceY);
|
||||
@@ -959,9 +1479,7 @@ test('高数量 3D 局面使用更稳定的物理参数', () => {
|
||||
const smallPlan = resolveMatch3DPhysicsStabilityPlan(30);
|
||||
const largePlan = resolveMatch3DPhysicsStabilityPlan(300);
|
||||
|
||||
expect(largePlan.contactFriction).toBeGreaterThan(
|
||||
smallPlan.contactFriction,
|
||||
);
|
||||
expect(largePlan.contactFriction).toBeGreaterThan(smallPlan.contactFriction);
|
||||
expect(largePlan.contactRestitution).toBeLessThan(
|
||||
smallPlan.contactRestitution,
|
||||
);
|
||||
@@ -995,7 +1513,10 @@ test('100 次局面的新物体会按层级延迟生成并逐层回落', () => {
|
||||
const smallTimingPlan = resolveMatch3DSpawnTimingPlan(30);
|
||||
const largeTimingPlan = resolveMatch3DSpawnTimingPlan(300);
|
||||
const bottomDelay = resolveMatch3DSpawnDelay(0, largeDepthPlan.layerCapacity);
|
||||
const middleDelay = resolveMatch3DSpawnDelay(30, largeDepthPlan.layerCapacity);
|
||||
const middleDelay = resolveMatch3DSpawnDelay(
|
||||
30,
|
||||
largeDepthPlan.layerCapacity,
|
||||
);
|
||||
const topDelay = resolveMatch3DSpawnDelay(120, largeDepthPlan.layerCapacity);
|
||||
const dynamicCapacityDelay = resolveMatch3DSpawnDelay(
|
||||
120,
|
||||
@@ -1031,7 +1552,11 @@ test('100 次局面的新物体会按层级延迟生成并逐层回落', () => {
|
||||
smallTimingPlan.layerDelayMs,
|
||||
);
|
||||
expect(
|
||||
resolveMatch3DSpawnDelay(299, largeDepthPlan.layerCapacity, largeTimingPlan),
|
||||
resolveMatch3DSpawnDelay(
|
||||
299,
|
||||
largeDepthPlan.layerCapacity,
|
||||
largeTimingPlan,
|
||||
),
|
||||
).toBeGreaterThan(5000);
|
||||
});
|
||||
|
||||
@@ -1125,7 +1650,9 @@ test('积木视觉键渲染为无文字纯色图标', () => {
|
||||
|
||||
expect(screen.getByTestId('match3d-visual-block-red-2x4')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByTestId('match3d-visual-block-clear-ring').getAttribute('data-shape'),
|
||||
screen
|
||||
.getByTestId('match3d-visual-block-clear-ring')
|
||||
.getAttribute('data-shape'),
|
||||
).toBe('ring');
|
||||
expect(
|
||||
screen
|
||||
@@ -1156,7 +1683,9 @@ test('运行态支持长条、斜坡和圆柱等差异化积木造型', () => {
|
||||
renderRuntime(run);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('match3d-visual-block-black-1x8').getAttribute('data-shape'),
|
||||
screen
|
||||
.getByTestId('match3d-visual-block-black-1x8')
|
||||
.getAttribute('data-shape'),
|
||||
).toBe('brick');
|
||||
expect(
|
||||
screen
|
||||
@@ -1192,3 +1721,27 @@ test('异常旧坐标只做显示层收束,不让物品贴出圆形空间', ()
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user