362edcc49d
Direct 活动回合读取失败后的重试定时器随组件卸载清理,避免测试环境销毁后访问 window。 后台素材查询大量缩略图测试合并可见性触发,并补充独立超时预算。 创作首页活动图与资源卡预览调度重载用例分别补充独立超时预算。 清单快照测试补齐 Direct 活动回合 IPC mock,避免未预期命令进入重试。
360 lines
12 KiB
TypeScript
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 });
|
|
});
|
|
});
|