import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import { createProjectManifestMergeState, describeProjectManifestMergeRejection, mergeProjectManifestSnapshot, type ProjectManifestMergeRecoveryStage, projectManifestMergeRejectionDecision, type ProjectManifestSnapshot, rereadAuthoritativeProjectManifestSnapshot, resolveResourceFocusIntent, type ResourceFocusIntent, } from '../src/view/project-development/projectResourceLiveUpdateModel'; function manifest(projectId: string, assetIds: string[] = []) { return { schemaVersion: 'game-creation-app.v1', projectId, name: projectId, tasks: [], assets: assetIds.map((id) => ({ id, kind: 'asset', mediaType: 'image/png', localPath: `assets/${id}.png`, source: { kind: 'canvas' as const, taskId: null }, })), versions: [], } as unknown as GameCreationAppManifest; } function snapshot( revision: number, source: ProjectManifestSnapshot['source'], assetIds: string[] = [], ): ProjectManifestSnapshot { const value = manifest('project-live', assetIds); return { projectPath: '/tmp/project-live', projectId: value.projectId, revision, manifest: value, source, commitId: assetIds.length > 0 ? `commit-${assetIds.at(-1)}` : undefined, eventId: source === 'asset-event' ? `event-${revision}` : undefined, }; } function focusIntent( input?: Partial, ): ResourceFocusIntent { 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('重新打开项目'); }); });