Match3D & Puzzle: runtime UI, assets, drag fix
Backend: stop treating background music as a required draft asset and remove auto-submit/plan for background music; load persisted generated UI/assets into Match3D agent session responses (added helpers to resolve profile id and fetch existing generated assets). Frontend: make Match3D result preview reuse runtime UI styles, unify runtime settings entry, update PuzzleRuntime to apply immediate pointermove transforms (disable drag transition), use SVG clipPath for merged piece rounding, ensure PuzzleRuntimeShell supplies platform theme classes, and adjust related tests. Docs & logs: update decision log, pitfalls and product docs to reflect these changes.
This commit is contained in:
@@ -168,6 +168,7 @@ test('拼图界面不调用 mocap,也不渲染 mocap 光标或调试面板', (
|
||||
});
|
||||
|
||||
test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
const onDragPiece = vi.fn();
|
||||
const playingRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
@@ -207,6 +208,11 @@ test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
|
||||
if (!board) {
|
||||
throw new Error('缺少测试棋盘');
|
||||
}
|
||||
const requestAnimationFrame = vi.fn(() => 1);
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
configurable: true,
|
||||
value: requestAnimationFrame,
|
||||
});
|
||||
board.getBoundingClientRect = () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -233,6 +239,9 @@ test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
|
||||
clientY: 70,
|
||||
});
|
||||
});
|
||||
expect(piece.style.transform).toBe('translate3d(30px, 30px, 0) scale(1.03)');
|
||||
expect(piece.style.transition).toBe('none');
|
||||
expect(requestAnimationFrame).not.toHaveBeenCalled();
|
||||
act(() => {
|
||||
dispatchPointerEvent(piece, 'pointermove', {
|
||||
pointerId: 11,
|
||||
@@ -252,11 +261,14 @@ test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
|
||||
expect(onDragPiece).toHaveBeenCalledWith(
|
||||
expect.objectContaining({pieceId: 'piece-0'}),
|
||||
);
|
||||
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
configurable: true,
|
||||
value: originalRequestAnimationFrame,
|
||||
});
|
||||
});
|
||||
|
||||
test('指针拖拽合并大块时按大块锚点提交拖拽', () => {
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||
const onDragPiece = vi.fn();
|
||||
const mergedRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
@@ -289,16 +301,7 @@ test('指针拖拽合并大块时按大块锚点提交拖拽', () => {
|
||||
},
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => 1),
|
||||
});
|
||||
Object.defineProperty(window, 'cancelAnimationFrame', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
|
||||
const { container, unmount } = renderPuzzleRuntime(
|
||||
const { container } = renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={mergedRun}
|
||||
onBack={vi.fn()}
|
||||
@@ -346,6 +349,13 @@ test('指针拖拽合并大块时按大块锚点提交拖拽', () => {
|
||||
clientY: 210,
|
||||
});
|
||||
});
|
||||
const mergedGroup = container.querySelector(
|
||||
'[data-merged-group-id="group-large"]',
|
||||
) as HTMLElement | null;
|
||||
expect(mergedGroup?.style.transform).toBe(
|
||||
'translate3d(150px, 150px, 0) scale(1.02)',
|
||||
);
|
||||
expect(mergedGroup?.style.transition).toBe('none');
|
||||
act(() => {
|
||||
dispatchPointerEvent(mergedPiece, 'pointerup', {
|
||||
pointerId: 12,
|
||||
@@ -360,16 +370,6 @@ test('指针拖拽合并大块时按大块锚点提交拖拽', () => {
|
||||
targetRow: 2,
|
||||
targetCol: 2,
|
||||
});
|
||||
|
||||
unmount();
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
configurable: true,
|
||||
value: originalRequestAnimationFrame,
|
||||
});
|
||||
Object.defineProperty(window, 'cancelAnimationFrame', {
|
||||
configurable: true,
|
||||
value: originalCancelAnimationFrame,
|
||||
});
|
||||
});
|
||||
|
||||
test('拖拽合并大块时底层单格不显示选中色块', () => {
|
||||
@@ -483,6 +483,78 @@ test('拖拽合并大块时底层单格不显示选中色块', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('拖动拼图片时不显示已选择状态', () => {
|
||||
const playingRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now(),
|
||||
remainingMs: 300_000,
|
||||
timeLimitMs: 300_000,
|
||||
board: {
|
||||
...clearedRun.currentLevel!.board,
|
||||
allTilesResolved: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={playingRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const board = container.querySelector(
|
||||
'[data-testid="puzzle-board"]',
|
||||
) as HTMLElement | null;
|
||||
if (!board) {
|
||||
throw new Error('缺少测试棋盘');
|
||||
}
|
||||
board.getBoundingClientRect = () =>
|
||||
({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 300,
|
||||
bottom: 300,
|
||||
width: 300,
|
||||
height: 300,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
const piece = container.querySelector(
|
||||
'[data-piece-id="piece-0"]',
|
||||
) as HTMLElement | null;
|
||||
if (!piece) {
|
||||
throw new Error('缺少测试拼图片');
|
||||
}
|
||||
|
||||
act(() => {
|
||||
dispatchPointerEvent(piece, 'pointerdown', {
|
||||
pointerId: 14,
|
||||
clientX: 40,
|
||||
clientY: 40,
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.getByText('已选择')).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
dispatchPointerEvent(piece, 'pointermove', {
|
||||
pointerId: 14,
|
||||
clientX: 70,
|
||||
clientY: 70,
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.queryByText('已选择')).toBeNull();
|
||||
expect(piece.className).not.toContain('puzzle-runtime-piece--selected');
|
||||
});
|
||||
|
||||
test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
|
||||
vi.useFakeTimers();
|
||||
const onAdvanceNextLevel = vi.fn();
|
||||
@@ -842,7 +914,11 @@ test('右上角设置按钮打开拼图设置并支持音量调节', () => {
|
||||
authValue,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开拼图设置' }));
|
||||
const settingsButton = screen.getByRole('button', { name: '打开拼图设置' });
|
||||
expect(settingsButton.querySelector('img')).toBeNull();
|
||||
expect(settingsButton.querySelector('svg')).toBeTruthy();
|
||||
|
||||
fireEvent.click(settingsButton);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '拼图设置' });
|
||||
const slider = within(dialog).getByRole('slider', { name: '拼图音乐音量' });
|
||||
@@ -852,6 +928,44 @@ test('右上角设置按钮打开拼图设置并支持音量调节', () => {
|
||||
expect(authValue.setMusicVolume).toHaveBeenCalledWith(0.77);
|
||||
});
|
||||
|
||||
test('拼图设置面板展示进行中关卡的实时当前用时', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-15T08:00:10.000Z'));
|
||||
const startedAtMs = Date.now() - 8_500;
|
||||
const playingRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
clearedLevelCount: 0,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs,
|
||||
clearedAtMs: null,
|
||||
elapsedMs: null,
|
||||
remainingMs: 291_500,
|
||||
board: {
|
||||
...clearedRun.currentLevel!.board,
|
||||
allTilesResolved: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={playingRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开拼图设置' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '拼图设置' });
|
||||
expect(within(dialog).getByText('0:08.50')).toBeTruthy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('推荐页嵌入拼图时隐藏返回和设置里的退出入口', () => {
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
@@ -926,17 +1040,37 @@ test('拼图运行态主体使用主题语义类承接明暗主题', () => {
|
||||
expect(container.firstElementChild?.className).toContain(
|
||||
'puzzle-runtime-shell',
|
||||
);
|
||||
expect(container.firstElementChild?.className).toContain(
|
||||
'platform-theme--light',
|
||||
);
|
||||
expect(container.querySelector('.puzzle-runtime-pill')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('拼图直达页在没有外层主题壳时也会自行补齐平台主题类', () => {
|
||||
const { container } = render(
|
||||
<PuzzleRuntimeShell
|
||||
run={null}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.firstElementChild?.className).toContain(
|
||||
'platform-theme--light',
|
||||
);
|
||||
});
|
||||
|
||||
test('合并块不叠加可见轮廓和单块阴影', () => {
|
||||
const mergedRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now(),
|
||||
board: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now(),
|
||||
coverImageSrc: '/puzzle.png',
|
||||
board: {
|
||||
...clearedRun.currentLevel!.board,
|
||||
allTilesResolved: false,
|
||||
mergedGroups: [
|
||||
@@ -974,26 +1108,33 @@ test('合并块不叠加可见轮廓和单块阴影', () => {
|
||||
|
||||
expect(outlinedPieces).toHaveLength(3);
|
||||
expect(container.querySelector('.ring-2.ring-emerald-100\\/58')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-merged-group-outline="true"]'),
|
||||
).toBeNull();
|
||||
const outlineStroke = container.querySelector(
|
||||
'[data-merged-group-outline-stroke="true"]',
|
||||
const mergedGroupClipLayer = container.querySelector(
|
||||
'[data-merged-group-id="group-l"] [data-merged-group-clip="true"]',
|
||||
) as SVGSVGElement | null;
|
||||
const mergedGroupClipPath = mergedGroupClipLayer?.querySelector('path');
|
||||
const mergedPieceVisuals = container.querySelectorAll(
|
||||
'[data-merged-piece-visual="true"]',
|
||||
);
|
||||
expect(outlineStroke).toBeNull();
|
||||
expect((outlinedPieces[0] as HTMLElement).style.clipPath).toBe('');
|
||||
expect(mergedGroupClipLayer?.tagName.toLowerCase()).toBe('svg');
|
||||
expect(mergedGroupClipLayer?.getAttribute('viewBox')).toBe('0 0 2 2');
|
||||
expect(mergedGroupClipPath?.getAttribute('d')).toContain('Q 2 1 1.84 1');
|
||||
expect(mergedGroupClipPath?.getAttribute('d')).toContain('Q 1 1 1 1.192');
|
||||
expect(mergedPieceVisuals).toHaveLength(3);
|
||||
const mergedImageSlices = mergedGroupClipLayer?.querySelectorAll('image');
|
||||
expect(mergedImageSlices).toHaveLength(3);
|
||||
expect(mergedImageSlices?.[0]?.getAttribute('href')).toBe('/puzzle.png');
|
||||
expect(mergedImageSlices?.[0]?.getAttribute('width')).toBe('3');
|
||||
expect(mergedImageSlices?.[0]?.getAttribute('height')).toBe('3');
|
||||
for (const outlinedPiece of outlinedPieces) {
|
||||
const outlinedPieceElement = outlinedPiece as HTMLElement;
|
||||
expect(outlinedPieceElement.style.clipPath).toBe('');
|
||||
expect(outlinedPieceElement.querySelector('.absolute.inset-0')).toBeNull();
|
||||
expect(outlinedPieceElement.className).not.toContain('bg-emerald-300/10');
|
||||
expect(outlinedPieceElement.className).not.toContain('shadow-[');
|
||||
expect(
|
||||
outlinedPieceElement.querySelector('.absolute.inset-0.bg-black\\/8'),
|
||||
).toBeNull();
|
||||
}
|
||||
const clippedLayer = container.querySelector(
|
||||
'[style*="clip-path"]',
|
||||
) as HTMLElement | null;
|
||||
expect(clippedLayer).toBeNull();
|
||||
});
|
||||
|
||||
test('合并块轮廓路径为内凹角生成圆角曲线', () => {
|
||||
@@ -1017,10 +1158,10 @@ test('合并块轮廓路径为内凹角生成圆角曲线', () => {
|
||||
});
|
||||
|
||||
expect(outlinePath).toContain('Q 2 1 1.84 1');
|
||||
expect(outlinePath).toContain('Q 1 1 1 1.16');
|
||||
expect(outlinePath).toContain('Q 1 1 1 1.192');
|
||||
});
|
||||
|
||||
test('基础单块不叠加边框圆角或图片蒙版', () => {
|
||||
test('基础单块使用圆角裁切且不叠加图片蒙版', () => {
|
||||
const playingRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
@@ -1050,7 +1191,7 @@ test('基础单块不叠加边框圆角或图片蒙版', () => {
|
||||
) as HTMLElement | null;
|
||||
expect(basePiece?.className).toContain('overflow-hidden');
|
||||
expect(basePiece?.className).toContain('border-0');
|
||||
expect(basePiece?.className).not.toContain('rounded-[0.85rem]');
|
||||
expect(basePiece?.style.clipPath).toContain('url(#');
|
||||
expect(basePiece?.className).not.toContain('border-2');
|
||||
expect(basePiece?.querySelector('.puzzle-runtime-piece-overlay')).toBeNull();
|
||||
});
|
||||
@@ -1442,7 +1583,7 @@ test('失败续时扣费失败时保留确认弹窗', async () => {
|
||||
expect(screen.getByText('泥点余额不足')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('查看原图开关打开覆盖层并在关闭后恢复计时', async () => {
|
||||
test('查看原图显示独立原图层并在关闭后恢复计时', async () => {
|
||||
const onPauseChange = vi.fn();
|
||||
const onUseProp = vi.fn().mockResolvedValue(clearedRun);
|
||||
const playingRun: PuzzleRunSnapshot = {
|
||||
@@ -1478,12 +1619,21 @@ test('查看原图开关打开覆盖层并在关闭后恢复计时', async () =>
|
||||
});
|
||||
|
||||
expect(onUseProp).toHaveBeenCalledWith('reference');
|
||||
expect(screen.getByTestId('puzzle-original-overlay')).toBeTruthy();
|
||||
const originalViewer = screen.getByTestId('puzzle-original-viewer');
|
||||
const board = screen.getByTestId('puzzle-board');
|
||||
expect(originalViewer).toBeTruthy();
|
||||
expect(originalViewer.parentElement).not.toBe(board);
|
||||
expect(
|
||||
within(originalViewer)
|
||||
.getByRole('img', { name: '潮雾拼图 原图' })
|
||||
.getAttribute('src'),
|
||||
).toBe('/puzzle.png');
|
||||
expect(screen.queryByTestId('puzzle-original-overlay')).toBeNull();
|
||||
expect(onPauseChange).toHaveBeenLastCalledWith(true);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '原图' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭原图' }));
|
||||
|
||||
expect(screen.queryByTestId('puzzle-original-overlay')).toBeNull();
|
||||
expect(screen.queryByTestId('puzzle-original-viewer')).toBeNull();
|
||||
expect(onPauseChange).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user