f270812219
Add token library/placements, keep play-time moves for the session, lock presentation interactions, and fix export/import modal layout plus freeform trap label. Co-authored-by: Cursor <cursoragent@cursor.com>
239 lines
7.9 KiB
TypeScript
239 lines
7.9 KiB
TypeScript
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: [],
|
|
tokens: [],
|
|
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>): 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> = {}): 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);
|
|
});
|