Files
Genarrative/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts
T
suzmii 028b20a590 修复清单快照误判:CAS 判据面收窄为「资源 + 版本」,预览起停不再触发用户可见拒收
- projectResourceLiveUpdateModel 新增 manifestCasFingerprint,按 { assets, versions } 判定同版本号冲突
- 同 revision 且判据面一致时,仅非 CAS 账目状态(preview 运行地址/端口、任务进度、项目名)变化改为 accepted 并让内容落地,revision 不推不退
- 判据面变化仍判 revision-conflict,保留「改了被保护内容却没推 revision」的可观测信号,不吞真实缺陷
- 新增回归用例:同 revision 仅预览状态变化必须被接受;同 revision 资产变化必须仍被拒收
- 变异验证:把判据改回整份 JSON 指纹后,新用例以 expected 'revision-conflict' to be 'accepted' 失败
- 定向验证:projectResourceLiveUpdateModel 17 passed、workspaceLauncherManifestMerge 5 passed、appSurface 413 passed、npm run typecheck exit 0、npm run check:encoding 4399 files passed、git diff --check 干净
- 同步 decision-log 与 pitfalls 记录 CAS 判据面契约与本次取证
2026-09-12 13:30:18 +08:00

499 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, 'supervisor', {
...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, 'supervisor', ['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',
);
});
});