-
Notifications
You must be signed in to change notification settings - Fork 9.8k
Expand file tree
/
Copy pathlayoutUtils.test.ts
More file actions
178 lines (152 loc) · 6.05 KB
/
Copy pathlayoutUtils.test.ts
File metadata and controls
178 lines (152 loc) · 6.05 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
/**
* Jest tests for the auto-layout fallback used by flows whose nodes carry no
* `position` — e.g. payloads produced by `GET /api/v1/starter-projects/`, which
* emits nodes without `position` and edges without `id`/`sourceHandle`/
* `targetHandle`.
*
* Regression coverage for two defects:
* 1. getLayoutedNodes passed `id: undefined` to ELK for edges and for ports
* derived from absent handles, so ELK threw
* `JsonImportException: Id must be a string or an integer: 'null'` for
* exactly the flows that needed the layout.
* 2. processFlows never awaits processDataFromFlow, so the layout landed after
* the nodes had already been handed to React Flow, whose
* getNodePositionWithOrigin dereferences `node.position.x` unguarded.
*/
import type { AllNodeType, EdgeType } from "@/types/flow";
import { getFallbackGridPositions, getLayoutedNodes } from "../layoutUtils";
import { needsLayout, processFlows } from "../reactflowUtils";
const makeNode = (id: string): AllNodeType =>
({
id,
data: { id, type: id.split("-")[0], node: { template: {}, outputs: [] } },
}) as unknown as AllNodeType;
// Shaped like a /starter-projects/ edge: no id, no sourceHandle, no targetHandle.
const makeHandlelessEdge = (source: string, target: string): EdgeType =>
({ source, target, data: {} }) as unknown as EdgeType;
const hasNumericPosition = (node: AllNodeType) =>
!!node.position &&
typeof node.position.x === "number" &&
typeof node.position.y === "number" &&
!Number.isNaN(node.position.x) &&
!Number.isNaN(node.position.y);
describe("getFallbackGridPositions", () => {
it("gives every node a numeric position", () => {
const nodes = ["A", "B", "C", "D", "E"].map(makeNode);
expect(getFallbackGridPositions(nodes).every(hasNumericPosition)).toBe(
true,
);
});
it("does not place two nodes at the same coordinates", () => {
const positioned = getFallbackGridPositions(
["A", "B", "C", "D"].map(makeNode),
);
const coords = positioned.map((n) => `${n.position.x},${n.position.y}`);
expect(new Set(coords).size).toBe(coords.length);
});
it("handles an empty node list", () => {
expect(getFallbackGridPositions([])).toEqual([]);
});
});
describe("needsLayout", () => {
it("flags nodes with no position at all", () => {
expect(needsLayout([makeNode("A")])).toBe(true);
});
it("flags nodes whose coordinates are not numbers", () => {
const node = {
...makeNode("A"),
position: { x: "12" as unknown as number, y: 0 },
};
expect(needsLayout([node])).toBe(true);
});
it("flags NaN coordinates", () => {
const node = { ...makeNode("A"), position: { x: Number.NaN, y: 0 } };
expect(needsLayout([node])).toBe(true);
});
it("flags non-finite coordinates", () => {
const positive = {
...makeNode("A"),
position: { x: Number.POSITIVE_INFINITY, y: 0 },
};
const negative = {
...makeNode("B"),
position: { x: 0, y: Number.NEGATIVE_INFINITY },
};
expect(needsLayout([positive])).toBe(true);
expect(needsLayout([negative])).toBe(true);
});
it("accepts well-formed positions", () => {
const node = { ...makeNode("A"), position: { x: 0, y: 0 } };
expect(needsLayout([node])).toBe(false);
});
});
describe("getLayoutedNodes with handle-less edges", () => {
it("lays out a graph whose edges have no id or handles", async () => {
const nodes = ["ChatInput-a", "Prompt-b", "ChatOutput-c"].map(makeNode);
const edges = [
makeHandlelessEdge("ChatInput-a", "Prompt-b"),
makeHandlelessEdge("Prompt-b", "ChatOutput-c"),
];
const layouted = await getLayoutedNodes(nodes, edges);
expect(layouted).toHaveLength(3);
expect(layouted.every(hasNumericPosition)).toBe(true);
// A real layered layout must separate the nodes, not stack them at 0,0.
expect(new Set(layouted.map((n) => n.position.x)).size).toBeGreaterThan(1);
});
it("still positions every node when there are no edges", async () => {
const nodes = ["A", "B"].map(makeNode);
const layouted = await getLayoutedNodes(nodes, []);
expect(layouted.every(hasNumericPosition)).toBe(true);
});
});
describe("getLayoutedNodes when ELK rejects", () => {
afterEach(() => {
jest.dontMock("elkjs/lib/elk.bundled.js");
jest.resetModules();
jest.restoreAllMocks();
});
it("falls back to the deterministic grid instead of propagating", async () => {
jest.resetModules();
jest.doMock("elkjs/lib/elk.bundled.js", () => ({
__esModule: true,
default: class {
layout = jest.fn().mockRejectedValue(new Error("ELK exploded"));
},
}));
jest.spyOn(console, "error").mockImplementation(() => {});
// Re-require so the module picks up the failing ELK singleton.
const {
getFallbackGridPositions: freshFallback,
getLayoutedNodes: withFailingElk,
} = require("../layoutUtils");
const nodes = ["ChatInput-a", "Prompt-b", "ChatOutput-c"].map(makeNode);
const edges = [makeHandlelessEdge("ChatInput-a", "Prompt-b")];
const layouted = await withFailingElk(nodes, edges);
// Resolves rather than rejecting, and every node is usable.
expect(layouted).toHaveLength(3);
expect(layouted.every(hasNumericPosition)).toBe(true);
expect(layouted).toEqual(freshFallback(nodes));
});
});
describe("processFlows on a flow with no positions", () => {
const makeFlow = () =>
({
id: "flow-1",
name: "starter",
description: "",
is_component: false,
data: {
nodes: ["ChatInput-a", "ChatOutput-b"].map(makeNode),
edges: [makeHandlelessEdge("ChatInput-a", "ChatOutput-b")],
},
}) as never;
it("positions every node synchronously, before React Flow can adopt them", () => {
const flow = makeFlow() as unknown as {
data: { nodes: AllNodeType[] };
};
processFlows([flow as never]);
// processFlows returns without awaiting the async layout, so the guarantee
// has to hold at this point — this is the reference the store captures.
expect(flow.data.nodes.every(hasNumericPosition)).toBe(true);
});
});