Files
Genarrative/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts
T
k88936 876529e668 删除 Project Supervisor 前端链路并将项目对话收敛到 DirectProject 与立项策划
- 删除 ProjectSupervisorView、SupervisorChatOnlyView、ProjectWorkspaceChatPane、AgentConversationOverlay、DeveloperProjectPanels、DeveloperRuntimePanels 与 features/agent-runtime/panels.tsx
- 删除 Supervisor 独立调试窗口:windows.rs 的 supervisor_chat_window_url / open_project_supervisor_chat_window、main.rs 的 invoke 注册、?supervisor-chat 与 ?agent-chat 前端入口、developer.json capability 和对应 Rust 用例
- 删除工作台壳的开发者 Agent 面板:DeveloperAgentPanel、useDeveloperAgentPanel、useDeveloperAgentState、developerAgentControls
- App.tsx 删除只服务退役面板的 state/ref/effect/handler(agentConversation*、文件/记忆/资产/画板/预览面板处理、commandLog、llmConfigStatus、editorBaseUrl、回放历史与 trace 面板状态、selectedAgent 等)以及由此产生的空分支,并把 requestRuntimeConfigOpen 接回本地 RuntimeConfigDialog
- 立项策划独立成模块:Design Agent 与 Planning V2 的容器和表现移到 view/project-development/planning/(PlanningChatView、PlanningUserInputCard、GddApprovalCard、DesignAgentSurface、PlanningLaneRuntimeStrip、planningLane、planningSessionV2、planningSessionContract)
- 改名到中性概念:ProjectSupervisorComponentProps→ProjectChatComponentProps、WorkspaceLauncherShellProps.ProjectSupervisor→ProjectChat、ProjectDevelopmentView.supervisor→chat、orchestrationMode→agentDockVisible、initialSupervisorMessageClaims→initialTurnClaims、ProjectManifestSnapshotSource 'supervisor'→'chat'、CSS project-supervisor-*/project-planning-*→project-chat-*
- 删除 PROJECT_SUPERVISOR_AGENT_ID 与 PROJECT_SUPERVISOR_PLAN_SOURCE 常量
- 工作台壳自己订阅 game-creator-manifest-invalidated,用 revision→清单→revision 配对读(source: asset-event)并入 currentProjectContext,且不重新检查项目目录;测试夹具改为按监听器集合广播该事件
- 删除只钉住退役界面的 appSurface 用例(旧调试窗口、开发者面板、Agent 对话浮层、运行历史面板)
- 同步文档:ADR、DirectProject 聊天模块抽离实施计划与里程碑、Provider 推理里程碑、策划会话 RuntimeV2、AI 游戏创作实施计划,并在 decision-log 新增 2026-09-19 条
- 删除随退役面板失去调用方的模块与导出:projectSummaryCommands.ts(1054 行旧 Supervisor 斜杠命令处理器)、AGENT_RUN_HISTORY_* 常量、agent-runtime/model.ts 与 project-summary/agentPresentation.ts 里的死导出,以及 agentRunTrace / memoryCommands / projectCommandPolicy 中无调用方的校验与解析函数
- 更新 scripts/check-config.mjs 与 scripts/check-native-shells.mjs 的窗口与命令守卫
2026-09-19 21:59:44 +08:00

501 lines
18 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.
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import {
createProjectManifestMergeState,
describeProjectManifestMergeRejection,
mergeProjectManifestSnapshot,
type ProjectManifestMergeRecoveryStage,
projectManifestMergeRejectionDecision,
type ProjectManifestSnapshot,
rereadAuthoritativeProjectManifestSnapshot,
resolveResourceFocusIntent,
type ResourceFocusIntent,
uploadProjectAssetFilesAndReadSnapshot,
} from '../src/view/project-development/projectResourceLiveUpdateModel';
function manifest(projectId: string, assetIds: string[] = []) {
return {
schemaVersion: 'game-creation-app.v1',
projectId,
name: projectId,
tasks: [],
assets: assetIds.map((id) => ({
id,
kind: 'asset',
mediaType: 'image/png',
localPath: `assets/${id}.png`,
source: { kind: 'canvas' as const, taskId: null },
})),
versions: [],
} as unknown as GameCreationAppManifest;
}
function snapshot(
revision: number,
source: ProjectManifestSnapshot['source'],
assetIds: string[] = [],
): ProjectManifestSnapshot {
const value = manifest('project-live', assetIds);
return {
projectPath: '/tmp/project-live',
projectId: value.projectId,
revision,
manifest: value,
source,
commitId: assetIds.length > 0 ? `commit-${assetIds.at(-1)}` : undefined,
eventId: source === 'asset-event' ? `event-${revision}` : undefined,
};
}
function snapshotWithManifest(
revision: number,
source: ProjectManifestSnapshot['source'],
value: GameCreationAppManifest,
): ProjectManifestSnapshot {
return {
projectPath: '/tmp/project-live',
projectId: value.projectId,
revision,
manifest: value,
source,
};
}
function focusIntent(
input?: Partial<ResourceFocusIntent>,
): ResourceFocusIntent {
return {
flowId: 'flow-1',
saveAttemptId: 'save-1',
sessionId: 'session-1',
draftId: 'draft-1',
commitId: 'commit-1',
projectPath: '/tmp/project-live',
projectId: 'project-live',
focusGeneration: 3,
resourceId: 'asset:new-art',
completed: false,
...input,
};
}
function focusEnvironment(
input?: Partial<Parameters<typeof resolveResourceFocusIntent>[1]>,
) {
return {
projectPath: '/tmp/project-live',
projectId: 'project-live',
flowId: 'flow-1',
focusGeneration: 3,
projected: true,
dependencyLayoutSettled: true,
dependencyPositioned: true,
typeLayoutSettled: true,
typePositioned: true,
visible: true,
domRendered: true,
...input,
};
}
describe('project resource live update model', () => {
it('deduplicates command-first and event-first commit delivery', () => {
const initial = createProjectManifestMergeState(snapshot(4, 'initial'));
const command = snapshot(5, 'asset-command', ['new-art']);
const event = {
...snapshot(5, 'asset-event', ['new-art']),
eventId: 'event-5',
};
const commandFirst = mergeProjectManifestSnapshot(initial, command);
expect(commandFirst.decision).toBe('accepted');
const commandThenEvent = mergeProjectManifestSnapshot(
commandFirst.state,
event,
);
expect(commandThenEvent.decision).toBe('duplicate');
expect(commandThenEvent.state.seenEventIds).toEqual(['event-5']);
const eventFirst = mergeProjectManifestSnapshot(initial, event);
expect(eventFirst.decision).toBe('accepted');
expect(
mergeProjectManifestSnapshot(eventFirst.state, command).decision,
).toBe('duplicate');
});
it('rejects an old poll and fails closed on a divergent equal revision', () => {
const accepted = createProjectManifestMergeState(
snapshot(8, 'asset-event', ['new-art']),
);
expect(
mergeProjectManifestSnapshot(accepted, snapshot(7, 'poll')).decision,
).toBe('stale-revision');
expect(
mergeProjectManifestSnapshot(
accepted,
snapshot(8, 'poll', ['different-art']),
).decision,
).toBe('revision-conflict');
});
it('adopts same-revision bookkeeping changes instead of rejecting them', () => {
// 预览起停只写 `manifest.preview``project/manifest.rs::record_preview_state`)而不推
// project revision——预览起停不是源码变更。拿整份清单当 CAS 判据,会把每一次预览起停
// 都变成一条用户可见拒收:「资源清单更新被拒收(同一版本号上的清单内容不一致)」。
const held = createProjectManifestMergeState(
snapshotWithManifest(4, 'initial', {
...manifest('project-live'),
preview: { status: 'stopped' },
}),
);
const running = snapshotWithManifest(4, 'chat', {
...manifest('project-live'),
preview: {
status: 'running',
url: 'http://127.0.0.1:58144/',
port: 58144,
},
});
const merged = mergeProjectManifestSnapshot(held, running);
expect(merged.decision).toBe('accepted');
expect(projectManifestMergeRejectionDecision(merged.decision)).toBeNull();
// revision 既不推进也不回退:账目状态变化本身不是新版本。
expect(merged.state.revision).toBe(4);
});
it('still fails closed when the protected assets change under the same revision', () => {
// 对照钉子:判据面收窄到「资源 + 版本」不等于放过真正的同版本号内容漂移。
const held = createProjectManifestMergeState(
snapshot(4, 'initial', ['art']),
);
const divergent = snapshot(4, 'chat', ['art', 'smuggled-art']);
expect(mergeProjectManifestSnapshot(held, divergent).decision).toBe(
'revision-conflict',
);
});
it('keeps project switching and user selection generations from stealing focus', () => {
expect(
resolveResourceFocusIntent(
focusIntent(),
focusEnvironment({ projectId: 'other-project' }),
),
).toBe('invalid');
expect(
resolveResourceFocusIntent(
focusIntent(),
focusEnvironment({ focusGeneration: 4 }),
),
).toBe('invalid');
});
it('lets only the latest consecutive save intent focus', () => {
expect(
resolveResourceFocusIntent(
focusIntent({ flowId: 'flow-old', focusGeneration: 2 }),
focusEnvironment(),
),
).toBe('invalid');
expect(resolveResourceFocusIntent(focusIntent(), focusEnvironment())).toBe(
'focus',
);
});
it('distinguishes projection, both layouts, filtering, DOM and one-shot completion', () => {
const intent = focusIntent();
expect(
resolveResourceFocusIntent(
intent,
focusEnvironment({ projected: false }),
),
).toBe('wait-projection');
expect(
resolveResourceFocusIntent(
intent,
focusEnvironment({ dependencyLayoutSettled: false }),
),
).toBe('wait-layout');
expect(
resolveResourceFocusIntent(
intent,
focusEnvironment({ typePositioned: false }),
),
).toBe('wait-layout');
expect(
resolveResourceFocusIntent(intent, focusEnvironment({ visible: false })),
).toBe('hidden');
expect(
resolveResourceFocusIntent(
intent,
focusEnvironment({ domRendered: false }),
),
).toBe('wait-dom');
expect(resolveResourceFocusIntent(intent, focusEnvironment())).toBe(
'focus',
);
expect(
resolveResourceFocusIntent(
{ ...intent, completed: true },
focusEnvironment(),
),
).toBe('completed');
});
it('does not implement the live completion path with reload or project reopen', () => {
const source = readFileSync(
new URL('../src/view/project-development/index.tsx', import.meta.url),
'utf8',
);
expect(source).not.toMatch(/(?:location|window\.location)\.reload\s*\(/u);
expect(source).not.toContain('open_local_game_project');
});
});
describe('清单快照被拒收后的可见性与恢复', () => {
function rereadInput(overrides: {
projectPath?: string;
projectId?: string;
revisions?: number[];
manifestProjectId?: string;
readManifest?: () => Promise<GameCreationAppManifest>;
}) {
const revisions = [...(overrides.revisions ?? [4])];
let manifestReads = 0;
const order: string[] = [];
const input = {
projectPath: overrides.projectPath ?? '/tmp/project-live',
projectId: overrides.projectId ?? 'project-live',
readRevision: async () => {
order.push('revision');
return revisions.length > 1 ? revisions.shift()! : revisions[0]!;
},
readManifest: async () => {
order.push('manifest');
manifestReads += 1;
const manifestProjectId = overrides.manifestProjectId ?? 'project-live';
return (
overrides.readManifest?.() ??
Promise.resolve(manifest(manifestProjectId, ['new-art']))
);
},
};
return { input, order, manifestReads: () => manifestReads };
}
it('only treats the two decisions that lose an update as user-visible rejections', () => {
// `/assets` 之类的路由命令不算拒收,不能弹提示。
expect(projectManifestMergeRejectionDecision('accepted')).toBeNull();
expect(projectManifestMergeRejectionDecision('duplicate')).toBeNull();
expect(projectManifestMergeRejectionDecision('scope-mismatch')).toBeNull();
expect(projectManifestMergeRejectionDecision('stale-revision')).toBe(
'stale-revision',
);
expect(projectManifestMergeRejectionDecision('revision-conflict')).toBe(
'revision-conflict',
);
});
it('rereads the disk revision, manifest and revision again before adopting', async () => {
const { input, order } = rereadInput({ revisions: [7] });
const snapshotRead =
await rereadAuthoritativeProjectManifestSnapshot(input);
// 两次读 revision 夹一次读清单:只有版本号前后一致,这一对才配被采信。
expect(order).toEqual(['revision', 'manifest', 'revision']);
expect(snapshotRead).not.toBeNull();
expect(snapshotRead).toMatchObject({
projectPath: '/tmp/project-live',
projectId: 'project-live',
revision: 7,
source: 'poll',
});
expect(snapshotRead?.manifest.assets.map((asset) => asset.id)).toEqual([
'new-art',
]);
});
it('gives up when a write lands between the two revision reads', async () => {
const { input } = rereadInput({ revisions: [7, 8] });
expect(await rereadAuthoritativeProjectManifestSnapshot(input)).toBeNull();
});
it('refuses a manifest that belongs to another project', async () => {
const { input } = rereadInput({
revisions: [7],
manifestProjectId: 'project-other',
});
expect(await rereadAuthoritativeProjectManifestSnapshot(input)).toBeNull();
});
it('refuses an unusable revision instead of adopting the snapshot', async () => {
for (const revision of [-1, 1.5, Number.NaN]) {
const { input, manifestReads } = rereadInput({ revisions: [revision] });
expect(
await rereadAuthoritativeProjectManifestSnapshot(input),
).toBeNull();
// 版本号不可用时连清单都不该读:宁可报"未解决",也不能拿着坏 revision 落状态。
expect(manifestReads()).toBe(0);
}
});
it('describes every recovery stage so the notice never renders blank', () => {
const stages: ProjectManifestMergeRecoveryStage[] = [
'recovering',
'recovered',
'unresolved',
];
const texts = new Map<string, string>();
for (const decision of ['revision-conflict', 'stale-revision'] as const) {
for (const stage of stages) {
const text = describeProjectManifestMergeRejection(decision, stage);
expect(text.length).toBeGreaterThan(0);
texts.set(`${decision}:${stage}`, text);
}
}
// 三种阶段必须是三句不同的话,否则用户从提示上分不出"正在重读/已对齐/要重开项目"。
expect(
new Set(stages.map((stage) => texts.get(`revision-conflict:${stage}`)))
.size,
).toBe(3);
expect(
new Set(stages.map((stage) => texts.get(`stale-revision:${stage}`))).size,
).toBe(3);
// 两种拒收成因不同,文案必须能区分。
expect(texts.get('revision-conflict:recovering')).not.toBe(
texts.get('stale-revision:recovering'),
);
expect(texts.get('revision-conflict:recovered')).toContain(
'已按磁盘清单重新对齐',
);
expect(texts.get('stale-revision:unresolved')).toContain('重新打开项目');
});
it('does not claim the disk reread failed when the reread succeeded but was not adopted', () => {
// `unresolved` 有三条进入路径,其中两条**重读是成功的**:
// 读到的一对比手上旧(不能回退 revision)、或读到的是撕裂的一对(不能采信)。
// 文案对"读盘失败"的断言必须在两条路径上都不成立,否则就是在误报。
for (const decision of ['revision-conflict', 'stale-revision'] as const) {
const text = describeProjectManifestMergeRejection(
decision,
'unresolved',
);
expect(text).not.toContain('读取磁盘清单失败');
expect(text).toContain('未能按磁盘清单重新对齐');
expect(text).toContain('重新打开项目');
}
});
});
/**
* ⚠️ **覆盖边界**:下面这组断言打在生产函数 `uploadProjectAssetFilesAndReadSnapshot` 上 ——
* 它同时拥有「上传」与「配对读」,所以整条配对语义是真测的;但 AGC 侧**没有**"资源面板上传 →
* 拒收提示条"的端到端 UI 用例(现有 harness 只有聊天入口的 `/asset.upload` 通路,资源面板的
* file input 没有用例,补一条要渲染整个 `ProjectDevelopmentView`)。因此「界面上不再出现拒收
* 提示」这一层是**推理结论**(配对正确 ⇒ merge 判 accepted ⇒ 不产生拒收决策 ⇒ 不渲染提示条),
* 不是端到端断言。别把它当成端到端覆盖引用。
*/
describe('素材上传后的清单快照必须配对', () => {
/**
* 假后端:上传推进 revision,其余读命令返回当前磁盘状态。
* 真机事实(Rust `advance_agent_runtime_project_revision_locked`):上传本身会推进
* 项目 revision,所以"上传前读到的 revision"注定配不上"上传后读到的清单"。
*/
function fakeDisk(input?: { advanceOnUpload?: boolean }) {
const advanceOnUpload = input?.advanceOnUpload ?? true;
const state = {
revision: 3,
manifest: manifest('project-live', []),
};
const invoke = async <T>(command: string): Promise<T> => {
switch (command) {
case 'upload_local_asset':
if (advanceOnUpload) {
state.revision += 1;
state.manifest = manifest('project-live', ['uploaded-art']);
}
return undefined as T;
case 'get_local_game_project_revision':
return { revision: state.revision } as T;
case 'get_local_game_manifest':
return state.manifest as T;
default:
throw new Error(`unexpected invoke ${command}`);
}
};
return { state, invoke };
}
function heldState(revision: number) {
return createProjectManifestMergeState({
projectPath: '/tmp/project-live',
projectId: 'project-live',
revision,
manifest: manifest('project-live'),
source: 'initial',
});
}
it('produces a snapshot the merge accepts instead of one it rejects as a revision conflict', async () => {
const disk = fakeDisk();
const uploaded = await uploadProjectAssetFilesAndReadSnapshot({
projectPath: '/tmp/project-live',
projectId: 'project-live',
commitId: 'asset-upload:1',
files: [
{ fileName: 'art.png', mediaType: 'image/png', bytes: [1, 2, 3] },
],
invoke: disk.invoke,
});
expect(uploaded).not.toBeNull();
// 用户可见判据放最前:上传一次不得被判成拒收,新素材必须进清单。
const merged = mergeProjectManifestSnapshot(heldState(3), uploaded!);
expect(merged.decision).toBe('accepted');
expect(projectManifestMergeRejectionDecision(merged.decision)).toBeNull();
expect(merged.state.revision).toBe(4);
expect(uploaded!.manifest.assets.map((asset) => asset.id)).toEqual([
'uploaded-art',
]);
expect(uploaded).toMatchObject({
projectPath: '/tmp/project-live',
projectId: 'project-live',
// 上传推进后的版本号,不是上传前那个。
revision: 4,
source: 'asset-command',
commitId: 'asset-upload:1',
});
});
it('reports the old pre-upload revision pairing as a user-visible rejection', async () => {
// 对照钉子:把"上传前读 revision + 上传后读清单"这条旧形状显式地写出来,
// 它必须被判成 `revision-conflict` —— 这正是用户每次上传都吃到的拒收提示。
// 这条对照证明上面那条用例的判据不是空断言:错配确实会被拒收。
const disk = fakeDisk();
const staleRevision = (
await disk.invoke<{ revision: number }>('get_local_game_project_revision')
).revision;
await disk.invoke('upload_local_asset', {});
const freshManifest = await disk.invoke<GameCreationAppManifest>(
'get_local_game_manifest',
);
const merged = mergeProjectManifestSnapshot(heldState(3), {
projectPath: '/tmp/project-live',
projectId: 'project-live',
revision: staleRevision,
manifest: freshManifest,
source: 'asset-command',
});
expect(merged.decision).toBe('revision-conflict');
expect(projectManifestMergeRejectionDecision(merged.decision)).toBe(
'revision-conflict',
);
});
});