Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasGenerationPlaceholder.test.tsx
suzmii b4ce6877c8 修掉「拖动占位卡仍触发点击」的真因:点击抑制的计时起点必须在松手那一刻
- useResourceCanvasGenerationPlaceholders:抑制状态在越过拖动阈值时只登记、不起计时器,改到 pointerup 才起 0ms 计时(deferDragClickSuppressionCleanup);pointercancel 与切项目走 clearDragClickSuppression 立刻收干净。
- 原实现(越过阈值时就起 0ms 计时器)在真实鼠标下不成立:一次拖动里 pointermove 横跨多轮宏任务,计时器早在松手前就把抑制清掉,收尾那次 click 照样生效——jsdom 同步发 move/up 看不见这一档,所以上一版用例是假绿。
- 用例补上真实时序:拖动过程中显式让时钟走一轮宏任务再松手;宿主用例还补上浏览器在 pointerup 之后补发的 lostpointercapture,并断言它不能把这次拖动的抑制一起清掉。
- 敏感性已双向验证:把计时器搬回「越过阈值那一刻」、以及让 lostpointercapture 清抑制,两条用例都会红;改用例前的同步版本对两种错法都会绿(所以上一版没抓住)。
- 注释写明计时起点为什么是松手那一刻,并与资源卡链路的 deferSkippedResourceCardClickCleanup 对齐口径。
2026-09-21 12:34:47 +08:00

487 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { renderHook } from '@testing-library/react';
import { useEffect, useState } from 'react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE } from '../../../packages/shared/src/contracts/gameCreationApp';
import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
import { ResourceCanvasGenerationPlaceholderCardView } from '../src/features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView';
import {
bindResourceCanvasGenerationPlaceholderTask,
failResourceCanvasGenerationPlaceholder,
moveResourceCanvasGenerationPlaceholder,
placeResourceCanvasGenerationPlaceholder,
removeResourceCanvasGenerationPlaceholder,
resolveResourceCanvasGenerationPanelStyle,
type ResourceCanvasGenerationPlaceholder,
resourceCanvasGenerationPlaceholderByDraftId,
resourceCanvasGenerationPlaceholderByTaskId,
resourceCanvasGenerationPlaceholdersForProject,
resourceCanvasGenerationPlaceholderSize,
} from '../src/features/resource-canvas/resourceCanvasGenerationPlaceholderModel';
import { useResourceCanvasGenerationPlaceholders } from '../src/features/resource-canvas/useResourceCanvasGenerationPlaceholders';
// 指针事件与 DOM 尺寸的 jsdom 补丁统一由 appSurface harness 提供(`PointerEvent` 等),
// 与既有画布手势用例同一套测试环境;不在这里另写一份补丁。
import { act, cleanup, fireEvent, render, screen } from './appSurface/harness';
afterEach(cleanup);
const imageAction: ResourceCanvasAssetToolAction = {
id: 'generate-image',
route: 'asset',
label: '生成图片',
assetKind: 'image',
audioKind: null,
assetName: 'AI 生成图片',
promptPlaceholder: '今天想生成什么画面?',
adjustableDimensions: true,
aspectRatio: '1:1',
imageSize: '1K',
requiresIconSpecReference: false,
writesIconSpecReference: false,
};
function placeholderFixture(
overrides: Partial<ResourceCanvasGenerationPlaceholder> = {},
): ResourceCanvasGenerationPlaceholder {
const size = resourceCanvasGenerationPlaceholderSize();
return {
draftId: 'draft-1',
projectId: 'project-1',
category: 'scene',
actionId: 'generate-image',
actionLabel: '生成图片',
assetName: 'AI 生成图片',
panel: { route: 'asset', action: imageAction },
x: 0,
y: 0,
width: size.width,
height: size.height,
taskId: null,
status: 'draft',
error: null,
...overrides,
};
}
describe('生成占位模型', () => {
test('空栏目落在原点,已有内容时排到最下面一行之下(占位之间也不重叠)', () => {
const size = resourceCanvasGenerationPlaceholderSize();
expect(placeResourceCanvasGenerationPlaceholder({ occupied: [] })).toEqual({
x: 0,
y: 0,
});
const first = placeResourceCanvasGenerationPlaceholder({
occupied: [{ x: 0, y: 0, width: size.width, height: size.height }],
});
expect(first.x).toBe(0);
expect(first.y).toBe(size.height + 16);
const second = placeResourceCanvasGenerationPlaceholder({
occupied: [
{ x: 0, y: 0, width: size.width, height: size.height },
{ x: 0, y: first.y, width: size.width, height: size.height },
],
});
expect(second.y).toBe(first.y + size.height + 16);
});
test('拖动只改坐标:有限数取整、越界收到坐标域、非法值保持原位', () => {
const base = placeholderFixture({ x: 10, y: 20 });
expect(
moveResourceCanvasGenerationPlaceholder(base, 33.6, -7.4),
).toMatchObject({ x: 34, y: -7 });
expect(
moveResourceCanvasGenerationPlaceholder(base, 1e9, -1e9),
).toMatchObject({
x: GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
y: -GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
});
expect(
moveResourceCanvasGenerationPlaceholder(base, Number.NaN, Number.NaN),
).toMatchObject({ x: 10, y: 20 });
});
test('提交绑定任务、失败保留占位、删除只移除展示', () => {
const bound = bindResourceCanvasGenerationPlaceholderTask(
placeholderFixture(),
'task-1',
);
expect(bound).toMatchObject({ status: 'submitted', taskId: 'task-1' });
const failed = failResourceCanvasGenerationPlaceholder(bound, '远端拒绝');
expect(failed).toMatchObject({
status: 'failed',
taskId: 'task-1',
error: '远端拒绝',
});
// 失败不删占位:同一份输入与位置还要用来重试。
expect(
resourceCanvasGenerationPlaceholderByTaskId([failed], 'task-1'),
).toBe(failed);
expect(
resourceCanvasGenerationPlaceholderByDraftId([failed], 'draft-1'),
).toBe(failed);
expect(
resourceCanvasGenerationPlaceholderByTaskId([failed], 'task-2'),
).toBe(null);
expect(
removeResourceCanvasGenerationPlaceholder([failed], 'draft-1'),
).toEqual([]);
});
test('归属按项目收口:切项目只留当前项目的占位', () => {
const placeholders = [
placeholderFixture({ draftId: 'draft-a', projectId: 'project-1' }),
placeholderFixture({ draftId: 'draft-b', projectId: 'project-2' }),
];
expect(
resourceCanvasGenerationPlaceholdersForProject(
placeholders,
'project-2',
).map((item) => item.draftId),
).toEqual(['draft-b']);
});
test('浮层锚点贴着占位下沿居中(与快速编辑同一条几何)', () => {
const style = resolveResourceCanvasGenerationPanelStyle({
placeholder: placeholderFixture({ x: 100, y: 200 }),
viewport: { x: 0, y: 0, scale: 2 },
canvasSize: { width: 800, height: 600 },
});
const size = resourceCanvasGenerationPlaceholderSize();
expect(style?.left).toBe((100 + size.width / 2) * 2);
expect(style?.top).toBeGreaterThan((200 + size.height) * 2);
});
});
describe('占位卡组件', () => {
test('呈现名称与状态,点卡开合浮层、点删除只删除自己', () => {
const onTogglePanel = vi.fn();
const onRemove = vi.fn();
render(
<ResourceCanvasGenerationPlaceholderCardView
placeholder={placeholderFixture({
status: 'failed',
error: '远端拒绝',
})}
active={false}
dragging={false}
onPointerDown={() => undefined}
onPointerMove={() => undefined}
onPointerUp={() => undefined}
onPointerCancel={() => undefined}
onTogglePanel={onTogglePanel}
onRemove={onRemove}
/>,
);
const card = screen.getByRole('button', {
name: 'AI 生成图片(生成失败)',
});
expect(
card.getAttribute('data-resource-canvas-generation-placeholder'),
).toBe('draft-1');
fireEvent.click(card);
expect(onTogglePanel).toHaveBeenCalledTimes(1);
fireEvent.click(
screen.getByRole('button', { name: '删除占位 AI 生成图片' }),
);
expect(onRemove).toHaveBeenCalledTimes(1);
// 删除按钮不能把点击透到卡片上(否则删完立刻又开一次浮层)。
expect(onTogglePanel).toHaveBeenCalledTimes(1);
});
/**
* 同一类缺陷的相邻一档:删除按钮自己 `stopPropagation`,「按在删除按钮上、松手落回卡片」
* 那次 click 会被派给共同祖先(卡片)。判据按**起手点**判,不看移动距离——从删除按钮起手的
* 手势永远不是「点卡片」。
*/
test('从删除按钮起手、松手落回卡片时不当作点卡片', () => {
const onTogglePanel = vi.fn();
const onRemove = vi.fn();
render(
<ResourceCanvasGenerationPlaceholderCardView
placeholder={placeholderFixture()}
active={false}
dragging={false}
onPointerDown={() => undefined}
onPointerMove={() => undefined}
onPointerUp={() => undefined}
onPointerCancel={() => undefined}
onTogglePanel={onTogglePanel}
onRemove={onRemove}
/>,
);
const card = screen.getByRole('button', {
name: 'AI 生成图片(待提交)',
});
const remove = screen.getByRole('button', {
name: '删除占位 AI 生成图片',
});
fireEvent.pointerDown(remove, {
pointerId: 3,
button: 0,
isPrimary: true,
clientX: 10,
clientY: 10,
});
// 松手落回卡片:浏览器把 click 派给共同祖先(卡片)。
fireEvent.click(card);
expect(onTogglePanel).not.toHaveBeenCalled();
expect(onRemove).not.toHaveBeenCalled();
// 之后正常点卡片仍然开合浮层(起手标记不残留到下一手势)。
fireEvent.pointerDown(card, {
pointerId: 4,
button: 0,
isPrimary: true,
clientX: 20,
clientY: 20,
});
fireEvent.click(card);
expect(onTogglePanel).toHaveBeenCalledTimes(1);
});
});
describe('占位宿主 Hook', () => {
function renderPlaceholderHook(projectId = 'project-1') {
return renderHook(
({ currentProjectId }: { currentProjectId: string }) =>
useResourceCanvasGenerationPlaceholders({
projectId: currentProjectId,
resolvePlacement: ({ placeholders: sameSection }) => ({
x: 0,
y: sameSection.length * 100,
}),
viewportScale: () => 2,
}),
{ initialProps: { currentProjectId: projectId } },
);
}
test('创建占位、绑定任务与失败收口', () => {
const hook = renderPlaceholderHook();
let draftId = '';
act(() => {
const created = hook.result.current.createPlaceholder({
category: 'scene',
actionId: 'generate-image',
actionLabel: '生成图片',
assetName: 'AI 生成图片',
panel: { route: 'asset', action: imageAction },
});
draftId = created.draftId;
});
expect(hook.result.current.placeholders).toHaveLength(1);
expect(hook.result.current.placeholders[0]).toMatchObject({
projectId: 'project-1',
category: 'scene',
status: 'draft',
taskId: null,
});
act(() => hook.result.current.bindTask(draftId, 'task-1'));
expect(hook.result.current.placeholders[0]).toMatchObject({
status: 'submitted',
taskId: 'task-1',
});
act(() => hook.result.current.failTask('task-1', '远端拒绝'));
expect(hook.result.current.placeholders[0]).toMatchObject({
status: 'failed',
error: '远端拒绝',
});
act(() => hook.result.current.remove(draftId));
expect(hook.result.current.placeholders).toEqual([]);
});
test('切项目清掉上一份会话的占位', () => {
const hook = renderPlaceholderHook();
act(() => {
hook.result.current.createPlaceholder({
category: 'scene',
actionId: 'generate-image',
actionLabel: '生成图片',
assetName: 'AI 生成图片',
panel: { route: 'asset', action: imageAction },
});
});
expect(hook.result.current.placeholders).toHaveLength(1);
act(() => hook.rerender({ currentProjectId: 'project-2' }));
expect(hook.result.current.placeholders).toEqual([]);
});
/**
* 把 hook 的返回值也带出来:阈值、拖动中标记与点击抑制都在 hook 这一层,
* 只渲染卡片看不到它们。
*/
function renderDragHarness() {
const hookRef: {
current: ReturnType<
typeof useResourceCanvasGenerationPlaceholders
> | null;
} = { current: null };
function Harness() {
const [created, setCreated] = useState(false);
const placeholders = useResourceCanvasGenerationPlaceholders({
projectId: 'project-1',
resolvePlacement: () => ({ x: 10, y: 20 }),
viewportScale: () => 2,
});
hookRef.current = placeholders;
useEffect(() => {
if (created) {
return;
}
placeholders.createPlaceholder({
category: 'scene',
actionId: 'generate-image',
actionLabel: '生成图片',
assetName: 'AI 生成图片',
panel: { route: 'asset', action: imageAction },
});
setCreated(true);
}, [created, placeholders]);
const placeholder = placeholders.placeholders[0];
if (!placeholder) {
return null;
}
return (
<ResourceCanvasGenerationPlaceholderCardView
placeholder={placeholder}
active={false}
dragging={placeholders.draggingDraftId === placeholder.draftId}
{...placeholders.dragHandlersFor(placeholder)}
onTogglePanel={() => undefined}
onRemove={() => undefined}
/>
);
}
render(<Harness />);
const hook = () => {
if (!hookRef.current) {
throw new Error('hook 还没挂上');
}
return hookRef.current;
};
return { hook };
}
test('拖动按视口缩放换算坐标,指针取消回到拖动前位置', () => {
renderDragHarness();
const card = screen.getByRole('button', {
name: 'AI 生成图片(待提交)',
});
expect(card.style.left).toBe('10px');
expect(card.style.top).toBe('20px');
fireEvent.pointerDown(card, {
pointerId: 1,
button: 0,
isPrimary: true,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(card, {
pointerId: 1,
clientX: 140,
clientY: 130,
});
// 视口缩放 2:屏幕 40 / 30 px 对应画布 20 / 15 px。
expect(card.style.left).toBe('30px');
expect(card.style.top).toBe('35px');
fireEvent.pointerUp(card, { pointerId: 1, clientX: 140, clientY: 130 });
// 取消手势:回到拖动前坐标,不留下半截位置。
fireEvent.pointerDown(card, {
pointerId: 2,
button: 0,
isPrimary: true,
clientX: 200,
clientY: 200,
});
fireEvent.pointerMove(card, {
pointerId: 2,
clientX: 260,
clientY: 260,
});
expect(card.style.left).toBe('60px');
fireEvent.pointerCancel(card, { pointerId: 2 });
expect(card.style.left).toBe('30px');
expect(card.style.top).toBe('35px');
});
/**
* 验收现场那条「拖动未生成的资源卡片会触发点击事件」在单元层的钉子:
* 拖动与点击的判据是**移动距离**(与资源卡共用 `RESOURCE_CANVAS_DRAG_THRESHOLD`),
* 而且拖动收尾那一次 click 要被吃掉一次——只有这一次。
*/
test('位移没越过阈值不算拖动:卡片不动、也不吃掉后面的点击', () => {
const { hook } = renderDragHarness();
const card = screen.getByRole('button', {
name: 'AI 生成图片(待提交)',
});
const draftId = hook().placeholders[0]!.draftId;
fireEvent.pointerDown(card, {
pointerId: 11,
button: 0,
isPrimary: true,
clientX: 100,
clientY: 100,
});
// 按下还不算拖动:拖动样式不该在点一下的时候闪。
expect(hook().draggingDraftId).toBeNull();
// 抖动 2px(阈值 5px)不构成拖动。
fireEvent.pointerMove(card, { pointerId: 11, clientX: 102, clientY: 101 });
expect(card.style.left).toBe('10px');
expect(card.style.top).toBe('20px');
expect(hook().draggingDraftId).toBeNull();
fireEvent.pointerUp(card, { pointerId: 11, clientX: 102, clientY: 101 });
// 这一次是点击而不是拖动:后面的 click 必须照常生效。
expect(hook().consumeDragClick(draftId)).toBe(false);
});
test('越过阈值才算拖动:抑制活到松手那一刻,下一次点击照常生效', async () => {
const { hook } = renderDragHarness();
const card = screen.getByRole('button', {
name: 'AI 生成图片(待提交)',
});
const draftId = hook().placeholders[0]!.draftId;
fireEvent.pointerDown(card, {
pointerId: 12,
button: 0,
isPrimary: true,
clientX: 100,
clientY: 100,
});
// 视口缩放 2:屏幕 6px > 阈值 5px,越过阈值这一刻才算拖动的开始。
fireEvent.pointerMove(card, { pointerId: 12, clientX: 106, clientY: 100 });
expect(hook().draggingDraftId).toBe(draftId);
expect(card.style.left).toBe('13px');
/*
* 真实鼠标的拖动会横跨很多轮宏任务:这里显式让时钟走一轮再继续。
*
* 这一行是这条用例的关键——抑制的**计时起点**必须是松手那一刻;如果实现改回「越过阈值时
* 就起 0ms 计时器」,抑制会在松手之前被清掉,下面那次 `consumeDragClick` 就会变成 false,
* 而真实浏览器里表现为「拖一下还是触发了点击」(jsdom 同步发 move / up 时看不见这一档)。
*/
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
fireEvent.pointerUp(card, { pointerId: 12, clientX: 106, clientY: 100 });
expect(hook().draggingDraftId).toBeNull();
// 拖动收尾那次 click 被吃掉一次,之后恢复正常。
expect(hook().consumeDragClick(draftId)).toBe(true);
expect(hook().consumeDragClick(draftId)).toBe(false);
});
});