Files
Genarrative/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts
T
k88936 6981648796
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m56s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m48s
Project CI / Native shell tests (pull_request) Failing after 45s
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Frontend tests (pull_request) Failing after 1m52s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m42s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m57s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m1s
合并 origin/master:Supervisor 永久退役,项目对话收敛为 DirectProject 与 Design Agent
- 解决 refactor/split-direct-project 与 origin/master 在 App.tsx、立项策划聊天视图、Direct composer/引用输入区、styles.css、Rust direct user item 与 appSurface 用例上的冲突,按「Supervisor 永久退役」口径保留 DirectProject 独立聊天容器与 Design Agent 两条产品路径
- 采纳 master 的策划 Agent V1/V2 退役:删除 GDD 审批卡、策划输入卡、planningLane、planningSessionV2、planningSessionContract、规划展示适配与 Rust planning_*_v2 命令、模块、契约及对应用例,不保留兼容别名或双跑路径
- 把 master「折叠思考显示单行预览」的目的落到当前结构:新增共享表现 chat/components/AgentReasoning/AgentReasoning.tsx(折叠态单行纯文本预览 + 箭头、展开态安全 Markdown),DirectProject 回合与策划回合共用,删掉两处写死的 pre 折叠实现
- 把 master「策划入口可选模型 / 推理档」的目的接到当前策划输入盒:复用 ConversationModelSelect 与 ComposerReasoningEffortSelect,配置写回仍走客户端配置通道
- App.tsx 删除只服务退役 Supervisor / 策划 V2 的 state、ref、effect、回调与死参数,并删除两条读路径都退役后的 workspaceProjectKind;openWorkspace 的工程类型入参保留为未使用契约
- Rust 侧保留本分支 canonical→wire 投影、无审计 Direct 回合与 direct user item 严格校验,并入 master 的 prepare_new_web_project_at 前置复核
- 更新 ADR 与 shared-memory 决策记录:策划当前只有 Design Agent、两条路径的共享表现清单,以及本次合并的口径、代价与验证证据
- 验证:AGC 与仓库 typecheck、check:encoding、check:doc-index、git diff --check、改动文件 eslint 0 error;AGC vitest 168 个文件中除 5 个 jsdom localStorage 环境失败文件与本分支既有 resourceTagStatsRefresh 失败外全绿,appSurface 198 passed / 13 skipped;Rust 定向用例 direct_codex_user_item、skill_pack、sessions 全过(整套分片在本容器受 /sbin -> usr/bin 触发沙箱预检失败,与本合并无关)
2026-09-21 21:09:34 +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: 'image',
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',
);
});
});