c8ab9dd567
Добавлена полноценная поддержка побочных сюжетных линий в редакторе и на пульте: визуальное выделение компонент графа, запрет недопустимых связей между основной и побочными линиями, метки «ПОБОЧНАЯ» и названия линий. Реализован partial export/import сюжетных линий: - экспорт выбранных линий в урезанный .ttrpg.zip с manifest в project.json; - импорт в открытый проект с выбором линий, разрешением конфликтов названий сцен (одна модалка на операцию) и отчётом о результате; - новое окно источника импорта: «Из проекта» (dropdown, без текущего) или «Из файла»; на главном экране — только полный импорт из файла. Исправлены гонки при открытии/закрытии проекта (сериализация open/close в main, сброс зависших состояний UI), залипание оверлея прогресса экспорта и старт Electron в dev после готовности Vite. Добавлены unit-тесты для lineage, export/import и контрактов zipStore. Co-authored-by: Cursor <cursoragent@cursor.com>
171 lines
7.0 KiB
TypeScript
171 lines
7.0 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
|
|
import { isSceneGraphEdgeRejected } from './sceneGraphEdgeRules';
|
|
import {
|
|
canSetSideStoryStart,
|
|
getNodeStoryRoot,
|
|
isNodeInSideStoryline,
|
|
isSideStoryEdge,
|
|
listSideStoryStarts,
|
|
} from './sceneGraphLineage';
|
|
import type { SceneGraphEdge, SceneGraphNode } from '../types';
|
|
import { asGraphNodeId, asSceneId } from '../types/ids';
|
|
|
|
function node(id: string, scene: string, opts?: Partial<SceneGraphNode>): SceneGraphNode {
|
|
return {
|
|
id: asGraphNodeId(id),
|
|
sceneId: asSceneId(scene),
|
|
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) };
|
|
}
|
|
|
|
void test('canSetSideStoryStart: false when main or side marker in component', () => {
|
|
const nodes = [
|
|
node('main', 's1', { isStartScene: true }),
|
|
node('a', 's2'),
|
|
node('side', 's3', { isSideStoryStart: true }),
|
|
node('b', 's4'),
|
|
node('orphan', 's5'),
|
|
];
|
|
const edges = [edge('e1', 'main', 'a'), edge('e2', 'side', 'b')];
|
|
assert.equal(canSetSideStoryStart(nodes, edges, asGraphNodeId('a')), false);
|
|
assert.equal(canSetSideStoryStart(nodes, edges, asGraphNodeId('b')), false);
|
|
assert.equal(canSetSideStoryStart(nodes, edges, asGraphNodeId('orphan')), true);
|
|
});
|
|
|
|
void test('getNodeStoryRoot classifies main, side, orphan', () => {
|
|
const nodes = [
|
|
node('main', 's1', { isStartScene: true }),
|
|
node('a', 's2'),
|
|
node('side', 's3', { isSideStoryStart: true }),
|
|
node('b', 's4'),
|
|
node('orphan', 's5'),
|
|
];
|
|
const edges = [edge('e1', 'main', 'a'), edge('e2', 'side', 'b')];
|
|
assert.equal(getNodeStoryRoot(nodes, edges, asGraphNodeId('main')), 'main');
|
|
assert.equal(getNodeStoryRoot(nodes, edges, asGraphNodeId('a')), 'main');
|
|
assert.equal(getNodeStoryRoot(nodes, edges, asGraphNodeId('b')), asGraphNodeId('side'));
|
|
assert.equal(getNodeStoryRoot(nodes, edges, asGraphNodeId('orphan')), null);
|
|
assert.equal(isNodeInSideStoryline(nodes, edges, asGraphNodeId('main')), false);
|
|
assert.equal(isNodeInSideStoryline(nodes, edges, asGraphNodeId('a')), false);
|
|
assert.equal(isNodeInSideStoryline(nodes, edges, asGraphNodeId('b')), true);
|
|
});
|
|
|
|
void test('isSceneGraphEdgeRejected: blocks main↔side and side↔side', () => {
|
|
const nodes = [
|
|
node('main', 's1', { isStartScene: true }),
|
|
node('side1', 's2', { isSideStoryStart: true }),
|
|
node('side2', 's3', { isSideStoryStart: true }),
|
|
node('a', 's4'),
|
|
node('b', 's5'),
|
|
];
|
|
const edges = [edge('e1', 'side1', 'a'), edge('e2', 'side2', 'b')];
|
|
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('main'), asGraphNodeId('side1')), true);
|
|
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('side1'), asGraphNodeId('main')), true);
|
|
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('main'), asGraphNodeId('a')), true);
|
|
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('a'), asGraphNodeId('main')), true);
|
|
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('a'), asGraphNodeId('b')), true);
|
|
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('a'), asGraphNodeId('side1')), false);
|
|
});
|
|
|
|
void test('isSceneGraphEdgeRejected: blocks main to any node in side component without badge', () => {
|
|
const nodes = [
|
|
node('main', 's1', { isStartScene: true }),
|
|
node('side1', 's2', { isSideStoryStart: true }),
|
|
node('a', 's3'),
|
|
node('b', 's4'),
|
|
];
|
|
const edges = [edge('e1', 'side1', 'a'), edge('e2', 'a', 'b')];
|
|
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('main'), asGraphNodeId('a')), true);
|
|
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('main'), asGraphNodeId('b')), true);
|
|
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('a'), asGraphNodeId('main')), true);
|
|
});
|
|
|
|
void test('isSideStoryEdge: side chain edges are blue even without badge on endpoint', () => {
|
|
const nodes = [
|
|
node('side', 's1', { isSideStoryStart: true }),
|
|
node('a', 's2'),
|
|
node('b', 's3'),
|
|
];
|
|
const edges = [edge('e1', 'side', 'a'), edge('e2', 'a', 'b')];
|
|
assert.equal(isSideStoryEdge(nodes, edges, edges[0]!), true);
|
|
assert.equal(isSideStoryEdge(nodes, edges, edges[1]!), true);
|
|
});
|
|
|
|
void test('isNodeInSideStoryline: follows current component after reconnect', () => {
|
|
const nodes = [
|
|
node('main', 's1', { isStartScene: true }),
|
|
node('side', 's2', { isSideStoryStart: true }),
|
|
node('x', 's3'),
|
|
];
|
|
const mainEdge = [edge('e1', 'main', 'x')];
|
|
const sideEdge = [edge('e2', 'side', 'x')];
|
|
assert.equal(isNodeInSideStoryline(nodes, mainEdge, asGraphNodeId('x')), false);
|
|
assert.equal(isNodeInSideStoryline(nodes, sideEdge, asGraphNodeId('x')), true);
|
|
});
|
|
|
|
void test('isSideStoryEdge: main edges are not side edges', () => {
|
|
const nodes = [
|
|
node('main', 's1', { isStartScene: true }),
|
|
node('a', 's2'),
|
|
node('side', 's3', { isSideStoryStart: true }),
|
|
node('b', 's4'),
|
|
];
|
|
const edges = [edge('e1', 'main', 'a'), edge('e2', 'side', 'b')];
|
|
assert.equal(isSideStoryEdge(nodes, edges, edges[0]!), false);
|
|
assert.equal(isSideStoryEdge(nodes, edges, edges[1]!), true);
|
|
});
|
|
|
|
void test('isSceneGraphEdgeRejected: allows main-to-main edges', () => {
|
|
const nodes = [node('main', 's1', { isStartScene: true }), node('a', 's2')];
|
|
const edges: SceneGraphEdge[] = [];
|
|
assert.equal(
|
|
isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('main'), asGraphNodeId('a')),
|
|
false,
|
|
);
|
|
});
|
|
|
|
void test('isSceneGraphEdgeRejected: allows main to free connected chain without markers', () => {
|
|
const nodes = [
|
|
node('main', 's1', { isStartScene: true }),
|
|
node('a', 's2'),
|
|
node('b', 's3'),
|
|
node('c', 's4'),
|
|
];
|
|
const edges = [edge('e1', 'main', 'a'), edge('e2', 'b', 'c')];
|
|
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('main'), asGraphNodeId('b')), false);
|
|
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('a'), asGraphNodeId('b')), false);
|
|
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('a'), asGraphNodeId('c')), false);
|
|
});
|
|
|
|
void test('isSceneGraphEdgeRejected: allows connections within main component without start badge on target', () => {
|
|
const nodes = [
|
|
node('main', 's1', { isStartScene: true }),
|
|
node('a', 's2'),
|
|
node('b', 's3'),
|
|
node('c', 's4'),
|
|
];
|
|
const edges = [edge('e1', 'main', 'a'), edge('e2', 'a', 'b')];
|
|
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('main'), asGraphNodeId('b')), false);
|
|
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('a'), asGraphNodeId('c')), false);
|
|
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('b'), asGraphNodeId('c')), false);
|
|
});
|
|
|
|
void test('listSideStoryStarts returns all side starters', () => {
|
|
const nodes = [
|
|
node('side1', 's1', { isSideStoryStart: true, sideStoryLineTitle: 'Quest A' }),
|
|
node('side2', 's2', { isSideStoryStart: true }),
|
|
];
|
|
assert.equal(listSideStoryStarts(nodes).length, 2);
|
|
});
|