Files
Genarrative/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx
lhk229 362edcc49d 修复前端测试偶发超时与异步清理
Direct 活动回合读取失败后的重试定时器随组件卸载清理,避免测试环境销毁后访问 window。

后台素材查询大量缩略图测试合并可见性触发,并补充独立超时预算。

创作首页活动图与资源卡预览调度重载用例分别补充独立超时预算。

清单快照测试补齐 Direct 活动回合 IPC mock,避免未预期命令进入重试。
2026-09-16 21:53:19 +08:00

360 lines
12 KiB
TypeScript

// @vitest-environment jsdom
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
import {
createGameCreationAppManifest,
type GameCreationAppManifest,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import type { ProjectSupervisorComponentProps } from '../src/features/app-shell/model';
import { WorkspaceLauncherShell } from '../src/features/app-shell/WorkspaceLauncher';
const PROJECT_PATH = '/tmp/manifest-merge-notice-project';
const PROJECT_ID = 'local-project-draft';
const HELD_REVISION = 3;
const captured = vi.hoisted(() => ({
supervisorProps: null as ProjectSupervisorComponentProps | null,
}));
/**
* 只关心工作台壳的清单归并:把工作台视图换成壳,只把壳持有的 `supervisor` 元素挂出去,
* 这样 `syncActiveProjectManifest` 会被真的调通,而不用把整个资源画本拖进用例。
*/
vi.mock('../src/view/project-development', async () => {
const react = await import('react');
return {
default: (props: { supervisor?: React.ReactNode }) =>
react.createElement(
'div',
{ 'data-stub-project-development': 'true' },
props.supervisor ?? null,
),
};
});
function StubSupervisor(props: ProjectSupervisorComponentProps) {
captured.supervisorProps = props;
return React.createElement('div', { 'data-stub-supervisor': 'true' });
}
const testAuthUser: AuthUser = {
id: 'user-test',
publicUserCode: 'tn-test',
displayName: '测试用户',
avatarUrl: null,
phoneNumber: null,
phoneNumberMasked: '138****0000',
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
wechatDisplayName: null,
wechatAccount: null,
};
function manifestWithAsset(assetId: string | null): GameCreationAppManifest {
const value = createGameCreationAppManifest(PROJECT_ID, '拒收提示项目');
if (assetId) {
value.assets = [
{
id: assetId,
kind: 'upload',
mediaType: 'image/png',
localPath: `assets/uploads/${assetId}.png`,
source: { kind: 'uploaded' },
},
];
}
return value;
}
/** 磁盘清单:打开项目时还没有新素材,上传落盘之后才有。 */
let diskManifest = manifestWithAsset(null);
function installInvokeMock() {
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
switch (command) {
case 'read_game_creator_app_config':
return { config: { selectedModelId: 'quality' } };
case 'pick_local_project_directory':
return PROJECT_PATH;
case 'inspect_local_project_directory':
return {
projectPath: PROJECT_PATH,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: '拒收提示项目',
godotProjectRoot: null,
recentRunStatus: null,
recentRunStopReason: null,
};
case 'get_local_game_manifest':
return diskManifest;
case 'get_local_game_project_revision':
return { revision: HELD_REVISION };
case 'get_design_agent_runtime_mode':
return null;
case 'list_game_creator_direct_active_turns':
return [];
case 'read_project_permission_policy':
return {
projectPath: PROJECT_PATH,
defaultDecision: 'allow',
rules: [],
};
default:
throw new Error(
`unexpected invoke ${command} ${JSON.stringify(args ?? {})}`,
);
}
},
);
window.__TAURI__ = { core: { invoke: invoke as never } };
return invoke;
}
async function openProjectThroughLauncher() {
render(
React.createElement(WorkspaceLauncherShell, {
currentUser: testAuthUser,
onLogout: vi.fn(),
initialView: 'projects',
ProjectSupervisor: StubSupervisor,
}),
);
fireEvent.click(await screen.findByRole('button', { name: '打开项目' }));
await waitFor(() =>
expect(
document.querySelector('[data-stub-supervisor="true"]'),
).not.toBeNull(),
);
}
/** 资产命令把「写盘前读到的 revision」贴在了「写盘后读到的清单」上:同版本号、内容不同。 */
async function pushDivergentEqualRevisionSnapshot() {
diskManifest = manifestWithAsset('asset-new-art');
await act(async () => {
captured.supervisorProps?.onManifestChange?.(PROJECT_PATH, diskManifest, {
projectId: PROJECT_ID,
revision: HELD_REVISION,
source: 'asset-command',
commitId: 'asset-upload:1',
});
await Promise.resolve();
});
}
describe('清单快照被拒收时的用户可见性与恢复', () => {
/**
* 每一轮都重新查一次节点。
*
* 拒收提示会走过 `recovering → recovered / unresolved` 三个阶段,React 在阶段切换时
* 可能替换掉节点;抓着第一次查到的引用去断言,会在负载高的时候拿到过期节点而瞬时变红。
*/
function mergeNotice() {
return document.querySelector<HTMLElement>(
'[data-manifest-merge-decision]',
);
}
/** 恢复要等一次磁盘重读(两个 revision + 一次清单,全是真实 promise),负载下留足超时。 */
async function waitForMergeNoticeStage(
stage: 'recovering' | 'recovered' | 'unresolved',
) {
await waitFor(
() =>
expect(mergeNotice()?.getAttribute('data-manifest-merge-stage')).toBe(
stage,
),
{ timeout: 4000 },
);
}
async function waitForRejectionNotice() {
await waitFor(
() =>
expect(
mergeNotice()?.getAttribute('data-manifest-merge-decision'),
).toBe('revision-conflict'),
{ timeout: 4000 },
);
return mergeNotice()!;
}
beforeEach(() => {
diskManifest = manifestWithAsset(null);
captured.supervisorProps = null;
});
afterEach(() => {
cleanup();
window.__TAURI__ = undefined;
vi.restoreAllMocks();
});
it('shows a rejection notice, rereads disk truth and adopts the new asset', async () => {
const invoke = installInvokeMock();
await openProjectThroughLauncher();
await pushDivergentEqualRevisionSnapshot();
// 拒收不再静默:提示条 + 排障观察点都要出现。
await waitForRejectionNotice();
expect(mergeNotice()!.getAttribute('data-manifest-merge-source')).toBe(
'asset-command',
);
expect(
mergeNotice()!.getAttribute('data-manifest-merge-held-revision'),
).toBe(String(HELD_REVISION));
expect(
mergeNotice()!.getAttribute('data-manifest-merge-snapshot-revision'),
).toBe(String(HELD_REVISION));
// 恢复:重读磁盘的 revision + 清单,并把新素材真的带进项目上下文。
await waitForMergeNoticeStage('recovered');
expect(invoke).toHaveBeenCalledWith('get_local_game_manifest', {
projectPath: PROJECT_PATH,
commandId: 'asset.list',
});
expect(
captured.supervisorProps?.initialProjectManifest?.assets.map(
(asset) => asset.id,
),
).toEqual(['asset-new-art']);
expect(mergeNotice()!.textContent).toContain('已按磁盘清单重新对齐');
});
it('reports an unresolved rejection instead of dropping it when the reread fails', async () => {
const invoke = installInvokeMock();
await openProjectThroughLauncher();
invoke.mockImplementation(async (command: string) => {
if (command === 'get_local_game_manifest') {
throw new Error('清单读取被策略拒绝');
}
if (command === 'get_local_game_project_revision') {
return { revision: HELD_REVISION };
}
throw new Error(`unexpected invoke ${command}`);
});
await pushDivergentEqualRevisionSnapshot();
await waitForRejectionNotice();
await waitForMergeNoticeStage('unresolved');
expect(mergeNotice()!.textContent).toContain('重新打开项目');
});
it('never moves the held revision backwards when disk reports an older one', async () => {
const invoke = installInvokeMock();
await openProjectThroughLauncher();
// 磁盘版本比手上的旧:重读不能把状态挪回旧版本,那就是绕开 CAS 本身。
invoke.mockImplementation(async (command: string) => {
if (command === 'get_local_game_project_revision') {
return { revision: HELD_REVISION - 1 };
}
if (command === 'get_local_game_manifest') {
return manifestWithAsset('asset-new-art');
}
throw new Error(`unexpected invoke ${command}`);
});
await pushDivergentEqualRevisionSnapshot();
await waitForRejectionNotice();
await waitForMergeNoticeStage('unresolved');
expect(captured.supervisorProps?.initialProjectManifest?.assets).toEqual(
[],
);
});
it('keeps a snapshot that belongs to another project silent', async () => {
const invoke = installInvokeMock();
await openProjectThroughLauncher();
const callsBefore = invoke.mock.calls.filter(
([command]) => command === 'get_local_game_manifest',
).length;
await act(async () => {
captured.supervisorProps?.onManifestChange?.(
PROJECT_PATH,
{ ...manifestWithAsset('asset-other'), projectId: 'project-other' },
{
projectId: PROJECT_ID,
revision: HELD_REVISION + 1,
source: 'supervisor',
},
);
await Promise.resolve();
});
// 串项目的快照对用户没有可执行语义:既不弹提示,也不触发重读。
expect(document.querySelector('[data-manifest-merge-decision]')).toBeNull();
expect(
invoke.mock.calls.filter(
([command]) => command === 'get_local_game_manifest',
),
).toHaveLength(callsBefore);
});
it('clears the rejection notice when the user switches to another project', async () => {
// 反向钉子:进入项目那一次不许清(同一提交窗口里落下的拒收提示必须留下),
// 但真的换了项目,上一个项目的提示必须跟着走掉。
const otherPath = '/tmp/manifest-merge-notice-other-project';
const invoke = installInvokeMock();
await openProjectThroughLauncher();
await pushDivergentEqualRevisionSnapshot();
await waitForRejectionNotice();
invoke.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'pick_local_project_directory') {
return otherPath;
}
if (command === 'inspect_local_project_directory') {
return {
projectPath: otherPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: '另一个项目',
godotProjectRoot: null,
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return createGameCreationAppManifest(
'other-project-draft',
'另一个项目',
);
}
if (command === 'get_design_agent_runtime_mode') {
return null;
}
if (command === 'get_local_game_project_revision') {
return { revision: 9 };
}
throw new Error(
`unexpected invoke ${command} ${JSON.stringify(args ?? {})}`,
);
},
);
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
fireEvent.click(await screen.findByRole('button', { name: '打开项目' }));
await waitFor(() => expect(mergeNotice()).toBeNull(), { timeout: 4000 });
});
});