import assert from 'node:assert/strict'; import test from 'node:test'; import type { Project, Scene, SceneGraphEdge, SceneGraphNode } from '../types'; import { asAssetId, asGraphNodeId, asProjectId, asSceneId } from '../types/ids'; import { buildPartialExportProject, collectStorylineGraphNodeIds, computeGraphImportOffsetX, findSceneTitleConflicts, listImportableStorylines, mergeStorylinesIntoProject, newExportBundleProjectId, } from './storylineExportImport'; const LABELS = { main: 'Основная линия', untitled: 'Без названия' }; function scene(id: string, title: string): Scene { return { id: asSceneId(id), title, description: '', previewAssetId: null, previewAssetType: null, previewThumbAssetId: null, previewVideoAutostart: false, previewRotationDeg: 0, darkenScene: false, traps: [], grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' }, media: { videos: [], audios: [] }, settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false }, connections: [], layout: { x: 0, y: 0 }, }; } function node(id: string, sceneId: string, opts?: Partial): SceneGraphNode { return { id: asGraphNodeId(id), sceneId: asSceneId(sceneId), x: 0, y: 0, isStartScene: false, isSideStoryStart: false, sideStoryLineTitle: '', ...opts, }; } function edge(id: string, src: string, tgt: string): SceneGraphEdge { return { id, sourceGraphNodeId: asGraphNodeId(src), targetGraphNodeId: asGraphNodeId(tgt) }; } function minimalProject(overrides: Partial = {}): Project { return { id: asProjectId('p1'), meta: { name: 'Test', fileBaseName: 'test', createdAt: '2020-01-01T00:00:00.000Z', updatedAt: '2020-01-01T00:00:00.000Z', createdWithAppVersion: '1', appVersion: '1', schemaVersion: 9, }, scenes: {}, sceneListOrder: [], assets: {}, campaignAudios: [], materials: [], npcs: [], npcGroups: [], npcRelations: [], currentSceneId: null, currentGraphNodeId: null, sceneGraphNodes: [], sceneGraphEdges: [], ...overrides, }; } void test('collectStorylineGraphNodeIds: main line excludes orphan and side nodes', () => { const project = minimalProject({ scenes: { [asSceneId('s1')]: scene('s1', 'Start'), [asSceneId('s2')]: scene('s2', 'A'), [asSceneId('s3')]: scene('s3', 'Side'), [asSceneId('s4')]: scene('s4', 'Orphan'), }, sceneGraphNodes: [ node('main', 's1', { isStartScene: true, x: 0 }), node('a', 's2', { x: 100 }), node('side', 's3', { isSideStoryStart: true, x: 200 }), node('orphan', 's4', { x: 300 }), ], sceneGraphEdges: [edge('e1', 'main', 'a'), edge('e2', 'side', 'orphan')], }); const mainIds = collectStorylineGraphNodeIds(project, { kind: 'main' }); assert.deepEqual([...mainIds].sort(), ['a', 'main'].sort()); }); void test('buildPartialExportProject: subset, manifest, no free nodes', () => { const source = minimalProject({ scenes: { [asSceneId('s1')]: scene('s1', 'Start'), [asSceneId('s2')]: scene('s2', 'A'), [asSceneId('s3')]: scene('s3', 'Side'), [asSceneId('s4')]: scene('s4', 'Orphan'), }, sceneGraphNodes: [ node('main', 's1', { isStartScene: true }), node('a', 's2'), node('side', 's3', { isSideStoryStart: true, sideStoryLineTitle: 'Квест' }), node('orphan', 's4'), ], sceneGraphEdges: [edge('e1', 'main', 'a'), edge('e2', 'side', 'orphan')], }); const partial = buildPartialExportProject( source, [{ kind: 'side', startGraphNodeId: asGraphNodeId('side') }], { newProjectId: newExportBundleProjectId(), exportTitle: 'Export', labels: LABELS }, ); assert.equal(partial.sceneGraphNodes.length, 2); assert.ok(partial.sceneGraphNodes.some((n) => n.id === asGraphNodeId('side'))); assert.ok(partial.sceneGraphNodes.some((n) => n.id === asGraphNodeId('orphan'))); assert.equal(Object.keys(partial.scenes).length, 2); assert.ok(partial.scenes[asSceneId('s3')]); assert.ok(!partial.scenes[asSceneId('s1')]); assert.ok(!partial.scenes[asSceneId('s2')]); assert.equal(partial.exportedStorylines?.length, 1); assert.equal(partial.exportedStorylines![0]!.label, 'Квест'); assert.equal(partial.exportedStorylines![0]!.kind, 'side'); }); void test('listImportableStorylines: disables main when target already has start', () => { const source = minimalProject({ exportedStorylines: [ { kind: 'main', startGraphNodeId: null, label: 'Основная линия' }, { kind: 'side', startGraphNodeId: asGraphNodeId('side'), label: 'Побочная' }, ], }); const items = listImportableStorylines(source, LABELS, true); assert.equal(items[0]!.disabled, true); assert.equal(items[0]!.disabledReason, 'main_exists'); assert.ok(!items[1]!.disabled); }); void test('findSceneTitleConflicts: case-insensitive trimmed match', () => { const target = minimalProject({ scenes: { [asSceneId('t1')]: scene('t1', ' Tavern ') }, }); const source = minimalProject({ scenes: { [asSceneId('s1')]: scene('s1', 'tavern') }, }); const conflicts = findSceneTitleConflicts(target, source, [asSceneId('s1')]); assert.equal(conflicts.length, 1); assert.equal(conflicts[0]!.sourceTitle, 'tavern'); assert.equal(conflicts[0]!.matches[0]!.sceneId, asSceneId('t1')); }); void test('mergeStorylinesIntoProject: offset X, create scene, rename side title', () => { const target = minimalProject({ scenes: { [asSceneId('t1')]: scene('t1', 'Existing') }, sceneGraphNodes: [node('tgn', 't1', { x: 400 })], }); const source = minimalProject({ scenes: { [asSceneId('s1')]: scene('s1', 'New scene'), [asSceneId('s2')]: scene('s2', 'Side scene'), }, sceneGraphNodes: [ node('side', 's2', { isSideStoryStart: true, sideStoryLineTitle: 'Квест', x: 50, y: 10 }), node('a', 's1', { x: 150, y: 10 }), ], sceneGraphEdges: [edge('e1', 'side', 'a')], }); target.sceneGraphNodes.push( node('existingSide', 't1', { isSideStoryStart: true, sideStoryLineTitle: 'Квест' }), ); const offsetX = computeGraphImportOffsetX(target); const { project: merged, report } = mergeStorylinesIntoProject( target, source, [{ kind: 'side', startGraphNodeId: asGraphNodeId('side') }], [{ sourceSceneId: asSceneId('s1'), mode: 'create' }, { sourceSceneId: asSceneId('s2'), mode: 'create' }], { graphOffsetX: offsetX }, ); const imported = merged.sceneGraphNodes.filter((n) => n.id !== asGraphNodeId('tgn') && n.id !== asGraphNodeId('existingSide')); assert.equal(imported.length, 2); assert.equal(imported[0]!.x, offsetX); assert.equal(imported[1]!.x, offsetX + 100); const sideStart = imported.find((n) => n.isSideStoryStart); assert.equal(sideStart?.sideStoryLineTitle, 'Квест (2)'); assert.equal(report.renamedSideTitles.length, 1); assert.equal(report.scenesCreated, 2); assert.equal(report.graphNodesAdded, 2); assert.equal(report.edgesAdded, 1); }); void test('mergeStorylinesIntoProject: reuse existing scene by resolution', () => { const targetSceneId = asSceneId('t1'); const target = minimalProject({ scenes: { [targetSceneId]: scene('t1', 'Tavern') }, sceneGraphNodes: [], }); const source = minimalProject({ scenes: { [asSceneId('s1')]: scene('s1', 'Tavern') }, sceneGraphNodes: [node('gn', 's1', { isStartScene: true, x: 0 })], sceneGraphEdges: [], }); const { project: merged, report } = mergeStorylinesIntoProject( target, source, [{ kind: 'main' }], [{ sourceSceneId: asSceneId('s1'), mode: 'use', targetSceneId }], { graphOffsetX: 100 }, ); assert.equal(report.scenesReused, 1); assert.equal(report.scenesCreated, 0); assert.equal(merged.sceneGraphNodes.length, 1); assert.equal(merged.sceneGraphNodes[0]!.sceneId, targetSceneId); });