Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasGenerationHostLifecycle.test.tsx
T
suzmii cd8f480719 修复生成草稿隔离与回合计时恢复及布局排队
按占位身份隔离生成面板并保存草稿,阻止改变失败请求输入后伪装重试。
保留用户真实发送时间,按既有用户条目身份恢复空运行态回合的终态边界。
修复跨栏目整理队列覆盖及多选拖动预览起点漂移。
补齐六项 review 发现的回归、原生绑定、交叉审查及浏览器验收记录。
2026-09-18 13:25:18 +08:00

861 lines
30 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[];
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);
});
});