028b20a590
- 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 判据面契约与本次取证
499 lines
18 KiB
TypeScript
499 lines
18 KiB
TypeScript
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',
|
||
);
|
||
});
|
||
});
|