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, uploadProjectAssetFilesAndReadSnapshot, } 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 { 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[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; }) { 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('重新打开项目'); }); it('does not claim the disk reread failed when the reread succeeded but was not adopted', () => { // `unresolved` 有三条进入路径,其中两条**重读是成功的**: // 读到的一对比手上旧(不能回退 revision)、或读到的是撕裂的一对(不能采信)。 // 文案对"读盘失败"的断言必须在两条路径上都不成立,否则就是在误报。 for (const decision of ['revision-conflict', 'stale-revision'] as const) { const text = describeProjectManifestMergeRejection( decision, 'unresolved', ); expect(text).not.toContain('读取磁盘清单失败'); expect(text).toContain('未能按磁盘清单重新对齐'); expect(text).toContain('重新打开项目'); } }); }); /** * ⚠️ **覆盖边界**:下面这组断言打在生产函数 `uploadProjectAssetFilesAndReadSnapshot` 上 —— * 它同时拥有「上传」与「配对读」,所以整条配对语义是真测的;但 AGC 侧**没有**"资源面板上传 → * 拒收提示条"的端到端 UI 用例(现有 harness 只有聊天入口的 `/asset.upload` 通路,资源面板的 * file input 没有用例,补一条要渲染整个 `ProjectDevelopmentView`)。因此「界面上不再出现拒收 * 提示」这一层是**推理结论**(配对正确 ⇒ merge 判 accepted ⇒ 不产生拒收决策 ⇒ 不渲染提示条), * 不是端到端断言。别把它当成端到端覆盖引用。 */ describe('素材上传后的清单快照必须配对', () => { /** * 假后端:上传推进 revision,其余读命令返回当前磁盘状态。 * 真机事实(Rust `advance_agent_runtime_project_revision_locked`):上传本身会推进 * 项目 revision,所以"上传前读到的 revision"注定配不上"上传后读到的清单"。 */ function fakeDisk(input?: { advanceOnUpload?: boolean }) { const advanceOnUpload = input?.advanceOnUpload ?? true; const state = { revision: 3, manifest: manifest('project-live', []), }; const invoke = async (command: string): Promise => { switch (command) { case 'upload_local_asset': if (advanceOnUpload) { state.revision += 1; state.manifest = manifest('project-live', ['uploaded-art']); } return undefined as T; case 'get_local_game_project_revision': return { revision: state.revision } as T; case 'get_local_game_manifest': return state.manifest as T; default: throw new Error(`unexpected invoke ${command}`); } }; return { state, invoke }; } function heldState(revision: number) { return createProjectManifestMergeState({ projectPath: '/tmp/project-live', projectId: 'project-live', revision, manifest: manifest('project-live'), source: 'initial', }); } it('produces a snapshot the merge accepts instead of one it rejects as a revision conflict', async () => { const disk = fakeDisk(); const uploaded = await uploadProjectAssetFilesAndReadSnapshot({ projectPath: '/tmp/project-live', projectId: 'project-live', commitId: 'asset-upload:1', files: [ { fileName: 'art.png', mediaType: 'image/png', bytes: [1, 2, 3] }, ], invoke: disk.invoke, }); expect(uploaded).not.toBeNull(); // 用户可见判据放最前:上传一次不得被判成拒收,新素材必须进清单。 const merged = mergeProjectManifestSnapshot(heldState(3), uploaded!); expect(merged.decision).toBe('accepted'); expect(projectManifestMergeRejectionDecision(merged.decision)).toBeNull(); expect(merged.state.revision).toBe(4); expect(uploaded!.manifest.assets.map((asset) => asset.id)).toEqual([ 'uploaded-art', ]); expect(uploaded).toMatchObject({ projectPath: '/tmp/project-live', projectId: 'project-live', // 上传推进后的版本号,不是上传前那个。 revision: 4, source: 'asset-command', commitId: 'asset-upload:1', }); }); it('reports the old pre-upload revision pairing as a user-visible rejection', async () => { // 对照钉子:把"上传前读 revision + 上传后读清单"这条旧形状显式地写出来, // 它必须被判成 `revision-conflict` —— 这正是用户每次上传都吃到的拒收提示。 // 这条对照证明上面那条用例的判据不是空断言:错配确实会被拒收。 const disk = fakeDisk(); const staleRevision = ( await disk.invoke<{ revision: number }>('get_local_game_project_revision') ).revision; await disk.invoke('upload_local_asset', {}); const freshManifest = await disk.invoke( 'get_local_game_manifest', ); const merged = mergeProjectManifestSnapshot(heldState(3), { projectPath: '/tmp/project-live', projectId: 'project-live', revision: staleRevision, manifest: freshManifest, source: 'asset-command', }); expect(merged.decision).toBe('revision-conflict'); expect(projectManifestMergeRejectionDecision(merged.decision)).toBe( 'revision-conflict', ); }); });