b4ce6877c8
- useResourceCanvasGenerationPlaceholders:抑制状态在越过拖动阈值时只登记、不起计时器,改到 pointerup 才起 0ms 计时(deferDragClickSuppressionCleanup);pointercancel 与切项目走 clearDragClickSuppression 立刻收干净。 - 原实现(越过阈值时就起 0ms 计时器)在真实鼠标下不成立:一次拖动里 pointermove 横跨多轮宏任务,计时器早在松手前就把抑制清掉,收尾那次 click 照样生效——jsdom 同步发 move/up 看不见这一档,所以上一版用例是假绿。 - 用例补上真实时序:拖动过程中显式让时钟走一轮宏任务再松手;宿主用例还补上浏览器在 pointerup 之后补发的 lostpointercapture,并断言它不能把这次拖动的抑制一起清掉。 - 敏感性已双向验证:把计时器搬回「越过阈值那一刻」、以及让 lostpointercapture 清抑制,两条用例都会红;改用例前的同步版本对两种错法都会绿(所以上一版没抓住)。 - 注释写明计时起点为什么是松手那一刻,并与资源卡链路的 deferSkippedResourceCardClickCleanup 对齐口径。
487 lines
17 KiB
TypeScript
487 lines
17 KiB
TypeScript
// @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);
|
||
});
|
||
});
|