import assert from 'node:assert/strict'; import test from 'node:test'; import type { Project, ProjectNpc, ProjectNpcGroup, ProjectNpcRelation, Scene, SceneGraphEdge, SceneGraphNode } from '../types'; import { asAssetId, asGraphNodeId, asNpcGroupId, asNpcId, asNpcRelationId, asProjectId, asSceneId, asSceneNpcTokenId, } from '../types/ids'; import { PROJECT_SCHEMA_VERSION } from '../types'; import { buildPartialExportProject, collectStorylineGraphNodeIds, computeGraphImportOffsetX, findSceneTitleConflicts, listExportedNpcsFromBundle, listImportableStorylines, mergeStorylinesIntoProject, newExportBundleProjectId, selectNpcsByIds, } 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: [], tokens: [], npcTokens: [], 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: PROJECT_SCHEMA_VERSION, }, 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('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations, ancestor groups', () => { const gRoot = asNpcGroupId('g_root'); const gChild = asNpcGroupId('g_child'); const n1 = asNpcId('npc1'); const n2 = asNpcId('npc2'); const n3 = asNpcId('npc3'); const avatar = asAssetId('a1'); const groups: ProjectNpcGroup[] = [ { id: gRoot, name: 'Root', color: '#ffffff', parentId: null }, { id: gChild, name: 'Child', color: '#ff0000', parentId: gRoot }, { id: asNpcGroupId('g_other'), name: 'Other', color: '#00ff00', parentId: null }, ]; const npcs: ProjectNpc[] = [ { id: n1, name: 'A', avatarAssetId: avatar, description: '', x: 0, y: 0, groupId: gChild, ringColor: '#c9a227', imageOffset: { x: 0, y: 0 }, imageScale: 1, }, { id: n2, name: 'B', avatarAssetId: avatar, description: '', x: 10, y: 0, groupId: gChild, ringColor: '#c9a227', imageOffset: { x: 0, y: 0 }, imageScale: 1, }, { id: n3, name: 'C', avatarAssetId: avatar, description: '', x: 20, y: 0, groupId: asNpcGroupId('g_other'), ringColor: '#c9a227', imageOffset: { x: 0, y: 0 }, imageScale: 1, }, ]; const relations: ProjectNpcRelation[] = [ { id: asNpcRelationId('r1'), sourceNpcId: n1, targetNpcId: n2, label: 'friends' }, { id: asNpcRelationId('r2'), sourceNpcId: n1, targetNpcId: n3, label: 'rivals' }, ]; const source = minimalProject({ scenes: { [asSceneId('s1')]: scene('s1', 'Start') }, sceneGraphNodes: [node('main', 's1', { isStartScene: true })], assets: { [avatar]: { id: avatar, type: 'image', mime: 'image/png', originalName: 'a.png', relPath: 'assets/a.png', sha256: 'abc', sizeBytes: 1, createdAt: '2020-01-01T00:00:00.000Z', }, }, npcs, npcGroups: groups, npcRelations: relations, }); assert.deepEqual( selectNpcsByIds(source, [n1, n2]).map((n) => n.id), [n1, n2], ); const partial = buildPartialExportProject(source, [{ kind: 'main' }], { newProjectId: newExportBundleProjectId(), exportTitle: 'Export', labels: LABELS, npcIds: [n1, n2], }); assert.equal(partial.npcs.length, 2); assert.ok(partial.npcs.every((n) => n.id === n1 || n.id === n2)); assert.equal(partial.npcRelations.length, 1); assert.equal(partial.npcRelations[0]!.label, 'friends'); assert.deepEqual( [...partial.npcGroups.map((g) => g.id)].sort(), [gRoot, gChild].sort(), ); assert.ok(!partial.npcGroups.some((g) => g.id === asNpcGroupId('g_other'))); }); void test('buildPartialExportProject: keeps npcTokens only for exported NPCs', () => { const n1 = asNpcId('npc1'); const n2 = asNpcId('npc2'); const avatar = asAssetId('a1'); const sceneId = asSceneId('s1'); const source = minimalProject({ scenes: { [sceneId]: { ...scene('s1', 'Start'), npcTokens: [ { id: asSceneNpcTokenId('nt1'), npcId: n1, nx: 0.2, ny: 0.3, sizeN: 0.1 }, { id: asSceneNpcTokenId('nt2'), npcId: n2, nx: 0.5, ny: 0.5, sizeN: 0.1 }, { id: asSceneNpcTokenId('nt3'), npcId: asNpcId('missing'), nx: 0.1, ny: 0.1, sizeN: 0.1 }, ], }, }, sceneGraphNodes: [node('main', 's1', { isStartScene: true })], assets: { [avatar]: { id: avatar, type: 'image', mime: 'image/png', originalName: 'a.png', relPath: 'assets/a.png', sha256: 'abc', sizeBytes: 1, createdAt: '2020-01-01T00:00:00.000Z', }, }, npcs: [ { id: n1, name: 'A', avatarAssetId: avatar, description: '', x: 0, y: 0, groupId: null, ringColor: '#c9a227', imageOffset: { x: 0, y: 0 }, imageScale: 1, }, { id: n2, name: 'B', avatarAssetId: avatar, description: '', x: 10, y: 0, groupId: null, ringColor: '#c9a227', imageOffset: { x: 0, y: 0 }, imageScale: 1, }, ], }); const partial = buildPartialExportProject(source, [{ kind: 'main' }], { newProjectId: newExportBundleProjectId(), exportTitle: 'Export', labels: LABELS, npcIds: [n1], }); const tokens = partial.scenes[sceneId]?.npcTokens ?? []; assert.equal(tokens.length, 1); assert.equal(tokens[0]!.npcId, n1); }); void test('mergeStorylinesIntoProject: remaps npcTokens to created NPC ids', () => { const avatar = asAssetId('a1'); const sourceNpcId = asNpcId('src_npc'); const source = minimalProject({ scenes: { [asSceneId('s1')]: { ...scene('s1', 'Side'), npcTokens: [ { id: asSceneNpcTokenId('nt1'), npcId: sourceNpcId, nx: 0.4, ny: 0.6, sizeN: 0.12 }, ], }, }, sceneGraphNodes: [node('side', 's1', { isSideStoryStart: true, sideStoryLineTitle: 'Side', x: 0 })], assets: { [avatar]: { id: avatar, type: 'image', mime: 'image/png', originalName: 'a.png', relPath: 'assets/a.png', sha256: 'npcsha', sizeBytes: 1, createdAt: '2020-01-01T00:00:00.000Z', }, }, npcs: [ { id: sourceNpcId, name: 'Hero', avatarAssetId: avatar, description: '', x: 0, y: 0, groupId: null, ringColor: '#aabbcc', imageOffset: { x: 0.1, y: -0.1 }, imageScale: 1, }, ], }); const target = minimalProject({ scenes: { [asSceneId('t1')]: scene('t1', 'Existing') }, sceneGraphNodes: [node('tgn', 't1', { x: 0 })], }); const { project: merged } = mergeStorylinesIntoProject( target, source, [{ kind: 'side', startGraphNodeId: asGraphNodeId('side') }], [{ sourceSceneId: asSceneId('s1'), mode: 'create' }], { graphOffsetX: 200, npcResolutions: [{ sourceNpcId, mode: 'create' }], }, ); assert.equal(merged.npcs.length, 1); const importedScene = Object.values(merged.scenes).find((s) => s.title === 'Side'); assert.ok(importedScene); assert.equal(importedScene!.npcTokens.length, 1); assert.equal(importedScene!.npcTokens[0]!.npcId, merged.npcs[0]!.id); assert.notEqual(importedScene!.npcTokens[0]!.npcId, sourceNpcId); assert.equal(merged.npcs[0]!.ringColor, '#aabbcc'); }); void test('mergeStorylinesIntoProject: imports all NPCs from export bundle', () => { const avatar = asAssetId('a1'); const sourceNpcId = asNpcId('src_npc'); const source = minimalProject({ scenes: { [asSceneId('s1')]: scene('s1', 'Side') }, sceneGraphNodes: [node('side', 's1', { isSideStoryStart: true, sideStoryLineTitle: 'Side', x: 0 })], assets: { [avatar]: { id: avatar, type: 'image', mime: 'image/png', originalName: 'a.png', relPath: 'assets/a.png', sha256: 'npcsha', sizeBytes: 1, createdAt: '2020-01-01T00:00:00.000Z', }, }, npcs: [ { id: sourceNpcId, name: 'Hero', avatarAssetId: avatar, description: '', x: 0, y: 0, groupId: null, ringColor: '#c9a227', imageOffset: { x: 0, y: 0 }, imageScale: 1, }, ], }); const target = minimalProject({ scenes: { [asSceneId('t1')]: scene('t1', 'Existing') }, sceneGraphNodes: [node('tgn', 't1', { x: 0 })], }); assert.equal(listExportedNpcsFromBundle(source).length, 1); const { project: merged, report } = mergeStorylinesIntoProject( target, source, [{ kind: 'side', startGraphNodeId: asGraphNodeId('side') }], [{ sourceSceneId: asSceneId('s1'), mode: 'create' }], { graphOffsetX: 200, npcResolutions: [{ sourceNpcId, mode: 'create' }], }, ); assert.equal(report.npcsCreated, 1); assert.equal(merged.npcs.length, 1); assert.equal(merged.npcs[0]!.name, 'Hero'); assert.ok(!('binding' in (merged.npcs[0] as object))); }); 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); });