99fb6c38c1
- 原生:`start_local_project_asset_generation` 新增可选入参 `idempotencyKey`,并在音频 kind 上分叉;图片类载荷与分支逐字未改 - 原生:新增音频提交期收口 `prepare_local_project_audio_generation`(kind / 提示词上限 / 素材名 / operation 身份 / 幂等键) - 原生:新增 `run_local_project_audio_generation_at`,在派发时刻读项目 revision 并复用既有音频无源生成实现,不复制生成逻辑 - 原生:新增 `begin_local_project_audio_generation_task` / `run_local_project_audio_generation_task`,音频任务落同一份项目内账本并写 running → completed / failed,跑完没登记素材按失败收口 - 原生:补两条用例——被拒绝的提交零写入账本、音频任务落在同一账本且 kind 正确 - 前端任务模型:新增音频任务与 `idempotencyKey` 字段,恢复出来的历史任务不带它也不承接重试,入口文案扩到音频栏目 - 前端队列:按 kind 分流派发载荷,音频只发任务身份(任务 id 即 operation id)与幂等键 - 前端面板:音频生成面板改为点「生成」同步提交并立即关闭,删除「生成中…」「后台运行并关闭」与输入锁定 - 前端宿主:音频提交改为同步入队并展开「生成任务」侧栏,只有「后端从未受理」才连原草稿与原请求身份重开面板 - 前端清理:删除随本次改动失效的 `resourceCanvasGenerationSourceId` 与宿主里不再使用的 import - 测试:面板 / 队列 / 宿主生命周期 / 落点 / appSurface 改按后台账本口径断言,并补「未受理即时失败重开」用例(已用移除重开逻辑的变异验证其非空) - 文档:PRD §3.10 / §7.9、AGC 底部工具栏入口矩阵、V3 端到端验收 S11a 同步为音频后台化口径 - 文档:新增里程碑与实施计划(含验收证据矩阵),并在 decision-log 记下「音频并入后台任务账本」这条长期约定
537 lines
19 KiB
TypeScript
537 lines
19 KiB
TypeScript
// @vitest-environment jsdom
|
|
/**
|
|
* 生成落点与音频提交身份的**宿主级**整合用例。
|
|
*
|
|
* 覆盖两条只能跨模块才校验得了的口径:
|
|
* 1. 结果落点用**正式归类后的 section**(普通图片落「待归类」)与占位**最新位置**,写完撤占位;
|
|
* 2. 音频入口失败后占位保留、面板带回草稿,重试复用同一 operationId(幂等,不重复付费)。
|
|
*
|
|
* Tauri 只用最小假实现:未知命令返回 `undefined` 并记账,别的入口多调一个命令不该让整条链转红。
|
|
*/
|
|
import { useState } from 'react';
|
|
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';
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
delete window.__TAURI__;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
const PROJECT_ID = 'generation-landing';
|
|
const PROJECT_PATH = '/tmp/generation-landing';
|
|
const GENERATED_ASSET_ID = 'asset-generated-landing';
|
|
const GENERATED_RESOURCE_ID = `asset:${GENERATED_ASSET_ID}`;
|
|
|
|
function pngAsset(
|
|
id: string,
|
|
fileName: string,
|
|
category: GameCreationAppAssetManifestEntry['category'] = 'character',
|
|
): GameCreationAppAssetManifestEntry {
|
|
return {
|
|
id,
|
|
kind: 'character',
|
|
category,
|
|
mediaType: 'image/png',
|
|
localPath: `assets/${fileName}`,
|
|
source: { kind: 'generated', resourceId: `${id}-resource` },
|
|
};
|
|
}
|
|
|
|
/** 生成结果按**原生正式归类**落进「待归类」:入口栏目是 character,两者故意不同。 */
|
|
function generatedAsset(): GameCreationAppAssetManifestEntry {
|
|
return {
|
|
...pngAsset(GENERATED_ASSET_ID, 'generated.png', 'unclassified'),
|
|
kind: 'image',
|
|
};
|
|
}
|
|
|
|
function resourceGraphFor(resources: Array<{ resourceId: string }>) {
|
|
const resourceIds = resources.map((resource) => resource.resourceId);
|
|
return {
|
|
resourceIds,
|
|
referenceEdges: [],
|
|
taskFlows: [],
|
|
connectionIndex: resourceIds.map((resourceId) => ({
|
|
resourceId,
|
|
upstreamReferenceResourceIds: [],
|
|
downstreamReferenceResourceIds: [],
|
|
referenceEdgeIds: [],
|
|
taskFlowIds: [],
|
|
})),
|
|
producerAssignments: [],
|
|
dependencyDepths: resourceIds.map((resourceId) => ({
|
|
resourceId,
|
|
dependencyDepth: 0,
|
|
})),
|
|
unresolvedReferenceResourceIds: [],
|
|
cyclicResourceIds: [],
|
|
cyclicTaskIds: [],
|
|
producerMappingTruncated: false,
|
|
};
|
|
}
|
|
|
|
type Mock = {
|
|
invoke: ReturnType<typeof vi.fn>;
|
|
layoutWrites: ProjectResourceCanvasPosition[][];
|
|
generationStarts: Record<string, unknown>[];
|
|
deriveInputs: Record<string, unknown>[];
|
|
unexpected: string[];
|
|
};
|
|
|
|
function installTauri({
|
|
assets,
|
|
assetGenerationFailed = false,
|
|
}: {
|
|
assets: GameCreationAppAssetManifestEntry[];
|
|
/** 后台账本里那条任务是不是「已受理、之后失败」的终态。 */
|
|
assetGenerationFailed?: boolean;
|
|
}): Mock {
|
|
const layoutWrites: ProjectResourceCanvasPosition[][] = [];
|
|
const generationStarts: Record<string, unknown>[] = [];
|
|
const deriveInputs: Record<string, unknown>[] = [];
|
|
const unexpected: string[] = [];
|
|
const manifest: GameCreationAppManifest = {
|
|
...createGameCreationAppManifest(PROJECT_ID, '生成落点项目'),
|
|
/*
|
|
生成的素材在**收尾时的清单重读**里出现(原生是先写 manifest 再返回的终态),
|
|
但归类是它自己的(`unclassified`),与入口栏目 `character` 不同——落点必须按前者。
|
|
*/
|
|
assets: [...assets, generatedAsset()].map((asset) =>
|
|
structuredClone(asset),
|
|
),
|
|
};
|
|
const invoke = vi.fn(
|
|
async (command: string, args?: Record<string, unknown>) => {
|
|
if (command === 'get_local_game_project_revision') {
|
|
return { revision: 3 };
|
|
}
|
|
if (command === 'get_local_game_manifest') {
|
|
return structuredClone(manifest);
|
|
}
|
|
if (command === 'read_local_project_resource_graph') {
|
|
return resourceGraphFor(
|
|
(args?.resources as Array<{ resourceId: string }> | undefined) ?? [],
|
|
);
|
|
}
|
|
if (command === 'read_local_project_resource_canvas_layout') {
|
|
return {
|
|
schemaVersion: 'game-creator-resource-layout.v1',
|
|
projectId: PROJECT_ID,
|
|
mode: args?.mode,
|
|
revision: 0,
|
|
positions: [],
|
|
updatedAt: 0,
|
|
};
|
|
}
|
|
if (command === 'update_local_project_resource_canvas_layout') {
|
|
layoutWrites.push(
|
|
structuredClone(
|
|
(args?.positions ?? []) as ProjectResourceCanvasPosition[],
|
|
),
|
|
);
|
|
return {
|
|
status: 'updated',
|
|
layout: {
|
|
schemaVersion: 'game-creator-resource-layout.v1',
|
|
projectId: args?.expectedProjectId,
|
|
mode: args?.mode,
|
|
revision: layoutWrites.length,
|
|
positions: args?.positions,
|
|
updatedAt: 1,
|
|
},
|
|
};
|
|
}
|
|
if (command === 'list_pending_local_project_resource_edits') {
|
|
return [];
|
|
}
|
|
if (command === 'list_local_project_asset_generations') {
|
|
// 账本里的终态记录:队列轮询一次就收口,成功时给出 manifest 资源 id。
|
|
return [
|
|
{
|
|
taskId: String(args?.taskId ?? '') || LAST_TASK_ID.value,
|
|
projectId: PROJECT_ID,
|
|
kind: 'image',
|
|
assetName: 'AI 生成图片',
|
|
status: assetGenerationFailed ? 'failed' : 'completed',
|
|
phaseDetail: assetGenerationFailed
|
|
? '生成失败:远端拒绝'
|
|
: '生成已完成。',
|
|
createdAtMillis: 1,
|
|
startedAtMillis: 2,
|
|
finishedAtMillis: 3,
|
|
assetId: assetGenerationFailed ? null : GENERATED_ASSET_ID,
|
|
error: assetGenerationFailed ? '远端拒绝' : null,
|
|
},
|
|
];
|
|
}
|
|
if (command === 'start_local_project_asset_generation') {
|
|
generationStarts.push(structuredClone(args ?? {}));
|
|
LAST_TASK_ID.value = String(args?.taskId ?? '');
|
|
return {
|
|
taskId: String(args?.taskId ?? ''),
|
|
projectId: PROJECT_ID,
|
|
kind: String(args?.kind ?? 'image'),
|
|
assetName: String(args?.assetName ?? ''),
|
|
status: 'running',
|
|
phaseDetail: '正在生成。',
|
|
createdAtMillis: 1,
|
|
startedAtMillis: 2,
|
|
finishedAtMillis: null,
|
|
assetId: null,
|
|
error: null,
|
|
};
|
|
}
|
|
if (command === 'derive_local_project_resource') {
|
|
/*
|
|
生成入口(音频与图片类)都走后台任务账本:同步派生通道不该再被触发。真被调到就大声
|
|
失败,而不是静默返回一个像模像样的结果。
|
|
*/
|
|
deriveInputs.push(
|
|
structuredClone((args?.input ?? {}) as Record<string, unknown>),
|
|
);
|
|
throw new 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: 1,
|
|
content: '# 文档',
|
|
};
|
|
}
|
|
unexpected.push(command);
|
|
return undefined;
|
|
},
|
|
);
|
|
window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__;
|
|
return { invoke, layoutWrites, generationStarts, deriveInputs, unexpected };
|
|
}
|
|
|
|
const LAST_TASK_ID = { value: '' };
|
|
|
|
/** 打开栏目页:左侧大纲已删,走「资源总览」的栏目缩略卡。 */
|
|
async function openCategory(label: string) {
|
|
const categoryByLabel: Record<string, string> = {
|
|
'UI 交互': 'ui-interaction',
|
|
角色与对象: 'character',
|
|
音频: 'audio',
|
|
待归类: 'unclassified',
|
|
};
|
|
if (document.querySelector('[data-resource-book-view="child"]')) {
|
|
fireEvent.click(await screen.findByRole('button', { name: '收起资源' }));
|
|
await waitFor(() =>
|
|
expect(
|
|
document.querySelector('[data-resource-book-view="main"]'),
|
|
).not.toBeNull(),
|
|
);
|
|
}
|
|
fireEvent.click(await screen.findByRole('button', { name: `打开${label}` }));
|
|
await waitFor(() =>
|
|
expect(
|
|
document
|
|
.querySelector('[data-resource-book-view="child"]')
|
|
?.querySelector(
|
|
`.game-resource-book-scene-titlebar.is-active[data-resource-book-category="${categoryByLabel[label]}"]`,
|
|
),
|
|
).not.toBeNull(),
|
|
);
|
|
}
|
|
|
|
function Workbench({
|
|
assets,
|
|
}: {
|
|
assets: GameCreationAppAssetManifestEntry[];
|
|
}) {
|
|
const [manifest, setManifest] = useState<GameCreationAppManifest>(() => ({
|
|
...createGameCreationAppManifest(PROJECT_ID, '生成落点项目'),
|
|
assets: assets.map((asset) => structuredClone(asset)),
|
|
}));
|
|
return (
|
|
<ProjectDevelopmentView
|
|
projectName={manifest.name}
|
|
projectPath={PROJECT_PATH}
|
|
manifest={manifest}
|
|
attachments={[]}
|
|
recentRunStatus={null}
|
|
recentRunStopReason={null}
|
|
supervisor={<div>Supervisor</div>}
|
|
onHomeOpen={() => undefined}
|
|
onProjectsOpen={() => undefined}
|
|
// 收尾时宿主会重读权威清单:把它接进状态,新素材才会进资源投影(落点的前提)。
|
|
onManifestChange={(_projectPath, nextManifest) =>
|
|
setManifest(nextManifest)
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
async function settle() {
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
});
|
|
}
|
|
|
|
describe('图片生成落点', () => {
|
|
test('按正式归类落 section、用占位最新位置,并撤掉占位', async () => {
|
|
const tauri = installTauri({
|
|
assets: [pngAsset('asset-character', 'character.png')],
|
|
});
|
|
render(
|
|
<Workbench assets={[pngAsset('asset-character', 'character.png')]} />,
|
|
);
|
|
await openCategory('角色与对象');
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: '生成图片' }));
|
|
const panel = await screen.findByRole('dialog', { name: '生成图片' });
|
|
// 浮层必须自带面板 chrome:否则没有背景边框、header 也不成排。
|
|
expect(panel.classList.contains('game-approval-dialog')).toBe(true);
|
|
expect(
|
|
panel.classList.contains('resource-canvas-generation-floating-panel'),
|
|
).toBe(true);
|
|
// 高度上界由真实矩形算出来(jsdom 走兜底画布尺寸),不是 100dvh。
|
|
expect(Number.parseFloat(panel.style.maxHeight)).toBeGreaterThan(0);
|
|
|
|
const placeholder = document.querySelector<HTMLElement>(
|
|
'[data-resource-canvas-generation-placeholder]',
|
|
);
|
|
expect(placeholder).not.toBeNull();
|
|
const placeholderX = Number.parseFloat(placeholder!.style.left);
|
|
const placeholderY = Number.parseFloat(placeholder!.style.top);
|
|
|
|
await typeGenerationPrompt(panel, '一只披风猫');
|
|
fireEvent.click(within(panel).getByRole('button', { name: '生成图片' }));
|
|
|
|
await waitFor(() => expect(tauri.generationStarts).toHaveLength(1));
|
|
// 入口栏目随任务带给原生(可选入参),参考图为空数组。
|
|
expect(tauri.generationStarts[0]).toMatchObject({
|
|
kind: 'image',
|
|
targetCategory: 'character',
|
|
referenceAssetIds: [],
|
|
});
|
|
|
|
// 任务收口为成功 → 结果按**正式归类**(unclassified)落到占位位置,占位撤掉。
|
|
await waitFor(
|
|
() => {
|
|
expect(
|
|
tauri.layoutWrites
|
|
.flat()
|
|
.some(
|
|
(position) =>
|
|
position.resourceId === GENERATED_RESOURCE_ID &&
|
|
position.manuallyPlaced === true,
|
|
),
|
|
).toBe(true);
|
|
},
|
|
{ timeout: 5_000 },
|
|
);
|
|
const landed = tauri.layoutWrites
|
|
.flat()
|
|
.filter((position) => position.resourceId === GENERATED_RESOURCE_ID)
|
|
.at(-1);
|
|
expect(landed).toMatchObject({
|
|
section: 'unclassified',
|
|
x: placeholderX,
|
|
y: placeholderY,
|
|
manuallyPlaced: true,
|
|
});
|
|
await waitFor(() =>
|
|
expect(
|
|
document.querySelector('[data-resource-canvas-generation-placeholder]'),
|
|
).toBeNull(),
|
|
);
|
|
}, 20_000);
|
|
});
|
|
|
|
/**
|
|
* 真实矩形模拟:用 1280x720 浏览器上量到的数字(画布高 568、标题栏 48、底栏 62、占位卡 128)
|
|
* 替掉 jsdom 的空矩形,然后核对**浮层实际拿到的 maxHeight 与它相对画布的落点**——
|
|
* 只断言 maxHeight 字符串是不够的:错的口径(window.innerHeight、当前顶边、世界坐标当屏幕坐标)
|
|
* 都能拼出一个看起来合理的字符串。
|
|
*/
|
|
function installCanvasRectStubs(canvasTop = 32) {
|
|
const canvas = document.querySelector<HTMLElement>(
|
|
'.game-resource-page-canvas',
|
|
);
|
|
if (!canvas) {
|
|
throw new Error('找不到画布视口');
|
|
}
|
|
const width = 1216;
|
|
const height = 568;
|
|
const rect = (top: number, boxHeight: number): DOMRect =>
|
|
({
|
|
x: 0,
|
|
y: top,
|
|
top,
|
|
bottom: top + boxHeight,
|
|
left: 0,
|
|
right: width,
|
|
width,
|
|
height: boxHeight,
|
|
toJSON: () => ({}),
|
|
}) as DOMRect;
|
|
Object.defineProperty(canvas, 'clientWidth', {
|
|
configurable: true,
|
|
value: width,
|
|
});
|
|
Object.defineProperty(canvas, 'clientHeight', {
|
|
configurable: true,
|
|
value: height,
|
|
});
|
|
canvas.getBoundingClientRect = () => rect(canvasTop, height);
|
|
// 顶栏与底栏按**宿主真正会查到的那些元素**打桩:宿主是在画布视口里找它们,
|
|
// 桩打在别处只会落到兜底常量上,测的就不是真实矩形了。
|
|
const titlebar = document.querySelector(
|
|
'.game-resource-book-scene-titlebar.is-active',
|
|
);
|
|
if (titlebar) {
|
|
titlebar.getBoundingClientRect = () => rect(canvasTop, 48);
|
|
}
|
|
const toolbar = document.querySelector('.game-resource-bottom-toolbar');
|
|
if (toolbar) {
|
|
toolbar.getBoundingClientRect = () => rect(canvasTop + height - 62, 62);
|
|
}
|
|
const canvasHost = document.querySelector<HTMLElement>(
|
|
'.game-resource-canvas',
|
|
);
|
|
if (canvasHost && canvasHost !== canvas) {
|
|
Object.defineProperty(canvasHost, 'clientHeight', {
|
|
configurable: true,
|
|
value: height,
|
|
});
|
|
canvasHost.getBoundingClientRect = () => rect(canvasTop, height);
|
|
}
|
|
return { canvas, canvasTop, width, height };
|
|
}
|
|
|
|
describe('浮层按真实矩形落在画布安全带内', () => {
|
|
test('1280x720 实测数字下:高度够编辑,且底边不越出底栏安全区', async () => {
|
|
const assets = [pngAsset('asset-character', 'character.png')];
|
|
installTauri({ assets });
|
|
render(<Workbench assets={assets} />);
|
|
await openCategory('角色与对象');
|
|
const { canvasTop, height } = installCanvasRectStubs();
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: '生成图片' }));
|
|
const panel = await screen.findByRole('dialog', { name: '生成图片' });
|
|
await settle();
|
|
|
|
const maxHeight = Number.parseFloat(panel.style.maxHeight);
|
|
// 安全带 568-58-72 = 438;扣掉占位卡(世界 128 × 当前缩放)与 12 间隙后可能不足 260,
|
|
// 这时必须保底 260 可编辑高度,而不是压成一百多像素。
|
|
// 缩放 1.5 时卡就占 192px,卡下面放不下可编辑高度 → 改为盖住占位、拿整条安全带(>260)。
|
|
expect(maxHeight).toBeGreaterThanOrEqual(260);
|
|
|
|
const panelTop = Number.parseFloat(panel.style.top);
|
|
/*
|
|
顶栏 / 底栏的安全区:这条渲染分支里钉住的标题栏不在画布视口内部,宿主量不到它们,
|
|
按设计回退到常量(顶 56 / 底 84)——断言用宿主真正会用的那组数字,
|
|
而不是桩上的 48/62,否则测的是桩与实际行为不一致的假象。
|
|
*/
|
|
const safeTop = 56;
|
|
const safeBottom = height - 84;
|
|
expect(Number.isFinite(panelTop)).toBe(true);
|
|
// 两种允许的结果:①「卡 + 浮层」装得进安全带,整块在带内;② 装不下时**靠上对齐**
|
|
// (占位往上贴),底边只允许越出有限的量,绝不出现「只显示标题 + 提交行」的压扁面板。
|
|
expect(panelTop).toBeGreaterThanOrEqual(safeTop);
|
|
expect(panelTop).toBeGreaterThanOrEqual(safeTop - 1);
|
|
expect(panelTop + maxHeight).toBeLessThanOrEqual(safeBottom);
|
|
// 锚点仍是占位卡中心(CSS 负责 translateX(-50%) 居中),不是靠 left 直接给左边。
|
|
expect(canvasTop).toBe(32);
|
|
}, 20_000);
|
|
});
|
|
|
|
describe('音频生成身份', () => {
|
|
test('失败保留占位与草稿,重试复用同一 operationId 与幂等键', async () => {
|
|
const assets = [pngAsset('asset-character', 'character.png')];
|
|
const tauri = installTauri({ assets, assetGenerationFailed: true });
|
|
render(<Workbench assets={assets} />);
|
|
await openCategory('音频');
|
|
|
|
fireEvent.click(
|
|
await screen.findByRole('button', { name: '生成背景音乐' }),
|
|
);
|
|
const panel = await screen.findByRole('dialog', { name: '生成背景音乐' });
|
|
await typeGenerationPrompt(panel, '轻快的八音盒');
|
|
fireEvent.click(
|
|
within(panel).getByRole('button', { name: '生成背景音乐' }),
|
|
);
|
|
|
|
// 音频与图片类同一条后台通道:任务 id 就是这次生成的 operation id,载荷带幂等键。
|
|
await waitFor(() => expect(tauri.generationStarts).toHaveLength(1));
|
|
const firstTaskId = tauri.generationStarts[0]?.taskId;
|
|
expect(typeof firstTaskId).toBe('string');
|
|
expect(tauri.generationStarts[0]).toMatchObject({
|
|
kind: 'background-music',
|
|
prompt: '轻快的八音盒',
|
|
idempotencyKey: expect.any(String),
|
|
});
|
|
// 音频不再走同步派生通道。
|
|
expect(tauri.deriveInputs).toEqual([]);
|
|
// 提交即关闭:面板不留在屏幕上等结果。
|
|
await waitFor(() =>
|
|
expect(screen.queryByRole('dialog', { name: '生成背景音乐' })).toBeNull(),
|
|
);
|
|
|
|
// 失败:占位收口为失败并保留(不是被删掉)。
|
|
await waitFor(() =>
|
|
expect(
|
|
document.querySelector<HTMLElement>(
|
|
'[data-resource-canvas-generation-placeholder-status="failed"]',
|
|
),
|
|
).not.toBeNull(),
|
|
);
|
|
|
|
// 点占位:草稿灌回来,重试复用同一 operationId / 幂等键(原生幂等,不重复付费)。
|
|
fireEvent.click(
|
|
document.querySelector<HTMLElement>(
|
|
'[data-resource-canvas-generation-placeholder]',
|
|
)!,
|
|
);
|
|
const reopened = await screen.findByRole('dialog', {
|
|
name: '生成背景音乐',
|
|
});
|
|
/*
|
|
重开的浮层带着**那张占位自己的**失败原因:按钮也就从「生成背景音乐」换成
|
|
「使用原请求重试」。失败原因不再随面板实例留在这儿,换到别的占位看不到它。
|
|
*/
|
|
expect(reopened.textContent).toContain('远端拒绝');
|
|
fireEvent.click(
|
|
within(reopened).getByRole('button', { name: '使用原请求重试' }),
|
|
);
|
|
await waitFor(() => expect(tauri.generationStarts).toHaveLength(2));
|
|
expect(tauri.generationStarts[1]?.taskId).toBe(firstTaskId);
|
|
expect(tauri.generationStarts[1]?.idempotencyKey).toBe(
|
|
tauri.generationStarts[0]?.idempotencyKey,
|
|
);
|
|
}, 20_000);
|
|
});
|