006e9cc2f3
- 原缺陷:上传路径在上传**前**读 get_local_game_project_revision,上传完把那个**旧 revision** 交给 reloadManifestAfterAssetCommand;后者重新读盘拿**新清单**再打上旧 revision。上传本身会推进项目 revision(Rust advance_agent_runtime_project_revision_locked),于是这一对是撕裂的,「同 revision 不同指纹」被 projectResourceLiveUpdateModel 判成 revision-conflict —— 用户每上传一次素材就先看到一条「资源清单更新被拒收…」。 - projectResourceLiveUpdateModel.ts 新增 uploadProjectAssetFilesAndReadSnapshot:上传动作与"配对读"由同一个函数拥有,配对读复用既有 rereadAuthoritativeProjectManifestSnapshot(同一次读里配对 revision + 清单,并再读一次 revision 确认没被写盘插队)。调用方拿不到中间那个 revision,也就没有机会把它贴错。 - index.tsx 的 uploadResourcePanelFiles 改用它,删掉上传前那次 revision 读取与旧 revision 的传递。 - 附带文案误报:describeProjectManifestMergeRejection 的 unresolved 文案由「重新读取磁盘清单失败,请重新打开项目」改为「未能按磁盘清单重新对齐,请重新打开项目」。unresolved 有三条进入路径,其中两条**重读是成功的**(读到的一对比手上旧、或读到的是撕裂的一对),写成"读盘失败"是误报;新文案对所有路径都成立。WorkspaceLauncher.tsx 在"读到了但不采用"那一支补注释说明 stage 不是读失败。 - 断言:projectResourceLiveUpdateModel.test.ts 新增「素材上传后的清单快照必须配对」——假后端在上传时推进 revision,断言 merge 判 accepted 且 projectManifestMergeRejectionDecision 为 null(即"上传一次不产生拒收提示"),并附一条对照钉子显式写出旧形状、断言它必被判 revision-conflict;另新增 unresolved 文案不得包含"读取磁盘清单失败"的用例。 - 变异验证(提交前已跑):把 uploadProjectAssetFilesAndReadSnapshot 还原成"上传前读 revision + 上传后读清单"→ 配对用例变红(并给出 revision-conflict 的失败信息),还原后复跑 15 passed。 - 门禁:projectResourceLiveUpdateModel 15 passed、workspaceLauncherManifestMerge 5 passed。
442 lines
15 KiB
TypeScript
442 lines
15 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 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('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('重新打开项目');
|
|
}
|
|
});
|
|
});
|
|
|
|
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',
|
|
);
|
|
});
|
|
});
|