清单快照被拒收不再静默:可见提示 + 强制重读磁盘真相
- 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:
@@ -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>
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
+88
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user