cd8f480719
按占位身份隔离生成面板并保存草稿,阻止改变失败请求输入后伪装重试。 保留用户真实发送时间,按既有用户条目身份恢复空运行态回合的终态边界。 修复跨栏目整理队列覆盖及多选拖动预览起点漂移。 补齐六项 review 发现的回归、原生绑定、交叉审查及浏览器验收记录。
861 lines
30 KiB
TypeScript
861 lines
30 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[];
|
||
deriveResults: Array<
|
||
| { ok: true; assetId: string; hold?: boolean }
|
||
| { ok: false; message: string }
|
||
>;
|
||
/**
|
||
* 图片类入口(`start_local_project_asset_generation`)的账本收口方式。
|
||
*
|
||
* 不传时这条命令不被这些用例触发(音频入口走 `derive_local_project_resource`);
|
||
* `'failed'` 让队列在第一次轮询就看到一条失败记录,用来观察失败态重试。
|
||
*/
|
||
assetGenerationRecord?: 'failed';
|
||
}) {
|
||
const layoutWrites: LayoutWrite[] = [];
|
||
const deriveCalls: Array<Record<string, unknown>> = [];
|
||
const assetGenerationStarts: Array<Record<string, unknown>> = [];
|
||
const unexpectedCommands: string[] = [];
|
||
let revision = 0;
|
||
let releaseHeldDerive: (() => 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') {
|
||
return {
|
||
nodes: [],
|
||
taskFlowIds: [],
|
||
producerAssignments: [],
|
||
dependencyDepths: options.assets.map((asset) => ({
|
||
resourceId: `asset:${asset.id}`,
|
||
dependencyDepth: 0,
|
||
})),
|
||
unresolvedReferenceResourceIds: [],
|
||
cyclicResourceIds: [],
|
||
cyclicTaskIds: [],
|
||
producerMappingTruncated: false,
|
||
};
|
||
}
|
||
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') {
|
||
const input = args?.input as Record<string, unknown>;
|
||
deriveCalls.push(input);
|
||
const planned = options.deriveResults.shift();
|
||
if (!planned || !planned.ok) {
|
||
throw new Error(planned?.message ?? '测试:没有预置生成结果');
|
||
}
|
||
// `hold`:把这次生成停在后台运行中,用来观察占位的 `submitted` 中间态。
|
||
if (planned.hold) {
|
||
await new Promise<void>((resolve) => {
|
||
releaseHeldDerive = resolve;
|
||
});
|
||
}
|
||
const asset = bgmAsset(planned.assetId);
|
||
return {
|
||
asset,
|
||
manifest: manifestFor(PROJECT_ID, [...options.assets, asset]),
|
||
committedProjectRevision: 2,
|
||
operationId: input.operationId,
|
||
};
|
||
}
|
||
if (command === 'list_pending_local_project_resource_edits') {
|
||
return [];
|
||
}
|
||
if (command === 'start_local_project_asset_generation') {
|
||
const startArgs = args ?? {};
|
||
assetGenerationStarts.push(startArgs);
|
||
return {
|
||
taskId: String(startArgs.taskId ?? ''),
|
||
projectId: PROJECT_ID,
|
||
kind: String(startArgs.kind ?? ''),
|
||
assetName: String(startArgs.assetName ?? ''),
|
||
referenceAssetIds: Array.isArray(startArgs.referenceAssetIds)
|
||
? startArgs.referenceAssetIds
|
||
: [],
|
||
status: 'queued',
|
||
phaseDetail: '已受理',
|
||
startedAtMillis: 1,
|
||
finishedAtMillis: null,
|
||
assetId: null,
|
||
error: null,
|
||
};
|
||
}
|
||
if (command === 'list_local_project_asset_generations') {
|
||
return assetGenerationStarts.map((started) => ({
|
||
...started,
|
||
status: options.assetGenerationRecord ?? 'queued',
|
||
phaseDetail:
|
||
options.assetGenerationRecord === 'failed' ? '生成失败' : '已受理',
|
||
finishedAtMillis:
|
||
options.assetGenerationRecord === 'failed' ? 2 : null,
|
||
error:
|
||
options.assetGenerationRecord === 'failed'
|
||
? '测试:生成失败'
|
||
: null,
|
||
}));
|
||
}
|
||
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,
|
||
unexpectedCommands,
|
||
releaseHeldDerive: () => releaseHeldDerive?.(),
|
||
};
|
||
}
|
||
|
||
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')],
|
||
deriveResults: [{ ok: true, assetId: NEW_ASSET_ID, hold: true }],
|
||
});
|
||
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.deriveCalls).toEqual([]);
|
||
|
||
fireEvent.click(panelSubmitButton('生成背景音乐'));
|
||
await settle();
|
||
|
||
// 提交一次:走无源生成(create)链路;后台还在跑,占位收口为 submitted。
|
||
expect(tauri.deriveCalls).toHaveLength(1);
|
||
expect(tauri.deriveCalls[0]).toMatchObject({
|
||
editKind: 'background-music',
|
||
generationMode: 'create',
|
||
expectedProjectId: PROJECT_ID,
|
||
});
|
||
await waitFor(() =>
|
||
expect(
|
||
placeholderElement(draftId)?.dataset
|
||
.resourceCanvasGenerationPlaceholderStatus,
|
||
).toBe('submitted'),
|
||
);
|
||
|
||
// 放行后台任务:结果入库。
|
||
await act(async () => {
|
||
tauri.releaseHeldDerive();
|
||
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`);
|
||
});
|
||
await waitFor(() => expect(allPlaceholders()).toHaveLength(0));
|
||
});
|
||
|
||
test('失败后用同一份请求重试:复用同一个 operationId 与幂等键', async () => {
|
||
const tauri = installHostTauri({
|
||
assets: [seedBgmAsset('seed-bgm')],
|
||
deriveResults: [
|
||
{ ok: false, message: 'result-unknown: 测试网络中断' },
|
||
{ ok: true, assetId: NEW_ASSET_ID },
|
||
],
|
||
});
|
||
render(<HostWorkbench assets={[seedBgmAsset('seed-bgm')]} />);
|
||
await settle();
|
||
|
||
const { draftId } = await openBgmEntry();
|
||
fireEvent.click(panelSubmitButton('生成背景音乐'));
|
||
await settle();
|
||
expect(tauri.deriveCalls).toHaveLength(1);
|
||
// 失败:占位留着(输入与操作身份都还在),状态收口为 failed。
|
||
await waitFor(() =>
|
||
expect(
|
||
placeholderElement(draftId)?.dataset
|
||
.resourceCanvasGenerationPlaceholderStatus,
|
||
).toBe('failed'),
|
||
);
|
||
|
||
// 面板上的原请求重试:同一次生成,不该变成第二次付费请求。
|
||
const retry = screen
|
||
.getAllByRole('button')
|
||
.find((button) => button.textContent?.includes('重试'));
|
||
expect(retry).toBeDefined();
|
||
fireEvent.click(retry!);
|
||
await settle();
|
||
|
||
await waitFor(() => expect(tauri.deriveCalls).toHaveLength(2));
|
||
expect(tauri.deriveCalls[1]).toMatchObject({
|
||
operationId: tauri.deriveCalls[0]!.operationId,
|
||
idempotencyKey: tauri.deriveCalls[0]!.idempotencyKey,
|
||
prompt: tauri.deriveCalls[0]!.prompt,
|
||
});
|
||
});
|
||
});
|
||
|
||
/**
|
||
* 浮层**按占位隔离**的宿主回归。
|
||
*
|
||
* 浮层是非模态的:面板开着时用户仍能点另一条工具、另一张占位卡。这组用例钉住三件在宿主里才能
|
||
* 看到的事:
|
||
* 1. 换到另一张占位必须换一份面板状态(类型、输入、失败原因都不跟着走),而**未提交草稿必须留下**;
|
||
* 2. 失败请求的操作身份只属于它自己那张占位:在别的占位上提交是一次新请求,回到原占位才是重试;
|
||
* 3. 参考素材被删掉后重试:失效参考必须显式呈现并挡住提交,不许静默丢参考、也不许再发一次请求。
|
||
*/
|
||
describe('生成浮层按占位隔离(宿主回归)', () => {
|
||
test('同类不同草稿:切到另一条音频工具是新面板,切回来原草稿还在', async () => {
|
||
const tauri = installHostTauri({
|
||
assets: [seedBgmAsset('seed-bgm')],
|
||
deriveResults: [],
|
||
});
|
||
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')],
|
||
deriveResults: [
|
||
{ ok: false, message: 'result-unknown: 测试网络中断' },
|
||
{ ok: true, assetId: NEW_ASSET_ID },
|
||
],
|
||
});
|
||
render(<HostWorkbench assets={[seedBgmAsset('seed-bgm')]} />);
|
||
await settle();
|
||
|
||
const { draftId } = await openBgmEntry();
|
||
fireEvent.click(floatingPanelSubmit());
|
||
await settle();
|
||
expect(tauri.deriveCalls).toHaveLength(1);
|
||
await waitFor(() =>
|
||
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();
|
||
expect(tauri.deriveCalls).toHaveLength(2);
|
||
expect(tauri.deriveCalls[1]!.operationId).not.toBe(
|
||
tauri.deriveCalls[0]!.operationId,
|
||
);
|
||
expect(tauri.deriveCalls[1]).toMatchObject({ editKind: 'sound-effect' });
|
||
|
||
// 回到原占位重试:必须复用原请求的操作身份与幂等键(同一 operation 账本)。
|
||
fireEvent.click(placeholderElement(draftId)!);
|
||
await settle();
|
||
fireEvent.click(floatingPanelSubmit());
|
||
await settle();
|
||
expect(tauri.deriveCalls).toHaveLength(3);
|
||
expect(tauri.deriveCalls[2]).toMatchObject({
|
||
operationId: tauri.deriveCalls[0]!.operationId,
|
||
idempotencyKey: tauri.deriveCalls[0]!.idempotencyKey,
|
||
prompt: tauri.deriveCalls[0]!.prompt,
|
||
});
|
||
});
|
||
|
||
test('失效参考:保留原 ID 并挡住重试,不静默丢参考也不发新请求', async () => {
|
||
const user = userEvent.setup();
|
||
const tauri = installHostTauri({
|
||
assets: [imageAsset('ref-img', 'ref.png')],
|
||
deriveResults: [],
|
||
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')],
|
||
deriveResults: [],
|
||
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);
|
||
});
|
||
});
|