清单快照被拒收不再静默:可见提示 + 强制重读磁盘真相

- 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 非法/三阶段文案可区分六条断言。
This commit is contained in:
2026-09-11 19:23:25 +08:00
parent ee8e12783e
commit f88e6aa5fe
5 changed files with 782 additions and 48 deletions
@@ -14,10 +14,16 @@ import { type LauncherView, Sidebar } from '../../view/layout';
import ProjectDevelopmentView from '../../view/project-development';
import {
createProjectManifestMergeState,
describeProjectManifestMergeRejection,
mergeProjectManifestSnapshot,
type ProjectManifestMergeRecoveryStage,
type ProjectManifestMergeRejectionDecision,
projectManifestMergeRejectionDecision,
type ProjectManifestMergeState,
type ProjectManifestSnapshot,
type ProjectManifestSnapshotMetadata,
type ProjectManifestSnapshotSource,
rereadAuthoritativeProjectManifestSnapshot,
} from '../../view/project-development/projectResourceLiveUpdateModel';
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet';
@@ -133,6 +139,109 @@ export function WorkspaceLauncherShell({
setActiveVersionId(null);
}, [activeVersionId, projectVersionIds]);
/**
* 被 CAS 拒收的清单快照提示。
*
* 拒收此前是 `return` 一行静默丢弃:用户看到"生成/上传成功"但画布上没有新卡,
* 资源区提示永远停在"正在等待 manifest 投影"。这里把拒收变成一条可见状态,
* `data-manifest-merge-*` 同时作为排障观察点。
*/
const [manifestMergeNotice, setManifestMergeNotice] = useState<{
decision: ProjectManifestMergeRejectionDecision;
stage: ProjectManifestMergeRecoveryStage;
source: ProjectManifestSnapshotSource;
projectId: string;
heldRevision: number;
snapshotRevision: number;
} | null>(null);
/**
* 同一项目只允许一条恢复在飞。
*
* 拒收往往成串到达(一条命令 + 总控一次推送),每条都去重读磁盘会把读取槽打满,
* 也会让提示在几个阶段之间来回抖。换项目时提示与在飞标记一起清掉。
*/
const manifestMergeRecoveryInFlightRef = useRef<string | null>(null);
useEffect(() => {
manifestMergeRecoveryInFlightRef.current = null;
setManifestMergeNotice(null);
}, [currentProjectContext?.projectPath]);
const recoverRejectedManifestSnapshot = useCallback(
async (input: {
projectPath: string;
projectId: string;
decision: ProjectManifestMergeRejectionDecision;
source: ProjectManifestSnapshotSource;
heldRevision: number;
snapshotRevision: number;
}) => {
const patchStage = (stage: ProjectManifestMergeRecoveryStage) =>
setManifestMergeNotice((current) =>
current ? { ...current, stage } : current,
);
const invoke = resolveTauriInvoke();
if (!invoke) {
patchStage('unresolved');
return;
}
manifestMergeRecoveryInFlightRef.current = input.projectPath;
patchStage('recovering');
try {
const fresh = await rereadAuthoritativeProjectManifestSnapshot({
projectPath: input.projectPath,
projectId: input.projectId,
readRevision: async () => {
const status = await invoke<LocalGameProjectRevisionStatus>(
'get_local_game_project_revision',
{ projectPath: input.projectPath },
);
return status.revision;
},
readManifest: () =>
invoke<NonNullable<typeof currentProjectContext>['manifest']>(
'get_local_game_manifest',
{ projectPath: input.projectPath, commandId: 'asset.list' },
),
});
const held = manifestMergeRef.current;
const active = activeProjectContextRef.current;
if (
!fresh ||
!held ||
(active && active.projectPath !== fresh.projectPath)
) {
patchStage('unresolved');
return;
}
// 重读期间已经有更新的快照被接受时绝不回退 revision:宁可报"未解决"
// 也不能把状态挪回旧版本(那会绕开 CAS 保护本身)。
if (fresh.revision < held.revision) {
patchStage('unresolved');
return;
}
manifestMergeRef.current = createProjectManifestMergeState(fresh);
setCurrentProjectContext((current) =>
current && current.projectPath === fresh.projectPath
? {
...current,
manifest: fresh.manifest,
projectRevision: fresh.revision,
}
: current,
);
patchStage('recovered');
} catch (error) {
console.warn('[manifest-merge] 拒收后强制重读清单失败', error);
patchStage('unresolved');
} finally {
if (manifestMergeRecoveryInFlightRef.current === input.projectPath) {
manifestMergeRecoveryInFlightRef.current = null;
}
}
},
[setCurrentProjectContext],
);
const applyManifestSnapshot = useCallback(
(snapshot: ProjectManifestSnapshot) => {
const current = activeProjectContextRef.current;
@@ -166,6 +275,36 @@ export function WorkspaceLauncherShell({
const merged = mergeProjectManifestSnapshot(previous, snapshot);
manifestMergeRef.current = merged.state;
if (merged.decision !== 'accepted') {
// CAS 拒收语义保持不动,但拒收必须留下痕迹:此前这里直接 return,
// 用户侧表现为"命令成功、画布上没有新卡、提示永远停在等待投影"。
console.warn(
`[manifest-merge] 清单快照被拒收 decision=${merged.decision} source=${snapshot.source} snapshotRevision=${snapshot.revision} heldRevision=${merged.state.revision}`,
);
const rejection = projectManifestMergeRejectionDecision(
merged.decision,
);
if (rejection) {
setManifestMergeNotice({
decision: rejection,
stage: 'recovering',
source: snapshot.source,
projectId: snapshot.projectId,
heldRevision: merged.state.revision,
snapshotRevision: snapshot.revision,
});
if (
manifestMergeRecoveryInFlightRef.current !== snapshot.projectPath
) {
void recoverRejectedManifestSnapshot({
projectPath: snapshot.projectPath,
projectId: snapshot.projectId,
decision: rejection,
source: snapshot.source,
heldRevision: merged.state.revision,
snapshotRevision: snapshot.revision,
});
}
}
return;
}
}
@@ -181,8 +320,9 @@ export function WorkspaceLauncherShell({
: active,
);
},
[setCurrentProjectContext],
[recoverRejectedManifestSnapshot, setCurrentProjectContext],
);
const syncActiveProjectManifest = useCallback(
(
sourceProjectPath: string,
@@ -338,53 +478,103 @@ export function WorkspaceLauncherShell({
onRuntimeConfigOpen={() => setRuntimeConfigOpen(true)}
/>
) : launcherView === 'project-development' && currentProjectContext ? (
<ProjectDevelopmentView
orchestrationMode="single-supervisor"
projectName={currentProjectContext.projectName}
projectPath={currentProjectContext.projectPath}
manifest={currentProjectContext.manifest}
attachments={currentProjectContext.attachments}
recentRunStatus={currentProjectContext.recentRunStatus}
recentRunStopReason={currentProjectContext.recentRunStopReason}
preview={activeProjectPreview}
agentRuntimeSummaries={activeProjectAgentRuntimeSummaries}
agentResults={activeProjectAgentResults}
planningStartMode={currentProjectContext.startMode === 'planning'}
activeVersionId={activeVersionId}
onActiveVersionChange={setActiveVersionId}
onPlay={() =>
requestCurrentProjectPlay(currentProjectContext.projectPath)
}
onManifestChange={syncActiveProjectManifest}
onHomeOpen={() => setLauncherView('home')}
onProjectsOpen={() => setLauncherView('projects')}
supervisor={
<ProjectSupervisor
key={currentProjectContext.projectPath}
initialProjectPath={currentProjectContext.projectPath}
initialProjectManifest={currentProjectContext.manifest}
initialProjectKind={currentProjectContext.projectKind}
initialSupervisorMessage={currentProjectContext.initialPrompt}
initialCreationType={currentProjectContext.creationType}
initialAttachments={currentProjectContext.attachments}
activeVersionId={activeVersionId}
orchestrationMode="single-supervisor"
projectSupervisorOnly
planningStartMode={
currentProjectContext.startMode === 'planning'
}
playRequest={playRequest}
onPlayRequestHandled={handlePlayRequestHandled}
onManifestChange={syncActiveProjectManifest}
onPreviewChange={setActiveProjectPreview}
onAgentRuntimeSummariesChange={
setActiveProjectAgentRuntimeSummaries
}
onAgentResultsChange={setActiveProjectAgentResults}
onMakeGameFromApprovedGdd={startGameFromApprovedGdd}
/>
}
/>
<>
{manifestMergeNotice ? (
<div className="launcher-manifest-merge-notices">
<div
className="game-resource-live-notice"
role="status"
data-manifest-merge-decision={manifestMergeNotice.decision}
data-manifest-merge-stage={manifestMergeNotice.stage}
data-manifest-merge-source={manifestMergeNotice.source}
data-manifest-merge-held-revision={
manifestMergeNotice.heldRevision
}
data-manifest-merge-snapshot-revision={
manifestMergeNotice.snapshotRevision
}
>
<span>
{describeProjectManifestMergeRejection(
manifestMergeNotice.decision,
manifestMergeNotice.stage,
)}
</span>
{manifestMergeNotice.stage === 'recovering' ? null : (
<button
type="button"
onClick={() =>
void recoverRejectedManifestSnapshot({
projectPath: currentProjectContext.projectPath,
projectId: manifestMergeNotice.projectId,
decision: manifestMergeNotice.decision,
source: manifestMergeNotice.source,
heldRevision: manifestMergeNotice.heldRevision,
snapshotRevision:
manifestMergeNotice.snapshotRevision,
})
}
>
</button>
)}
<button
type="button"
onClick={() => setManifestMergeNotice(null)}
>
</button>
</div>
</div>
) : null}
<ProjectDevelopmentView
orchestrationMode="single-supervisor"
projectName={currentProjectContext.projectName}
projectPath={currentProjectContext.projectPath}
manifest={currentProjectContext.manifest}
attachments={currentProjectContext.attachments}
recentRunStatus={currentProjectContext.recentRunStatus}
recentRunStopReason={currentProjectContext.recentRunStopReason}
preview={activeProjectPreview}
agentRuntimeSummaries={activeProjectAgentRuntimeSummaries}
agentResults={activeProjectAgentResults}
planningStartMode={currentProjectContext.startMode === 'planning'}
activeVersionId={activeVersionId}
onActiveVersionChange={setActiveVersionId}
onPlay={() =>
requestCurrentProjectPlay(currentProjectContext.projectPath)
}
onManifestChange={syncActiveProjectManifest}
onHomeOpen={() => setLauncherView('home')}
onProjectsOpen={() => setLauncherView('projects')}
supervisor={
<ProjectSupervisor
key={currentProjectContext.projectPath}
initialProjectPath={currentProjectContext.projectPath}
initialProjectManifest={currentProjectContext.manifest}
initialProjectKind={currentProjectContext.projectKind}
initialSupervisorMessage={currentProjectContext.initialPrompt}
initialCreationType={currentProjectContext.creationType}
initialAttachments={currentProjectContext.attachments}
activeVersionId={activeVersionId}
orchestrationMode="single-supervisor"
projectSupervisorOnly
planningStartMode={
currentProjectContext.startMode === 'planning'
}
playRequest={playRequest}
onPlayRequestHandled={handlePlayRequestHandled}
onManifestChange={syncActiveProjectManifest}
onPreviewChange={setActiveProjectPreview}
onAgentRuntimeSummariesChange={
setActiveProjectAgentRuntimeSummaries
}
onAgentResultsChange={setActiveProjectAgentResults}
onMakeGameFromApprovedGdd={startGameFromApprovedGdd}
/>
}
/>
</>
) : (
<section className="launcher-page launcher-help-page">
<header>
+28
View File
@@ -6245,6 +6245,34 @@ iframe.preview-frame {
pointer-events: auto;
}
/*
* 清单快照被 CAS 拒收时的提示位(工作台顶层)。
*
* 用绝对定位而不是插进 grid 行:`.launcher-main` 的行高参与了工作台的高度 clamp,
* 多一行会把资源画布挤矮。容器整体 `pointer-events: none`、只有提示条自己恢复命中,
* 与 `.game-resource-book-notices` 同一套口径,避免空盒子吃掉下面的点击。
*/
.launcher-manifest-merge-notices {
position: absolute;
top: 12px;
left: 50%;
z-index: 60;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: max-content;
max-width: min(520px, calc(100% - 24px));
transform: translateX(-50%);
pointer-events: none;
}
.launcher-manifest-merge-notices > * {
width: auto;
max-width: 100%;
pointer-events: auto;
}
.game-resource-classification-dialog {
width: min(480px, 100%);
}
@@ -129,6 +129,94 @@ export function mergeProjectManifestSnapshot(
};
}
/**
* 会被用户看见的拒收决策。
*
* `mergeProjectManifestSnapshot` 的五个决策里只有这两个代表「本条快照被 CAS 挡下、
* 手上的清单继续用旧的」:`stale-revision` 是收到的快照版本比手上的旧,
* `revision-conflict` 是同一版本号上内容不一致。两者都会让新提交的资源进不了画布,
* 所以必须可见且可恢复。
*
* `scope-mismatch` **不在其列**:它表示这条快照根本不属于当前项目(上游串了项目),
* 对用户没有可执行的语义,只记日志、不弹提示。
*/
export type ProjectManifestMergeRejectionDecision =
| 'stale-revision'
| 'revision-conflict';
export function projectManifestMergeRejectionDecision(
decision: ProjectManifestMergeDecision,
): ProjectManifestMergeRejectionDecision | null {
return decision === 'stale-revision' || decision === 'revision-conflict'
? decision
: null;
}
/** 拒收之后的恢复阶段,直接决定提示文案与按钮可用性。 */
export type ProjectManifestMergeRecoveryStage =
| 'recovering'
| 'recovered'
| 'unresolved';
export function describeProjectManifestMergeRejection(
decision: ProjectManifestMergeRejectionDecision,
stage: ProjectManifestMergeRecoveryStage,
): string {
const cause =
decision === 'revision-conflict'
? '同一版本号上的清单内容不一致'
: '收到的是旧版本的清单快照';
if (stage === 'recovering') {
return `资源清单更新被拒收(${cause}),正在重新读取磁盘清单…`;
}
if (stage === 'recovered') {
return `资源清单更新被拒收(${cause}),已按磁盘清单重新对齐`;
}
return `资源清单更新被拒收(${cause}),重新读取磁盘清单失败,请重新打开项目`;
}
export type ProjectManifestRereadInput = {
projectPath: string;
projectId: string;
readRevision: () => Promise<number>;
readManifest: () => Promise<GameCreationAppManifest>;
};
/**
* 拒收之后的恢复手段:**重读磁盘真相**,而不是把被拒的快照硬收下(那等于放宽 CAS)。
*
* 为什么重读能解:拒收的成因是快照自带的版本号不足以定位内容(典型是资产命令把
* **写盘之前**读到的 revision 贴在了**写盘之后**读到的清单上),于是"同一版本号、
* 内容不同"被 CAS 挡下。此时唯一可靠的解法是回到磁盘问一次当前 `(revision, 清单)`
* 用它重建 merge 状态。
*
* 两次读 revision 夹一次读清单:中间有写盘落地就说明读到的是撕裂的一对,
* 直接放弃本次恢复(下一次快照会带上新的 revision),不猜、不凑。
*/
export async function rereadAuthoritativeProjectManifestSnapshot(
input: ProjectManifestRereadInput,
): Promise<ProjectManifestSnapshot | null> {
const revision = await input.readRevision();
if (!Number.isSafeInteger(revision) || revision < 0) {
return null;
}
const manifest = await input.readManifest();
if (manifest.projectId !== input.projectId) {
return null;
}
const confirmedRevision = await input.readRevision();
if (confirmedRevision !== revision) {
return null;
}
return {
projectPath: input.projectPath,
projectId: input.projectId,
revision,
manifest,
source: 'poll',
};
}
export type ResourceFocusIntent = {
flowId: string;
saveAttemptId: string;
@@ -5,8 +5,12 @@ 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';
@@ -198,3 +202,124 @@ describe('project resource live update model', () => {
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('重新打开项目');
});
});
@@ -0,0 +1,303 @@
// @vitest-environment jsdom
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
import {
createGameCreationAppManifest,
type GameCreationAppManifest,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import type { ProjectSupervisorComponentProps } from '../src/features/app-shell/model';
import { WorkspaceLauncherShell } from '../src/features/app-shell/WorkspaceLauncher';
const PROJECT_PATH = '/tmp/manifest-merge-notice-project';
const PROJECT_ID = 'local-project-draft';
const HELD_REVISION = 3;
const captured = vi.hoisted(() => ({
supervisorProps: null as ProjectSupervisorComponentProps | null,
}));
/**
* 只关心工作台壳的清单归并:把工作台视图换成壳,只把壳持有的 `supervisor` 元素挂出去,
* 这样 `syncActiveProjectManifest` 会被真的调通,而不用把整个资源画本拖进用例。
*/
vi.mock('../src/view/project-development', async () => {
const react = await import('react');
return {
default: (props: { supervisor?: React.ReactNode }) =>
react.createElement(
'div',
{ 'data-stub-project-development': 'true' },
props.supervisor ?? null,
),
};
});
function StubSupervisor(props: ProjectSupervisorComponentProps) {
captured.supervisorProps = props;
return React.createElement('div', { 'data-stub-supervisor': 'true' });
}
const testAuthUser: AuthUser = {
id: 'user-test',
publicUserCode: 'tn-test',
displayName: '测试用户',
avatarUrl: null,
phoneNumber: null,
phoneNumberMasked: '138****0000',
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
wechatDisplayName: null,
wechatAccount: null,
};
function manifestWithAsset(assetId: string | null): GameCreationAppManifest {
const value = createGameCreationAppManifest(PROJECT_ID, '拒收提示项目');
if (assetId) {
value.assets = [
{
id: assetId,
kind: 'upload',
mediaType: 'image/png',
localPath: `assets/uploads/${assetId}.png`,
source: { kind: 'uploaded' },
},
];
}
return value;
}
/** 磁盘清单:打开项目时还没有新素材,上传落盘之后才有。 */
let diskManifest = manifestWithAsset(null);
function installInvokeMock() {
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
switch (command) {
case 'read_game_creator_app_config':
return { config: { selectedModelId: 'quality' } };
case 'pick_local_project_directory':
return PROJECT_PATH;
case 'inspect_local_project_directory':
return {
projectPath: PROJECT_PATH,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: '拒收提示项目',
godotProjectRoot: null,
recentRunStatus: null,
recentRunStopReason: null,
};
case 'get_local_game_manifest':
return diskManifest;
case 'get_local_game_project_revision':
return { revision: HELD_REVISION };
case 'read_project_permission_policy':
return {
projectPath: PROJECT_PATH,
defaultDecision: 'allow',
rules: [],
};
default:
throw new Error(
`unexpected invoke ${command} ${JSON.stringify(args ?? {})}`,
);
}
},
);
window.__TAURI__ = { core: { invoke: invoke as never } };
return invoke;
}
async function openProjectThroughLauncher() {
render(
React.createElement(WorkspaceLauncherShell, {
currentUser: testAuthUser,
onLogout: vi.fn(),
initialView: 'projects',
ProjectSupervisor: StubSupervisor,
}),
);
fireEvent.click(await screen.findByRole('button', { name: '打开项目' }));
await waitFor(() =>
expect(
document.querySelector('[data-stub-supervisor="true"]'),
).not.toBeNull(),
);
}
/** 资产命令把「写盘前读到的 revision」贴在了「写盘后读到的清单」上:同版本号、内容不同。 */
async function pushDivergentEqualRevisionSnapshot() {
diskManifest = manifestWithAsset('asset-new-art');
await act(async () => {
captured.supervisorProps?.onManifestChange?.(PROJECT_PATH, diskManifest, {
projectId: PROJECT_ID,
revision: HELD_REVISION,
source: 'asset-command',
commitId: 'asset-upload:1',
});
await Promise.resolve();
});
}
describe('清单快照被拒收时的用户可见性与恢复', () => {
beforeEach(() => {
diskManifest = manifestWithAsset(null);
captured.supervisorProps = null;
});
afterEach(() => {
cleanup();
window.__TAURI__ = undefined;
vi.restoreAllMocks();
});
it('shows a rejection notice, rereads disk truth and adopts the new asset', async () => {
const invoke = installInvokeMock();
await openProjectThroughLauncher();
await pushDivergentEqualRevisionSnapshot();
// 拒收不再静默:提示条 + 排障观察点都要出现。
const notice = await waitFor(() => {
const element = document.querySelector<HTMLElement>(
'[data-manifest-merge-decision]',
);
expect(element).not.toBeNull();
return element!;
});
expect(notice.getAttribute('data-manifest-merge-decision')).toBe(
'revision-conflict',
);
expect(notice.getAttribute('data-manifest-merge-source')).toBe(
'asset-command',
);
expect(notice.getAttribute('data-manifest-merge-held-revision')).toBe(
String(HELD_REVISION),
);
expect(notice.getAttribute('data-manifest-merge-snapshot-revision')).toBe(
String(HELD_REVISION),
);
// 恢复:重读磁盘的 revision + 清单,并把新素材真的带进项目上下文。
await waitFor(() =>
expect(notice.getAttribute('data-manifest-merge-stage')).toBe(
'recovered',
),
);
expect(invoke).toHaveBeenCalledWith('get_local_game_manifest', {
projectPath: PROJECT_PATH,
commandId: 'asset.list',
});
expect(
captured.supervisorProps?.initialProjectManifest?.assets.map(
(asset) => asset.id,
),
).toEqual(['asset-new-art']);
expect(notice.textContent).toContain('已按磁盘清单重新对齐');
});
it('reports an unresolved rejection instead of dropping it when the reread fails', async () => {
const invoke = installInvokeMock();
await openProjectThroughLauncher();
invoke.mockImplementation(async (command: string) => {
if (command === 'get_local_game_manifest') {
throw new Error('清单读取被策略拒绝');
}
if (command === 'get_local_game_project_revision') {
return { revision: HELD_REVISION };
}
throw new Error(`unexpected invoke ${command}`);
});
await pushDivergentEqualRevisionSnapshot();
const notice = await waitFor(() => {
const element = document.querySelector<HTMLElement>(
'[data-manifest-merge-decision]',
);
expect(element).not.toBeNull();
return element!;
});
await waitFor(() =>
expect(notice.getAttribute('data-manifest-merge-stage')).toBe(
'unresolved',
),
);
expect(notice.textContent).toContain('重新打开项目');
});
it('never moves the held revision backwards when disk reports an older one', async () => {
const invoke = installInvokeMock();
await openProjectThroughLauncher();
// 磁盘版本比手上的旧:重读不能把状态挪回旧版本,那就是绕开 CAS 本身。
invoke.mockImplementation(async (command: string) => {
if (command === 'get_local_game_project_revision') {
return { revision: HELD_REVISION - 1 };
}
if (command === 'get_local_game_manifest') {
return manifestWithAsset('asset-new-art');
}
throw new Error(`unexpected invoke ${command}`);
});
await pushDivergentEqualRevisionSnapshot();
const notice = await waitFor(() => {
const element = document.querySelector<HTMLElement>(
'[data-manifest-merge-decision]',
);
expect(element).not.toBeNull();
return element!;
});
await waitFor(() =>
expect(notice.getAttribute('data-manifest-merge-stage')).toBe(
'unresolved',
),
);
expect(captured.supervisorProps?.initialProjectManifest?.assets).toEqual(
[],
);
});
it('keeps a snapshot that belongs to another project silent', async () => {
const invoke = installInvokeMock();
await openProjectThroughLauncher();
const callsBefore = invoke.mock.calls.filter(
([command]) => command === 'get_local_game_manifest',
).length;
await act(async () => {
captured.supervisorProps?.onManifestChange?.(
PROJECT_PATH,
{ ...manifestWithAsset('asset-other'), projectId: 'project-other' },
{
projectId: PROJECT_ID,
revision: HELD_REVISION + 1,
source: 'supervisor',
},
);
await Promise.resolve();
});
// 串项目的快照对用户没有可执行语义:既不弹提示,也不触发重读。
expect(document.querySelector('[data-manifest-merge-decision]')).toBeNull();
expect(
invoke.mock.calls.filter(
([command]) => command === 'get_local_game_manifest',
),
).toHaveLength(callsBefore);
});
});