f88e6aa5fe
- WorkspaceLauncher:applyManifestSnapshot 拒收分支此前直接 return,改为必须先留痕(console.warn 记 decision/source/两个 revision)再走恢复; - 新增 manifestMergeNotice 状态与提示条,DOM 暴露 data-manifest-merge-decision / -stage / -source / -held-revision / -snapshot-revision 作为排障观察点; - 新增 recoverRejectedManifestSnapshot:重读磁盘 revision + 清单并重建 merge 状态,把被拒快照漏掉的新资源真正带进项目上下文; - 同一项目只允许一条恢复在飞,避免拒收成串到达时打满读取槽; - 重读期间若有更新的快照已被接受,绝不回退 revision(宁可报未解决,不放宽 CAS); - scope-mismatch 仍只记日志不弹提示:串项目的快照对用户没有可执行语义; - projectResourceLiveUpdateModel:新增拒收判定、三阶段文案与 rereadAuthoritativeProjectManifestSnapshot(两次读 revision 夹一次读清单,撕裂就放弃); - styles.css:新增 .launcher-manifest-merge-notices 绝对定位提示位,容器不吃点击,与 .game-resource-book-notices 同口径; - 新增 apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx:拒收可见 + 重读采纳 + 重读失败报未解决 + 不回退 revision + 串项目快照保持静默; - projectResourceLiveUpdateModel.test.ts 追加拒收可见性/重读三读/撕裂放弃/跨项目拒绝/revision 非法/三阶段文案可区分六条断言。
326 lines
10 KiB
TypeScript
326 lines
10 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,
|
|
} 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('重新打开项目');
|
|
});
|
|
});
|