diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 289614325..dfd5dae6d 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -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(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( + 'get_local_game_project_revision', + { projectPath: input.projectPath }, + ); + return status.revision; + }, + readManifest: () => + invoke['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 ? ( - - requestCurrentProjectPlay(currentProjectContext.projectPath) - } - onManifestChange={syncActiveProjectManifest} - onHomeOpen={() => setLauncherView('home')} - onProjectsOpen={() => setLauncherView('projects')} - supervisor={ - - } - /> + <> + {manifestMergeNotice ? ( +
+
+ + {describeProjectManifestMergeRejection( + manifestMergeNotice.decision, + manifestMergeNotice.stage, + )} + + {manifestMergeNotice.stage === 'recovering' ? null : ( + + )} + +
+
+ ) : null} + + requestCurrentProjectPlay(currentProjectContext.projectPath) + } + onManifestChange={syncActiveProjectManifest} + onHomeOpen={() => setLauncherView('home')} + onProjectsOpen={() => setLauncherView('projects')} + supervisor={ + + } + /> + ) : (
diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 977653391..a9c1dc758 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -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%); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts b/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts index 2e0ddb29d..f91fd096e 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts @@ -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; + readManifest: () => Promise; +}; + +/** + * 拒收之后的恢复手段:**重读磁盘真相**,而不是把被拒的快照硬收下(那等于放宽 CAS)。 + * + * 为什么重读能解:拒收的成因是快照自带的版本号不足以定位内容(典型是资产命令把 + * **写盘之前**读到的 revision 贴在了**写盘之后**读到的清单上),于是"同一版本号、 + * 内容不同"被 CAS 挡下。此时唯一可靠的解法是回到磁盘问一次当前 `(revision, 清单)`, + * 用它重建 merge 状态。 + * + * 两次读 revision 夹一次读清单:中间有写盘落地就说明读到的是撕裂的一对, + * 直接放弃本次恢复(下一次快照会带上新的 revision),不猜、不凑。 + */ +export async function rereadAuthoritativeProjectManifestSnapshot( + input: ProjectManifestRereadInput, +): Promise { + 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; diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts b/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts index c70af9e8e..7d0d1a461 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts @@ -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; + }) { + 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(); + 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('重新打开项目'); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx b/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx new file mode 100644 index 000000000..c5e50ab9b --- /dev/null +++ b/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx @@ -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) => { + 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( + '[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( + '[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( + '[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); + }); +});