@elicdavis/node-flow 0.1.15 → 0.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.mocharc.json +5 -0
- package/dist/index.d.mts +139 -4
- package/dist/index.d.ts +139 -4
- package/dist/index.js +3754 -3352
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +3741 -3352
- package/dist/index.mjs.map +1 -1
- package/index.html +0 -30
- package/package.json +4 -2
- package/src/graph.ts +234 -41
- package/src/index.ts +82 -40
- package/src/input.ts +22 -14
- package/src/markdown/entry.ts +26 -3
- package/src/markdown/lexicalParser.ts +22 -32
- package/src/markdown/syntaxParser.ts +93 -68
- package/src/minimap.ts +178 -0
- package/src/node.ts +114 -0
- package/src/nodes/factory.ts +9 -5
- package/src/nodes/publisher.ts +39 -4
- package/src/nodes/subsystem.ts +218 -8
- package/src/notes/subsystem.ts +3 -0
- package/src/organize.ts +276 -181
- package/src/port.ts +143 -7
- package/src/render_scheduler.ts +49 -0
- package/src/styles/text.ts +8 -0
- package/src/theme.ts +8 -0
- package/src/types/text.ts +8 -4
- package/src/utils/string.ts +7 -1
- package/src/widgets/color.ts +5 -6
- package/src/widgets/factory.ts +1 -1
- package/src/widgets/image.ts +2 -0
- package/src/widgets/number.ts +2 -2
- package/src/widgets/slider.ts +2 -2
- package/src/widgets/string.ts +2 -2
- package/src/widgets/toggle.ts +2 -2
- package/src/widgets/widget.ts +22 -0
- package/test/markdown.test.ts +165 -0
- package/test/minimap.test.ts +103 -0
- package/test/node_menu_filter.test.ts +119 -0
- package/test/organize.test.ts +156 -0
- package/test/port.test.ts +125 -0
- package/test/render_scheduler.test.ts +119 -0
- package/tsconfig.json +6 -2
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import * as assert from 'assert';
|
|
2
|
+
import { Camera } from '../src/camera';
|
|
3
|
+
import { Minimap } from '../src/minimap';
|
|
4
|
+
import { FlowNode } from '../src/node';
|
|
5
|
+
import { Theme } from '../src/theme';
|
|
6
|
+
|
|
7
|
+
/** Records the fill and stroke colors a render asked for, in order. */
|
|
8
|
+
function recordingContext(): { ctx: any, fills: Array<string>, strokes: Array<string> } {
|
|
9
|
+
const fills: Array<string> = [];
|
|
10
|
+
const strokes: Array<string> = [];
|
|
11
|
+
|
|
12
|
+
const ctx: any = {
|
|
13
|
+
lineWidth: 1,
|
|
14
|
+
font: "",
|
|
15
|
+
textAlign: "left",
|
|
16
|
+
textBaseline: "top",
|
|
17
|
+
set fillStyle(value: string) { fills.push(value); },
|
|
18
|
+
get fillStyle() { return fills[fills.length - 1]; },
|
|
19
|
+
set strokeStyle(value: string) { strokes.push(value); },
|
|
20
|
+
get strokeStyle() { return strokes[strokes.length - 1]; },
|
|
21
|
+
measureText: () => ({ width: 40 }),
|
|
22
|
+
beginPath: () => { },
|
|
23
|
+
closePath: () => { },
|
|
24
|
+
roundRect: () => { },
|
|
25
|
+
rect: () => { },
|
|
26
|
+
moveTo: () => { },
|
|
27
|
+
lineTo: () => { },
|
|
28
|
+
arc: () => { },
|
|
29
|
+
bezierCurveTo: () => { },
|
|
30
|
+
quadraticCurveTo: () => { },
|
|
31
|
+
fill: () => { },
|
|
32
|
+
stroke: () => { },
|
|
33
|
+
fillRect: () => { },
|
|
34
|
+
strokeRect: () => { },
|
|
35
|
+
fillText: () => { },
|
|
36
|
+
save: () => { },
|
|
37
|
+
restore: () => { },
|
|
38
|
+
translate: () => { },
|
|
39
|
+
scale: () => { },
|
|
40
|
+
setLineDash: () => { },
|
|
41
|
+
clip: () => { },
|
|
42
|
+
createLinearGradient: () => ({ addColorStop: () => { } }),
|
|
43
|
+
drawImage: () => { },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return { ctx, fills, strokes };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function render(minimap: Minimap) {
|
|
50
|
+
const { ctx, fills, strokes } = recordingContext();
|
|
51
|
+
const canvas: any = { width: 800, height: 600 };
|
|
52
|
+
const node = new FlowNode({ title: "node", position: { x: 0, y: 0 } });
|
|
53
|
+
minimap.render(ctx, canvas, new Camera(), [node]);
|
|
54
|
+
return { fills, strokes };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('Minimap theming', () => {
|
|
58
|
+
const original = { ...Theme.Minimap };
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
Object.assign(Theme.Minimap, original);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('draws with the colors the theme carries', () => {
|
|
65
|
+
const minimap = new Minimap({ enabled: true });
|
|
66
|
+
|
|
67
|
+
const { fills, strokes } = render(minimap);
|
|
68
|
+
|
|
69
|
+
assert.ok(fills.includes(Theme.Minimap.BackgroundColor), "panel uses the theme background");
|
|
70
|
+
assert.ok(fills.includes(Theme.Minimap.NodeColor), "nodes use the theme node color");
|
|
71
|
+
assert.ok(fills.includes(Theme.Minimap.ViewportColor), "viewport uses the theme viewport color");
|
|
72
|
+
assert.ok(strokes.includes(Theme.Minimap.BorderColor), "border uses the theme border color");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// The whole point of moving these onto Theme: retheming the app restyles a
|
|
76
|
+
// minimap that is already on screen, without rebuilding it.
|
|
77
|
+
it('follows a theme change made after it was built', () => {
|
|
78
|
+
const minimap = new Minimap({ enabled: true });
|
|
79
|
+
render(minimap);
|
|
80
|
+
|
|
81
|
+
Theme.Minimap.BackgroundColor = "#ff0000";
|
|
82
|
+
Theme.Minimap.NodeColor = "#00ff00";
|
|
83
|
+
Theme.Minimap.ViewportColor = "#0000ff";
|
|
84
|
+
Theme.Minimap.BorderColor = "#ffff00";
|
|
85
|
+
|
|
86
|
+
const { fills, strokes } = render(minimap);
|
|
87
|
+
|
|
88
|
+
assert.ok(fills.includes("#ff0000"));
|
|
89
|
+
assert.ok(fills.includes("#00ff00"));
|
|
90
|
+
assert.ok(fills.includes("#0000ff"));
|
|
91
|
+
assert.ok(strokes.includes("#ffff00"));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('lets one minimap override the theme without changing it for the rest', () => {
|
|
95
|
+
const minimap = new Minimap({ enabled: true, backgroundColor: "#123456" });
|
|
96
|
+
|
|
97
|
+
const { fills } = render(minimap);
|
|
98
|
+
|
|
99
|
+
assert.ok(fills.includes("#123456"), "the override wins");
|
|
100
|
+
assert.ok(!fills.includes(Theme.Minimap.BackgroundColor), "the theme value is not also drawn");
|
|
101
|
+
assert.ok(fills.includes(Theme.Minimap.NodeColor), "ports left unset still follow the theme");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import * as assert from 'assert';
|
|
2
|
+
import { PortType, portConfigTypes, typeSetsCompatible } from '../src/port';
|
|
3
|
+
import { Publisher } from '../src/nodes/publisher';
|
|
4
|
+
import { ContextMenuConfig, ContextMenuItemConfig } from '../src/contextMenu';
|
|
5
|
+
|
|
6
|
+
function itemNames(menu: ContextMenuConfig): Array<string> {
|
|
7
|
+
const names: Array<string> = [];
|
|
8
|
+
|
|
9
|
+
const walk = (m: ContextMenuConfig) => {
|
|
10
|
+
(m.items ?? []).forEach((i: ContextMenuItemConfig) => names.push(i.name ?? ''));
|
|
11
|
+
(m.subMenus ?? []).forEach(walk);
|
|
12
|
+
};
|
|
13
|
+
walk(menu);
|
|
14
|
+
|
|
15
|
+
return names.sort();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const publisher = () => new Publisher({
|
|
19
|
+
name: 'test',
|
|
20
|
+
nodes: {
|
|
21
|
+
'Math/Add': {
|
|
22
|
+
inputs: [{ name: 'a', type: 'float64' }, { name: 'b', type: 'float64' }],
|
|
23
|
+
outputs: [{ name: 'sum', type: 'float64' }],
|
|
24
|
+
},
|
|
25
|
+
'Mesh/Cube': {
|
|
26
|
+
outputs: [{ name: 'out', type: 'mesh' }],
|
|
27
|
+
},
|
|
28
|
+
'Mesh/Translate': {
|
|
29
|
+
inputs: [{ name: 'mesh', type: 'mesh' }],
|
|
30
|
+
outputs: [{ name: 'out', type: 'mesh' }],
|
|
31
|
+
},
|
|
32
|
+
'Logic/Select': {
|
|
33
|
+
inputs: [{ name: 'a', type: 'T', anyType: true }],
|
|
34
|
+
outputs: [{ name: 'out', type: 'T', anyType: true }],
|
|
35
|
+
},
|
|
36
|
+
'Lifted/Negate': {
|
|
37
|
+
inputs: [{ name: 'in', type: 'float64', acceptedTypes: ['float64', '[]float64'] }],
|
|
38
|
+
outputs: [{ name: 'out', type: 'float64' }],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('type set matching', () => {
|
|
44
|
+
it('overlapping sets match', () => {
|
|
45
|
+
assert.ok(typeSetsCompatible(['mesh'], ['mesh']));
|
|
46
|
+
assert.ok(typeSetsCompatible(['float64', '[]float64'], ['[]float64']));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('disjoint sets do not', () => {
|
|
50
|
+
assert.ok(!typeSetsCompatible(['mesh'], ['float64']));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('an empty set takes anything, from either side', () => {
|
|
54
|
+
assert.ok(typeSetsCompatible([], ['mesh']));
|
|
55
|
+
assert.ok(typeSetsCompatible(['mesh'], []));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('reads a port config the same way a built port reports itself', () => {
|
|
59
|
+
assert.deepStrictEqual(portConfigTypes({ type: 'mesh' }), ['mesh']);
|
|
60
|
+
assert.deepStrictEqual(portConfigTypes({ type: 'T', anyType: true }), []);
|
|
61
|
+
assert.deepStrictEqual(
|
|
62
|
+
portConfigTypes({ type: 'float64', acceptedTypes: ['float64', '[]float64'] }),
|
|
63
|
+
['float64', '[]float64'],
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('filtering the new node menu to what could finish a wire', () => {
|
|
69
|
+
const graph = {} as any;
|
|
70
|
+
const at = { x: 0, y: 0 };
|
|
71
|
+
|
|
72
|
+
it('offers everything when nothing is being dragged', () => {
|
|
73
|
+
const names = itemNames(publisher().contextMenu(graph, at));
|
|
74
|
+
assert.deepStrictEqual(names, ['Add', 'Cube', 'Negate', 'Select', 'Translate']);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('dragging a mesh output offers only nodes taking a mesh', () => {
|
|
78
|
+
const names = itemNames(publisher().contextMenu(graph, at, {
|
|
79
|
+
needs: PortType.Input,
|
|
80
|
+
types: ['mesh'],
|
|
81
|
+
}));
|
|
82
|
+
// Select takes anything, so it fits too. Cube has no inputs at all.
|
|
83
|
+
assert.deepStrictEqual(names, ['Select', 'Translate']);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('dragging a mesh input offers only nodes producing a mesh', () => {
|
|
87
|
+
const names = itemNames(publisher().contextMenu(graph, at, {
|
|
88
|
+
needs: PortType.Output,
|
|
89
|
+
types: ['mesh'],
|
|
90
|
+
}));
|
|
91
|
+
assert.deepStrictEqual(names, ['Cube', 'Select', 'Translate']);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('matches a lifted port on either of the types it takes', () => {
|
|
95
|
+
const names = itemNames(publisher().contextMenu(graph, at, {
|
|
96
|
+
needs: PortType.Input,
|
|
97
|
+
types: ['[]float64'],
|
|
98
|
+
}));
|
|
99
|
+
assert.deepStrictEqual(names, ['Negate', 'Select'],
|
|
100
|
+
'Add only takes a single float64, so it is left out');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('an any-type drag offers everything with a port on that side', () => {
|
|
104
|
+
const names = itemNames(publisher().contextMenu(graph, at, {
|
|
105
|
+
needs: PortType.Input,
|
|
106
|
+
types: [],
|
|
107
|
+
}));
|
|
108
|
+
assert.deepStrictEqual(names, ['Add', 'Negate', 'Select', 'Translate']);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('drops submenus that matched nothing', () => {
|
|
112
|
+
const menu = publisher().contextMenu(graph, at, {
|
|
113
|
+
needs: PortType.Output,
|
|
114
|
+
types: ['mesh'],
|
|
115
|
+
});
|
|
116
|
+
const groups = (menu.subMenus ?? []).map((m) => m.name).sort();
|
|
117
|
+
assert.ok(!groups.includes('Math'), `Math should be gone, got ${groups}`);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import * as assert from 'assert';
|
|
2
|
+
import { assignLayers, orderWithinLayers, topologicalOrder } from '../src/organize';
|
|
3
|
+
|
|
4
|
+
/** Builds the upstream/downstream maps Organize works from. */
|
|
5
|
+
function graph(edges: Array<[number, number]>, nodes: Array<number>) {
|
|
6
|
+
const upstream = new Map<number, Array<number>>();
|
|
7
|
+
const downstream = new Map<number, Array<number>>();
|
|
8
|
+
for (const node of nodes) {
|
|
9
|
+
upstream.set(node, []);
|
|
10
|
+
downstream.set(node, []);
|
|
11
|
+
}
|
|
12
|
+
for (const [from, to] of edges) {
|
|
13
|
+
(upstream.get(to) as Array<number>).push(from);
|
|
14
|
+
(downstream.get(from) as Array<number>).push(to);
|
|
15
|
+
}
|
|
16
|
+
return { upstream, downstream, nodes };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('assignLayers', () => {
|
|
20
|
+
it('puts a chain in one node per layer, in order', () => {
|
|
21
|
+
const { upstream } = graph([[0, 1], [1, 2], [2, 3]], [0, 1, 2, 3]);
|
|
22
|
+
const layout = assignLayers([0, 1, 2, 3], upstream);
|
|
23
|
+
|
|
24
|
+
assert.deepStrictEqual(layout.layers, [[0], [1], [2], [3]]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('puts a node to the right of the furthest thing feeding it, not the nearest', () => {
|
|
28
|
+
// 0 feeds both 1 and a longer path 2 -> 3, and 4 consumes 1 and 3.
|
|
29
|
+
// Taking the nearest feeder would put 4 beside 3 instead of after it.
|
|
30
|
+
const { upstream } = graph([[0, 1], [0, 2], [2, 3], [1, 4], [3, 4]], [0, 1, 2, 3, 4]);
|
|
31
|
+
const layout = assignLayers([0, 1, 2, 3, 4], upstream);
|
|
32
|
+
|
|
33
|
+
assert.strictEqual(layout.layerOf.get(0), 0);
|
|
34
|
+
assert.strictEqual(layout.layerOf.get(3), 2);
|
|
35
|
+
assert.strictEqual(layout.layerOf.get(4), 3, 'the join waits for the long branch');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('never puts a node left of something it consumes', () => {
|
|
39
|
+
const edges: Array<[number, number]> = [[0, 2], [1, 2], [2, 3], [1, 3], [3, 4], [0, 4]];
|
|
40
|
+
const { upstream } = graph(edges, [0, 1, 2, 3, 4]);
|
|
41
|
+
const layout = assignLayers([0, 1, 2, 3, 4], upstream);
|
|
42
|
+
|
|
43
|
+
for (const [from, to] of edges) {
|
|
44
|
+
assert.ok(
|
|
45
|
+
(layout.layerOf.get(from) as number) < (layout.layerOf.get(to) as number),
|
|
46
|
+
`${from} -> ${to} points backwards`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('terminates on a cycle instead of hanging', () => {
|
|
52
|
+
const { upstream } = graph([[0, 1], [1, 2], [2, 0], [2, 3]], [0, 1, 2, 3]);
|
|
53
|
+
const layout = assignLayers([0, 1, 2, 3], upstream);
|
|
54
|
+
|
|
55
|
+
assert.strictEqual(layout.layerOf.size, 4);
|
|
56
|
+
// The cycle is spread across layers rather than collapsed into one.
|
|
57
|
+
assert.ok(layout.layers.length > 1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('starts every disconnected node at the first layer', () => {
|
|
61
|
+
const { upstream } = graph([], [0, 1, 2]);
|
|
62
|
+
const layout = assignLayers([0, 1, 2], upstream);
|
|
63
|
+
|
|
64
|
+
assert.deepStrictEqual(layout.layers.length, 1);
|
|
65
|
+
assert.deepStrictEqual(layout.layers[0].sort(), [0, 1, 2]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('handles a graph deep enough to overflow a recursive walk', () => {
|
|
69
|
+
const edges: Array<[number, number]> = [];
|
|
70
|
+
const nodes: Array<number> = [];
|
|
71
|
+
for (let i = 0; i < 20000; i++) {
|
|
72
|
+
nodes.push(i);
|
|
73
|
+
if (i > 0) {
|
|
74
|
+
edges.push([i - 1, i]);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const { upstream } = graph(edges, nodes);
|
|
78
|
+
const layout = assignLayers(nodes, upstream);
|
|
79
|
+
|
|
80
|
+
assert.strictEqual(layout.layers.length, 20000);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('orderWithinLayers', () => {
|
|
85
|
+
/** Counts pairs of edges between two layers that cross each other. */
|
|
86
|
+
function crossings(layout: ReturnType<typeof assignLayers>, edges: Array<[number, number]>): number {
|
|
87
|
+
const slot = new Map<number, number>();
|
|
88
|
+
for (const layer of layout.layers) {
|
|
89
|
+
layer.forEach((node, index) => slot.set(node, index));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let total = 0;
|
|
93
|
+
for (let i = 0; i < edges.length; i++) {
|
|
94
|
+
for (let j = i + 1; j < edges.length; j++) {
|
|
95
|
+
const [a1, a2] = edges[i];
|
|
96
|
+
const [b1, b2] = edges[j];
|
|
97
|
+
if (layout.layerOf.get(a1) !== layout.layerOf.get(b1)) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (layout.layerOf.get(a2) !== layout.layerOf.get(b2)) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const left = (slot.get(a1) as number) - (slot.get(b1) as number);
|
|
104
|
+
const right = (slot.get(a2) as number) - (slot.get(b2) as number);
|
|
105
|
+
if (left * right < 0) {
|
|
106
|
+
total++;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return total;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
it('untangles edges that start out crossed', () => {
|
|
114
|
+
// Three sources feeding three sinks in reverse order: laid out in
|
|
115
|
+
// declaration order every edge crosses every other.
|
|
116
|
+
const edges: Array<[number, number]> = [[0, 5], [1, 4], [2, 3]];
|
|
117
|
+
const nodes = [0, 1, 2, 3, 4, 5];
|
|
118
|
+
const { upstream, downstream } = graph(edges, nodes);
|
|
119
|
+
|
|
120
|
+
const layout = assignLayers(nodes, upstream);
|
|
121
|
+
const before = crossings(layout, edges);
|
|
122
|
+
orderWithinLayers(layout, upstream, downstream);
|
|
123
|
+
const after = crossings(layout, edges);
|
|
124
|
+
|
|
125
|
+
assert.ok(before > 0, 'the fixture should start tangled');
|
|
126
|
+
assert.strictEqual(after, 0, `expected no crossings, got ${after}`);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('keeps every node exactly once', () => {
|
|
130
|
+
const nodes = [0, 1, 2, 3, 4, 5];
|
|
131
|
+
const { upstream, downstream } = graph([[0, 3], [1, 3], [2, 4], [3, 5], [4, 5]], nodes);
|
|
132
|
+
|
|
133
|
+
const layout = assignLayers(nodes, upstream);
|
|
134
|
+
orderWithinLayers(layout, upstream, downstream);
|
|
135
|
+
|
|
136
|
+
const seen = layout.layers.flat().sort((a, b) => a - b);
|
|
137
|
+
assert.deepStrictEqual(seen, nodes);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('topologicalOrder', () => {
|
|
142
|
+
it('lists every node once', () => {
|
|
143
|
+
const { upstream } = graph([[0, 1], [1, 2], [0, 2]], [0, 1, 2]);
|
|
144
|
+
const order = topologicalOrder([0, 1, 2], upstream);
|
|
145
|
+
|
|
146
|
+
assert.deepStrictEqual(order.slice().sort(), [0, 1, 2]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('puts feeders before the nodes that consume them', () => {
|
|
150
|
+
const { upstream } = graph([[0, 1], [1, 2]], [0, 1, 2]);
|
|
151
|
+
const order = topologicalOrder([0, 1, 2], upstream);
|
|
152
|
+
|
|
153
|
+
assert.ok(order.indexOf(0) < order.indexOf(1));
|
|
154
|
+
assert.ok(order.indexOf(1) < order.indexOf(2));
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import * as assert from 'assert';
|
|
2
|
+
import { Port, PortConfig, PortType, portsCompatible } from '../src/port';
|
|
3
|
+
import { FlowNode } from '../src/node';
|
|
4
|
+
|
|
5
|
+
function port(config: PortConfig): Port {
|
|
6
|
+
// A port only reaches back to its node when it is drawn or connected,
|
|
7
|
+
// neither of which the type rules touch.
|
|
8
|
+
return new Port(undefined as unknown as FlowNode, PortType.Output, config);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('portsCompatible', () => {
|
|
12
|
+
it('joins two ports declaring the same type', () => {
|
|
13
|
+
assert.ok(portsCompatible(port({ type: 'mesh' }), port({ type: 'mesh' })));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('refuses two ports declaring different types', () => {
|
|
17
|
+
assert.ok(!portsCompatible(port({ type: 'mesh' }), port({ type: 'float64' })));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('joins an any-type port to anything, from either side', () => {
|
|
21
|
+
const anything = port({ type: 'T', anyType: true });
|
|
22
|
+
assert.ok(portsCompatible(anything, port({ type: 'mesh' })));
|
|
23
|
+
assert.ok(portsCompatible(port({ type: 'mesh' }), anything));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('joins two any-type ports', () => {
|
|
27
|
+
assert.ok(portsCompatible(
|
|
28
|
+
port({ type: 'T', anyType: true }),
|
|
29
|
+
port({ type: '[]T', anyType: true }),
|
|
30
|
+
));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('still refuses a port that is missing', () => {
|
|
34
|
+
assert.ok(!portsCompatible(port({ type: 'T', anyType: true }), undefined));
|
|
35
|
+
assert.ok(!portsCompatible(null, port({ type: 'mesh' })));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('does not treat an untyped port as accepting anything', () => {
|
|
39
|
+
assert.ok(!portsCompatible(port({}), port({ type: 'mesh' })));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('shows a type of its own before falling back to "any"', () => {
|
|
43
|
+
assert.strictEqual(port({ type: 'T', anyType: true }).getDataType(), 'T');
|
|
44
|
+
assert.ok(port({ type: 'T', anyType: true }).acceptsAnyType());
|
|
45
|
+
assert.ok(!port({ type: 'T' }).acceptsAnyType());
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('a port that takes more than one type', () => {
|
|
50
|
+
it('joins a port declaring either of them', () => {
|
|
51
|
+
const lifted = port({ type: 'float64', acceptedTypes: ['float64', '[]float64'] });
|
|
52
|
+
|
|
53
|
+
assert.ok(portsCompatible(lifted, port({ type: 'float64' })));
|
|
54
|
+
assert.ok(portsCompatible(lifted, port({ type: '[]float64' })));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('refuses a type outside its set', () => {
|
|
58
|
+
const lifted = port({ type: 'float64', acceptedTypes: ['float64', '[]float64'] });
|
|
59
|
+
|
|
60
|
+
assert.ok(!portsCompatible(lifted, port({ type: 'string' })),
|
|
61
|
+
'this is the whole point: the drag is refused rather than accepted then errored');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('joins another multi-type port when the sets overlap', () => {
|
|
65
|
+
const a = port({ type: 'float64', acceptedTypes: ['float64', '[]float64'] });
|
|
66
|
+
const b = port({ type: 'int', acceptedTypes: ['int', '[]float64'] });
|
|
67
|
+
const c = port({ type: 'int', acceptedTypes: ['int', '[]int'] });
|
|
68
|
+
|
|
69
|
+
assert.ok(portsCompatible(a, b));
|
|
70
|
+
assert.ok(!portsCompatible(a, c));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('still lets an any-type port through', () => {
|
|
74
|
+
const lifted = port({ type: 'float64', acceptedTypes: ['float64', '[]float64'] });
|
|
75
|
+
assert.ok(portsCompatible(lifted, port({ type: 'T', anyType: true })));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('narrows to one type once it settles', () => {
|
|
79
|
+
const lifted = port({ type: 'float64', acceptedTypes: ['float64', '[]float64'] });
|
|
80
|
+
lifted.settleOn('[]float64');
|
|
81
|
+
|
|
82
|
+
assert.strictEqual(lifted.getDataType(), '[]float64');
|
|
83
|
+
assert.deepStrictEqual(lifted.acceptedTypes(), ['[]float64']);
|
|
84
|
+
assert.ok(portsCompatible(lifted, port({ type: '[]float64' })));
|
|
85
|
+
assert.ok(!portsCompatible(lifted, port({ type: 'float64' })));
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('settling a port on a type', () => {
|
|
90
|
+
it('enforces the new type once it stops accepting anything', () => {
|
|
91
|
+
const settled = port({ type: 'T', anyType: true });
|
|
92
|
+
settled.setDataType('mesh');
|
|
93
|
+
settled.setAnyType(false);
|
|
94
|
+
|
|
95
|
+
assert.strictEqual(settled.getDataType(), 'mesh');
|
|
96
|
+
assert.ok(portsCompatible(settled, port({ type: 'mesh' })));
|
|
97
|
+
assert.ok(!portsCompatible(settled, port({ type: 'float64' })),
|
|
98
|
+
'the whole point: a settled port refuses the drag the Go side would reject');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('recolors a port whose fill came from its type', () => {
|
|
102
|
+
const settled = port({ type: 'T', anyType: true });
|
|
103
|
+
const before = settled.filledStyleColor();
|
|
104
|
+
settled.setDataType('mesh');
|
|
105
|
+
|
|
106
|
+
assert.notStrictEqual(settled.filledStyleColor(), before);
|
|
107
|
+
assert.strictEqual(settled.filledStyleColor(), port({ type: 'mesh' }).filledStyleColor(),
|
|
108
|
+
'and lands on the same color a port declaring that type gets');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('leaves a fill the caller picked alone', () => {
|
|
112
|
+
const settled = port({ type: 'T', anyType: true, filledStyle: { fillColor: '#abcdef' } });
|
|
113
|
+
settled.setDataType('mesh');
|
|
114
|
+
|
|
115
|
+
assert.strictEqual(settled.filledStyleColor(), '#abcdef');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('can be handed back to accepting anything', () => {
|
|
119
|
+
const settled = port({ type: 'T', anyType: true });
|
|
120
|
+
settled.setAnyType(false);
|
|
121
|
+
settled.setAnyType(true);
|
|
122
|
+
|
|
123
|
+
assert.ok(portsCompatible(settled, port({ type: 'float64' })));
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import * as assert from 'assert';
|
|
2
|
+
import { onRenderRequested, renderContinuously, requestRender } from '../src/render_scheduler';
|
|
3
|
+
|
|
4
|
+
// The scheduler reaches for the global requestAnimationFrame, so the test
|
|
5
|
+
// supplies one it can step by hand.
|
|
6
|
+
let pending: Array<() => void> = [];
|
|
7
|
+
|
|
8
|
+
function flush(): number {
|
|
9
|
+
const frame = pending;
|
|
10
|
+
pending = [];
|
|
11
|
+
frame.forEach((cb) => cb());
|
|
12
|
+
return frame.length;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('render scheduler', () => {
|
|
16
|
+
let unsubscribes: Array<() => void> = [];
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
pending = [];
|
|
20
|
+
(globalThis as any).requestAnimationFrame = (cb: () => void) => {
|
|
21
|
+
pending.push(cb);
|
|
22
|
+
return pending.length;
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
unsubscribes.forEach((stop) => stop());
|
|
28
|
+
unsubscribes = [];
|
|
29
|
+
pending = [];
|
|
30
|
+
delete (globalThis as any).requestAnimationFrame;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function subscribe(redraw: () => void): void {
|
|
34
|
+
unsubscribes.push(onRenderRequested(redraw));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
it('redraws once however many times it was asked', () => {
|
|
38
|
+
let draws = 0;
|
|
39
|
+
subscribe(() => { draws++; });
|
|
40
|
+
|
|
41
|
+
requestRender();
|
|
42
|
+
requestRender();
|
|
43
|
+
requestRender();
|
|
44
|
+
|
|
45
|
+
assert.strictEqual(pending.length, 1, 'only one frame is ever queued');
|
|
46
|
+
flush();
|
|
47
|
+
assert.strictEqual(draws, 1);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('does not redraw when nothing asked', () => {
|
|
51
|
+
let draws = 0;
|
|
52
|
+
subscribe(() => { draws++; });
|
|
53
|
+
|
|
54
|
+
flush();
|
|
55
|
+
assert.strictEqual(draws, 0, 'this is the whole point: idle costs nothing');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('can be asked again after a frame has gone out', () => {
|
|
59
|
+
let draws = 0;
|
|
60
|
+
subscribe(() => { draws++; });
|
|
61
|
+
|
|
62
|
+
requestRender();
|
|
63
|
+
flush();
|
|
64
|
+
requestRender();
|
|
65
|
+
flush();
|
|
66
|
+
|
|
67
|
+
assert.strictEqual(draws, 2);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('redraws every canvas that is listening', () => {
|
|
71
|
+
let a = 0, b = 0;
|
|
72
|
+
subscribe(() => { a++; });
|
|
73
|
+
subscribe(() => { b++; });
|
|
74
|
+
|
|
75
|
+
requestRender();
|
|
76
|
+
flush();
|
|
77
|
+
|
|
78
|
+
assert.strictEqual(a, 1);
|
|
79
|
+
assert.strictEqual(b, 1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('stops redrawing one that unsubscribed', () => {
|
|
83
|
+
let draws = 0;
|
|
84
|
+
const stop = onRenderRequested(() => { draws++; });
|
|
85
|
+
stop();
|
|
86
|
+
|
|
87
|
+
requestRender();
|
|
88
|
+
flush();
|
|
89
|
+
|
|
90
|
+
assert.strictEqual(draws, 0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('keeps asking for frames while held continuous, and stops after', () => {
|
|
94
|
+
let draws = 0;
|
|
95
|
+
subscribe(() => { draws++; });
|
|
96
|
+
|
|
97
|
+
const release = renderContinuously();
|
|
98
|
+
flush();
|
|
99
|
+
flush();
|
|
100
|
+
flush();
|
|
101
|
+
assert.ok(draws >= 3, `expected a frame every flush, got ${draws}`);
|
|
102
|
+
|
|
103
|
+
release();
|
|
104
|
+
|
|
105
|
+
// The frame queued before releasing still lands, which is what
|
|
106
|
+
// draws the final state. Nothing is queued behind it.
|
|
107
|
+
const after = draws;
|
|
108
|
+
flush();
|
|
109
|
+
assert.strictEqual(draws, after + 1, 'the already queued frame still lands');
|
|
110
|
+
flush();
|
|
111
|
+
flush();
|
|
112
|
+
assert.strictEqual(draws, after + 1, 'and then it goes quiet again');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('survives having no requestAnimationFrame at all', () => {
|
|
116
|
+
delete (globalThis as any).requestAnimationFrame;
|
|
117
|
+
assert.doesNotThrow(() => requestRender());
|
|
118
|
+
});
|
|
119
|
+
});
|
package/tsconfig.json
CHANGED
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
"sourceMap": true, // Generate source maps (optional)
|
|
5
5
|
"strict": true, // Enable strict type checking (optional)
|
|
6
6
|
"outDir": "./dist/js",
|
|
7
|
-
|
|
7
|
+
// The source already uses #private class fields, which are ES2022,
|
|
8
|
+
// and the bundlers emit esnext. ES2015 only made tsc downlevel
|
|
9
|
+
// those fields to WeakMaps in an output nothing ships.
|
|
10
|
+
"target": "ES2022",
|
|
8
11
|
},
|
|
9
12
|
"include": [
|
|
10
|
-
"src/**/*.ts"
|
|
13
|
+
"src/**/*.ts",
|
|
14
|
+
"test/**/*.ts"
|
|
11
15
|
],
|
|
12
16
|
"exclude": [
|
|
13
17
|
"node_modules"
|