Files
Genarrative/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts
T
suzmii 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 非法/三阶段文案可区分六条断言。
2026-09-11 19:23:25 +08:00

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('重新打开项目');
});
});