19e1883f71
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m55s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m54s
Project CI / Backend tests (pull_request) Successful in 5m4s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 10m18s
Project CI / Native shell tests (pull_request) Successful in 6m34s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m32s
Project CI / Frontend tests (pull_request) Successful in 3m15s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m29s
Project CI / Repository checks (pull_request) Successful in 1m42s
- 保留 DirectProject 独立聊天容器与 Supervisor 退役结果,不为冲突恢复运行时兼容分支 - 策划工作区文件导入接入 PlanningChatView 输入盒,导入期间暂缓发送、阶段批准、澄清与重试 - 工作台壳按项目记录导入进行态,导入未完成时不切换游戏运行态并提示等待 - 首页策划附件导入结果落到工作台 fileImportNotice 提示条,可单独关闭 - 设计态输入盒样式选择器由 project-supervisor-* 收敛为 project-chat-* - 策划导入回归用例适配退役后的 harness 与 chatProps 命名
413 lines
14 KiB
TypeScript
413 lines
14 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 { ProjectChatComponentProps } 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(() => ({
|
|
chatProps: null as ProjectChatComponentProps | null,
|
|
}));
|
|
|
|
/**
|
|
* 只关心工作台壳的清单归并:把工作台视图换成壳,只把壳持有的 `chat` 元素挂出去,
|
|
* 这样 `syncActiveProjectManifest` 会被真的调通,而不用把整个资源画本拖进用例。
|
|
*/
|
|
vi.mock('../src/view/project-development', async () => {
|
|
const react = await import('react');
|
|
return {
|
|
default: (props: { chat?: React.ReactNode }) =>
|
|
react.createElement(
|
|
'div',
|
|
{ 'data-stub-project-development': 'true' },
|
|
props.chat ?? null,
|
|
),
|
|
};
|
|
});
|
|
|
|
function StubChat(props: ProjectChatComponentProps) {
|
|
captured.chatProps = props;
|
|
return React.createElement('div', { 'data-stub-chat': '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 'set_active_project_snapshot_workspace':
|
|
return undefined;
|
|
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',
|
|
ProjectChat: StubChat,
|
|
}),
|
|
);
|
|
fireEvent.click(await screen.findByRole('button', { name: '打开项目' }));
|
|
await waitFor(() =>
|
|
expect(document.querySelector('[data-stub-chat="true"]')).not.toBeNull(),
|
|
);
|
|
}
|
|
|
|
/** 资产命令把「写盘前读到的 revision」贴在了「写盘后读到的清单」上:同版本号、内容不同。 */
|
|
async function pushDivergentEqualRevisionSnapshot() {
|
|
diskManifest = manifestWithAsset('asset-new-art');
|
|
await act(async () => {
|
|
captured.chatProps?.onManifestChange?.(PROJECT_PATH, diskManifest, {
|
|
projectId: PROJECT_ID,
|
|
revision: HELD_REVISION,
|
|
source: 'asset-command',
|
|
commitId: 'asset-upload:1',
|
|
});
|
|
await Promise.resolve();
|
|
});
|
|
}
|
|
|
|
describe('清单快照被拒收时的用户可见性与恢复', () => {
|
|
it('keeps the planning workspace open until reference imports finish', async () => {
|
|
const invoke = installInvokeMock();
|
|
const originalInvoke = invoke.getMockImplementation()!;
|
|
invoke.mockImplementation(async (command, args) => {
|
|
if (command === 'get_design_agent_runtime_mode')
|
|
return { activeRuntime: 'design' };
|
|
if (command === 'set_design_agent_runtime_mode')
|
|
return { activeRuntime: 'game' };
|
|
return originalInvoke(command, args);
|
|
});
|
|
await openProjectThroughLauncher();
|
|
await act(async () => {
|
|
captured.chatProps?.onDesignFilesImportingChange?.(PROJECT_PATH, true);
|
|
await captured.chatProps?.onSwitchToGameRuntime?.(PROJECT_PATH);
|
|
});
|
|
expect(screen.getByText('文件导入完成后,再做成游戏。')).not.toBeNull();
|
|
expect(
|
|
invoke.mock.calls.some(
|
|
([command]) => command === 'set_design_agent_runtime_mode',
|
|
),
|
|
).toBe(false);
|
|
await act(async () => {
|
|
captured.chatProps?.onDesignFilesImportingChange?.(PROJECT_PATH, false);
|
|
await captured.chatProps?.onSwitchToGameRuntime?.(PROJECT_PATH);
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('set_design_agent_runtime_mode', {
|
|
projectPath: PROJECT_PATH,
|
|
activeRuntime: 'game',
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 每一轮都重新查一次节点。
|
|
*
|
|
* 拒收提示会走过 `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.chatProps = null;
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
window.__TAURI__ = undefined;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('真实启动器打开项目后登记原生快照工作区,返回首页补交关闭', async () => {
|
|
const invoke = installInvokeMock();
|
|
await openProjectThroughLauncher();
|
|
await waitFor(() =>
|
|
expect(invoke).toHaveBeenCalledWith(
|
|
'set_active_project_snapshot_workspace',
|
|
{ projectPath: PROJECT_PATH },
|
|
),
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: '首页' }));
|
|
await waitFor(() =>
|
|
expect(
|
|
invoke.mock.calls
|
|
.filter(
|
|
([command]) => command === 'set_active_project_snapshot_workspace',
|
|
)
|
|
.at(-1),
|
|
).toEqual([
|
|
'set_active_project_snapshot_workspace',
|
|
{ projectPath: null },
|
|
]),
|
|
);
|
|
});
|
|
|
|
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.chatProps?.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.chatProps?.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.chatProps?.onManifestChange?.(
|
|
PROJECT_PATH,
|
|
{ ...manifestWithAsset('asset-other'), projectId: 'project-other' },
|
|
{
|
|
projectId: PROJECT_ID,
|
|
revision: HELD_REVISION + 1,
|
|
source: 'chat',
|
|
},
|
|
);
|
|
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 });
|
|
});
|
|
});
|