b4ce6877c8
- useResourceCanvasGenerationPlaceholders:抑制状态在越过拖动阈值时只登记、不起计时器,改到 pointerup 才起 0ms 计时(deferDragClickSuppressionCleanup);pointercancel 与切项目走 clearDragClickSuppression 立刻收干净。 - 原实现(越过阈值时就起 0ms 计时器)在真实鼠标下不成立:一次拖动里 pointermove 横跨多轮宏任务,计时器早在松手前就把抑制清掉,收尾那次 click 照样生效——jsdom 同步发 move/up 看不见这一档,所以上一版用例是假绿。 - 用例补上真实时序:拖动过程中显式让时钟走一轮宏任务再松手;宿主用例还补上浏览器在 pointerup 之后补发的 lostpointercapture,并断言它不能把这次拖动的抑制一起清掉。 - 敏感性已双向验证:把计时器搬回「越过阈值那一刻」、以及让 lostpointercapture 清抑制,两条用例都会红;改用例前的同步版本对两种错法都会绿(所以上一版没抓住)。 - 注释写明计时起点为什么是松手那一刻,并与资源卡链路的 deferSkippedResourceCardClickCleanup 对齐口径。
1079 lines
40 KiB
TypeScript
1079 lines
40 KiB
TypeScript
/** @vitest-environment jsdom */
|
||
|
||
import userEvent from '@testing-library/user-event';
|
||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||
|
||
import type {
|
||
GameCreationAppAssetManifestEntry,
|
||
GameCreationAppManifest,
|
||
ProjectResourceCanvasPosition,
|
||
} from '../../../packages/shared/src/contracts/gameCreationApp';
|
||
import ProjectDevelopmentView from '../src/view/project-development';
|
||
import {
|
||
act,
|
||
cleanup,
|
||
createGameCreationAppManifest,
|
||
fireEvent,
|
||
React,
|
||
render,
|
||
screen,
|
||
waitFor,
|
||
within,
|
||
} from './appSurface/harness';
|
||
import { typeGenerationPrompt } from './resourceGenerationPromptTestUtils';
|
||
|
||
/**
|
||
* 画布生成入口的**宿主生命周期**验收(真实 `ProjectDevelopmentView`,不做 props mock)。
|
||
*
|
||
* 覆盖三件事——它们都在宿主里(占位、提交身份、落点 effect),单测模型或组件看不到:
|
||
* 1. 音频 / 背景音乐入口:点工具立刻出占位,提交后那张占位进入 `submitted`;
|
||
* 2. 失败后用同一份请求重试:复用**同一个 operationId / 幂等键**,不会变成新的付费生成;
|
||
* 3. 成功后结果卡落到占位**最新位置**、占位被撤掉(结果接管它的位置)。
|
||
*
|
||
* 原生命令全部走本文件的假实现,不触发任何真实 Provider 调用。
|
||
*/
|
||
|
||
const PROJECT_ID = 'generation-host-project';
|
||
const PROJECT_PATH = '/tmp/generation-host-project';
|
||
const NEW_ASSET_ID = 'asset-bgm-1';
|
||
const NEW_RESOURCE_ID = `asset:${NEW_ASSET_ID}`;
|
||
|
||
type AssetFixture = GameCreationAppAssetManifestEntry;
|
||
type LayoutWrite = {
|
||
projectPath: string;
|
||
mode: string;
|
||
positions: ProjectResourceCanvasPosition[];
|
||
};
|
||
|
||
function imageAsset(id: string, fileName: string): AssetFixture {
|
||
return {
|
||
id,
|
||
kind: 'character',
|
||
category: 'character',
|
||
mediaType: 'image/png',
|
||
localPath: `assets/${fileName}`,
|
||
source: { kind: 'generated', resourceId: `${id}-resource` },
|
||
};
|
||
}
|
||
|
||
function bgmAsset(id: string): AssetFixture {
|
||
return {
|
||
id,
|
||
kind: 'background-music',
|
||
category: 'audio',
|
||
mediaType: 'audio/mpeg',
|
||
localPath: 'assets/bgm.mp3',
|
||
source: { kind: 'generated', resourceId: `${id}-resource` },
|
||
};
|
||
}
|
||
|
||
function seedBgmAsset(id: string): AssetFixture {
|
||
return { ...bgmAsset(id), localPath: `assets/${id}.mp3` };
|
||
}
|
||
|
||
function manifestFor(
|
||
projectId: string,
|
||
assets: AssetFixture[],
|
||
): GameCreationAppManifest {
|
||
return {
|
||
...createGameCreationAppManifest(projectId, `${projectId} 项目`),
|
||
assets: assets.map((asset) => structuredClone(asset)),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 只铺这条链路真正用到的本地命令;未知命令返回 `undefined` 并记账(不抛错),
|
||
* 免得画布里别的入口多调一个命令就把这组用例整体带红。
|
||
*/
|
||
function installHostTauri(options: {
|
||
assets: AssetFixture[];
|
||
/**
|
||
* 后台生成账本(`start_local_project_asset_generation`)的收口方式。
|
||
*
|
||
* 不传时任务停在**运行中**,由用例调 `completeAssetGeneration` 收口为完成(用来观察占位的
|
||
* `submitted` 中间态与结果落点);`'failed'` 让队列在第一次轮询就看到一条失败记录,用来观察
|
||
* 失败态与重试。
|
||
*/
|
||
assetGenerationRecord?: 'failed';
|
||
/**
|
||
* 提交那一刻就被拒绝(后端从未受理):`start_local_project_asset_generation` 抛这个原因。
|
||
*
|
||
* 用例可以中途改 `assetGenerationStartError.current`(改成 `undefined` 表示这次真受理了),
|
||
* 所以「未受理」与「已受理」两条路能在同一条用例里对照。
|
||
*/
|
||
assetGenerationStartError?: string;
|
||
/**
|
||
* 让**第一次** `start_local_project_asset_generation` 挂在半空(受理前)。
|
||
*
|
||
* 用来复现「两条音频提交重叠」:第一条还在等受理时提交第二条,第二条会排在本地队列里。
|
||
* 用例用返回的 `releaseHeldAssetGenerationStart()` 决定第一条什么时候收到结果。
|
||
*/
|
||
holdFirstAssetGenerationStart?: boolean;
|
||
}) {
|
||
const layoutWrites: LayoutWrite[] = [];
|
||
const deriveCalls: Array<Record<string, unknown>> = [];
|
||
const assetGenerationStarts: Array<Record<string, unknown>> = [];
|
||
const audioTaskRecords = new Map<string, Record<string, unknown>>();
|
||
/** 后台任务登记进清单的素材:完成收口时补进来,`get_local_game_manifest` 才读得到。 */
|
||
const registeredAssets: AssetFixture[] = [];
|
||
const unexpectedCommands: string[] = [];
|
||
let revision = 0;
|
||
const assetGenerationStartError = {
|
||
current: options.assetGenerationStartError,
|
||
};
|
||
let releaseHeldStart: (() => void) | null = null;
|
||
const positions: ProjectResourceCanvasPosition[] = [];
|
||
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'get_local_game_project_revision') {
|
||
return { revision: 1 };
|
||
}
|
||
if (command === 'read_local_project_resource_graph') {
|
||
// 关系图按**当前清单**(`resources` 入参)派生:后台生成落进清单的素材也要在图上。
|
||
const resources =
|
||
(args?.resources as Array<{ resourceId: string }> | undefined) ?? [];
|
||
return {
|
||
nodes: [],
|
||
taskFlowIds: [],
|
||
producerAssignments: [],
|
||
dependencyDepths: resources.map((resource) => ({
|
||
resourceId: resource.resourceId,
|
||
dependencyDepth: 0,
|
||
})),
|
||
unresolvedReferenceResourceIds: [],
|
||
cyclicResourceIds: [],
|
||
cyclicTaskIds: [],
|
||
producerMappingTruncated: false,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return manifestFor(PROJECT_ID, [
|
||
...options.assets,
|
||
...registeredAssets,
|
||
]);
|
||
}
|
||
if (command === 'read_local_project_resource_canvas_layout') {
|
||
return {
|
||
schemaVersion: 'game-creator-resource-layout.v1',
|
||
projectId: PROJECT_ID,
|
||
mode: String(args?.mode ?? ''),
|
||
revision,
|
||
positions: structuredClone(positions),
|
||
updatedAt: 0,
|
||
};
|
||
}
|
||
if (command === 'update_local_project_resource_canvas_layout') {
|
||
const next = structuredClone(
|
||
(args?.positions ?? []) as ProjectResourceCanvasPosition[],
|
||
);
|
||
positions.length = 0;
|
||
positions.push(...next);
|
||
revision += 1;
|
||
layoutWrites.push({
|
||
projectPath: String(args?.projectPath ?? ''),
|
||
mode: String(args?.mode ?? ''),
|
||
positions: next,
|
||
});
|
||
return {
|
||
status: 'updated',
|
||
layout: {
|
||
schemaVersion: 'game-creator-resource-layout.v1',
|
||
projectId: PROJECT_ID,
|
||
mode: String(args?.mode ?? ''),
|
||
revision,
|
||
positions: next,
|
||
updatedAt: revision,
|
||
},
|
||
};
|
||
}
|
||
if (command === 'derive_local_project_resource') {
|
||
/*
|
||
音频与图片类都走后台任务账本:这条同步派生通道不该再被生成入口触发。真被调到就大声
|
||
失败,而不是静默返回一个像模像样的结果。
|
||
*/
|
||
deriveCalls.push((args?.input as Record<string, unknown>) ?? {});
|
||
throw new Error('生成入口不应再走同步派生通道');
|
||
}
|
||
if (command === 'list_pending_local_project_resource_edits') {
|
||
return [];
|
||
}
|
||
if (command === 'start_local_project_asset_generation') {
|
||
const startArgs = args ?? {};
|
||
const isFirstStart = assetGenerationStarts.length === 0;
|
||
assetGenerationStarts.push(structuredClone(startArgs));
|
||
if (options.holdFirstAssetGenerationStart && isFirstStart) {
|
||
await new Promise<void>((resolve) => {
|
||
releaseHeldStart = resolve;
|
||
});
|
||
}
|
||
if (assetGenerationStartError.current) {
|
||
throw new Error(assetGenerationStartError.current);
|
||
}
|
||
const task = {
|
||
taskId: String(startArgs.taskId ?? ''),
|
||
projectId: PROJECT_ID,
|
||
kind: String(startArgs.kind ?? ''),
|
||
assetName: String(startArgs.assetName ?? ''),
|
||
referenceAssetIds: Array.isArray(startArgs.referenceAssetIds)
|
||
? startArgs.referenceAssetIds
|
||
: [],
|
||
status: 'running',
|
||
phaseDetail: '已受理',
|
||
createdAtMillis: 1,
|
||
startedAtMillis: 1,
|
||
finishedAtMillis: null,
|
||
assetId: null,
|
||
error: null,
|
||
};
|
||
audioTaskRecords.set(task.taskId, task);
|
||
return task;
|
||
}
|
||
if (command === 'list_local_project_asset_generations') {
|
||
const records = [...audioTaskRecords.values()];
|
||
if (options.assetGenerationRecord !== 'failed') {
|
||
return records;
|
||
}
|
||
// 「后端已受理、之后才失败」这一路:面板不再被带回来,原因留在占位那一侧。
|
||
return records.map((task) => ({
|
||
...task,
|
||
status: 'failed',
|
||
phaseDetail: '生成失败:测试拒绝',
|
||
finishedAtMillis: 2,
|
||
error: '测试:生成失败',
|
||
}));
|
||
}
|
||
if (command === 'read_local_project_image_preview') {
|
||
return {
|
||
path: String(args?.relativePath ?? ''),
|
||
mediaType: 'image/png',
|
||
byteLen: 1,
|
||
dataUrl: 'data:image/png;base64,AA==',
|
||
};
|
||
}
|
||
if (command === 'read_local_project_text_preview') {
|
||
return {
|
||
path: String(args?.relativePath ?? ''),
|
||
mediaType: 'text/markdown',
|
||
byteLen: 2,
|
||
content: '#',
|
||
};
|
||
}
|
||
unexpectedCommands.push(command);
|
||
return undefined;
|
||
},
|
||
);
|
||
|
||
window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__;
|
||
return {
|
||
invoke,
|
||
layoutWrites,
|
||
deriveCalls,
|
||
assetGenerationStarts,
|
||
assetGenerationStartError,
|
||
releaseHeldAssetGenerationStart: () => releaseHeldStart?.(),
|
||
unexpectedCommands,
|
||
/**
|
||
* 后台任务收口为完成:账本里那条记录变成 `completed` + `assetId`,素材同时进清单。
|
||
*
|
||
* 真实链路里这两件事都由 Rust 后台任务写(先登记 manifest 再写终态),这里只复刻结果。
|
||
* 队列下一次轮询(默认 2 秒一次)就会读到终态并把结果落到占位上。
|
||
*/
|
||
completeAssetGeneration: (asset: AssetFixture) => {
|
||
for (const [taskId, task] of audioTaskRecords) {
|
||
audioTaskRecords.set(taskId, {
|
||
...task,
|
||
status: 'completed',
|
||
phaseDetail: '生成已完成。',
|
||
finishedAtMillis: 2,
|
||
assetId: asset.id,
|
||
error: null,
|
||
});
|
||
}
|
||
registeredAssets.push(asset);
|
||
},
|
||
};
|
||
}
|
||
|
||
function HostWorkbench({ assets }: { assets: AssetFixture[] }) {
|
||
const [manifest, setManifest] = React.useState<GameCreationAppManifest>(() =>
|
||
manifestFor(PROJECT_ID, assets),
|
||
);
|
||
return (
|
||
<ProjectDevelopmentView
|
||
projectName={manifest.name}
|
||
projectPath={PROJECT_PATH}
|
||
manifest={manifest}
|
||
attachments={[]}
|
||
recentRunStatus={null}
|
||
recentRunStopReason={null}
|
||
supervisor={<div>Supervisor</div>}
|
||
onHomeOpen={() => undefined}
|
||
onProjectsOpen={() => undefined}
|
||
onManifestChange={(_path, nextManifest) => setManifest(nextManifest)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 带「从清单里删掉某个素材」按钮的宿主:失效参考的判据必须在**素材真的不在清单里**之后成立,
|
||
* 所以用例要能在渲染过程中推进一次 manifest(与用户在素材管理里删除同一条路径)。
|
||
*/
|
||
function HostWorkbenchWithReferenceRemoval({
|
||
assets,
|
||
referenceAssetId,
|
||
}: {
|
||
assets: AssetFixture[];
|
||
referenceAssetId: string;
|
||
}) {
|
||
const [manifest, setManifest] = React.useState<GameCreationAppManifest>(() =>
|
||
manifestFor(PROJECT_ID, assets),
|
||
);
|
||
return (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
setManifest((current) => ({
|
||
...current,
|
||
assets: current.assets.filter(
|
||
(asset) => asset.id !== referenceAssetId,
|
||
),
|
||
}))
|
||
}
|
||
>
|
||
删除参考素材
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setManifest(manifestFor(PROJECT_ID, assets))}
|
||
>
|
||
恢复参考素材
|
||
</button>
|
||
<ProjectDevelopmentView
|
||
projectName={manifest.name}
|
||
projectPath={PROJECT_PATH}
|
||
manifest={manifest}
|
||
attachments={[]}
|
||
recentRunStatus={null}
|
||
recentRunStopReason={null}
|
||
supervisor={<div>Supervisor</div>}
|
||
onHomeOpen={() => undefined}
|
||
onProjectsOpen={() => undefined}
|
||
onManifestChange={(_path, nextManifest) => setManifest(nextManifest)}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
async function settle() {
|
||
await act(async () => {
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
});
|
||
}
|
||
|
||
function placeholderElement(draftId: string) {
|
||
return document.querySelector<HTMLElement>(
|
||
`[data-resource-canvas-generation-placeholder="${draftId}"]`,
|
||
);
|
||
}
|
||
|
||
function allPlaceholders() {
|
||
return Array.from(
|
||
document.querySelectorAll<HTMLElement>(
|
||
'[data-resource-canvas-generation-placeholder]',
|
||
),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 音频入口只在「音频」栏目页出现(`RESOURCE_CANVAS_BOTTOM_TOOLS_BY_CATEGORY.audio`),
|
||
* 所以先打开音频栏目、再点「生成背景音乐」:占位立刻出现在该栏目里。
|
||
*/
|
||
async function openBgmEntry() {
|
||
const opener = screen
|
||
.getAllByRole('button')
|
||
.find((button) => /^打开音频/.test(button.textContent?.trim() ?? ''));
|
||
if (!opener) {
|
||
throw new Error(
|
||
`没找到音频栏目入口,现有入口:${screen
|
||
.getAllByRole('button')
|
||
.map((button) => button.textContent?.trim())
|
||
.filter((text) => text?.startsWith('打开'))
|
||
.join(' / ')}`,
|
||
);
|
||
}
|
||
fireEvent.click(opener);
|
||
await settle();
|
||
// 切到「按类型」:落点 effect 走的是**当前排序模式**那一侧的布局 hook,
|
||
// 类型侧的 sidecar 读完之后才 ready(依赖侧还要等关系图就绪)。
|
||
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
|
||
await settle();
|
||
fireEvent.click(screen.getByRole('button', { name: '生成背景音乐' }));
|
||
await settle();
|
||
const placeholder = allPlaceholders()[0];
|
||
if (!placeholder) {
|
||
throw new Error('点音频入口后没有出现占位卡');
|
||
}
|
||
const draftId = placeholder.dataset.resourceCanvasGenerationPlaceholder ?? '';
|
||
const prompt = document.querySelector<HTMLTextAreaElement>(
|
||
'textarea',
|
||
) as HTMLTextAreaElement;
|
||
if (prompt) {
|
||
fireEvent.change(prompt, { target: { value: '一段平静的夜晚钢琴曲' } });
|
||
}
|
||
return { draftId, placeholder };
|
||
}
|
||
|
||
/**
|
||
* 占位卡在画布世界坐标里的落点。宿主不是把它写进 DOM 数据集,而是由外层包装的
|
||
* transform 决定位置——按 ManualLayout 用例的同一口径从 style 里取数。
|
||
*/
|
||
function placeholderWorldPoint(element: HTMLElement) {
|
||
const styled = element.closest<HTMLElement>('[style*="translate"]');
|
||
const source = styled?.style.transform ?? '';
|
||
const [x, y] = Array.from(source.matchAll(/-?\d+(?:\.\d+)?/g), (match) =>
|
||
Number(match[0]),
|
||
);
|
||
return { x, y };
|
||
}
|
||
|
||
function cardElement(resourceId: string) {
|
||
return document.querySelector<HTMLElement>(
|
||
`.game-resource-card[data-resource-card-id="${resourceId}"]`,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 占位的**局部落点**由占位模型给定:这一栏已有素材(种子卡在原点)时,占位排到它下方
|
||
* 一行(`placeResourceCanvasGenerationPlaceholder`:x 贴左边、y = 最高下沿 + 间距)。
|
||
*
|
||
* 尺寸从种子卡画出来的盒子里读,避免在用例里再抄一份卡片尺寸常量。
|
||
*/
|
||
function placeholderLocalPoint(placeholder: HTMLElement) {
|
||
const seed = cardElement('asset:seed-bgm');
|
||
if (!seed) {
|
||
throw new Error('没找到音频栏目里的种子卡');
|
||
}
|
||
const wrapper = seed.closest<HTMLElement>('[style*="translate"]');
|
||
const style = wrapper?.getAttribute('style') ?? '';
|
||
const height = Number(
|
||
style.match(/height:\s*(-?\d+(?:\.\d+)?)px/)?.[1] ?? Number.NaN,
|
||
);
|
||
if (!Number.isFinite(height)) {
|
||
throw new Error(`种子卡没有可读的高度:${style}`);
|
||
}
|
||
expect(placeholder).not.toBeNull();
|
||
return { x: 0, y: height + 16 };
|
||
}
|
||
|
||
/**
|
||
* 面板里的提交按钮:它与底部工具栏的工具按钮**同名**(都是「生成背景音乐」),
|
||
* 所以要排掉工具栏那一支,否则点的是工具本身(再点一次入口,不会提交)。
|
||
*/
|
||
function panelSubmitButton(label: string) {
|
||
const submit = screen
|
||
.getAllByRole('button', { name: label })
|
||
.find((button) => !button.closest('.game-resource-bottom-toolbar'));
|
||
if (!submit) {
|
||
throw new Error(`没找到面板里的提交按钮「${label}」`);
|
||
}
|
||
return submit;
|
||
}
|
||
|
||
/** 当前挂着的生成浮层(音频与图片类共用同一个数据属性)。 */
|
||
function floatingPanel() {
|
||
return document.querySelector<HTMLElement>(
|
||
'[data-resource-canvas-generation-floating-panel]',
|
||
);
|
||
}
|
||
|
||
function floatingPanelPrompt() {
|
||
return (
|
||
floatingPanel()?.querySelector<HTMLTextAreaElement>('textarea') ?? null
|
||
);
|
||
}
|
||
|
||
/** 浮层里的提交按钮:不按文案挑,音频失败态会换成「使用原请求重试」。 */
|
||
function floatingPanelSubmit() {
|
||
const submit = floatingPanel()?.querySelector<HTMLButtonElement>(
|
||
'button[type="submit"]',
|
||
);
|
||
if (!submit) {
|
||
throw new Error('没找到浮层里的提交按钮');
|
||
}
|
||
return submit;
|
||
}
|
||
|
||
/** 浮层里存在某文案的按钮吗(工具栏上的同名按钮要排掉)。 */
|
||
function floatingPanelHasButton(label: string) {
|
||
const panel = floatingPanel();
|
||
if (!panel) {
|
||
return false;
|
||
}
|
||
return Array.from(panel.querySelectorAll('button')).some(
|
||
(button) => button.textContent?.trim() === label,
|
||
);
|
||
}
|
||
|
||
/** 底部工具栏上的工具按钮(与浮层里的同名提交按钮区分开)。 */
|
||
function toolbarToolButton(label: string) {
|
||
const button = screen
|
||
.getAllByRole('button', { name: label })
|
||
.find((candidate) => candidate.closest('.game-resource-bottom-toolbar'));
|
||
if (!button) {
|
||
throw new Error(`没找到工具栏上的工具「${label}」`);
|
||
}
|
||
return button;
|
||
}
|
||
|
||
/** 打开某个资源栏目:点总览卡入口、切到「按类型」,布局 hook 就绪后工具栏才出现。 */
|
||
async function openResourceCategory(entryLabel: string) {
|
||
const opener = screen
|
||
.getAllByRole('button')
|
||
.find(
|
||
(button) =>
|
||
/^打开/.test(button.textContent?.trim() ?? '') &&
|
||
button.textContent?.includes(entryLabel),
|
||
);
|
||
if (!opener) {
|
||
throw new Error(`没找到栏目入口「${entryLabel}」`);
|
||
}
|
||
fireEvent.click(opener);
|
||
await settle();
|
||
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
|
||
await settle();
|
||
}
|
||
|
||
/** 点击音频栏目的某条工具:占位与浮层都由这一个动作产生。 */
|
||
async function openAudioTool(toolLabel: string) {
|
||
fireEvent.click(toolbarToolButton(toolLabel));
|
||
await settle();
|
||
}
|
||
|
||
afterEach(() => {
|
||
cleanup();
|
||
delete (window as unknown as { __TAURI__?: unknown }).__TAURI__;
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
describe('画布生成入口的宿主生命周期', () => {
|
||
test('音频入口:提交后占位进入 submitted,成功后结果落到占位最新位置并撤掉占位', async () => {
|
||
const tauri = installHostTauri({
|
||
assets: [seedBgmAsset('seed-bgm')],
|
||
});
|
||
render(<HostWorkbench assets={[seedBgmAsset('seed-bgm')]} />);
|
||
await settle();
|
||
|
||
const { draftId, placeholder } = await openBgmEntry();
|
||
const placeholderPoint = placeholderLocalPoint(placeholder);
|
||
// 点入口只造占位:还没提交,也没发起任何生成。
|
||
expect(placeholder.dataset.resourceCanvasGenerationPlaceholderStatus).toBe(
|
||
'draft',
|
||
);
|
||
expect(tauri.assetGenerationStarts).toEqual([]);
|
||
|
||
fireEvent.click(panelSubmitButton('生成背景音乐'));
|
||
await settle();
|
||
|
||
/*
|
||
提交一次:音频与图片类走**同一条命令**(`start_local_project_asset_generation`),载荷只有
|
||
「任务 id = operation id」+ 幂等键,没有图片类那套比例 / 尺寸 / 参考 / 落点参数。
|
||
*/
|
||
expect(tauri.assetGenerationStarts).toHaveLength(1);
|
||
const musicStart = tauri.assetGenerationStarts[0]!;
|
||
expect(musicStart).toMatchObject({
|
||
projectPath: PROJECT_PATH,
|
||
projectId: PROJECT_ID,
|
||
kind: 'background-music',
|
||
prompt: '一段平静的夜晚钢琴曲',
|
||
assetName: '新背景音乐',
|
||
});
|
||
expect(Object.keys(musicStart).sort()).toEqual([
|
||
'assetName',
|
||
'idempotencyKey',
|
||
'kind',
|
||
'projectId',
|
||
'projectPath',
|
||
'prompt',
|
||
'taskId',
|
||
]);
|
||
expect(String(musicStart.taskId)).toMatch(/^[0-9a-f-]{36}$/);
|
||
expect(String(musicStart.idempotencyKey)).toMatch(/^[0-9a-f-]{36}$/);
|
||
// 音频不再走同步派生通道。
|
||
expect(tauri.deriveCalls).toEqual([]);
|
||
|
||
// 后台还在跑:占位收口为 submitted。
|
||
await waitFor(() =>
|
||
expect(
|
||
placeholderElement(draftId)?.dataset
|
||
.resourceCanvasGenerationPlaceholderStatus,
|
||
).toBe('submitted'),
|
||
);
|
||
|
||
// 后台任务收口:账本记完成并登记素材(真实链路里由 Rust 任务写这两件事)。
|
||
await act(async () => {
|
||
tauri.completeAssetGeneration(bgmAsset(NEW_ASSET_ID));
|
||
await Promise.resolve();
|
||
});
|
||
|
||
// 结果入库后:新卡落在占位坐标上(占位在空栏目里落在原点),占位被撤掉。
|
||
await waitFor(
|
||
() => {
|
||
expect(
|
||
tauri.layoutWrites
|
||
.filter((write) =>
|
||
write.positions.some(
|
||
(position) => position.resourceId === NEW_RESOURCE_ID,
|
||
),
|
||
)
|
||
.map((write) => {
|
||
const landed = write.positions.find(
|
||
(position) => position.resourceId === NEW_RESOURCE_ID,
|
||
)!;
|
||
return `${write.mode}:${landed.section}@${landed.x},${landed.y}${
|
||
landed.manuallyPlaced ? 'M' : 'A'
|
||
}`;
|
||
})
|
||
.join(' || '),
|
||
).toContain(`type:audio@${placeholderPoint.x},${placeholderPoint.y}M`);
|
||
},
|
||
{ timeout: 5_000 },
|
||
);
|
||
await waitFor(() => expect(allPlaceholders()).toHaveLength(0));
|
||
}, 20_000);
|
||
|
||
test('未受理的即时失败:面板连原草稿与原请求身份自动带回来', async () => {
|
||
const tauri = installHostTauri({
|
||
assets: [seedBgmAsset('seed-bgm')],
|
||
assetGenerationStartError: '项目权限策略拒绝执行:asset.register',
|
||
});
|
||
render(<HostWorkbench assets={[seedBgmAsset('seed-bgm')]} />);
|
||
await settle();
|
||
|
||
const { draftId } = await openBgmEntry();
|
||
fireEvent.click(panelSubmitButton('生成背景音乐'));
|
||
await settle();
|
||
expect(tauri.assetGenerationStarts).toHaveLength(1);
|
||
|
||
/*
|
||
后端**从未受理**:这次输入没有被任务接走,所以占位留在画布上,面板连同原草稿与原请求
|
||
身份一起被带回来——用户改完可以直接重试,而不会丢掉刚写的东西。
|
||
*/
|
||
await waitFor(() => expect(floatingPanel()).not.toBeNull());
|
||
expect(floatingPanel()?.textContent ?? '').toContain(
|
||
'项目权限策略拒绝执行:asset.register',
|
||
);
|
||
expect(floatingPanelPrompt()?.value ?? '').toBe('一段平静的夜晚钢琴曲');
|
||
expect(
|
||
placeholderElement(draftId)?.dataset
|
||
.resourceCanvasGenerationPlaceholderStatus,
|
||
).toBe('failed');
|
||
|
||
// 同一张占位再提交:同一对 operation / 幂等键——这是同一次生成的重试,不是第二次付费请求。
|
||
tauri.assetGenerationStartError.current = undefined;
|
||
fireEvent.click(floatingPanelSubmit());
|
||
await settle();
|
||
await waitFor(() => expect(tauri.assetGenerationStarts).toHaveLength(2));
|
||
expect(tauri.assetGenerationStarts[1]).toMatchObject({
|
||
taskId: tauri.assetGenerationStarts[0]!.taskId,
|
||
idempotencyKey: tauri.assetGenerationStarts[0]!.idempotencyKey,
|
||
kind: 'background-music',
|
||
prompt: '一段平静的夜晚钢琴曲',
|
||
});
|
||
// 这次后端受理了:面板同步关闭且**不再**被带回来(重开只属于「从未受理」)。
|
||
await waitFor(() => expect(floatingPanel()).toBeNull());
|
||
}, 20_000);
|
||
|
||
test('两条音频提交重叠:第一条未被受理时,它的面板仍会被带回来', async () => {
|
||
const tauri = installHostTauri({
|
||
assets: [seedBgmAsset('seed-bgm')],
|
||
assetGenerationStartError: '项目权限策略拒绝执行:asset.register',
|
||
holdFirstAssetGenerationStart: true,
|
||
});
|
||
render(<HostWorkbench assets={[seedBgmAsset('seed-bgm')]} />);
|
||
await settle();
|
||
|
||
// 第一条:背景音乐。派发挂在「受理」上,队列还没拿到它的终局。
|
||
const { draftId } = await openBgmEntry();
|
||
fireEvent.click(panelSubmitButton('生成背景音乐'));
|
||
await settle();
|
||
expect(tauri.assetGenerationStarts).toHaveLength(1);
|
||
|
||
// 第二条:音效(另一张占位)。第一条还在途,所以它排在本地队列里,还没有发 IPC。
|
||
await openAudioTool('生成音效');
|
||
fireEvent.change(
|
||
floatingPanel()?.querySelector('textarea') as HTMLTextAreaElement,
|
||
{ target: { value: '木门缓慢推开的吱呀声' } },
|
||
);
|
||
fireEvent.click(panelSubmitButton('生成音效'));
|
||
await settle();
|
||
expect(tauri.assetGenerationStarts).toHaveLength(1);
|
||
|
||
/*
|
||
放开第一条:它这次从未被受理。提交上下文按**任务 id** 各自留存,所以后来的第二条不许把
|
||
第一条的恢复上下文挤掉——面板必须连原草稿回到第一张占位。
|
||
*/
|
||
await act(async () => {
|
||
tauri.releaseHeldAssetGenerationStart();
|
||
await Promise.resolve();
|
||
});
|
||
await waitFor(() => expect(floatingPanel()).not.toBeNull());
|
||
expect(floatingPanelPrompt()?.value ?? '').toBe('一段平静的夜晚钢琴曲');
|
||
expect(
|
||
placeholderElement(draftId)?.dataset
|
||
.resourceCanvasGenerationPlaceholderStatus,
|
||
).toBe('failed');
|
||
}, 20_000);
|
||
|
||
test('失败后用同一份请求重试:复用同一个 operationId 与幂等键', async () => {
|
||
const tauri = installHostTauri({
|
||
assets: [seedBgmAsset('seed-bgm')],
|
||
assetGenerationRecord: 'failed',
|
||
});
|
||
render(<HostWorkbench assets={[seedBgmAsset('seed-bgm')]} />);
|
||
await settle();
|
||
|
||
const { draftId } = await openBgmEntry();
|
||
fireEvent.click(panelSubmitButton('生成背景音乐'));
|
||
await settle();
|
||
expect(tauri.assetGenerationStarts).toHaveLength(1);
|
||
// 失败:占位留着(输入与任务身份都还在),状态收口为 failed。
|
||
await waitFor(() =>
|
||
expect(
|
||
placeholderElement(draftId)?.dataset
|
||
.resourceCanvasGenerationPlaceholderStatus,
|
||
).toBe('failed'),
|
||
);
|
||
// 提交即关闭:面板不留在屏幕上等结果。
|
||
expect(floatingPanel()).toBeNull();
|
||
|
||
// 点占位把面板带回来:失败原因是**那张占位**的,重试仍是同一次生成。
|
||
fireEvent.click(placeholderElement(draftId)!);
|
||
await settle();
|
||
expect(floatingPanel()?.textContent ?? '').toContain('测试:生成失败');
|
||
fireEvent.click(floatingPanelSubmit());
|
||
await settle();
|
||
|
||
await waitFor(() => expect(tauri.assetGenerationStarts).toHaveLength(2));
|
||
expect(tauri.assetGenerationStarts[1]).toMatchObject({
|
||
taskId: tauri.assetGenerationStarts[0]!.taskId,
|
||
idempotencyKey: tauri.assetGenerationStarts[0]!.idempotencyKey,
|
||
kind: 'background-music',
|
||
prompt: tauri.assetGenerationStarts[0]!.prompt,
|
||
});
|
||
}, 20_000);
|
||
});
|
||
|
||
/**
|
||
* 浮层**按占位隔离**的宿主回归。
|
||
*
|
||
* 浮层是非模态的:面板开着时用户仍能点另一条工具、另一张占位卡。这组用例钉住三件在宿主里才能
|
||
* 看到的事:
|
||
* 1. 换到另一张占位必须换一份面板状态(类型、输入、失败原因都不跟着走),而**未提交草稿必须留下**;
|
||
* 2. 失败请求的操作身份只属于它自己那张占位:在别的占位上提交是一次新请求,回到原占位才是重试;
|
||
* 3. 参考素材被删掉后重试:失效参考必须显式呈现并挡住提交,不许静默丢参考、也不许再发一次请求。
|
||
*/
|
||
describe('生成浮层按占位隔离(宿主回归)', () => {
|
||
test('同类不同草稿:切到另一条音频工具是新面板,切回来原草稿还在', async () => {
|
||
const tauri = installHostTauri({
|
||
assets: [seedBgmAsset('seed-bgm')],
|
||
});
|
||
render(<HostWorkbench assets={[seedBgmAsset('seed-bgm')]} />);
|
||
await settle();
|
||
|
||
const { draftId } = await openBgmEntry();
|
||
expect(floatingPanelHasButton('生成背景音乐')).toBe(true);
|
||
expect(floatingPanelPrompt()?.value ?? '').toBe('一段平静的夜晚钢琴曲');
|
||
|
||
// 面板开着直接点另一条音频工具:这是另一张占位,必须是另一份面板状态。
|
||
await openAudioTool('生成音效');
|
||
expect(floatingPanelHasButton('生成音效')).toBe(true);
|
||
expect(floatingPanelHasButton('生成背景音乐')).toBe(false);
|
||
expect(floatingPanelPrompt()?.value ?? '').toBe('');
|
||
// 全程只点入口与输入,没有任何生成请求被发起。
|
||
expect(tauri.deriveCalls).toEqual([]);
|
||
|
||
// 切回第一张占位:未提交草稿不能因为面板卸载而丢。
|
||
fireEvent.click(placeholderElement(draftId)!);
|
||
await settle();
|
||
expect(floatingPanelHasButton('生成背景音乐')).toBe(true);
|
||
expect(floatingPanelPrompt()?.value ?? '').toBe('一段平静的夜晚钢琴曲');
|
||
});
|
||
|
||
test('失败态切换:失败原因与任务身份都不跟着换占位', async () => {
|
||
const tauri = installHostTauri({
|
||
assets: [seedBgmAsset('seed-bgm')],
|
||
assetGenerationRecord: 'failed',
|
||
});
|
||
render(<HostWorkbench assets={[seedBgmAsset('seed-bgm')]} />);
|
||
await settle();
|
||
|
||
const { draftId } = await openBgmEntry();
|
||
fireEvent.click(floatingPanelSubmit());
|
||
await settle();
|
||
expect(tauri.assetGenerationStarts).toHaveLength(1);
|
||
// 提交即关面板:失败由占位收口,原因留在那张占位上。
|
||
await waitFor(() =>
|
||
expect(
|
||
placeholderElement(draftId)?.dataset
|
||
.resourceCanvasGenerationPlaceholderStatus,
|
||
).toBe('failed'),
|
||
);
|
||
expect(floatingPanel()).toBeNull();
|
||
fireEvent.click(placeholderElement(draftId)!);
|
||
await settle();
|
||
expect(floatingPanel()?.textContent ?? '').toContain('测试:生成失败');
|
||
|
||
// 切到音效:上一条的失败原因不得跟着它走,输入也不该被锁。
|
||
await openAudioTool('生成音效');
|
||
expect(floatingPanel()?.textContent ?? '').not.toContain('测试:生成失败');
|
||
expect(floatingPanelPrompt()?.disabled).toBe(false);
|
||
expect(floatingPanelPrompt()?.value ?? '').toBe('');
|
||
|
||
// 在音效面板提交:这是**新的**请求,不能复用背景音乐那条失败请求的任务身份。
|
||
await typeGenerationPrompt(floatingPanel()!, '一段清脆的铃声');
|
||
fireEvent.click(floatingPanelSubmit());
|
||
await settle();
|
||
await waitFor(() => expect(tauri.assetGenerationStarts).toHaveLength(2));
|
||
expect(tauri.assetGenerationStarts[1]!.taskId).not.toBe(
|
||
tauri.assetGenerationStarts[0]!.taskId,
|
||
);
|
||
expect(tauri.assetGenerationStarts[1]).toMatchObject({
|
||
kind: 'sound-effect',
|
||
});
|
||
|
||
// 回到原占位重试:必须复用原任务的操作身份与幂等键(同一 operation 账本)。
|
||
fireEvent.click(placeholderElement(draftId)!);
|
||
await settle();
|
||
fireEvent.click(floatingPanelSubmit());
|
||
await settle();
|
||
await waitFor(() => expect(tauri.assetGenerationStarts).toHaveLength(3));
|
||
expect(tauri.assetGenerationStarts[2]).toMatchObject({
|
||
taskId: tauri.assetGenerationStarts[0]!.taskId,
|
||
idempotencyKey: tauri.assetGenerationStarts[0]!.idempotencyKey,
|
||
prompt: tauri.assetGenerationStarts[0]!.prompt,
|
||
});
|
||
});
|
||
|
||
test('失效参考:保留原 ID 并挡住重试,不静默丢参考也不发新请求', async () => {
|
||
const user = userEvent.setup();
|
||
const tauri = installHostTauri({
|
||
assets: [imageAsset('ref-img', 'ref.png')],
|
||
assetGenerationRecord: 'failed',
|
||
});
|
||
render(
|
||
<HostWorkbenchWithReferenceRemoval
|
||
assets={[imageAsset('ref-img', 'ref.png')]}
|
||
referenceAssetId="ref-img"
|
||
/>,
|
||
);
|
||
await settle();
|
||
|
||
await openResourceCategory('角色');
|
||
fireEvent.click(toolbarToolButton('生成图片'));
|
||
await settle();
|
||
const draftId =
|
||
allPlaceholders()[0]?.dataset.resourceCanvasGenerationPlaceholder ?? '';
|
||
expect(draftId).not.toBe('');
|
||
|
||
// 真选一张参考图:候选来自当前清单。
|
||
await typeGenerationPrompt(floatingPanel()!, '画一只猫');
|
||
const panel = floatingPanel()!;
|
||
await user.click(
|
||
within(panel).getByRole('button', { name: '插入素材引用' }),
|
||
);
|
||
const picker = await screen.findByRole('dialog', { name: '选择素材' });
|
||
await user.click(within(picker).getByRole('option', { name: /ref/ }));
|
||
await user.click(within(picker).getByRole('button', { name: '插入引用' }));
|
||
await waitFor(() =>
|
||
expect(within(floatingPanel()!).getByText('参考图 1/5')).not.toBeNull(),
|
||
);
|
||
|
||
fireEvent.click(floatingPanelSubmit());
|
||
await settle();
|
||
expect(tauri.assetGenerationStarts).toHaveLength(1);
|
||
expect(tauri.assetGenerationStarts[0]!.referenceAssetIds).toEqual([
|
||
'ref-img',
|
||
]);
|
||
await waitFor(() =>
|
||
expect(
|
||
placeholderElement(draftId)?.dataset
|
||
.resourceCanvasGenerationPlaceholderStatus,
|
||
).toBe('failed'),
|
||
);
|
||
|
||
// 参考素材被删掉之后重试:失效参考必须留在草稿里并被显式报出来。
|
||
fireEvent.click(screen.getByRole('button', { name: '删除参考素材' }));
|
||
await settle();
|
||
fireEvent.click(placeholderElement(draftId)!);
|
||
await settle();
|
||
|
||
const problem = floatingPanel()?.querySelector(
|
||
'[data-resource-canvas-generation-reference-problem]',
|
||
);
|
||
expect(problem?.textContent ?? '').toContain('已不在当前项目');
|
||
const submit = floatingPanelSubmit();
|
||
expect(submit.disabled).toBe(true);
|
||
fireEvent.click(submit);
|
||
await settle();
|
||
expect(tauri.assetGenerationStarts).toHaveLength(1);
|
||
});
|
||
|
||
test('已提交请求的重试:删掉失效参考再提交零新增请求,恢复原参考后才允许重试', async () => {
|
||
const user = userEvent.setup();
|
||
const tauri = installHostTauri({
|
||
assets: [imageAsset('ref-img', 'ref.png')],
|
||
assetGenerationRecord: 'failed',
|
||
});
|
||
render(
|
||
<HostWorkbenchWithReferenceRemoval
|
||
assets={[imageAsset('ref-img', 'ref.png')]}
|
||
referenceAssetId="ref-img"
|
||
/>,
|
||
);
|
||
await settle();
|
||
|
||
await openResourceCategory('角色');
|
||
fireEvent.click(toolbarToolButton('生成图片'));
|
||
await settle();
|
||
const draftId =
|
||
allPlaceholders()[0]?.dataset.resourceCanvasGenerationPlaceholder ?? '';
|
||
await typeGenerationPrompt(floatingPanel()!, '画一只猫');
|
||
const panel = floatingPanel()!;
|
||
await user.click(
|
||
within(panel).getByRole('button', { name: '插入素材引用' }),
|
||
);
|
||
const picker = await screen.findByRole('dialog', { name: '选择素材' });
|
||
await user.click(within(picker).getByRole('option', { name: /ref/ }));
|
||
await user.click(within(picker).getByRole('button', { name: '插入引用' }));
|
||
await waitFor(() =>
|
||
expect(within(floatingPanel()!).getByText('参考图 1/5')).not.toBeNull(),
|
||
);
|
||
|
||
fireEvent.click(floatingPanelSubmit());
|
||
await settle();
|
||
expect(tauri.assetGenerationStarts).toHaveLength(1);
|
||
const originalPrompt = tauri.assetGenerationStarts[0]!.prompt;
|
||
await waitFor(() =>
|
||
expect(
|
||
placeholderElement(draftId)?.dataset
|
||
.resourceCanvasGenerationPlaceholderStatus,
|
||
).toBe('failed'),
|
||
);
|
||
|
||
// 素材被删掉后重开重试面板:草稿里的失效参考被显式保留并挡住提交。
|
||
fireEvent.click(screen.getByRole('button', { name: '删除参考素材' }));
|
||
await settle();
|
||
fireEvent.click(placeholderElement(draftId)!);
|
||
await settle();
|
||
expect(
|
||
floatingPanel()?.querySelector(
|
||
'[data-resource-canvas-generation-reference-problem]',
|
||
),
|
||
).not.toBeNull();
|
||
|
||
/*
|
||
用户直接在正文里删掉 `@引用`:参考集合变空、失效参考的问题随之消失,但**输入已经不是
|
||
原请求的那份**——原生按指纹当新请求处理,也就是一次新的付费生成。这里必须挡住,
|
||
而不是让「旧 draft 悄悄变成新收费」。
|
||
*/
|
||
await typeGenerationPrompt(floatingPanel()!, '画一只猫');
|
||
expect(
|
||
floatingPanel()?.querySelector(
|
||
'[data-resource-canvas-generation-bound-request-changed]',
|
||
),
|
||
).not.toBeNull();
|
||
const blockedSubmit = floatingPanelSubmit();
|
||
expect(blockedSubmit.disabled).toBe(true);
|
||
fireEvent.click(blockedSubmit);
|
||
// 绕过按钮禁用直接提交表单也一样:处理函数自己再挡一次。
|
||
fireEvent.submit(floatingPanel()!.querySelector('form')!);
|
||
await settle();
|
||
expect(tauri.assetGenerationStarts).toHaveLength(1);
|
||
|
||
// 恢复原参考(同一 id)+ 重开面板拿回冻结草稿:这才是原请求的重试。
|
||
fireEvent.click(screen.getByRole('button', { name: '恢复参考素材' }));
|
||
await settle();
|
||
fireEvent.click(screen.getByRole('button', { name: '取消' }));
|
||
await settle();
|
||
fireEvent.click(placeholderElement(draftId)!);
|
||
await settle();
|
||
const retrySubmit = floatingPanelSubmit();
|
||
expect(retrySubmit.disabled).toBe(false);
|
||
fireEvent.click(retrySubmit);
|
||
await settle();
|
||
expect(tauri.assetGenerationStarts).toHaveLength(2);
|
||
expect(tauri.assetGenerationStarts[1]!.referenceAssetIds).toEqual([
|
||
'ref-img',
|
||
]);
|
||
expect(tauri.assetGenerationStarts[1]!.prompt).toBe(originalPrompt);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* 验收现场那条「拖动未生成的资源卡片会触发点击事件」(至少背景音乐与音效会)。
|
||
*
|
||
* 浏览器在 pointerdown 与 pointerup 落在同一个节点上时**一定会补一次 `click`**——拖完卡片
|
||
* 松手,紧跟着的那次 click 会把卡片自己的点击语义再执行一遍。占位卡的点击语义就是开合生成浮层,
|
||
* 所以表现为「拖一下就把浮层弹开 / 收起」。这里从真实宿主上钉住两件事:拖动收尾的 click 被吃掉,
|
||
* 而一次真正的点击仍然照常开合(抑制只活过一次手势)。
|
||
*/
|
||
describe('拖动生成占位卡不触发卡片的点击(宿主回归)', () => {
|
||
test('拖动收尾的那次 click 被吃掉,普通点击仍然照常开合浮层', async () => {
|
||
const tauri = installHostTauri({
|
||
assets: [seedBgmAsset('seed-bgm')],
|
||
});
|
||
render(<HostWorkbench assets={[seedBgmAsset('seed-bgm')]} />);
|
||
await settle();
|
||
|
||
const { draftId } = await openBgmEntry();
|
||
// 点入口时浮层是开着的(占位与浮层由同一个动作产生)。
|
||
expect(floatingPanel()).not.toBeNull();
|
||
|
||
const placeholder = placeholderElement(draftId)!;
|
||
fireEvent.pointerDown(placeholder, {
|
||
pointerId: 7,
|
||
button: 0,
|
||
isPrimary: true,
|
||
clientX: 200,
|
||
clientY: 200,
|
||
});
|
||
fireEvent.pointerMove(placeholder, {
|
||
pointerId: 7,
|
||
clientX: 260,
|
||
clientY: 230,
|
||
});
|
||
/*
|
||
* 真实鼠标的拖动会横跨很多轮宏任务(pointermove 一次次派发):这里让时钟真的走一轮再松手。
|
||
* 抑制的计时起点必须在松手那一刻;若实现改回「越过阈值时就起 0ms 计时器」,这一轮之后抑制
|
||
* 就已经被清掉,紧随其后的那次 click 会把浮层关掉,下面的断言立刻红。
|
||
*/
|
||
await settle();
|
||
fireEvent.pointerUp(placeholder, {
|
||
pointerId: 7,
|
||
clientX: 260,
|
||
clientY: 230,
|
||
});
|
||
/*
|
||
* 真实浏览器在 pointerup 之后会自动释放指针捕获并补一个 `lostpointercapture`(卡片把它接到
|
||
* 取消手势上)。它必须**不能**把这次拖动的点击抑制一起清掉——否则补上来的 click 又会漏过去。
|
||
*/
|
||
fireEvent.lostPointerCapture(placeholder, { pointerId: 7 });
|
||
// 浏览器补的那一次 click:抑制没生效时这里会立刻把浮层关掉。
|
||
fireEvent.click(placeholder);
|
||
await settle();
|
||
expect(floatingPanel()).not.toBeNull();
|
||
|
||
// 对照:没移动的点击是「点击」,仍然开合浮层(抑制不是把点击永久关掉)。
|
||
fireEvent.click(placeholder);
|
||
await settle();
|
||
expect(floatingPanel()).toBeNull();
|
||
// 拖动本身照常生效(拖动只改坐标,不取消这次生成)。
|
||
expect(placeholderElement(draftId)).not.toBeNull();
|
||
expect(tauri.assetGenerationStarts).toEqual([]);
|
||
}, 20_000);
|
||
});
|