@webiny/website-builder-sdk 0.0.0-unstable.6f45466a1d → 0.0.0-unstable.7be00a75a9

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 (175) hide show
  1. package/BindingsApi.js +29 -36
  2. package/BindingsApi.js.map +1 -1
  3. package/BindingsProcessor.js +34 -43
  4. package/BindingsProcessor.js.map +1 -1
  5. package/BindingsProcessor.test.js +82 -78
  6. package/BindingsProcessor.test.js.map +1 -1
  7. package/BindingsResolver.js +92 -122
  8. package/BindingsResolver.js.map +1 -1
  9. package/BindingsResolver.test.js +399 -363
  10. package/BindingsResolver.test.js.map +1 -1
  11. package/ComponentInputTraverser.js +28 -49
  12. package/ComponentInputTraverser.js.map +1 -1
  13. package/ComponentManifestToAstConverter.js +20 -21
  14. package/ComponentManifestToAstConverter.js.map +1 -1
  15. package/ComponentRegistry.js +33 -22
  16. package/ComponentRegistry.js.map +1 -1
  17. package/ComponentResolver.js +25 -29
  18. package/ComponentResolver.js.map +1 -1
  19. package/ConstraintEvaluator.d.ts +36 -0
  20. package/ConstraintEvaluator.js +288 -0
  21. package/ConstraintEvaluator.js.map +1 -0
  22. package/ConstraintEvaluator.test.d.ts +1 -0
  23. package/ConstraintEvaluator.test.js +1634 -0
  24. package/ConstraintEvaluator.test.js.map +1 -0
  25. package/ContentSdk.d.ts +3 -3
  26. package/ContentSdk.js +83 -90
  27. package/ContentSdk.js.map +1 -1
  28. package/DocumentStore.js +47 -59
  29. package/DocumentStore.js.map +1 -1
  30. package/DocumentStoreManager.js +17 -16
  31. package/DocumentStoreManager.js.map +1 -1
  32. package/EditingSdk.d.ts +2 -2
  33. package/EditingSdk.js +87 -121
  34. package/EditingSdk.js.map +1 -1
  35. package/ElementFactory.d.ts +2 -3
  36. package/ElementFactory.js +125 -164
  37. package/ElementFactory.js.map +1 -1
  38. package/ElementFactory.test.d.ts +1 -0
  39. package/ElementFactory.test.js +251 -0
  40. package/ElementFactory.test.js.map +1 -0
  41. package/Environment.js +18 -19
  42. package/Environment.js.map +1 -1
  43. package/FunctionConverter.js +8 -7
  44. package/FunctionConverter.js.map +1 -1
  45. package/HashObject.js +11 -12
  46. package/HashObject.js.map +1 -1
  47. package/HotkeyManager.js +41 -48
  48. package/HotkeyManager.js.map +1 -1
  49. package/IBindingsUpdater.js +0 -3
  50. package/IRedirects.js +0 -3
  51. package/InheritanceProcessor.js +99 -139
  52. package/InheritanceProcessor.js.map +1 -1
  53. package/InheritanceProcessor.test.js +178 -179
  54. package/InheritanceProcessor.test.js.map +1 -1
  55. package/InheritedValueResolver.js +15 -20
  56. package/InheritedValueResolver.js.map +1 -1
  57. package/InputBindingsProcessor.js +187 -289
  58. package/InputBindingsProcessor.js.map +1 -1
  59. package/InputsBindingsProcessor.test.js +334 -314
  60. package/InputsBindingsProcessor.test.js.map +1 -1
  61. package/InputsUpdater.d.ts +1 -1
  62. package/InputsUpdater.js +23 -26
  63. package/InputsUpdater.js.map +1 -1
  64. package/LiveSdk.d.ts +2 -2
  65. package/LiveSdk.js +12 -13
  66. package/LiveSdk.js.map +1 -1
  67. package/Logger.js +9 -8
  68. package/Logger.js.map +1 -1
  69. package/MouseTracker.js +77 -83
  70. package/MouseTracker.js.map +1 -1
  71. package/NullSdk.d.ts +4 -3
  72. package/NullSdk.js +22 -14
  73. package/NullSdk.js.map +1 -1
  74. package/PreviewDocument.js +27 -30
  75. package/PreviewDocument.js.map +1 -1
  76. package/PreviewSdk.d.ts +2 -2
  77. package/PreviewSdk.js +16 -17
  78. package/PreviewSdk.js.map +1 -1
  79. package/PreviewViewport.js +51 -63
  80. package/PreviewViewport.js.map +1 -1
  81. package/ResizeObserver.js +24 -31
  82. package/ResizeObserver.js.map +1 -1
  83. package/StylesBindingsProcessor.js +40 -79
  84. package/StylesBindingsProcessor.js.map +1 -1
  85. package/StylesUpdater.d.ts +1 -1
  86. package/StylesUpdater.js +20 -25
  87. package/StylesUpdater.js.map +1 -1
  88. package/Theme.js +29 -25
  89. package/Theme.js.map +1 -1
  90. package/ViewportManager.js +89 -101
  91. package/ViewportManager.js.map +1 -1
  92. package/constants.d.ts +1 -0
  93. package/constants.js +7 -5
  94. package/constants.js.map +1 -1
  95. package/createElement.js +5 -6
  96. package/createElement.js.map +1 -1
  97. package/createInput.js +85 -143
  98. package/createInput.js.map +1 -1
  99. package/createTheme.js +2 -3
  100. package/createTheme.js.map +1 -1
  101. package/dataProviders/ApiClient.js +40 -49
  102. package/dataProviders/ApiClient.js.map +1 -1
  103. package/dataProviders/DefaultDataProvider.d.ts +2 -2
  104. package/dataProviders/DefaultDataProvider.js +55 -44
  105. package/dataProviders/DefaultDataProvider.js.map +1 -1
  106. package/dataProviders/GET_PAGE_BY_ID.d.ts +1 -1
  107. package/dataProviders/GET_PAGE_BY_ID.js +3 -1
  108. package/dataProviders/GET_PAGE_BY_ID.js.map +1 -1
  109. package/dataProviders/GET_PAGE_BY_PATH.d.ts +1 -1
  110. package/dataProviders/GET_PAGE_BY_PATH.js +3 -1
  111. package/dataProviders/GET_PAGE_BY_PATH.js.map +1 -1
  112. package/dataProviders/LIST_PUBLISHED_PAGES.d.ts +1 -1
  113. package/dataProviders/LIST_PUBLISHED_PAGES.js +16 -5
  114. package/dataProviders/LIST_PUBLISHED_PAGES.js.map +1 -1
  115. package/dataProviders/NullDataProvider.d.ts +12 -4
  116. package/dataProviders/NullDataProvider.js +21 -13
  117. package/dataProviders/NullDataProvider.js.map +1 -1
  118. package/dataProviders/RedirectsProvider.js +24 -27
  119. package/dataProviders/RedirectsProvider.js.map +1 -1
  120. package/defaultBreakpoints.js +23 -22
  121. package/defaultBreakpoints.js.map +1 -1
  122. package/documentOperations/$addElementReferenceToParent.d.ts +1 -1
  123. package/documentOperations/$addElementReferenceToParent.js +30 -26
  124. package/documentOperations/$addElementReferenceToParent.js.map +1 -1
  125. package/documentOperations/AddElement.js +8 -7
  126. package/documentOperations/AddElement.js.map +1 -1
  127. package/documentOperations/AddToParent.d.ts +2 -2
  128. package/documentOperations/AddToParent.js +14 -13
  129. package/documentOperations/AddToParent.js.map +1 -1
  130. package/documentOperations/IDocumentOperation.js +0 -3
  131. package/documentOperations/RemoveElement.js +10 -7
  132. package/documentOperations/RemoveElement.js.map +1 -1
  133. package/documentOperations/SetGlobalInputBinding.js +23 -22
  134. package/documentOperations/SetGlobalInputBinding.js.map +1 -1
  135. package/documentOperations/SetGlobalStyleBinding.js +23 -23
  136. package/documentOperations/SetGlobalStyleBinding.js.map +1 -1
  137. package/documentOperations/SetInputBindingOverride.js +30 -29
  138. package/documentOperations/SetInputBindingOverride.js.map +1 -1
  139. package/documentOperations/SetStyleBindingOverride.js +30 -31
  140. package/documentOperations/SetStyleBindingOverride.js.map +1 -1
  141. package/documentOperations/index.js +9 -8
  142. package/documentOperations/index.js.map +1 -1
  143. package/findMatchingAstNode.js +11 -13
  144. package/findMatchingAstNode.js.map +1 -1
  145. package/generateElementId.js +2 -2
  146. package/generateElementId.js.map +1 -1
  147. package/headersProvider.js +4 -3
  148. package/headersProvider.js.map +1 -1
  149. package/index.d.ts +1 -0
  150. package/index.js +1 -2
  151. package/jsonPatch.js +5 -9
  152. package/jsonPatch.js.map +1 -1
  153. package/messages.js +12 -11
  154. package/messages.js.map +1 -1
  155. package/messenger/MessageOrigin.js +12 -11
  156. package/messenger/MessageOrigin.js.map +1 -1
  157. package/messenger/Messenger.js +58 -71
  158. package/messenger/Messenger.js.map +1 -1
  159. package/messenger/index.js +0 -2
  160. package/package.json +16 -16
  161. package/registerComponentGroup.js +5 -6
  162. package/registerComponentGroup.js.map +1 -1
  163. package/types/ShorthandCssProperties.js +0 -3
  164. package/types/WebsiteBuilderTheme.d.ts +7 -0
  165. package/types/WebsiteBuilderTheme.js +0 -3
  166. package/types.d.ts +173 -11
  167. package/types.js +0 -3
  168. package/IBindingsUpdater.js.map +0 -1
  169. package/IRedirects.js.map +0 -1
  170. package/documentOperations/IDocumentOperation.js.map +0 -1
  171. package/index.js.map +0 -1
  172. package/messenger/index.js.map +0 -1
  173. package/types/ShorthandCssProperties.js.map +0 -1
  174. package/types/WebsiteBuilderTheme.js.map +0 -1
  175. package/types.js.map +0 -1
@@ -0,0 +1,1634 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { evaluateConstraints, evaluateDeleteConstraint } from "./ConstraintEvaluator.js";
3
+ function makeElement(id, componentName, parent) {
4
+ return {
5
+ type: "Webiny/Element",
6
+ id,
7
+ component: {
8
+ name: componentName
9
+ },
10
+ parent
11
+ };
12
+ }
13
+ function makeManifest(name, overrides) {
14
+ return {
15
+ name,
16
+ label: name,
17
+ inputs: [],
18
+ tags: [],
19
+ ...overrides
20
+ };
21
+ }
22
+ function makeDocument(elements, bindings = {}) {
23
+ const elementMap = {};
24
+ for (const el of elements)elementMap[el.id] = el;
25
+ return {
26
+ id: "doc-1",
27
+ state: {},
28
+ version: 1,
29
+ properties: {},
30
+ metadata: {},
31
+ extensions: {},
32
+ bindings,
33
+ elements: elementMap
34
+ };
35
+ }
36
+ describe("evaluateConstraints", ()=>{
37
+ it("should allow placement when there are no constraints", ()=>{
38
+ const parent = makeElement("parent-1", "Container");
39
+ const doc = makeDocument([
40
+ parent
41
+ ]);
42
+ const components = {
43
+ Container: makeManifest("Container"),
44
+ Button: makeManifest("Button")
45
+ };
46
+ const result = evaluateConstraints({
47
+ componentName: "Button",
48
+ parentId: "parent-1",
49
+ slot: "children",
50
+ document: doc,
51
+ components
52
+ });
53
+ expect(result.allowed).toBe(true);
54
+ expect(result.violation).toBeUndefined();
55
+ });
56
+ it("should block when component's parent constraint fails", ()=>{
57
+ const parent = makeElement("parent-1", "Container");
58
+ const doc = makeDocument([
59
+ parent
60
+ ]);
61
+ const components = {
62
+ Container: makeManifest("Container"),
63
+ TabPanel: makeManifest("TabPanel", {
64
+ constraints: [
65
+ (ctx)=>{
66
+ if ("Tabs" !== ctx.parent.name) return ctx.block("TabPanel must be inside Tabs");
67
+ }
68
+ ]
69
+ })
70
+ };
71
+ const result = evaluateConstraints({
72
+ componentName: "TabPanel",
73
+ parentId: "parent-1",
74
+ slot: "children",
75
+ document: doc,
76
+ components
77
+ });
78
+ expect(result.allowed).toBe(false);
79
+ expect(result.violation).toBeDefined();
80
+ expect(result.violation.message).toBe("TabPanel must be inside Tabs");
81
+ });
82
+ it("should allow when parent constraint passes", ()=>{
83
+ const parent = makeElement("parent-1", "Tabs");
84
+ const doc = makeDocument([
85
+ parent
86
+ ]);
87
+ const components = {
88
+ Tabs: makeManifest("Tabs"),
89
+ TabPanel: makeManifest("TabPanel", {
90
+ constraints: [
91
+ (ctx)=>{
92
+ if ("Tabs" !== ctx.parent.name) return ctx.block("TabPanel must be inside Tabs");
93
+ }
94
+ ]
95
+ })
96
+ };
97
+ const result = evaluateConstraints({
98
+ componentName: "TabPanel",
99
+ parentId: "parent-1",
100
+ slot: "children",
101
+ document: doc,
102
+ components
103
+ });
104
+ expect(result.allowed).toBe(true);
105
+ expect(result.violation).toBeUndefined();
106
+ });
107
+ it("should block when ancestor constraint fails", ()=>{
108
+ const root = makeElement("root", "Page");
109
+ const section = makeElement("section-1", "Section", {
110
+ id: "root",
111
+ slot: "children"
112
+ });
113
+ const doc = makeDocument([
114
+ root,
115
+ section
116
+ ]);
117
+ const components = {
118
+ Page: makeManifest("Page"),
119
+ Section: makeManifest("Section"),
120
+ ProductPrice: makeManifest("ProductPrice", {
121
+ constraints: [
122
+ (ctx)=>{
123
+ if (!ctx.isDescendantOf("ProductBox")) return ctx.block("ProductPrice must be inside a ProductBox");
124
+ }
125
+ ]
126
+ })
127
+ };
128
+ const result = evaluateConstraints({
129
+ componentName: "ProductPrice",
130
+ parentId: "section-1",
131
+ slot: "children",
132
+ document: doc,
133
+ components
134
+ });
135
+ expect(result.allowed).toBe(false);
136
+ expect(result.violation.message).toBe("ProductPrice must be inside a ProductBox");
137
+ });
138
+ it("should allow when grandparent matches ancestor constraint", ()=>{
139
+ const root = makeElement("root", "ProductBox");
140
+ const inner = makeElement("inner-1", "Container", {
141
+ id: "root",
142
+ slot: "children"
143
+ });
144
+ const doc = makeDocument([
145
+ root,
146
+ inner
147
+ ]);
148
+ const components = {
149
+ ProductBox: makeManifest("ProductBox"),
150
+ Container: makeManifest("Container"),
151
+ ProductPrice: makeManifest("ProductPrice", {
152
+ constraints: [
153
+ (ctx)=>{
154
+ if (!ctx.isDescendantOf("ProductBox")) return ctx.block("ProductPrice must be inside a ProductBox");
155
+ }
156
+ ]
157
+ })
158
+ };
159
+ const result = evaluateConstraints({
160
+ componentName: "ProductPrice",
161
+ parentId: "inner-1",
162
+ slot: "children",
163
+ document: doc,
164
+ components
165
+ });
166
+ expect(result.allowed).toBe(true);
167
+ });
168
+ it("should block via slot children limit constraint using slotChildCount", ()=>{
169
+ const parent = makeElement("parent-1", "Grid");
170
+ const child1 = makeElement("child-1", "Cell", {
171
+ id: "parent-1",
172
+ slot: "children"
173
+ });
174
+ const child2 = makeElement("child-2", "Cell", {
175
+ id: "parent-1",
176
+ slot: "children"
177
+ });
178
+ const doc = makeDocument([
179
+ parent,
180
+ child1,
181
+ child2
182
+ ], {
183
+ "parent-1": {
184
+ inputs: {
185
+ children: {
186
+ id: "inp-1",
187
+ type: "slot",
188
+ static: [
189
+ "child-1",
190
+ "child-2"
191
+ ]
192
+ }
193
+ }
194
+ }
195
+ });
196
+ const components = {
197
+ Grid: makeManifest("Grid"),
198
+ Cell: makeManifest("Cell", {
199
+ constraints: [
200
+ (ctx)=>{
201
+ if (!(ctx.slotChildCount() < 2)) return ctx.block("Maximum 2 children allowed");
202
+ }
203
+ ]
204
+ })
205
+ };
206
+ const result = evaluateConstraints({
207
+ componentName: "Cell",
208
+ parentId: "parent-1",
209
+ slot: "children",
210
+ document: doc,
211
+ components
212
+ });
213
+ expect(result.allowed).toBe(false);
214
+ expect(result.violation.message).toBe("Maximum 2 children allowed");
215
+ });
216
+ it("should block via countInstances when max instances reached", ()=>{
217
+ const root = makeElement("root", "Page");
218
+ const hero = makeElement("hero-1", "Hero", {
219
+ id: "root",
220
+ slot: "children"
221
+ });
222
+ const doc = makeDocument([
223
+ root,
224
+ hero
225
+ ]);
226
+ const components = {
227
+ Page: makeManifest("Page"),
228
+ Hero: makeManifest("Hero", {
229
+ constraints: [
230
+ (ctx)=>{
231
+ if (!(ctx.countInstances("Hero") < 1)) return ctx.block("Only one Hero per page");
232
+ }
233
+ ]
234
+ })
235
+ };
236
+ const result = evaluateConstraints({
237
+ componentName: "Hero",
238
+ parentId: "root",
239
+ slot: "children",
240
+ document: doc,
241
+ components
242
+ });
243
+ expect(result.allowed).toBe(false);
244
+ expect(result.violation.message).toBe("Only one Hero per page");
245
+ });
246
+ it("should not evaluate parent's constraints against the placed component", ()=>{
247
+ const parent = makeElement("parent-1", "Tabs");
248
+ const doc = makeDocument([
249
+ parent
250
+ ]);
251
+ const components = {
252
+ Tabs: makeManifest("Tabs", {
253
+ constraints: [
254
+ (ctx)=>ctx.block("This should not run for children")
255
+ ]
256
+ }),
257
+ Button: makeManifest("Button")
258
+ };
259
+ const result = evaluateConstraints({
260
+ componentName: "Button",
261
+ parentId: "parent-1",
262
+ slot: "children",
263
+ document: doc,
264
+ components
265
+ });
266
+ expect(result.allowed).toBe(true);
267
+ });
268
+ it("should short-circuit on the first failed constraint", ()=>{
269
+ const parent = makeElement("parent-1", "Container");
270
+ const doc = makeDocument([
271
+ parent
272
+ ]);
273
+ const components = {
274
+ Container: makeManifest("Container"),
275
+ Widget: makeManifest("Widget", {
276
+ constraints: [
277
+ ()=>{},
278
+ (ctx)=>ctx.block("Always fails"),
279
+ (ctx)=>ctx.block("Also fails")
280
+ ]
281
+ })
282
+ };
283
+ const result = evaluateConstraints({
284
+ componentName: "Widget",
285
+ parentId: "parent-1",
286
+ slot: "children",
287
+ document: doc,
288
+ components
289
+ });
290
+ expect(result.allowed).toBe(false);
291
+ expect(result.violation.message).toBe("Always fails");
292
+ });
293
+ it("should allow placement when component manifest is not found", ()=>{
294
+ const parent = makeElement("parent-1", "Container");
295
+ const doc = makeDocument([
296
+ parent
297
+ ]);
298
+ const components = {
299
+ Container: makeManifest("Container")
300
+ };
301
+ const result = evaluateConstraints({
302
+ componentName: "Unknown",
303
+ parentId: "parent-1",
304
+ slot: "children",
305
+ document: doc,
306
+ components
307
+ });
308
+ expect(result.allowed).toBe(true);
309
+ });
310
+ it("should allow placement when parent element is not found", ()=>{
311
+ const doc = makeDocument([]);
312
+ const components = {
313
+ Button: makeManifest("Button")
314
+ };
315
+ const result = evaluateConstraints({
316
+ componentName: "Button",
317
+ parentId: "nonexistent",
318
+ slot: "children",
319
+ document: doc,
320
+ components
321
+ });
322
+ expect(result.allowed).toBe(true);
323
+ });
324
+ it("should handle multi-slot parent constraint checking specific slot", ()=>{
325
+ const parent = makeElement("parent-1", "TwoColumn");
326
+ const child = makeElement("child-1", "Widget", {
327
+ id: "parent-1",
328
+ slot: "leftColumn"
329
+ });
330
+ const doc = makeDocument([
331
+ parent,
332
+ child
333
+ ], {
334
+ "parent-1": {
335
+ inputs: {
336
+ leftColumn: {
337
+ id: "inp-left",
338
+ type: "slot",
339
+ static: [
340
+ "child-1"
341
+ ]
342
+ },
343
+ rightColumn: {
344
+ id: "inp-right",
345
+ type: "slot",
346
+ static: []
347
+ }
348
+ }
349
+ }
350
+ });
351
+ const components = {
352
+ TwoColumn: makeManifest("TwoColumn"),
353
+ Widget: makeManifest("Widget", {
354
+ constraints: [
355
+ (ctx)=>{
356
+ if (!(ctx.slotChildCount() < 1)) return ctx.block("Slot is full");
357
+ }
358
+ ]
359
+ })
360
+ };
361
+ const leftResult = evaluateConstraints({
362
+ componentName: "Widget",
363
+ parentId: "parent-1",
364
+ slot: "leftColumn",
365
+ document: doc,
366
+ components
367
+ });
368
+ expect(leftResult.allowed).toBe(false);
369
+ const rightResult = evaluateConstraints({
370
+ componentName: "Widget",
371
+ parentId: "parent-1",
372
+ slot: "rightColumn",
373
+ document: doc,
374
+ components
375
+ });
376
+ expect(rightResult.allowed).toBe(true);
377
+ });
378
+ it("should provide a default message when constraint has no message", ()=>{
379
+ const parent = makeElement("parent-1", "Container");
380
+ const doc = makeDocument([
381
+ parent
382
+ ]);
383
+ const components = {
384
+ Container: makeManifest("Container"),
385
+ Widget: makeManifest("Widget", {
386
+ constraints: [
387
+ (ctx)=>ctx.block("Blocked")
388
+ ]
389
+ })
390
+ };
391
+ const result = evaluateConstraints({
392
+ componentName: "Widget",
393
+ parentId: "parent-1",
394
+ slot: "children",
395
+ document: doc,
396
+ components
397
+ });
398
+ expect(result.violation.message).toBe("Blocked");
399
+ });
400
+ it("should use thrown error message as the violation message", ()=>{
401
+ const parent = makeElement("parent-1", "Container");
402
+ const doc = makeDocument([
403
+ parent
404
+ ]);
405
+ const components = {
406
+ Container: makeManifest("Container"),
407
+ Widget: makeManifest("Widget", {
408
+ constraints: [
409
+ (ctx)=>{
410
+ throw new Error(`Widget cannot be placed inside ${ctx.parent.name}`);
411
+ }
412
+ ]
413
+ })
414
+ };
415
+ const result = evaluateConstraints({
416
+ componentName: "Widget",
417
+ parentId: "parent-1",
418
+ slot: "children",
419
+ document: doc,
420
+ components
421
+ });
422
+ expect(result.allowed).toBe(false);
423
+ expect(result.violation.message).toBe("Widget cannot be placed inside Container");
424
+ });
425
+ it("should fall back to static message when a non-Error is thrown", ()=>{
426
+ const parent = makeElement("parent-1", "Container");
427
+ const doc = makeDocument([
428
+ parent
429
+ ]);
430
+ const components = {
431
+ Container: makeManifest("Container"),
432
+ Widget: makeManifest("Widget", {
433
+ constraints: [
434
+ ()=>{
435
+ throw "not an error object";
436
+ }
437
+ ]
438
+ })
439
+ };
440
+ const result = evaluateConstraints({
441
+ componentName: "Widget",
442
+ parentId: "parent-1",
443
+ slot: "children",
444
+ document: doc,
445
+ components
446
+ });
447
+ expect(result.allowed).toBe(false);
448
+ expect(result.violation.message).toBe("Cannot place Widget here.");
449
+ });
450
+ it("should fall back to default message when a non-Error is thrown and no static message", ()=>{
451
+ const parent = makeElement("parent-1", "Container");
452
+ const doc = makeDocument([
453
+ parent
454
+ ]);
455
+ const components = {
456
+ Container: makeManifest("Container"),
457
+ Widget: makeManifest("Widget", {
458
+ constraints: [
459
+ ()=>{
460
+ throw null;
461
+ }
462
+ ]
463
+ })
464
+ };
465
+ const result = evaluateConstraints({
466
+ componentName: "Widget",
467
+ parentId: "parent-1",
468
+ slot: "children",
469
+ document: doc,
470
+ components
471
+ });
472
+ expect(result.allowed).toBe(false);
473
+ expect(result.violation.message).toBe("Cannot place Widget here.");
474
+ });
475
+ describe("ctx.isChildOf", ()=>{
476
+ it("should return true when direct parent matches", ()=>{
477
+ const parent = makeElement("parent-1", "Tabs");
478
+ const doc = makeDocument([
479
+ parent
480
+ ]);
481
+ const components = {
482
+ Tabs: makeManifest("Tabs"),
483
+ TabPanel: makeManifest("TabPanel", {
484
+ constraints: [
485
+ (ctx)=>{
486
+ if (!ctx.isChildOf("Tabs")) return ctx.block("TabPanel must be a direct child of Tabs");
487
+ }
488
+ ]
489
+ })
490
+ };
491
+ const result = evaluateConstraints({
492
+ componentName: "TabPanel",
493
+ parentId: "parent-1",
494
+ slot: "children",
495
+ document: doc,
496
+ components
497
+ });
498
+ expect(result.allowed).toBe(true);
499
+ });
500
+ it("should return false when direct parent does not match", ()=>{
501
+ const parent = makeElement("parent-1", "Container");
502
+ const doc = makeDocument([
503
+ parent
504
+ ]);
505
+ const components = {
506
+ Container: makeManifest("Container"),
507
+ TabPanel: makeManifest("TabPanel", {
508
+ constraints: [
509
+ (ctx)=>{
510
+ if (!ctx.isChildOf("Tabs")) return ctx.block("TabPanel must be a direct child of Tabs");
511
+ }
512
+ ]
513
+ })
514
+ };
515
+ const result = evaluateConstraints({
516
+ componentName: "TabPanel",
517
+ parentId: "parent-1",
518
+ slot: "children",
519
+ document: doc,
520
+ components
521
+ });
522
+ expect(result.allowed).toBe(false);
523
+ });
524
+ });
525
+ describe("ctx.isDescendantOf", ()=>{
526
+ it("should return true when direct parent matches", ()=>{
527
+ const parent = makeElement("parent-1", "ProductBox");
528
+ const doc = makeDocument([
529
+ parent
530
+ ]);
531
+ const components = {
532
+ ProductBox: makeManifest("ProductBox"),
533
+ ProductPrice: makeManifest("ProductPrice", {
534
+ constraints: [
535
+ (ctx)=>{
536
+ if (!ctx.isDescendantOf("ProductBox")) return ctx.block("Blocked");
537
+ }
538
+ ]
539
+ })
540
+ };
541
+ const result = evaluateConstraints({
542
+ componentName: "ProductPrice",
543
+ parentId: "parent-1",
544
+ slot: "children",
545
+ document: doc,
546
+ components
547
+ });
548
+ expect(result.allowed).toBe(true);
549
+ });
550
+ it("should return true when grandparent matches", ()=>{
551
+ const root = makeElement("root", "ProductBox");
552
+ const inner = makeElement("inner", "Container", {
553
+ id: "root",
554
+ slot: "children"
555
+ });
556
+ const doc = makeDocument([
557
+ root,
558
+ inner
559
+ ]);
560
+ const components = {
561
+ ProductBox: makeManifest("ProductBox"),
562
+ Container: makeManifest("Container"),
563
+ ProductPrice: makeManifest("ProductPrice", {
564
+ constraints: [
565
+ (ctx)=>{
566
+ if (!ctx.isDescendantOf("ProductBox")) return ctx.block("Blocked");
567
+ }
568
+ ]
569
+ })
570
+ };
571
+ const result = evaluateConstraints({
572
+ componentName: "ProductPrice",
573
+ parentId: "inner",
574
+ slot: "children",
575
+ document: doc,
576
+ components
577
+ });
578
+ expect(result.allowed).toBe(true);
579
+ });
580
+ it("should return false when no ancestor matches", ()=>{
581
+ const root = makeElement("root", "Page");
582
+ const inner = makeElement("inner", "Section", {
583
+ id: "root",
584
+ slot: "children"
585
+ });
586
+ const doc = makeDocument([
587
+ root,
588
+ inner
589
+ ]);
590
+ const components = {
591
+ Page: makeManifest("Page"),
592
+ Section: makeManifest("Section"),
593
+ ProductPrice: makeManifest("ProductPrice", {
594
+ constraints: [
595
+ (ctx)=>{
596
+ if (!ctx.isDescendantOf("ProductBox")) return ctx.block("Blocked");
597
+ }
598
+ ]
599
+ })
600
+ };
601
+ const result = evaluateConstraints({
602
+ componentName: "ProductPrice",
603
+ parentId: "inner",
604
+ slot: "children",
605
+ document: doc,
606
+ components
607
+ });
608
+ expect(result.allowed).toBe(false);
609
+ });
610
+ });
611
+ describe("ctx.slotChildCount", ()=>{
612
+ it("should return the number of children in the target slot", ()=>{
613
+ const parent = makeElement("parent-1", "Grid");
614
+ const doc = makeDocument([
615
+ parent
616
+ ], {
617
+ "parent-1": {
618
+ inputs: {
619
+ children: {
620
+ id: "inp-1",
621
+ type: "slot",
622
+ static: [
623
+ "child-1",
624
+ "child-2",
625
+ "child-3"
626
+ ]
627
+ }
628
+ }
629
+ }
630
+ });
631
+ const components = {
632
+ Grid: makeManifest("Grid"),
633
+ Cell: makeManifest("Cell", {
634
+ constraints: [
635
+ (ctx)=>{
636
+ if (!(ctx.slotChildCount() < 3)) return ctx.block("Max 3 children");
637
+ }
638
+ ]
639
+ })
640
+ };
641
+ const result = evaluateConstraints({
642
+ componentName: "Cell",
643
+ parentId: "parent-1",
644
+ slot: "children",
645
+ document: doc,
646
+ components
647
+ });
648
+ expect(result.allowed).toBe(false);
649
+ });
650
+ it("should return 0 when slot has no bindings", ()=>{
651
+ const parent = makeElement("parent-1", "Container");
652
+ const doc = makeDocument([
653
+ parent
654
+ ]);
655
+ const components = {
656
+ Container: makeManifest("Container"),
657
+ Widget: makeManifest("Widget", {
658
+ constraints: [
659
+ (ctx)=>{
660
+ if (!(ctx.slotChildCount() < 5)) return ctx.block("Blocked");
661
+ }
662
+ ]
663
+ })
664
+ };
665
+ const result = evaluateConstraints({
666
+ componentName: "Widget",
667
+ parentId: "parent-1",
668
+ slot: "children",
669
+ document: doc,
670
+ components
671
+ });
672
+ expect(result.allowed).toBe(true);
673
+ });
674
+ });
675
+ describe("ctx.hasTag", ()=>{
676
+ it("should return true when the placed component has the tag", ()=>{
677
+ const parent = makeElement("parent-1", "Container");
678
+ const doc = makeDocument([
679
+ parent
680
+ ]);
681
+ const components = {
682
+ Container: makeManifest("Container"),
683
+ FunnelField: makeManifest("FunnelField", {
684
+ tags: [
685
+ "funnel-field",
686
+ "input"
687
+ ],
688
+ constraints: [
689
+ (ctx)=>{
690
+ if (ctx.hasTag("funnel-field")) return ctx.block("Funnel fields not allowed here");
691
+ }
692
+ ]
693
+ })
694
+ };
695
+ const result = evaluateConstraints({
696
+ componentName: "FunnelField",
697
+ parentId: "parent-1",
698
+ slot: "children",
699
+ document: doc,
700
+ components
701
+ });
702
+ expect(result.allowed).toBe(false);
703
+ });
704
+ it("should return false when the placed component does not have the tag", ()=>{
705
+ const parent = makeElement("parent-1", "Container");
706
+ const doc = makeDocument([
707
+ parent
708
+ ]);
709
+ const components = {
710
+ Container: makeManifest("Container"),
711
+ Button: makeManifest("Button", {
712
+ constraints: [
713
+ (ctx)=>{
714
+ if (ctx.hasTag("funnel-field")) return ctx.block("Blocked");
715
+ }
716
+ ]
717
+ })
718
+ };
719
+ const result = evaluateConstraints({
720
+ componentName: "Button",
721
+ parentId: "parent-1",
722
+ slot: "children",
723
+ document: doc,
724
+ components
725
+ });
726
+ expect(result.allowed).toBe(true);
727
+ });
728
+ });
729
+ describe("ctx.getAncestor", ()=>{
730
+ it("should return the matching ancestor element context", ()=>{
731
+ const root = makeElement("root", "ProductBox");
732
+ const inner = makeElement("inner", "Container", {
733
+ id: "root",
734
+ slot: "children"
735
+ });
736
+ const doc = makeDocument([
737
+ root,
738
+ inner
739
+ ]);
740
+ const components = {
741
+ ProductBox: makeManifest("ProductBox", {
742
+ tags: [
743
+ "product"
744
+ ]
745
+ }),
746
+ Container: makeManifest("Container"),
747
+ ProductPrice: makeManifest("ProductPrice", {
748
+ constraints: [
749
+ (ctx)=>{
750
+ const ancestor = ctx.getAncestor("ProductBox");
751
+ if (!(void 0 !== ancestor && "ProductBox" === ancestor.name)) return ctx.block("Blocked");
752
+ }
753
+ ]
754
+ })
755
+ };
756
+ const result = evaluateConstraints({
757
+ componentName: "ProductPrice",
758
+ parentId: "inner",
759
+ slot: "children",
760
+ document: doc,
761
+ components
762
+ });
763
+ expect(result.allowed).toBe(true);
764
+ });
765
+ it("should return undefined when no ancestor matches", ()=>{
766
+ const root = makeElement("root", "Page");
767
+ const doc = makeDocument([
768
+ root
769
+ ]);
770
+ const components = {
771
+ Page: makeManifest("Page"),
772
+ Widget: makeManifest("Widget", {
773
+ constraints: [
774
+ (ctx)=>{
775
+ if (void 0 === ctx.getAncestor("ProductBox")) return ctx.block("Blocked");
776
+ }
777
+ ]
778
+ })
779
+ };
780
+ const result = evaluateConstraints({
781
+ componentName: "Widget",
782
+ parentId: "root",
783
+ slot: "children",
784
+ document: doc,
785
+ components
786
+ });
787
+ expect(result.allowed).toBe(false);
788
+ });
789
+ });
790
+ describe("ctx.countInstances", ()=>{
791
+ it("should count instances of a component in the document", ()=>{
792
+ const root = makeElement("root", "Page");
793
+ const hero1 = makeElement("hero-1", "Hero", {
794
+ id: "root",
795
+ slot: "children"
796
+ });
797
+ const hero2 = makeElement("hero-2", "Hero", {
798
+ id: "root",
799
+ slot: "children"
800
+ });
801
+ const doc = makeDocument([
802
+ root,
803
+ hero1,
804
+ hero2
805
+ ]);
806
+ const components = {
807
+ Page: makeManifest("Page"),
808
+ Hero: makeManifest("Hero", {
809
+ constraints: [
810
+ (ctx)=>{
811
+ if (!(ctx.countInstances("Hero") < 2)) return ctx.block("Max 2 Heroes");
812
+ }
813
+ ]
814
+ })
815
+ };
816
+ const result = evaluateConstraints({
817
+ componentName: "Hero",
818
+ parentId: "root",
819
+ slot: "children",
820
+ document: doc,
821
+ components
822
+ });
823
+ expect(result.allowed).toBe(false);
824
+ });
825
+ });
826
+ describe("ctx.component", ()=>{
827
+ it("should expose the placed component's name and tags", ()=>{
828
+ const parent = makeElement("parent-1", "Container");
829
+ const doc = makeDocument([
830
+ parent
831
+ ]);
832
+ let capturedComponent;
833
+ const components = {
834
+ Container: makeManifest("Container"),
835
+ Widget: makeManifest("Widget", {
836
+ tags: [
837
+ "interactive",
838
+ "form"
839
+ ],
840
+ constraints: [
841
+ (ctx)=>{
842
+ capturedComponent = ctx.component;
843
+ }
844
+ ]
845
+ })
846
+ };
847
+ evaluateConstraints({
848
+ componentName: "Widget",
849
+ parentId: "parent-1",
850
+ slot: "children",
851
+ document: doc,
852
+ components
853
+ });
854
+ expect(capturedComponent).toBeDefined();
855
+ expect(capturedComponent.name).toBe("Widget");
856
+ expect(capturedComponent.tags).toEqual([
857
+ "interactive",
858
+ "form"
859
+ ]);
860
+ });
861
+ });
862
+ describe("parent.getParent", ()=>{
863
+ it("should return the grandparent element context", ()=>{
864
+ const root = makeElement("root", "Page");
865
+ const section = makeElement("section", "Section", {
866
+ id: "root",
867
+ slot: "children"
868
+ });
869
+ const doc = makeDocument([
870
+ root,
871
+ section
872
+ ]);
873
+ let grandparentName;
874
+ const components = {
875
+ Page: makeManifest("Page"),
876
+ Section: makeManifest("Section"),
877
+ Widget: makeManifest("Widget", {
878
+ constraints: [
879
+ (ctx)=>{
880
+ const grandparent = ctx.parent.getParent();
881
+ grandparentName = grandparent?.name;
882
+ }
883
+ ]
884
+ })
885
+ };
886
+ evaluateConstraints({
887
+ componentName: "Widget",
888
+ parentId: "section",
889
+ slot: "children",
890
+ document: doc,
891
+ components
892
+ });
893
+ expect(grandparentName).toBe("Page");
894
+ });
895
+ it("should return undefined for root element", ()=>{
896
+ const root = makeElement("root", "Page");
897
+ const doc = makeDocument([
898
+ root
899
+ ]);
900
+ let parentResult = "not-called";
901
+ const components = {
902
+ Page: makeManifest("Page"),
903
+ Widget: makeManifest("Widget", {
904
+ constraints: [
905
+ (ctx)=>{
906
+ parentResult = ctx.parent.getParent();
907
+ }
908
+ ]
909
+ })
910
+ };
911
+ evaluateConstraints({
912
+ componentName: "Widget",
913
+ parentId: "root",
914
+ slot: "children",
915
+ document: doc,
916
+ components
917
+ });
918
+ expect(parentResult).toBeUndefined();
919
+ });
920
+ });
921
+ describe("parent.childIndex / childCount / isFirstChild / isLastChild", ()=>{
922
+ it("should return -1 for elements not in a list slot", ()=>{
923
+ const root = makeElement("root", "Page");
924
+ const doc = makeDocument([
925
+ root
926
+ ]);
927
+ let capturedIndex;
928
+ let capturedCount;
929
+ const components = {
930
+ Page: makeManifest("Page"),
931
+ Widget: makeManifest("Widget", {
932
+ constraints: [
933
+ (ctx)=>{
934
+ capturedIndex = ctx.parent.childIndex();
935
+ capturedCount = ctx.parent.childCount();
936
+ }
937
+ ]
938
+ })
939
+ };
940
+ evaluateConstraints({
941
+ componentName: "Widget",
942
+ parentId: "root",
943
+ slot: "children",
944
+ document: doc,
945
+ components
946
+ });
947
+ expect(capturedIndex).toBe(-1);
948
+ expect(capturedCount).toBe(-1);
949
+ });
950
+ it("should parse child index from list slot path", ()=>{
951
+ const root = makeElement("root", "FunnelBuilder");
952
+ const step = makeElement("step-1", "Step", {
953
+ id: "root",
954
+ slot: "steps/2/step"
955
+ });
956
+ const doc = makeDocument([
957
+ root,
958
+ step
959
+ ], {
960
+ root: {
961
+ inputs: {
962
+ "steps/0/step": {
963
+ id: "s0",
964
+ type: "slot",
965
+ static: [
966
+ "step-0"
967
+ ]
968
+ },
969
+ "steps/1/step": {
970
+ id: "s1",
971
+ type: "slot",
972
+ static: [
973
+ "step-other"
974
+ ]
975
+ },
976
+ "steps/2/step": {
977
+ id: "s2",
978
+ type: "slot",
979
+ static: [
980
+ "step-1"
981
+ ]
982
+ }
983
+ }
984
+ }
985
+ });
986
+ let capturedIndex;
987
+ let capturedCount;
988
+ let capturedIsLast;
989
+ let capturedIsFirst;
990
+ const components = {
991
+ FunnelBuilder: makeManifest("FunnelBuilder"),
992
+ Step: makeManifest("Step"),
993
+ Widget: makeManifest("Widget", {
994
+ constraints: [
995
+ (ctx)=>{
996
+ capturedIndex = ctx.parent.childIndex();
997
+ capturedCount = ctx.parent.childCount();
998
+ capturedIsLast = ctx.parent.isLastChild();
999
+ capturedIsFirst = ctx.parent.isFirstChild();
1000
+ }
1001
+ ]
1002
+ })
1003
+ };
1004
+ evaluateConstraints({
1005
+ componentName: "Widget",
1006
+ parentId: "step-1",
1007
+ slot: "children",
1008
+ document: doc,
1009
+ components
1010
+ });
1011
+ expect(capturedIndex).toBe(2);
1012
+ expect(capturedCount).toBe(3);
1013
+ expect(capturedIsLast).toBe(true);
1014
+ expect(capturedIsFirst).toBe(false);
1015
+ });
1016
+ it("should identify the first child correctly", ()=>{
1017
+ const root = makeElement("root", "FunnelBuilder");
1018
+ const step = makeElement("step-0", "Step", {
1019
+ id: "root",
1020
+ slot: "steps/0/step"
1021
+ });
1022
+ const doc = makeDocument([
1023
+ root,
1024
+ step
1025
+ ], {
1026
+ root: {
1027
+ inputs: {
1028
+ "steps/0/step": {
1029
+ id: "s0",
1030
+ type: "slot",
1031
+ static: [
1032
+ "step-0"
1033
+ ]
1034
+ },
1035
+ "steps/1/step": {
1036
+ id: "s1",
1037
+ type: "slot",
1038
+ static: [
1039
+ "step-1"
1040
+ ]
1041
+ }
1042
+ }
1043
+ }
1044
+ });
1045
+ let capturedIsFirst;
1046
+ let capturedIsLast;
1047
+ const components = {
1048
+ FunnelBuilder: makeManifest("FunnelBuilder"),
1049
+ Step: makeManifest("Step"),
1050
+ Widget: makeManifest("Widget", {
1051
+ constraints: [
1052
+ (ctx)=>{
1053
+ capturedIsFirst = ctx.parent.isFirstChild();
1054
+ capturedIsLast = ctx.parent.isLastChild();
1055
+ }
1056
+ ]
1057
+ })
1058
+ };
1059
+ evaluateConstraints({
1060
+ componentName: "Widget",
1061
+ parentId: "step-0",
1062
+ slot: "children",
1063
+ document: doc,
1064
+ components
1065
+ });
1066
+ expect(capturedIsFirst).toBe(true);
1067
+ expect(capturedIsLast).toBe(false);
1068
+ });
1069
+ it("should resolve position from bindings even when element.parent.slot is stale", ()=>{
1070
+ const root = makeElement("root", "FunnelBuilder");
1071
+ const step = makeElement("step-final", "Step", {
1072
+ id: "root",
1073
+ slot: "steps/1/step"
1074
+ });
1075
+ const doc = makeDocument([
1076
+ root,
1077
+ step
1078
+ ], {
1079
+ root: {
1080
+ inputs: {
1081
+ "steps/0/step": {
1082
+ id: "s0",
1083
+ type: "slot",
1084
+ static: "step-0"
1085
+ },
1086
+ "steps/1/step": {
1087
+ id: "s1",
1088
+ type: "slot",
1089
+ static: "step-1"
1090
+ },
1091
+ "steps/2/step": {
1092
+ id: "s2",
1093
+ type: "slot",
1094
+ static: "step-2"
1095
+ },
1096
+ "steps/3/step": {
1097
+ id: "s3",
1098
+ type: "slot",
1099
+ static: "step-final"
1100
+ }
1101
+ }
1102
+ }
1103
+ });
1104
+ let capturedIndex;
1105
+ let capturedIsLast;
1106
+ const components = {
1107
+ FunnelBuilder: makeManifest("FunnelBuilder"),
1108
+ Step: makeManifest("Step"),
1109
+ Widget: makeManifest("Widget", {
1110
+ constraints: [
1111
+ (ctx)=>{
1112
+ capturedIndex = ctx.parent.childIndex();
1113
+ capturedIsLast = ctx.parent.isLastChild();
1114
+ }
1115
+ ]
1116
+ })
1117
+ };
1118
+ evaluateConstraints({
1119
+ componentName: "Widget",
1120
+ parentId: "step-final",
1121
+ slot: "children",
1122
+ document: doc,
1123
+ components
1124
+ });
1125
+ expect(capturedIndex).toBe(3);
1126
+ expect(capturedIsLast).toBe(true);
1127
+ });
1128
+ it("should resolve position from direct list slot binding (static is an array of IDs)", ()=>{
1129
+ const root = makeElement("root", "FunnelBuilder");
1130
+ const step0 = makeElement("step-0", "Step", {
1131
+ id: "root",
1132
+ slot: "steps"
1133
+ });
1134
+ const step1 = makeElement("step-1", "Step", {
1135
+ id: "root",
1136
+ slot: "steps"
1137
+ });
1138
+ const step2 = makeElement("step-2", "Step", {
1139
+ id: "root",
1140
+ slot: "steps"
1141
+ });
1142
+ const doc = makeDocument([
1143
+ root,
1144
+ step0,
1145
+ step1,
1146
+ step2
1147
+ ], {
1148
+ root: {
1149
+ inputs: {
1150
+ steps: {
1151
+ id: "s",
1152
+ type: "slot",
1153
+ list: true,
1154
+ static: [
1155
+ "step-0",
1156
+ "step-1",
1157
+ "step-2"
1158
+ ]
1159
+ }
1160
+ }
1161
+ }
1162
+ });
1163
+ let captured = {};
1164
+ const comps = {
1165
+ FunnelBuilder: makeManifest("FunnelBuilder"),
1166
+ Step: makeManifest("Step"),
1167
+ Widget: makeManifest("Widget", {
1168
+ constraints: [
1169
+ (ctx)=>{
1170
+ captured = {
1171
+ index: ctx.parent.childIndex(),
1172
+ count: ctx.parent.childCount(),
1173
+ isFirst: ctx.parent.isFirstChild(),
1174
+ isLast: ctx.parent.isLastChild()
1175
+ };
1176
+ }
1177
+ ]
1178
+ })
1179
+ };
1180
+ evaluateConstraints({
1181
+ componentName: "Widget",
1182
+ parentId: "step-0",
1183
+ slot: "children",
1184
+ document: doc,
1185
+ components: comps
1186
+ });
1187
+ expect(captured.index).toBe(0);
1188
+ expect(captured.count).toBe(3);
1189
+ expect(captured.isFirst).toBe(true);
1190
+ expect(captured.isLast).toBe(false);
1191
+ evaluateConstraints({
1192
+ componentName: "Widget",
1193
+ parentId: "step-1",
1194
+ slot: "children",
1195
+ document: doc,
1196
+ components: comps
1197
+ });
1198
+ expect(captured.index).toBe(1);
1199
+ expect(captured.count).toBe(3);
1200
+ expect(captured.isFirst).toBe(false);
1201
+ expect(captured.isLast).toBe(false);
1202
+ evaluateConstraints({
1203
+ componentName: "Widget",
1204
+ parentId: "step-2",
1205
+ slot: "children",
1206
+ document: doc,
1207
+ components: comps
1208
+ });
1209
+ expect(captured.index).toBe(2);
1210
+ expect(captured.count).toBe(3);
1211
+ expect(captured.isFirst).toBe(false);
1212
+ expect(captured.isLast).toBe(true);
1213
+ });
1214
+ });
1215
+ describe("descendantConstraints", ()=>{
1216
+ it("should block a direct child via parent's descendantConstraints", ()=>{
1217
+ const parent = makeElement("parent-1", "Step");
1218
+ const doc = makeDocument([
1219
+ parent
1220
+ ]);
1221
+ const components = {
1222
+ Step: makeManifest("Step", {
1223
+ descendantConstraints: [
1224
+ (ctx)=>{
1225
+ if (ctx.hasTag("funnel-field")) return ctx.block("No funnel fields allowed");
1226
+ }
1227
+ ]
1228
+ }),
1229
+ TextField: makeManifest("TextField", {
1230
+ tags: [
1231
+ "funnel-field"
1232
+ ]
1233
+ })
1234
+ };
1235
+ const result = evaluateConstraints({
1236
+ componentName: "TextField",
1237
+ parentId: "parent-1",
1238
+ slot: "children",
1239
+ document: doc,
1240
+ components
1241
+ });
1242
+ expect(result.allowed).toBe(false);
1243
+ expect(result.violation.message).toBe("No funnel fields allowed");
1244
+ });
1245
+ it("should block a nested descendant via ancestor's descendantConstraints", ()=>{
1246
+ const step = makeElement("step-1", "Step");
1247
+ const container = makeElement("container-1", "Container", {
1248
+ id: "step-1",
1249
+ slot: "children"
1250
+ });
1251
+ const doc = makeDocument([
1252
+ step,
1253
+ container
1254
+ ]);
1255
+ const components = {
1256
+ Step: makeManifest("Step", {
1257
+ descendantConstraints: [
1258
+ (ctx)=>{
1259
+ if (ctx.hasTag("funnel-field")) return ctx.block("No funnel fields in this step");
1260
+ }
1261
+ ]
1262
+ }),
1263
+ Container: makeManifest("Container"),
1264
+ TextField: makeManifest("TextField", {
1265
+ tags: [
1266
+ "funnel-field"
1267
+ ]
1268
+ })
1269
+ };
1270
+ const result = evaluateConstraints({
1271
+ componentName: "TextField",
1272
+ parentId: "container-1",
1273
+ slot: "children",
1274
+ document: doc,
1275
+ components
1276
+ });
1277
+ expect(result.allowed).toBe(false);
1278
+ expect(result.violation.message).toBe("No funnel fields in this step");
1279
+ });
1280
+ it("should allow when descendantConstraints pass", ()=>{
1281
+ const step = makeElement("step-1", "Step");
1282
+ const doc = makeDocument([
1283
+ step
1284
+ ]);
1285
+ const components = {
1286
+ Step: makeManifest("Step", {
1287
+ descendantConstraints: [
1288
+ (ctx)=>{
1289
+ if (ctx.hasTag("funnel-field")) return ctx.block("Blocked");
1290
+ }
1291
+ ]
1292
+ }),
1293
+ Button: makeManifest("Button")
1294
+ };
1295
+ const result = evaluateConstraints({
1296
+ componentName: "Button",
1297
+ parentId: "step-1",
1298
+ slot: "children",
1299
+ document: doc,
1300
+ components
1301
+ });
1302
+ expect(result.allowed).toBe(true);
1303
+ });
1304
+ it("should not run parent's descendantConstraints for unrelated ancestors", ()=>{
1305
+ const page = makeElement("page", "Page");
1306
+ const step = makeElement("step-1", "Step", {
1307
+ id: "page",
1308
+ slot: "children"
1309
+ });
1310
+ const doc = makeDocument([
1311
+ page,
1312
+ step
1313
+ ]);
1314
+ const components = {
1315
+ Page: makeManifest("Page", {
1316
+ descendantConstraints: [
1317
+ (ctx)=>ctx.block("Page blocks everything")
1318
+ ]
1319
+ }),
1320
+ Step: makeManifest("Step"),
1321
+ Button: makeManifest("Button")
1322
+ };
1323
+ const result = evaluateConstraints({
1324
+ componentName: "Button",
1325
+ parentId: "step-1",
1326
+ slot: "children",
1327
+ document: doc,
1328
+ components
1329
+ });
1330
+ expect(result.allowed).toBe(false);
1331
+ expect(result.violation.message).toBe("Page blocks everything");
1332
+ });
1333
+ it("should evaluate component constraints before descendantConstraints", ()=>{
1334
+ const parent = makeElement("parent-1", "Step");
1335
+ const doc = makeDocument([
1336
+ parent
1337
+ ]);
1338
+ const components = {
1339
+ Step: makeManifest("Step", {
1340
+ descendantConstraints: [
1341
+ (ctx)=>ctx.block("descendant constraint")
1342
+ ]
1343
+ }),
1344
+ Widget: makeManifest("Widget", {
1345
+ constraints: [
1346
+ (ctx)=>ctx.block("component constraint")
1347
+ ]
1348
+ })
1349
+ };
1350
+ const result = evaluateConstraints({
1351
+ componentName: "Widget",
1352
+ parentId: "parent-1",
1353
+ slot: "children",
1354
+ document: doc,
1355
+ components
1356
+ });
1357
+ expect(result.violation.message).toBe("component constraint");
1358
+ });
1359
+ });
1360
+ });
1361
+ describe("evaluateDeleteConstraint", ()=>{
1362
+ it("should allow when canDelete is undefined", ()=>{
1363
+ const el = makeElement("el-1", "Widget", {
1364
+ id: "root",
1365
+ slot: "children"
1366
+ });
1367
+ const root = makeElement("root", "Page");
1368
+ const doc = makeDocument([
1369
+ root,
1370
+ el
1371
+ ]);
1372
+ const components = {
1373
+ Page: makeManifest("Page"),
1374
+ Widget: makeManifest("Widget")
1375
+ };
1376
+ const result = evaluateDeleteConstraint({
1377
+ elementId: "el-1",
1378
+ document: doc,
1379
+ components
1380
+ });
1381
+ expect(result.allowed).toBe(true);
1382
+ });
1383
+ it("should allow when canDelete is true", ()=>{
1384
+ const el = makeElement("el-1", "Widget", {
1385
+ id: "root",
1386
+ slot: "children"
1387
+ });
1388
+ const root = makeElement("root", "Page");
1389
+ const doc = makeDocument([
1390
+ root,
1391
+ el
1392
+ ]);
1393
+ const components = {
1394
+ Page: makeManifest("Page"),
1395
+ Widget: makeManifest("Widget", {
1396
+ canDelete: true
1397
+ })
1398
+ };
1399
+ const result = evaluateDeleteConstraint({
1400
+ elementId: "el-1",
1401
+ document: doc,
1402
+ components
1403
+ });
1404
+ expect(result.allowed).toBe(true);
1405
+ });
1406
+ it("should block when canDelete is false", ()=>{
1407
+ const el = makeElement("el-1", "GridColumn", {
1408
+ id: "root",
1409
+ slot: "children"
1410
+ });
1411
+ const root = makeElement("root", "Grid");
1412
+ const doc = makeDocument([
1413
+ root,
1414
+ el
1415
+ ]);
1416
+ const components = {
1417
+ Grid: makeManifest("Grid"),
1418
+ GridColumn: makeManifest("GridColumn", {
1419
+ canDelete: false
1420
+ })
1421
+ };
1422
+ const result = evaluateDeleteConstraint({
1423
+ elementId: "el-1",
1424
+ document: doc,
1425
+ components
1426
+ });
1427
+ expect(result.allowed).toBe(false);
1428
+ expect(result.violation.message).toBe("GridColumn cannot be deleted.");
1429
+ });
1430
+ it("should allow when canDelete check returns true", ()=>{
1431
+ const funnel = makeElement("funnel", "Funnel");
1432
+ const step1 = makeElement("step-1", "Step", {
1433
+ id: "funnel",
1434
+ slot: "steps/0/step"
1435
+ });
1436
+ const step2 = makeElement("step-2", "Step", {
1437
+ id: "funnel",
1438
+ slot: "steps/1/step"
1439
+ });
1440
+ const step3 = makeElement("step-3", "Step", {
1441
+ id: "funnel",
1442
+ slot: "steps/2/step"
1443
+ });
1444
+ const doc = makeDocument([
1445
+ funnel,
1446
+ step1,
1447
+ step2,
1448
+ step3
1449
+ ], {
1450
+ funnel: {
1451
+ inputs: {
1452
+ "steps/0/step": {
1453
+ id: "s0",
1454
+ type: "slot",
1455
+ static: "step-1"
1456
+ },
1457
+ "steps/1/step": {
1458
+ id: "s1",
1459
+ type: "slot",
1460
+ static: "step-2"
1461
+ },
1462
+ "steps/2/step": {
1463
+ id: "s2",
1464
+ type: "slot",
1465
+ static: "step-3"
1466
+ }
1467
+ }
1468
+ }
1469
+ });
1470
+ const components = {
1471
+ Funnel: makeManifest("Funnel"),
1472
+ Step: makeManifest("Step", {
1473
+ canDelete: (ctx)=>{
1474
+ if (!(ctx.countInstances("Step") > 2)) return ctx.block("Blocked");
1475
+ }
1476
+ })
1477
+ };
1478
+ const result = evaluateDeleteConstraint({
1479
+ elementId: "step-2",
1480
+ document: doc,
1481
+ components
1482
+ });
1483
+ expect(result.allowed).toBe(true);
1484
+ });
1485
+ it("should block when canDelete check returns false", ()=>{
1486
+ const funnel = makeElement("funnel", "Funnel");
1487
+ const step1 = makeElement("step-1", "Step", {
1488
+ id: "funnel",
1489
+ slot: "steps/0/step"
1490
+ });
1491
+ const step2 = makeElement("step-2", "Step", {
1492
+ id: "funnel",
1493
+ slot: "steps/1/step"
1494
+ });
1495
+ const doc = makeDocument([
1496
+ funnel,
1497
+ step1,
1498
+ step2
1499
+ ], {
1500
+ funnel: {
1501
+ inputs: {
1502
+ "steps/0/step": {
1503
+ id: "s0",
1504
+ type: "slot",
1505
+ static: "step-1"
1506
+ },
1507
+ "steps/1/step": {
1508
+ id: "s1",
1509
+ type: "slot",
1510
+ static: "step-2"
1511
+ }
1512
+ }
1513
+ }
1514
+ });
1515
+ const components = {
1516
+ Funnel: makeManifest("Funnel"),
1517
+ Step: makeManifest("Step", {
1518
+ canDelete: (ctx)=>{
1519
+ if (!(ctx.countInstances("Step") > 2)) return ctx.block("Need at least 2 steps");
1520
+ }
1521
+ })
1522
+ };
1523
+ const result = evaluateDeleteConstraint({
1524
+ elementId: "step-1",
1525
+ document: doc,
1526
+ components
1527
+ });
1528
+ expect(result.allowed).toBe(false);
1529
+ expect(result.violation.message).toBe("Need at least 2 steps");
1530
+ });
1531
+ it("should use thrown error message as violation message", ()=>{
1532
+ const root = makeElement("root", "Page");
1533
+ const el = makeElement("el-1", "Widget", {
1534
+ id: "root",
1535
+ slot: "children"
1536
+ });
1537
+ const doc = makeDocument([
1538
+ root,
1539
+ el
1540
+ ]);
1541
+ const components = {
1542
+ Page: makeManifest("Page"),
1543
+ Widget: makeManifest("Widget", {
1544
+ canDelete: ()=>{
1545
+ throw new Error("Cannot delete this widget right now");
1546
+ }
1547
+ })
1548
+ };
1549
+ const result = evaluateDeleteConstraint({
1550
+ elementId: "el-1",
1551
+ document: doc,
1552
+ components
1553
+ });
1554
+ expect(result.allowed).toBe(false);
1555
+ expect(result.violation.message).toBe("Cannot delete this widget right now");
1556
+ });
1557
+ it("should provide correct context to the check function", ()=>{
1558
+ const root = makeElement("root", "Funnel");
1559
+ const step = makeElement("step-1", "Step", {
1560
+ id: "root",
1561
+ slot: "steps/0/step"
1562
+ });
1563
+ const doc = makeDocument([
1564
+ root,
1565
+ step
1566
+ ], {
1567
+ root: {
1568
+ inputs: {
1569
+ "steps/0/step": {
1570
+ id: "s0",
1571
+ type: "slot",
1572
+ static: "step-1"
1573
+ },
1574
+ "steps/1/step": {
1575
+ id: "s1",
1576
+ type: "slot",
1577
+ static: "step-2"
1578
+ }
1579
+ }
1580
+ }
1581
+ });
1582
+ let capturedCtx;
1583
+ const components = {
1584
+ Funnel: makeManifest("Funnel"),
1585
+ Step: makeManifest("Step", {
1586
+ tags: [
1587
+ "funnel-step"
1588
+ ],
1589
+ canDelete: (ctx)=>{
1590
+ capturedCtx = ctx;
1591
+ }
1592
+ })
1593
+ };
1594
+ evaluateDeleteConstraint({
1595
+ elementId: "step-1",
1596
+ document: doc,
1597
+ components
1598
+ });
1599
+ expect(capturedCtx).toBeDefined();
1600
+ expect(capturedCtx.component.name).toBe("Step");
1601
+ expect(capturedCtx.component.tags).toEqual([
1602
+ "funnel-step"
1603
+ ]);
1604
+ expect(capturedCtx.parent.name).toBe("Funnel");
1605
+ expect(capturedCtx.slot).toBe("steps/0/step");
1606
+ expect(capturedCtx.isChildOf("Funnel")).toBe(true);
1607
+ expect(capturedCtx.hasTag("funnel-step")).toBe(true);
1608
+ });
1609
+ it("should allow when element is not found", ()=>{
1610
+ const doc = makeDocument([]);
1611
+ const components = {};
1612
+ const result = evaluateDeleteConstraint({
1613
+ elementId: "nonexistent",
1614
+ document: doc,
1615
+ components
1616
+ });
1617
+ expect(result.allowed).toBe(true);
1618
+ });
1619
+ it("should allow when manifest is not found", ()=>{
1620
+ const el = makeElement("el-1", "Unknown");
1621
+ const doc = makeDocument([
1622
+ el
1623
+ ]);
1624
+ const components = {};
1625
+ const result = evaluateDeleteConstraint({
1626
+ elementId: "el-1",
1627
+ document: doc,
1628
+ components
1629
+ });
1630
+ expect(result.allowed).toBe(true);
1631
+ });
1632
+ });
1633
+
1634
+ //# sourceMappingURL=ConstraintEvaluator.test.js.map