Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasGenerationHostLifecycle.test.tsx
T
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

1079 lines
40 KiB
TypeScript
Raw 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 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);
});