Files
DndGamePlayer/app/shared/graph/sceneGraphLineage.test.ts
Ivan Fontosh c8ab9dd567 feat(editor): побочные сюжетные линии и импорт/экспорт линий
Добавлена полноценная поддержка побочных сюжетных линий в редакторе и на
пульте: визуальное выделение компонент графа, запрет недопустимых связей
между основной и побочными линиями, метки «ПОБОЧНАЯ» и названия линий.

Реализован 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>
2026-07-09 14:47:28 +08:00

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);
});