@synnaxlabs/client 0.55.0 → 0.56.1

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.
Files changed (197) hide show
  1. package/.turbo/turbo-build.log +10 -13
  2. package/dist/client.cjs +60 -36
  3. package/dist/client.js +6435 -4786
  4. package/dist/src/access/policy/client.d.ts +70 -80
  5. package/dist/src/access/policy/client.d.ts.map +1 -1
  6. package/dist/src/access/policy/types.gen.d.ts +18 -20
  7. package/dist/src/access/policy/types.gen.d.ts.map +1 -1
  8. package/dist/src/access/role/client.d.ts.map +1 -1
  9. package/dist/src/access/role/types.gen.d.ts +2 -2
  10. package/dist/src/actions/actions.d.ts +68 -0
  11. package/dist/src/actions/actions.d.ts.map +1 -0
  12. package/dist/src/actions/actions.spec.d.ts +2 -0
  13. package/dist/src/actions/actions.spec.d.ts.map +1 -0
  14. package/dist/src/actions/external.d.ts +2 -0
  15. package/dist/src/actions/external.d.ts.map +1 -0
  16. package/dist/src/actions/index.d.ts +2 -0
  17. package/dist/src/actions/index.d.ts.map +1 -0
  18. package/dist/src/arc/client.d.ts.map +1 -1
  19. package/dist/src/arc/compiler/types.gen.d.ts +1 -1
  20. package/dist/src/arc/compiler/types.gen.d.ts.map +1 -1
  21. package/dist/src/arc/graph/types.gen.d.ts +29 -29
  22. package/dist/src/arc/graph/types.gen.d.ts.map +1 -1
  23. package/dist/src/arc/ir/types.gen.d.ts +123 -123
  24. package/dist/src/arc/ir/types.gen.d.ts.map +1 -1
  25. package/dist/src/arc/module/types.gen.d.ts +45 -45
  26. package/dist/src/arc/program/types.gen.d.ts +45 -45
  27. package/dist/src/arc/types/types.gen.d.ts +11 -11
  28. package/dist/src/arc/types/types.gen.d.ts.map +1 -1
  29. package/dist/src/arc/types.gen.d.ts +99 -99
  30. package/dist/src/auth/auth.d.ts +3 -3
  31. package/dist/src/auth/auth.d.ts.map +1 -1
  32. package/dist/src/channel/client.d.ts +2 -2
  33. package/dist/src/channel/client.d.ts.map +1 -1
  34. package/dist/src/channel/retriever.d.ts +5 -8
  35. package/dist/src/channel/retriever.d.ts.map +1 -1
  36. package/dist/src/channel/types.gen.d.ts +3 -3
  37. package/dist/src/channel/writer.d.ts.map +1 -1
  38. package/dist/src/connection/checker.d.ts +1 -1
  39. package/dist/src/connection/checker.d.ts.map +1 -1
  40. package/dist/src/device/client.d.ts.map +1 -1
  41. package/dist/src/device/types.gen.d.ts +6 -8
  42. package/dist/src/device/types.gen.d.ts.map +1 -1
  43. package/dist/src/errors.d.ts +2 -0
  44. package/dist/src/errors.d.ts.map +1 -1
  45. package/dist/src/framer/adapter.d.ts.map +1 -1
  46. package/dist/src/framer/client.d.ts +2 -2
  47. package/dist/src/framer/codec.d.ts +9 -1
  48. package/dist/src/framer/codec.d.ts.map +1 -1
  49. package/dist/src/framer/deleter.d.ts.map +1 -1
  50. package/dist/src/framer/frame.d.ts +1 -1
  51. package/dist/src/framer/iterator.d.ts +84 -3
  52. package/dist/src/framer/iterator.d.ts.map +1 -1
  53. package/dist/src/framer/streamProxy.d.ts.map +1 -1
  54. package/dist/src/framer/streamer.d.ts +1 -3
  55. package/dist/src/framer/streamer.d.ts.map +1 -1
  56. package/dist/src/framer/types.gen.d.ts +18 -0
  57. package/dist/src/framer/types.gen.d.ts.map +1 -1
  58. package/dist/src/framer/writer.d.ts +8 -8
  59. package/dist/src/framer/writer.d.ts.map +1 -1
  60. package/dist/src/group/client.d.ts +1 -2
  61. package/dist/src/group/client.d.ts.map +1 -1
  62. package/dist/src/group/types.gen.d.ts +2 -2
  63. package/dist/src/index.d.ts +2 -1
  64. package/dist/src/index.d.ts.map +1 -1
  65. package/dist/src/label/client.d.ts +5 -8
  66. package/dist/src/label/client.d.ts.map +1 -1
  67. package/dist/src/lineplot/client.d.ts.map +1 -1
  68. package/dist/src/lineplot/types.gen.d.ts +2 -2
  69. package/dist/src/log/client.d.ts.map +1 -1
  70. package/dist/src/log/types.gen.d.ts +2 -2
  71. package/dist/src/ontology/client.d.ts +1 -3
  72. package/dist/src/ontology/client.d.ts.map +1 -1
  73. package/dist/src/ontology/payload.d.ts +12 -16
  74. package/dist/src/ontology/payload.d.ts.map +1 -1
  75. package/dist/src/ontology/types.gen.d.ts +1 -2
  76. package/dist/src/ontology/types.gen.d.ts.map +1 -1
  77. package/dist/src/ontology/writer.d.ts +5 -10
  78. package/dist/src/ontology/writer.d.ts.map +1 -1
  79. package/dist/src/rack/client.d.ts.map +1 -1
  80. package/dist/src/rack/types.gen.d.ts +3 -3
  81. package/dist/src/ranger/alias/client.d.ts.map +1 -1
  82. package/dist/src/ranger/client.d.ts.map +1 -1
  83. package/dist/src/ranger/kv/client.d.ts.map +1 -1
  84. package/dist/src/ranger/types.gen.d.ts +6 -6
  85. package/dist/src/ranger/types.gen.d.ts.map +1 -1
  86. package/dist/src/ranger/writer.d.ts +2 -3
  87. package/dist/src/ranger/writer.d.ts.map +1 -1
  88. package/dist/src/schematic/actions.d.ts +147 -0
  89. package/dist/src/schematic/actions.d.ts.map +1 -0
  90. package/dist/src/schematic/actions.gen.d.ts +484 -0
  91. package/dist/src/schematic/actions.gen.d.ts.map +1 -0
  92. package/dist/src/schematic/actions.spec.d.ts +2 -0
  93. package/dist/src/schematic/actions.spec.d.ts.map +1 -0
  94. package/dist/src/schematic/client.d.ts +53 -2
  95. package/dist/src/schematic/client.d.ts.map +1 -1
  96. package/dist/src/schematic/external.d.ts +2 -0
  97. package/dist/src/schematic/external.d.ts.map +1 -1
  98. package/dist/src/schematic/symbol/client.d.ts.map +1 -1
  99. package/dist/src/schematic/symbol/types.gen.d.ts +48 -58
  100. package/dist/src/schematic/symbol/types.gen.d.ts.map +1 -1
  101. package/dist/src/schematic/types.gen.d.ts +131 -5
  102. package/dist/src/schematic/types.gen.d.ts.map +1 -1
  103. package/dist/src/status/client.d.ts.map +1 -1
  104. package/dist/src/status/payload.d.ts +3 -3
  105. package/dist/src/table/actions.d.ts +156 -0
  106. package/dist/src/table/actions.d.ts.map +1 -0
  107. package/dist/src/table/actions.gen.d.ts +587 -0
  108. package/dist/src/table/actions.gen.d.ts.map +1 -0
  109. package/dist/src/table/client.d.ts +28 -2
  110. package/dist/src/table/client.d.ts.map +1 -1
  111. package/dist/src/table/external.d.ts +2 -0
  112. package/dist/src/table/external.d.ts.map +1 -1
  113. package/dist/src/table/types.gen.d.ts +71 -4
  114. package/dist/src/table/types.gen.d.ts.map +1 -1
  115. package/dist/src/task/client.d.ts.map +1 -1
  116. package/dist/src/task/types.gen.d.ts +7 -7
  117. package/dist/src/task/types.gen.d.ts.map +1 -1
  118. package/dist/src/user/client.d.ts +2 -2
  119. package/dist/src/user/client.d.ts.map +1 -1
  120. package/dist/src/user/types.gen.d.ts +2 -2
  121. package/dist/src/view/client.d.ts.map +1 -1
  122. package/dist/src/view/types.gen.d.ts +2 -2
  123. package/dist/src/workspace/client.d.ts.map +1 -1
  124. package/dist/src/workspace/types.gen.d.ts +3 -3
  125. package/dist/src/workspace/types.gen.d.ts.map +1 -1
  126. package/package.json +12 -11
  127. package/src/access/policy/client.ts +4 -7
  128. package/src/access/role/client.ts +6 -26
  129. package/src/actions/actions.spec.ts +229 -0
  130. package/src/actions/actions.ts +104 -0
  131. package/src/actions/external.ts +10 -0
  132. package/src/actions/index.ts +10 -0
  133. package/src/arc/client.ts +3 -7
  134. package/src/arc/compiler/types.gen.ts +2 -1
  135. package/src/arc/ir/types.gen.ts +2 -2
  136. package/src/arc/lsp.spec.ts +3 -7
  137. package/src/arc/types/types.gen.ts +3 -3
  138. package/src/auth/auth.spec.ts +12 -13
  139. package/src/auth/auth.ts +36 -34
  140. package/src/channel/batchRetriever.spec.ts +13 -4
  141. package/src/channel/client.ts +8 -6
  142. package/src/channel/retriever.ts +7 -16
  143. package/src/channel/writer.ts +4 -20
  144. package/src/connection/checker.ts +6 -6
  145. package/src/connection/connection.spec.ts +5 -8
  146. package/src/device/client.ts +5 -8
  147. package/src/device/types.gen.ts +4 -4
  148. package/src/errors.ts +9 -9
  149. package/src/framer/adapter.ts +2 -4
  150. package/src/framer/client.ts +1 -1
  151. package/src/framer/codec.spec.ts +53 -3
  152. package/src/framer/codec.ts +58 -25
  153. package/src/framer/deleter.ts +2 -8
  154. package/src/framer/iterator.ts +43 -40
  155. package/src/framer/streamProxy.ts +13 -13
  156. package/src/framer/streamer.spec.ts +12 -3
  157. package/src/framer/streamer.ts +7 -12
  158. package/src/framer/types.gen.ts +20 -0
  159. package/src/framer/writer.spec.ts +77 -0
  160. package/src/framer/writer.ts +51 -28
  161. package/src/group/client.ts +4 -7
  162. package/src/index.ts +3 -2
  163. package/src/label/client.ts +6 -16
  164. package/src/lineplot/client.ts +6 -21
  165. package/src/log/client.ts +6 -21
  166. package/src/ontology/client.ts +3 -4
  167. package/src/ontology/types.gen.ts +0 -1
  168. package/src/ontology/writer.ts +4 -7
  169. package/src/rack/client.ts +4 -7
  170. package/src/ranger/alias/client.ts +6 -11
  171. package/src/ranger/client.ts +3 -4
  172. package/src/ranger/kv/client.ts +5 -8
  173. package/src/ranger/writer.ts +4 -17
  174. package/src/schematic/access.spec.ts +6 -6
  175. package/src/schematic/actions.gen.ts +200 -0
  176. package/src/schematic/actions.spec.ts +699 -0
  177. package/src/schematic/actions.ts +168 -0
  178. package/src/schematic/client.ts +34 -30
  179. package/src/schematic/external.ts +2 -0
  180. package/src/schematic/schematic.spec.ts +233 -69
  181. package/src/schematic/symbol/client.ts +6 -11
  182. package/src/schematic/symbol/types.gen.ts +1 -10
  183. package/src/schematic/types.gen.ts +55 -6
  184. package/src/status/client.ts +4 -10
  185. package/src/table/access.spec.ts +0 -6
  186. package/src/table/actions.gen.ts +243 -0
  187. package/src/table/actions.ts +255 -0
  188. package/src/table/client.ts +21 -25
  189. package/src/table/external.ts +2 -0
  190. package/src/table/table.spec.ts +588 -43
  191. package/src/table/types.gen.ts +58 -5
  192. package/src/task/client.ts +7 -11
  193. package/src/task/types.gen.ts +8 -6
  194. package/src/user/client.ts +6 -11
  195. package/src/view/client.ts +4 -7
  196. package/src/workspace/client.ts +6 -16
  197. package/src/workspace/types.gen.ts +2 -1
@@ -0,0 +1,699 @@
1
+ // Copyright 2026 Synnax Labs, Inc.
2
+ //
3
+ // Use of this software is governed by the Business Source License included in the file
4
+ // licenses/BSL.txt.
5
+ //
6
+ // As of the Change Date specified in that file, in accordance with the Business Source
7
+ // License, use of this software will be governed by the Apache License, Version 2.0,
8
+ // included in the file licenses/APL.txt.
9
+
10
+ import { describe, expect, it } from "vitest";
11
+ import { z } from "zod";
12
+
13
+ import { isUndoable, reduceAll } from "@/schematic/actions";
14
+ import {
15
+ type Action,
16
+ actionZ,
17
+ addEdge,
18
+ removeEdge,
19
+ removeNode,
20
+ rename,
21
+ setConfig,
22
+ setNode,
23
+ setNodeMeasured,
24
+ setNodePosition,
25
+ } from "@/schematic/actions.gen";
26
+ import { type Edge, type Node, type Schematic } from "@/schematic/types.gen";
27
+
28
+ const node = (key: string, x: number, y: number): Node => ({
29
+ key,
30
+ position: { x, y },
31
+ });
32
+
33
+ const edge = (
34
+ key: string,
35
+ srcNode: string,
36
+ srcParam: string,
37
+ tgtNode: string,
38
+ tgtParam: string,
39
+ ): Edge => ({
40
+ key,
41
+ source: { node: srcNode, param: srcParam },
42
+ target: { node: tgtNode, param: tgtParam },
43
+ });
44
+
45
+ const empty = (overrides: Partial<Schematic> = {}): Schematic => ({
46
+ key: "00000000-0000-0000-0000-000000000000",
47
+ name: "",
48
+ snapshot: false,
49
+ nodes: [],
50
+ edges: [],
51
+ configs: {},
52
+ ...overrides,
53
+ });
54
+
55
+ const apply = (state: Schematic, ...actions: Action[]): Schematic =>
56
+ reduceAll(state, actions).next;
57
+
58
+ describe("schematic reducer", () => {
59
+ describe("rename", () => {
60
+ it("should set the schematic name to the payload name", () => {
61
+ const state = empty({ name: "old" });
62
+ const out = apply(state, rename({ name: "new" }));
63
+ expect(out.name).toEqual("new");
64
+ });
65
+ it("should accept an empty name", () => {
66
+ const state = empty({ name: "old" });
67
+ const out = apply(state, rename({ name: "" }));
68
+ expect(out.name).toEqual("");
69
+ });
70
+ it("should leave nodes, edges, and configs untouched", () => {
71
+ const state = empty({
72
+ name: "old",
73
+ nodes: [node("n1", 0, 0)],
74
+ edges: [edge("e1", "a", "o", "b", "i")],
75
+ configs: { n1: { label: "Pump" } },
76
+ });
77
+ const out = apply(state, rename({ name: "new" }));
78
+ expect(out.nodes).toEqual(state.nodes);
79
+ expect(out.edges).toEqual(state.edges);
80
+ expect(out.configs).toEqual(state.configs);
81
+ });
82
+ });
83
+
84
+ describe("setNodePosition", () => {
85
+ it("should move the matching node to the new position", () => {
86
+ const state = empty({ nodes: [node("n1", 0, 0), node("n2", 5, 5)] });
87
+ const out = apply(
88
+ state,
89
+ setNodePosition({ key: "n1", position: { x: 100, y: 200 } }),
90
+ );
91
+ expect(out.nodes).toEqual([node("n1", 100, 200), node("n2", 5, 5)]);
92
+ });
93
+ it("should be a no-op when the key does not match any node", () => {
94
+ const state = empty({ nodes: [node("n1", 0, 0)] });
95
+ const out = apply(
96
+ state,
97
+ setNodePosition({ key: "ghost", position: { x: 9, y: 9 } }),
98
+ );
99
+ expect(out.nodes).toEqual([node("n1", 0, 0)]);
100
+ });
101
+ it("should only move the first matching node when keys are duplicated", () => {
102
+ const state = empty({ nodes: [node("dup", 0, 0), node("dup", 1, 1)] });
103
+ const out = apply(
104
+ state,
105
+ setNodePosition({ key: "dup", position: { x: 9, y: 9 } }),
106
+ );
107
+ expect(out.nodes[0].position).toEqual({ x: 9, y: 9 });
108
+ expect(out.nodes[1].position).toEqual({ x: 1, y: 1 });
109
+ });
110
+ });
111
+
112
+ describe("setNode", () => {
113
+ it("should append the node to the end of the slice when no node has the same key", () => {
114
+ const state = empty({ nodes: [node("n1", 0, 0)] });
115
+ const out = apply(state, setNode({ node: node("n2", 1, 2) }));
116
+ expect(out.nodes).toEqual([node("n1", 0, 0), node("n2", 1, 2)]);
117
+ });
118
+ it("should write config under the node's key when config is non-undefined", () => {
119
+ const out = apply(
120
+ empty(),
121
+ setNode({ node: node("n1", 0, 0), config: { label: "Pump", color: "#f00" } }),
122
+ );
123
+ expect(out.configs).toEqual({ n1: { label: "Pump", color: "#f00" } });
124
+ });
125
+ it("should leave configs untouched when the action's config is undefined", () => {
126
+ const out = apply(empty(), setNode({ node: node("n1", 0, 0) }));
127
+ expect(out.configs).toEqual({});
128
+ });
129
+ it("should replace an existing node in place when the key already exists, preserving slice index", () => {
130
+ const state = empty({
131
+ nodes: [node("n1", 0, 0), node("n2", 1, 1), node("n3", 2, 2)],
132
+ });
133
+ const out = apply(state, setNode({ node: node("n2", 9, 9) }));
134
+ expect(out.nodes).toHaveLength(3);
135
+ expect(out.nodes[0]).toEqual(node("n1", 0, 0));
136
+ expect(out.nodes[1]).toEqual(node("n2", 9, 9));
137
+ expect(out.nodes[2]).toEqual(node("n3", 2, 2));
138
+ });
139
+ });
140
+
141
+ describe("removeNode", () => {
142
+ it("should remove the matching node and any config stored under its key", () => {
143
+ const state = empty({
144
+ nodes: [node("n1", 0, 0), node("n2", 1, 1)],
145
+ configs: { n1: { label: "Pump" }, n2: { label: "Tank" } },
146
+ });
147
+ const out = apply(state, removeNode({ key: "n1" }));
148
+ expect(out.nodes).toEqual([node("n2", 1, 1)]);
149
+ expect(out.configs).toEqual({ n2: { label: "Tank" } });
150
+ });
151
+ it("should leave existing edges intact even when they reference the removed node", () => {
152
+ const state = empty({
153
+ nodes: [node("n1", 0, 0), node("n2", 1, 1)],
154
+ edges: [edge("e1", "n1", "out", "n2", "in")],
155
+ });
156
+ const out = apply(state, removeNode({ key: "n1" }));
157
+ expect(out.edges).toHaveLength(1);
158
+ expect(out.edges[0].source.node).toBe("n1");
159
+ });
160
+ it("should be a no-op when the key does not match any node", () => {
161
+ const state = empty({
162
+ nodes: [node("n1", 0, 0)],
163
+ configs: { n1: { label: "Pump" } },
164
+ });
165
+ const out = apply(state, removeNode({ key: "ghost" }));
166
+ expect(out.nodes).toEqual(state.nodes);
167
+ expect(out.configs).toEqual(state.configs);
168
+ });
169
+ });
170
+
171
+ describe("setEdge", () => {
172
+ it("should append an edge whose key is not yet present", () => {
173
+ const state = empty({ edges: [edge("e1", "a", "o", "b", "i")] });
174
+ const out = apply(state, addEdge({ edge: edge("e2", "b", "o", "c", "i") }));
175
+ expect(out.edges).toHaveLength(2);
176
+ expect(out.edges[1].key).toBe("e2");
177
+ });
178
+ it("should be a no-op when an edge with the same key already exists", () => {
179
+ const original = edge("e2", "b", "o", "c", "i");
180
+ const state = empty({
181
+ edges: [
182
+ edge("e1", "a", "o", "b", "i"),
183
+ original,
184
+ edge("e3", "c", "o", "d", "i"),
185
+ ],
186
+ });
187
+ const out = apply(state, addEdge({ edge: edge("e2", "x", "y", "z", "w") }));
188
+ expect(out.edges).toHaveLength(3);
189
+ expect(out.edges[0].key).toBe("e1");
190
+ expect(out.edges[1]).toEqual(original);
191
+ expect(out.edges[2].key).toBe("e3");
192
+ });
193
+ });
194
+
195
+ describe("removeEdge", () => {
196
+ it("should remove the matching edge", () => {
197
+ const state = empty({
198
+ edges: [edge("e1", "a", "o", "b", "i"), edge("e2", "b", "o", "c", "i")],
199
+ });
200
+ const out = apply(state, removeEdge({ key: "e1" }));
201
+ expect(out.edges).toEqual([edge("e2", "b", "o", "c", "i")]);
202
+ });
203
+ it("should be a no-op when the key does not match any edge", () => {
204
+ const state = empty({ edges: [edge("e1", "a", "o", "b", "i")] });
205
+ const out = apply(state, removeEdge({ key: "ghost" }));
206
+ expect(out.edges).toEqual(state.edges);
207
+ });
208
+ });
209
+
210
+ describe("setConfig", () => {
211
+ it("should write the config entry under the given key", () => {
212
+ const out = apply(empty(), setConfig({ key: "n1", config: { label: "Pump" } }));
213
+ expect(out.configs).toEqual({ n1: { label: "Pump" } });
214
+ });
215
+ it("should merge payload fields into an existing config entry", () => {
216
+ const state = empty({
217
+ configs: { n1: { label: "Old", color: "#ff0000" } },
218
+ });
219
+ const out = apply(state, setConfig({ key: "n1", config: { label: "New" } }));
220
+ expect(out.configs).toEqual({ n1: { label: "New", color: "#ff0000" } });
221
+ });
222
+ it("should accept a key that does not match any node or edge", () => {
223
+ const out = apply(empty(), setConfig({ key: "orphan", config: { data: 1 } }));
224
+ expect(out.configs).toEqual({ orphan: { data: 1 } });
225
+ });
226
+ it("should override the payload color with the source node's color when the new entry is for an edge", () => {
227
+ const state = empty({
228
+ edges: [edge("e1", "src", "o", "tgt", "i")],
229
+ configs: { src: { color: [0, 1, 0, 1] } },
230
+ });
231
+ const out = apply(
232
+ state,
233
+ setConfig({ key: "e1", config: { variant: "pipe", color: [0, 0, 0, 0] } }),
234
+ );
235
+ expect(out.configs.e1).toEqual({ variant: "pipe", color: [0, 1, 0, 1] });
236
+ });
237
+ it("should inherit the source node's color when the payload omits color", () => {
238
+ const state = empty({
239
+ edges: [edge("e1", "src", "o", "tgt", "i")],
240
+ configs: { src: { color: [0, 1, 0, 1] } },
241
+ });
242
+ const out = apply(state, setConfig({ key: "e1", config: { variant: "pipe" } }));
243
+ expect(out.configs.e1).toEqual({ variant: "pipe", color: [0, 1, 0, 1] });
244
+ });
245
+ it("should leave the payload untouched when the source node has a zero color", () => {
246
+ const state = empty({
247
+ edges: [edge("e1", "src", "o", "tgt", "i")],
248
+ configs: { src: { color: [0, 0, 0, 0] } },
249
+ });
250
+ const out = apply(
251
+ state,
252
+ setConfig({ key: "e1", config: { variant: "pipe", color: [0, 0, 0, 0] } }),
253
+ );
254
+ expect(out.configs.e1).toEqual({ variant: "pipe", color: [0, 0, 0, 0] });
255
+ });
256
+ it("should leave the payload untouched when the source node has no color", () => {
257
+ const state = empty({
258
+ edges: [edge("e1", "src", "o", "tgt", "i")],
259
+ configs: { src: { label: "Pump" } },
260
+ });
261
+ const out = apply(
262
+ state,
263
+ setConfig({ key: "e1", config: { variant: "pipe", color: [0, 0, 0, 0] } }),
264
+ );
265
+ expect(out.configs.e1).toEqual({ variant: "pipe", color: [0, 0, 0, 0] });
266
+ });
267
+ it("should leave the payload untouched when the source node has no config", () => {
268
+ const state = empty({
269
+ edges: [edge("e1", "src", "o", "tgt", "i")],
270
+ });
271
+ const out = apply(
272
+ state,
273
+ setConfig({ key: "e1", config: { variant: "pipe", color: [0, 0, 0, 0] } }),
274
+ );
275
+ expect(out.configs.e1).toEqual({ variant: "pipe", color: [0, 0, 0, 0] });
276
+ });
277
+ it("should not override the color when merging into an existing edge config", () => {
278
+ const state = empty({
279
+ edges: [edge("e1", "src", "o", "tgt", "i")],
280
+ configs: {
281
+ src: { color: [0, 1, 0, 1] },
282
+ e1: { variant: "pipe", color: [0, 0, 0, 0] },
283
+ },
284
+ });
285
+ const out = apply(
286
+ state,
287
+ setConfig({ key: "e1", config: { variant: "electric" } }),
288
+ );
289
+ expect(out.configs.e1).toEqual({ variant: "electric", color: [0, 0, 0, 0] });
290
+ });
291
+ it("should inherit the source color end-to-end when addEdge is followed by setConfig in one batch", () => {
292
+ const state = empty({
293
+ nodes: [node("src", 0, 0), node("tgt", 100, 0)],
294
+ configs: { src: { color: [0, 1, 0, 1] } },
295
+ });
296
+ const out = apply(
297
+ state,
298
+ addEdge({ edge: edge("e1", "src", "o", "tgt", "i") }),
299
+ setConfig({
300
+ key: "e1",
301
+ config: { variant: "pipe", color: [0, 0, 0, 0], segments: [] },
302
+ }),
303
+ );
304
+ expect(out.configs.e1).toEqual({
305
+ variant: "pipe",
306
+ color: [0, 1, 0, 1],
307
+ segments: [],
308
+ });
309
+ });
310
+ });
311
+
312
+ describe("immutability", () => {
313
+ it("should leave the input state object unmodified", () => {
314
+ const state = empty({ nodes: [node("n1", 0, 0)] });
315
+ const before = structuredClone(state);
316
+ reduceAll(state, [setNodePosition({ key: "n1", position: { x: 9, y: 9 } })]);
317
+ expect(state).toEqual(before);
318
+ });
319
+ it("should return a new state object when any action mutates", () => {
320
+ const state = empty({ nodes: [node("n1", 0, 0)] });
321
+ const out = reduceAll(state, [
322
+ setNodePosition({ key: "n1", position: { x: 9, y: 9 } }),
323
+ ]);
324
+ expect(out.next).not.toBe(state);
325
+ expect(out.next.nodes).not.toBe(state.nodes);
326
+ });
327
+ it("should return the same state object when an action is a no-op", () => {
328
+ const state = empty({ nodes: [node("n1", 0, 0)] });
329
+ const out = reduceAll(state, [
330
+ setNodePosition({ key: "ghost", position: { x: 9, y: 9 } }),
331
+ ]);
332
+ expect(out.next).toBe(state);
333
+ });
334
+ });
335
+
336
+ describe("real-world scenarios", () => {
337
+ it("should converge to the final position after a 30-action drag storm", () => {
338
+ const state = empty({ nodes: [node("pump", 0, 0)] });
339
+ const actions: Action[] = [];
340
+ for (let i = 0; i < 30; i++)
341
+ actions.push(setNodePosition({ key: "pump", position: { x: i, y: i * 2 } }));
342
+ const out = reduceAll(state, actions).next;
343
+ expect(out.nodes[0].position).toEqual({ x: 29, y: 58 });
344
+ });
345
+
346
+ it("should build a complete graph from an empty schematic", () => {
347
+ const out = apply(
348
+ empty(),
349
+ setNode({ node: node("pump", 0, 0) }),
350
+ setNode({ node: node("valve", 100, 0) }),
351
+ setNode({ node: node("tank", 200, 0) }),
352
+ addEdge({ edge: edge("e1", "pump", "out", "valve", "in") }),
353
+ addEdge({ edge: edge("e2", "valve", "out", "tank", "in") }),
354
+ setConfig({ key: "pump", config: { label: "Main Pump" } }),
355
+ setConfig({ key: "e1", config: { variant: "pipe" } }),
356
+ );
357
+ expect(out.nodes).toHaveLength(3);
358
+ expect(out.edges).toHaveLength(2);
359
+ expect(out.configs).toEqual({
360
+ pump: { label: "Main Pump" },
361
+ e1: { variant: "pipe" },
362
+ });
363
+ });
364
+
365
+ it("should drop config but keep dangling edges when a node is removed and re-added", () => {
366
+ const state = empty({
367
+ nodes: [node("n1", 0, 0), node("n2", 1, 1)],
368
+ edges: [edge("e1", "n1", "o", "n2", "i")],
369
+ configs: { n1: { label: "v1" } },
370
+ });
371
+ const out = apply(
372
+ state,
373
+ removeNode({ key: "n1" }),
374
+ setNode({ node: node("n1", 50, 50) }),
375
+ );
376
+ expect(out.nodes).toHaveLength(2);
377
+ expect(out.nodes[1]).toEqual(node("n1", 50, 50));
378
+ expect(out.configs).toEqual({});
379
+ expect(out.edges).toHaveLength(1);
380
+ expect(out.edges[0].source.node).toBe("n1");
381
+ });
382
+
383
+ it("should converge an idempotent action sequence to the same state as a single application", () => {
384
+ const state = empty({ nodes: [node("n1", 0, 0)] });
385
+ const a = setNodePosition({ key: "n1", position: { x: 10, y: 20 } });
386
+ expect(reduceAll(state, [a]).next).toEqual(reduceAll(state, [a, a, a]).next);
387
+ });
388
+
389
+ it("should apply a 50-action editor session and converge to a coherent schematic", () => {
390
+ const state = empty();
391
+ const actions: Action[] = [];
392
+ for (let i = 0; i < 5; i++)
393
+ actions.push(setNode({ node: node(`n${i}`, i * 100, 0) }));
394
+ for (let i = 0; i < 5; i++) {
395
+ actions.push(
396
+ setNodePosition({ key: `n${i}`, position: { x: i * 100, y: 50 } }),
397
+ );
398
+ actions.push(
399
+ setNodePosition({ key: `n${i}`, position: { x: i * 100, y: 100 } }),
400
+ );
401
+ }
402
+ for (let i = 0; i < 4; i++)
403
+ actions.push(
404
+ addEdge({
405
+ edge: edge(`e${i}`, `n${i}`, "out", `n${i + 1}`, "in"),
406
+ }),
407
+ );
408
+ for (let i = 0; i < 3; i++)
409
+ actions.push(setConfig({ key: `n${i}`, config: { label: `node ${i}` } }));
410
+ actions.push(setConfig({ key: "e1", config: { variant: "electric" } }));
411
+ const out = reduceAll(state, actions).next;
412
+ expect(out.nodes).toHaveLength(5);
413
+ expect(out.nodes[0].position).toEqual({ x: 0, y: 100 });
414
+ expect(out.nodes[4].position).toEqual({ x: 400, y: 100 });
415
+ expect(out.edges).toHaveLength(4);
416
+ expect(Object.keys(out.configs)).toHaveLength(4);
417
+ });
418
+
419
+ it("should leave state untouched when given an empty action list", () => {
420
+ const state = empty({ nodes: [node("n1", 0, 0)] });
421
+ expect(reduceAll(state, []).next).toBe(state);
422
+ });
423
+ });
424
+
425
+ describe("zod parsing", () => {
426
+ it("should reject an action with an unknown discriminator with a ZodError", () => {
427
+ expect(() => actionZ.parse({ type: "unknown", payload: { foo: 1 } })).toThrow(
428
+ z.ZodError,
429
+ );
430
+ });
431
+
432
+ it("should reject a setNodePosition action missing required fields with a ZodError", () => {
433
+ expect(() =>
434
+ actionZ.parse({ type: "set_node_position", setNodePosition: {} }),
435
+ ).toThrow(z.ZodError);
436
+ });
437
+
438
+ it("should accept a fully populated setNodePosition action", () => {
439
+ const a = setNodePosition({ key: "n1", position: { x: 1, y: 2 } });
440
+ expect(actionZ.parse(a)).toEqual(a);
441
+ });
442
+ });
443
+ });
444
+
445
+ describe("schematic reducer inverses", () => {
446
+ const expectRoundTrip = (state: Schematic, actions: Action[]) => {
447
+ const { next, inverse } = reduceAll(state, actions);
448
+ const restored = reduceAll(next, inverse).next;
449
+ expect(restored).toEqual(state);
450
+ };
451
+
452
+ // Acknowledges the documented gap in setConfig's inverse: keys newly added
453
+ // by a SetConfig action cannot be removed by the inverse (SetConfig is a
454
+ // merge, not a replace). Asserts that nodes and edges round-trip cleanly,
455
+ // and that every config key present in the original is restored to its
456
+ // original value. Keys absent from the original may persist as phantom
457
+ // entries on the restored state.
458
+ const expectUserVisibleRoundTrip = (state: Schematic, actions: Action[]) => {
459
+ const { next, inverse } = reduceAll(state, actions);
460
+ const restored = reduceAll(next, inverse).next;
461
+ expect(restored.nodes).toEqual(state.nodes);
462
+ expect(restored.edges).toEqual(state.edges);
463
+ for (const [k, v] of Object.entries(state.configs))
464
+ expect(restored.configs[k]).toEqual(v);
465
+ };
466
+
467
+ describe("rename", () => {
468
+ it("should invert to restore the prior name", () => {
469
+ const state = empty({ name: "old" });
470
+ expectRoundTrip(state, [rename({ name: "new" })]);
471
+ });
472
+ it("should round-trip when renaming to the same name", () => {
473
+ const state = empty({ name: "same" });
474
+ expectRoundTrip(state, [rename({ name: "same" })]);
475
+ });
476
+ it("should report the schematic key as a target so concurrent renames invalidate each other", () => {
477
+ const state = empty({ key: "11111111-1111-1111-1111-111111111111", name: "a" });
478
+ const { targets } = reduceAll(state, [rename({ name: "b" })]);
479
+ expect(targets).toEqual([state.key]);
480
+ });
481
+ });
482
+
483
+ describe("setNodePosition", () => {
484
+ it("should invert to restore the prior position", () => {
485
+ const state = empty({ nodes: [node("n1", 1, 2)] });
486
+ expectRoundTrip(state, [
487
+ setNodePosition({ key: "n1", position: { x: 99, y: 100 } }),
488
+ ]);
489
+ });
490
+ it("should produce an empty inverse for a no-op", () => {
491
+ const state = empty({ nodes: [node("n1", 1, 2)] });
492
+ const { inverse } = reduceAll(state, [
493
+ setNodePosition({ key: "ghost", position: { x: 9, y: 9 } }),
494
+ ]);
495
+ expect(inverse).toEqual([]);
496
+ });
497
+ });
498
+
499
+ describe("setNode", () => {
500
+ it("should invert an insert with a removeNode", () => {
501
+ const state = empty({ nodes: [node("n1", 0, 0)] });
502
+ expectRoundTrip(state, [
503
+ setNode({ node: node("n2", 5, 5), config: { label: "x" } }),
504
+ ]);
505
+ });
506
+ it("should invert a replace with a setNode of the prior node and config", () => {
507
+ const state = empty({
508
+ nodes: [node("n1", 1, 1)],
509
+ configs: { n1: { label: "Old", color: "#ff0000" } },
510
+ });
511
+ expectRoundTrip(state, [
512
+ setNode({ node: { ...node("n1", 9, 9) }, config: { label: "New" } }),
513
+ ]);
514
+ });
515
+ it("should invert a replace with no new config by restoring the prior node", () => {
516
+ const state = empty({
517
+ nodes: [node("n1", 1, 1)],
518
+ configs: { n1: { label: "Old" } },
519
+ });
520
+ expectRoundTrip(state, [setNode({ node: node("n1", 9, 9) })]);
521
+ });
522
+ });
523
+
524
+ describe("removeNode", () => {
525
+ it("should invert by re-inserting the node and its config (order not preserved)", () => {
526
+ // setNode appends rather than inserting at a specific index, so a
527
+ // remove + undo cycle can rearrange the nodes array. The contents are
528
+ // restored but the original index is lost. Would be addressed by an
529
+ // InsertNode(node, idx) action.
530
+ const state = empty({
531
+ nodes: [node("n1", 0, 0), node("n2", 1, 1)],
532
+ configs: { n1: { label: "Pump" }, n2: { label: "Tank" } },
533
+ });
534
+ const { next, inverse } = reduceAll(state, [removeNode({ key: "n1" })]);
535
+ const restored = reduceAll(next, inverse).next;
536
+ expect(restored.configs).toEqual(state.configs);
537
+ const byKey = (ns: Schematic["nodes"]) =>
538
+ Object.fromEntries(ns.map((n) => [n.key, n]));
539
+ expect(byKey(restored.nodes)).toEqual(byKey(state.nodes));
540
+ });
541
+ it("should invert a single-node removal cleanly when order is unambiguous", () => {
542
+ const state = empty({ nodes: [node("n1", 0, 0)] });
543
+ expectRoundTrip(state, [removeNode({ key: "n1" })]);
544
+ });
545
+ it("should produce an empty inverse for a no-op removal", () => {
546
+ const state = empty({ nodes: [node("n1", 0, 0)] });
547
+ const { inverse } = reduceAll(state, [removeNode({ key: "ghost" })]);
548
+ expect(inverse).toEqual([]);
549
+ });
550
+ });
551
+
552
+ describe("addEdge / removeEdge", () => {
553
+ it("should invert addEdge with removeEdge", () => {
554
+ const state = empty();
555
+ expectRoundTrip(state, [addEdge({ edge: edge("e1", "a", "o", "b", "i") })]);
556
+ });
557
+ it("should invert removeEdge with addEdge", () => {
558
+ const state = empty({ edges: [edge("e1", "a", "o", "b", "i")] });
559
+ expectRoundTrip(state, [removeEdge({ key: "e1" })]);
560
+ });
561
+ it("should produce an empty inverse for a duplicate addEdge", () => {
562
+ const state = empty({ edges: [edge("e1", "a", "o", "b", "i")] });
563
+ const { inverse } = reduceAll(state, [
564
+ addEdge({ edge: edge("e1", "x", "y", "z", "w") }),
565
+ ]);
566
+ expect(inverse).toEqual([]);
567
+ });
568
+ });
569
+
570
+ describe("setConfig", () => {
571
+ it("should restore overwritten fields and leave phantom new fields", () => {
572
+ const state = empty({
573
+ configs: { n1: { label: "Old", color: "#ff0000" } },
574
+ });
575
+ const { next, inverse } = reduceAll(state, [
576
+ setConfig({ key: "n1", config: { label: "New", count: 1 } }),
577
+ ]);
578
+ const restored = reduceAll(next, inverse).next;
579
+ // Overwritten fields are restored to their original values:
580
+ expect(restored.configs.n1).toMatchObject({ label: "Old", color: "#ff0000" });
581
+ // Newly-added fields persist as phantom entries — documented limitation
582
+ // until a ReplaceConfig action exists.
583
+ expect(restored.configs.n1).toHaveProperty("count", 1);
584
+ });
585
+ it("should produce an empty inverse when no key in the payload was previously present", () => {
586
+ const state = empty({ configs: { n1: { label: "Old" } } });
587
+ const { inverse } = reduceAll(state, [
588
+ setConfig({ key: "n1", config: { count: 1 } }),
589
+ ]);
590
+ expect(inverse).toEqual([]);
591
+ });
592
+ });
593
+
594
+ describe("setNodeMeasured", () => {
595
+ it("should be reported as not undoable", () => {
596
+ expect(
597
+ isUndoable(setNodeMeasured({ key: "n1", measured: { width: 10, height: 20 } })),
598
+ ).toBe(false);
599
+ });
600
+ it("should produce an empty inverse", () => {
601
+ const state = empty({ nodes: [node("n1", 0, 0)] });
602
+ const { inverse } = reduceAll(state, [
603
+ setNodeMeasured({ key: "n1", measured: { width: 10, height: 20 } }),
604
+ ]);
605
+ expect(inverse).toEqual([]);
606
+ });
607
+ it("should report no targets so it does not invalidate concurrent undoables", () => {
608
+ const state = empty({ nodes: [node("n1", 0, 0)] });
609
+ const { targets } = reduceAll(state, [
610
+ setNodeMeasured({ key: "n1", measured: { width: 10, height: 20 } }),
611
+ ]);
612
+ expect(targets).toEqual([]);
613
+ });
614
+ });
615
+
616
+ describe("multi-action transactions", () => {
617
+ it("should invert a build sequence (nodes and edges restored; phantom edge config persists)", () => {
618
+ // Inverse cannot strip the e1 config that setConfig added — see
619
+ // expectUserVisibleRoundTrip's docstring for the limitation.
620
+ expectUserVisibleRoundTrip(empty(), [
621
+ setNode({ node: node("pump", 0, 0) }),
622
+ setNode({ node: node("valve", 100, 0) }),
623
+ addEdge({ edge: edge("e1", "pump", "out", "valve", "in") }),
624
+ setConfig({ key: "e1", config: { variant: "pipe" } }),
625
+ ]);
626
+ });
627
+ it("should invert a multi-step move to the original positions", () => {
628
+ const state = empty({ nodes: [node("n1", 0, 0), node("n2", 1, 1)] });
629
+ expectRoundTrip(state, [
630
+ setNodePosition({ key: "n1", position: { x: 10, y: 20 } }),
631
+ setNodePosition({ key: "n2", position: { x: 30, y: 40 } }),
632
+ ]);
633
+ });
634
+ it("should invert a remove + re-add by restoring the original schematic", () => {
635
+ const state = empty({
636
+ nodes: [node("n1", 0, 0)],
637
+ configs: { n1: { label: "Pump" } },
638
+ });
639
+ expectRoundTrip(state, [
640
+ removeNode({ key: "n1" }),
641
+ setNode({ node: node("n1", 50, 50), config: { label: "Pump" } }),
642
+ ]);
643
+ });
644
+ });
645
+
646
+ describe("isUndoable", () => {
647
+ it("should return true for every action other than setNodeMeasured", () => {
648
+ expect(isUndoable(rename({ name: "x" }))).toBe(true);
649
+ expect(isUndoable(setNodePosition({ key: "n1", position: { x: 0, y: 0 } }))).toBe(
650
+ true,
651
+ );
652
+ expect(isUndoable(setNode({ node: node("n1", 0, 0) }))).toBe(true);
653
+ expect(isUndoable(removeNode({ key: "n1" }))).toBe(true);
654
+ expect(isUndoable(addEdge({ edge: edge("e1", "a", "o", "b", "i") }))).toBe(true);
655
+ expect(isUndoable(removeEdge({ key: "e1" }))).toBe(true);
656
+ expect(isUndoable(setConfig({ key: "n1", config: {} }))).toBe(true);
657
+ });
658
+ });
659
+
660
+ describe("single-action reduce against a draft", () => {
661
+ it("should return the inverse and mutate the draft via reduceAll", () => {
662
+ const state = empty({ nodes: [node("n1", 0, 0)] });
663
+ const { next, inverse } = reduceAll(state, [
664
+ setNodePosition({ key: "n1", position: { x: 10, y: 20 } }),
665
+ ]);
666
+ expect(next.nodes[0].position).toEqual({ x: 10, y: 20 });
667
+ expect(inverse).toEqual([
668
+ setNodePosition({ key: "n1", position: { x: 0, y: 0 } }),
669
+ ]);
670
+ });
671
+ });
672
+
673
+ describe("coalesced setConfig undo (regression)", () => {
674
+ it("should restore the original config after two rapid setConfig calls", () => {
675
+ const initial = empty({
676
+ nodes: [node("n1", 0, 0)],
677
+ configs: { n1: { label: { level: "p" }, scale: 1, color: "red" } },
678
+ });
679
+ // Each dispatch carries the FULL form values, matching IndividualConfig's
680
+ // onChange: ({ values }) => dispatch(setConfig({ key, config: values })).
681
+ const r1 = reduceAll(initial, [
682
+ setConfig({
683
+ key: "n1",
684
+ config: { label: { level: "h2" }, scale: 1, color: "red" },
685
+ }),
686
+ ]);
687
+ const r2 = reduceAll(r1.next, [
688
+ setConfig({
689
+ key: "n1",
690
+ config: { label: { level: "h3" }, scale: 1, color: "red" },
691
+ }),
692
+ ]);
693
+ // Matches pushOnto's coalescing: inverse = [next.inverse, ...top.inverse]
694
+ const mergedInverse = [...r2.inverse, ...r1.inverse];
695
+ const restored = reduceAll(r2.next, mergedInverse).next;
696
+ expect(restored.configs.n1).toEqual(initial.configs.n1);
697
+ });
698
+ });
699
+ });