@kubex/zinc 1.1.95 → 1.1.96

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.
@@ -11,7 +11,12 @@ The Flow Builder is a three-panel editor for visual automations, with an optiona
11
11
  - **Header** (optional) — a full-width action bar rendered only when `slot="header-left"` / `slot="header-right"`
12
12
  content is provided (e.g. Close / Undo All Changes on the left, Apply Changes on the right).
13
13
  - **Left panel** — the steps panel (searchable, tabbed by step group).
14
- - **Canvas** (center) — a pannable, zoomable surface. Connections are **append-only**: click any **output port**
14
+ - **Canvas** (center) — a pannable, zoomable surface. A node's branches fan out along a bus below it,
15
+ spaced by what they actually hold: two branches with children keep a full card lane apart, while a
16
+ branch that is only a pill sits close in, so declaring branches you have not wired costs little room.
17
+ Wires never run through a card: a branch that reaches past the next row keeps its pill on its own bus
18
+ and takes its wire out around the outside of whatever it flies over, so it always reads as ending where
19
+ it ends. Connections are **append-only**: click any **output port**
15
20
  on a node to start a **stray branch** — the wire follows your cursor until you click a node (or its output
16
21
  port) to attach it. Fan-in is allowed, and so are **loops** — a branch may point back to an earlier step (e.g.
17
22
  an answer that restarts the questioning); only wiring a node directly to itself is refused. Cancel by clicking
@@ -359,6 +364,12 @@ they're declarations that drive the rendered panel):
359
364
  trigger with no input.
360
365
  - `outputs` — a JSON array of ports, each a string id or a `{"id","label"}` object (e.g.
361
366
  `outputs='[{"id":"true","label":"TRUE"},{"id":"false","label":"FALSE"}]'`). Omit for a single default output.
367
+ - `fixed-outputs` — the declared outputs are the *only* branches this step has: their pills carry no
368
+ delete, and clicking a fully-wired node's output adds nothing. Use it when the branches mirror fixed
369
+ fields in your own model (a success / failure / skip triple, say), where an extra or missing branch has
370
+ nowhere to be stored. Branches a step declares but has not wired cost almost no room — they are spaced
371
+ by their pill rather than by a whole child lane — so a step can offer every branch it supports without
372
+ fanning empty lanes across the canvas.
362
373
 
363
374
  Set an optional per-tab hint with the `triggers-hint` / `actions-hint` / `rules-hint` attributes on the
364
375
  builder. (A node's inspector `renderConfig` can't be expressed in markup — supply it via
@@ -604,6 +615,11 @@ builder.registerNodeTypes([
604
615
  A node type with `inputs: []` is a starting point (a trigger) with no incoming port. A type with
605
616
  multiple `outputs` renders one labeled port per output, so a branch can fan out to different steps.
606
617
 
618
+ Add **`fixedOutputs: true`** when those outputs are the only branches the type can have — the canvas
619
+ drops the delete on their pills and refuses to materialise an extra one. Unwired branches are spaced by
620
+ their pill rather than a child lane, so a type can declare its full set of branches up front without the
621
+ canvas fanning empty lanes for the ones a given node is not using.
622
+
607
623
  #### User-configurable outputs
608
624
 
609
625
  A type's `outputs` are the default. For steps whose branches are defined by the user (e.g. a conditional
@@ -62,24 +62,13 @@ toggle to opt out.
62
62
 
63
63
  Style the panel through `inspector`, `inspector-header` and `inspector-body` parts.
64
64
 
65
- A container section additionally gets a **Layout** group at the top of the inspector: a column
66
- count, a weight per column, a "Keep adding rows" toggle, and — when growing is off — a row count.
67
- Every edit here is lossless: changing the column count re-chunks the same ordered list of
68
- stacks into the new shape, changing the row count only pads or trims trailing empty rows
69
- (clamping at the last row that still holds content), and changing a column's weight leaves
70
- every cell's contents untouched. All of it is undoable like any other edit.
71
-
72
65
  ## JavaScript API
73
66
 
74
67
  - `state` — get/set the current `PageState`. The getter returns a deep copy; the setter
75
68
  replaces the state wholesale (like the `config` attribute, it does not emit `zn-page-change`).
76
69
  - `addSection(type, index?)` — insert a new section of a registered type (default: at the end).
77
- - `addSectionToCell(type, containerId, cellIndex, insertIndex?)` — insert a new section of a
78
- registered type into a container's cell. `cellIndex` is the position in the container's flat,
79
- row-major `cells` list; `insertIndex` (default `0`, the top of the stack) is where in that
80
- cell's stack it lands. Returns the new section, or `null` if the drop isn't allowed — because
81
- the type isn't in the container's `accepts` list, or because it would exceed the two-level
82
- nesting cap.
70
+ - `addSectionToSlot(type, containerId, slotIndex)` — insert into a container's empty slot,
71
+ honouring its `accepts` list. Returns the new section, or `null` if not allowed.
83
72
  - `undo()` / `redo()` — step through edit history (bounded at 50 entries). There is no built-in
84
73
  toolbar: wire these to your own header buttons or keyboard shortcuts.
85
74
  - `registerSectionType(type)` / `registerSectionTypes(types)` — programmatic registration,
@@ -125,8 +114,8 @@ or call `restoreAutoSave()` yourself.
125
114
 
126
115
  Every edit emits `zn-page-change` with the full page state (`event.detail.state`, also
127
116
  readable via the `state` property) — plain JSON the host persists and later feeds back in
128
- through the `config` attribute. Sections appear in page order; a container section additionally
129
- carries `layout` and `cells`, per the Containers section below:
117
+ through the `config` attribute. Sections appear in page order; container sections carry a
118
+ `children` array sized to their slot count, with `null` for empty slots:
130
119
 
131
120
  ```json
132
121
  {
@@ -141,11 +130,10 @@ carries `layout` and `cells`, per the Containers section below:
141
130
  "type": "article-grid",
142
131
  "label": "Popular articles",
143
132
  "data": {"title": "Popular"},
144
- "layout": {"widths": [1, 1, 1], "grow": false},
145
- "cells": [
146
- [{"id": "s-mc42h-2", "type": "article-tile", "data": {"article": "art_42"}}],
147
- [{"id": "s-mc42p-3", "type": "article-tile", "data": {"article": "art_7"}}],
148
- []
133
+ "children": [
134
+ {"id": "s-mc42h-2", "type": "article-tile", "data": {"article": "art_42"}},
135
+ {"id": "s-mc42p-3", "type": "article-tile", "data": {"article": "art_7"}},
136
+ null, null, null, null
149
137
  ]
150
138
  },
151
139
  {
@@ -157,10 +145,6 @@ carries `layout` and `cells`, per the Containers section below:
157
145
  }
158
146
  ```
159
147
 
160
- A host loading a config saved before this model — the old flat, null-padded `children` array —
161
- still has it accepted and migrated on load; see "Legacy `slots`" further down for what that
162
- older shape looked like and how it's converted.
163
-
164
148
  ## A required first section
165
149
 
166
150
  Pages that must always open with a particular section — a hero banner, a masthead — set
@@ -189,87 +173,32 @@ nothing about the lock is written into the persisted config.
189
173
  ```
190
174
 
191
175
  The config above declares only a rich-text section, so the hero is inserted above it — loading a
192
- page that lacks the required section normalises it rather than rejecting it. Two things follow
193
- from that. The inserted section's `data` is empty, so a host that wants the pinned section
194
- prefilled should put it into the `config` it hands over rather than rely on the insert. And the
195
- guard is client-side, so a host that persists the config should enforce the same rule on save.
196
-
197
- ## Containers
198
-
199
- A section type with the `container` attribute becomes a full-row container: its card renders a
200
- grid of **cells** beneath it, and each cell holds an ordered **stack** of sections. The type
201
- author only declares that it's a container and what it starts as; the editor reshapes it after
202
- placing it, from the inspector's Layout group.
203
-
204
- | Attribute | Meaning |
205
- |---|---|
206
- | `container` | Marks the type a container. Required. |
207
- | `columns="4"` | Seeds a new instance with 4 equal columns. |
208
- | `widths="1 2 1"` | Seeds the column weights directly (comma- or whitespace-separated). Wins over `columns`. |
209
- | `grow` | Seeds the instance growable — it always offers a further empty row. |
210
- | `accepts="a,b"` | Restricts which types the cells take. Omit to allow any type, within the nesting cap below. |
176
+ page that lacks the required section normalises it rather than rejecting it. The guard is
177
+ client-side, so a host that persists the config should still enforce the same rule on save.
211
178
 
212
- If `widths` is present but unparsable — non-numeric tokens are discarded rather than kept as
213
- columns — the container falls back to `columns`, and if that's absent too, to three equal
214
- columns (`[1, 1, 1]`).
179
+ ## Container tiles
215
180
 
216
- Drag sections from the palette into a cell, stack several sections in one cell, or drag them
217
- between cells or out onto the page. Containers may nest **two levels deep** a container inside
218
- a cell, itself holding another container and a drop that would nest a third level is refused.
219
- That cap holds even when a container's `accepts` list names another container type: `accepts`
220
- can't be used to bypass it.
181
+ A section type with a `slots` attribute becomes a full-row container: its card renders a
182
+ 3-column grid of that many child slots beneath it. Drag sections from the palette into empty
183
+ cells, drag children **between cells to reorder**, or out onto the page. `accepts` restricts
184
+ which types the slots take. Containers can't be placed inside other containers, and slot
185
+ contents persist as `children` on the section (empty slots are `null`).
221
186
 
222
187
  ```html:preview
223
188
  <zn-page-builder heading="KB Homepage" style="height: 560px"
224
- config='{"sections":[{"id":"g1","type":"row","data":{},"layout":{"widths":[1,2,1],"grow":false},"cells":[[],[],[]]}]}'>
225
- <template type="row" slot="config" label="Row" icon="view_column" category="Layout"
226
- description="A row of columns you can weight" container widths="1 2 1">
227
- <zn-input name="title" label="Row title"></zn-input>
228
- </template>
229
- <template type="grid" slot="config" label="Tile Grid" icon="grid_view" category="Layout"
230
- description="Keeps adding rows as you fill it" container columns="3" grow>
189
+ config='{"sections":[{"id":"g1","type":"article-grid","data":{}}]}'>
190
+ <template type="article-grid" slot="config" label="Article Grid" icon="grid_view"
191
+ category="Layout" description="A 3x2 grid of article tiles"
192
+ slots="6" accepts="article-tile">
231
193
  <zn-input name="title" label="Grid title"></zn-input>
232
194
  </template>
233
- <template type="article" slot="config" label="Article" icon="article" category="Content"
195
+ <template type="article-tile" slot="config" label="Article" icon="article" category="Content"
234
196
  description="A single article tile">
235
197
  <zn-input name="article" label="Article id"></zn-input>
236
198
  </template>
237
199
  </zn-page-builder>
238
200
  ```
239
201
 
240
- ### The container config
241
-
242
- A container persists its `layout` and its `cells` — a flat, row-major list of stacks whose
243
- length is always a whole multiple of `layout.widths.length`. Rows are implicit
244
- (`cells.length / layout.widths.length`), so there's no row count to keep in sync:
245
-
246
- ```json
247
- {
248
- "id": "s-mc42a-1",
249
- "type": "row",
250
- "layout": { "widths": [1, 2, 1], "grow": false },
251
- "cells": [
252
- [ { "id": "s-1", "type": "nav", "data": {} },
253
- { "id": "s-2", "type": "links", "data": {} } ],
254
- [ { "id": "s-3", "type": "hero", "data": {} } ],
255
- []
256
- ]
257
- }
258
- ```
259
-
260
- Render it by mapping each weight to a grid track and each cell to a stack. A **growable**
261
- container never persists a trailing all-empty row — the builder adds that row itself at render
262
- time, so don't expect it in the JSON. A **fixed** container's trailing empty row, by contrast, is
263
- part of its layout and does persist.
264
-
265
- ### Legacy `slots`
266
-
267
- `slots="6"` still declares a container, and pages persisted with the old flat, null-padded
268
- `children` array still load: they're migrated to `layout` + `cells` on load and re-saved in the
269
- new shape — `children` is never written back. A `slots`-declared container that has no explicit
270
- `accepts` also keeps its older, stricter rule of refusing container types in its cells (rather
271
- than allowing anything up to the nesting cap). Prefer `container` going forward.
272
-
273
202
  ## List sections
274
203
 
275
204
  Sections that show a set of existing items (categories, articles, …) reference them by id:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.95",
3
+ "version": "1.1.96",
4
4
  "description": "A collection of web components for building web applications based off of @shoelace-style/Shoelace",
5
5
  "keywords": [
6
6
  "web components",
@@ -493,6 +493,7 @@ export default class ZnFlowBuilder extends ZincElement {
493
493
  description: el.getAttribute('description') ?? undefined,
494
494
  inputs: ZnFlowBuilder._parsePorts(el.getAttribute('inputs')),
495
495
  outputs: ZnFlowBuilder._parsePorts(el.getAttribute('outputs')),
496
+ fixedOutputs: el.hasAttribute('fixed-outputs'),
496
497
  branchFilters: ZnFlowBuilder._parseBranchFilters(el),
497
498
  };
498
499
  }
@@ -810,10 +811,20 @@ export default class ZnFlowBuilder extends ZincElement {
810
811
  // Centred under the branch drop (where the pill hangs), a full layer below
811
812
  // the source (same rhythm as untangle) — the child lands in a straight
812
813
  // line under the branch instead of being shoved sideways by collision.
813
- const x = branchDropXs(source, t => this.registry.get(t))[idx];
814
+ const x = branchDropXs(source, t => this.registry.get(t), this._state.connections)[idx];
814
815
  return {x: Math.round(x - NODE_WIDTH / 2), y: source.y + LAYOUT_V_GAP};
815
816
  }
816
817
 
818
+ /**
819
+ * Whether a node refuses the branch being asked of it: a fixed-output type
820
+ * has only the branches it declares, so the "new branch" sentinel has nowhere
821
+ * to go. Checked before the history push, so a refused gesture is a no-op
822
+ * rather than an empty undo step.
823
+ */
824
+ private _refusesBranch(node: FlowNodeInstance, port: string): boolean {
825
+ return port === NEW_OUTPUT_PORT && !!this.registry.get(node.type)?.fixedOutputs;
826
+ }
827
+
817
828
  /**
818
829
  * Resolve an output port id on a node: the "new branch" sentinel materialises a
819
830
  * fresh, labelled output port (per-instance), so it exists before connecting.
@@ -847,6 +858,7 @@ export default class ZnFlowBuilder extends ZincElement {
847
858
  if (!source || !type) return;
848
859
  const inPort = typeInputs(type)[0]?.id;
849
860
  if (!inPort) return; // an entrypoint takes no inputs, so it can't be attached
861
+ if (this._refusesBranch(source, port)) return;
850
862
  this._pushHistory();
851
863
  port = this._ensureOutput(source, port);
852
864
  this._ensureBranchLabel(source, port);
@@ -882,6 +894,7 @@ export default class ZnFlowBuilder extends ZincElement {
882
894
  if (!source || !target || sourceId === targetId) return;
883
895
  const inPort = firstInputId(target, this.registry.get(target.type));
884
896
  if (inPort === null) return;
897
+ if (this._refusesBranch(source, port)) return;
885
898
  this._pushHistory();
886
899
  port = this._ensureOutput(source, port);
887
900
  this._ensureBranchLabel(source, port);
@@ -970,6 +983,7 @@ export default class ZnFlowBuilder extends ZincElement {
970
983
  private _onBranchDelete = (e: CustomEvent<{ nodeId: string; port: string }>) => {
971
984
  const node = this._state.nodes.find(n => n.id === e.detail.nodeId);
972
985
  if (!node) return;
986
+ if (this.registry.get(node.type)?.fixedOutputs) return; // its branches are the only ones it has
973
987
  this._pushHistory();
974
988
  const outputs = nodeOutputs(node, this.registry.get(node.type)).filter(p => p.id !== e.detail.port);
975
989
  // Deleting the last branch leaves a plain open output, so the node stays extensible.
@@ -1041,6 +1055,7 @@ export default class ZnFlowBuilder extends ZincElement {
1041
1055
  if (!owner || !moving) return;
1042
1056
  const inPort = firstInputId(moving, this.registry.get(moving.type));
1043
1057
  if (inPort === null) return;
1058
+ if (this._refusesBranch(owner, e.detail.port)) return;
1044
1059
 
1045
1060
  this._pushHistory();
1046
1061
  const outPort = this._ensureOutput(owner, e.detail.port);
@@ -1,6 +1,6 @@
1
1
  import '../../../dist/zn.min.js';
2
2
  import {expect, fixture, html} from '@open-wc/testing';
3
- import type {FlowNodeType, FlowState} from './flow.types';
3
+ import {type FlowNodeType, type FlowState, NEW_OUTPUT_PORT, NODE_HEIGHT, NODE_WIDTH} from './flow.types';
4
4
  import type ZnFlowBuilder from './flow-builder.component';
5
5
 
6
6
  const TRIGGER: FlowNodeType = {type: 'webhook', label: 'Webhook', group: 'trigger', category: 'Contacts'};
@@ -293,4 +293,209 @@ describe('<zn-flow-builder>', () => {
293
293
  ]);
294
294
  });
295
295
  });
296
+ describe('fixed outputs', () => {
297
+ const FIXED: FlowNodeType = {
298
+ type: 'step',
299
+ label: 'Step',
300
+ group: 'action',
301
+ fixedOutputs: true,
302
+ outputs: [
303
+ {id: 'success', label: 'Success'},
304
+ {id: 'failure', label: 'Failure'},
305
+ {id: 'skip', label: 'Skip'},
306
+ ],
307
+ };
308
+
309
+ const wiredState: FlowState = {
310
+ nodes: [
311
+ {id: 'n1', type: 'step', x: 0, y: 0, data: {}},
312
+ {id: 'n2', type: 'step', x: 0, y: 220, data: {}},
313
+ ],
314
+ connections: [
315
+ {id: 'c1', source: {node: 'n1', port: 'success'}, target: {node: 'n2', port: 'in'}},
316
+ {id: 'c2', source: {node: 'n1', port: 'failure'}, target: {node: 'n2', port: 'in'}},
317
+ {id: 'c3', source: {node: 'n1', port: 'skip'}, target: {node: 'n2', port: 'in'}},
318
+ ],
319
+ notes: [],
320
+ };
321
+
322
+ const makeFixed = async (state: FlowState) => {
323
+ const el = await fixture<ZnFlowBuilder>(html`
324
+ <zn-flow-builder></zn-flow-builder>`);
325
+ el.registerNodeTypes([FIXED]);
326
+ el.setState(state);
327
+ await el.updateComplete;
328
+ return el;
329
+ };
330
+
331
+ it('should refuse to delete one of a fixed type\'s branches', async () => {
332
+ const el = await makeFixed(wiredState);
333
+ el.dispatchEvent(new CustomEvent('flow-branch-delete', {detail: {nodeId: 'n1', port: 'failure'}}));
334
+ await el.updateComplete;
335
+
336
+ const node = el.getState().nodes[0];
337
+ expect(node.outputs ?? FIXED.outputs).to.have.length(3);
338
+ expect(el.getState().connections).to.have.length(3);
339
+ });
340
+
341
+ it('should not offer a delete on a fixed type\'s branch pills', async () => {
342
+ const el = await makeFixed(wiredState);
343
+ const canvas = el.shadowRoot?.querySelector('zn-flow-canvas');
344
+ expect(canvas?.shadowRoot?.querySelector('.branch-pill')).to.exist;
345
+ expect(canvas?.shadowRoot?.querySelector('.branch-pill-delete')).to.not.exist;
346
+ });
347
+
348
+ it('should not grow a fixed type an extra branch when every one is wired', async () => {
349
+ const el = await makeFixed(wiredState);
350
+ el.dispatchEvent(new CustomEvent('flow-link-assign', {
351
+ detail: {nodeId: 'n1', port: NEW_OUTPUT_PORT, targetId: 'n2'},
352
+ }));
353
+ await el.updateComplete;
354
+
355
+ const node = el.getState().nodes[0];
356
+ expect(node.outputs ?? FIXED.outputs).to.have.length(3);
357
+ expect(el.getState().connections).to.have.length(3);
358
+ });
359
+
360
+ it('should still let an ordinary type grow a branch', async () => {
361
+ const el = await fixture<ZnFlowBuilder>(html`
362
+ <zn-flow-builder></zn-flow-builder>`);
363
+ el.registerNodeTypes([{...FIXED, fixedOutputs: false}]);
364
+ el.setState(wiredState);
365
+ await el.updateComplete;
366
+
367
+ el.dispatchEvent(new CustomEvent('flow-link-assign', {
368
+ detail: {nodeId: 'n1', port: NEW_OUTPUT_PORT, targetId: 'n2'},
369
+ }));
370
+ await el.updateComplete;
371
+
372
+ expect(el.getState().nodes[0].outputs).to.have.length(4);
373
+ });
374
+
375
+ it('should read fixed-outputs off a declared step', async () => {
376
+ const el = await fixture<ZnFlowBuilder>(html`
377
+ <zn-flow-builder>
378
+ <zn-flow-step type="step" group="action" label="Step" fixed-outputs
379
+ outputs='[{"id":"success","label":"Success"},{"id":"failure","label":"Failure"}]'>
380
+ </zn-flow-step>
381
+ </zn-flow-builder>`);
382
+ el.setState({
383
+ nodes: [{id: 'n1', type: 'step', x: 0, y: 0, data: {}}],
384
+ connections: [],
385
+ notes: [],
386
+ });
387
+ await el.updateComplete;
388
+
389
+ el.dispatchEvent(new CustomEvent('flow-branch-delete', {detail: {nodeId: 'n1', port: 'failure'}}));
390
+ await el.updateComplete;
391
+ expect(el.getState().nodes[0].outputs).to.be.undefined; // untouched: still the type's two
392
+ });
393
+ });
394
+ describe('wire routing', () => {
395
+ const TRIPLE: FlowNodeType = {
396
+ type: 'step',
397
+ label: 'Step',
398
+ group: 'action',
399
+ fixedOutputs: true,
400
+ outputs: [
401
+ {id: 'success', label: 'Success'},
402
+ {id: 'failure', label: 'Failure'},
403
+ {id: 'skip', label: 'Skip'},
404
+ ],
405
+ };
406
+
407
+ // A's failure skips the middle row entirely and lands on C, while A's other
408
+ // branches and B's success feed the rows in between — the arrangement that
409
+ // drew A's failure wire straight down through B, so it read as though the
410
+ // branch ended at B.
411
+ const SKIP_A_ROW: FlowState = {
412
+ nodes: [
413
+ {id: 'a', type: 'step', x: 200, y: 700, data: {}},
414
+ {id: 'b', type: 'step', x: 200, y: 920, data: {}},
415
+ {id: 'c', type: 'step', x: 200, y: 1140, data: {}},
416
+ ],
417
+ connections: [
418
+ {id: 'c1', source: {node: 'a', port: 'success'}, target: {node: 'b', port: 'in'}},
419
+ {id: 'c2', source: {node: 'a', port: 'skip'}, target: {node: 'b', port: 'in'}},
420
+ {id: 'c3', source: {node: 'a', port: 'failure'}, target: {node: 'c', port: 'in'}},
421
+ {id: 'c4', source: {node: 'b', port: 'success'}, target: {node: 'c', port: 'in'}},
422
+ ],
423
+ notes: [],
424
+ };
425
+
426
+ const points = (d: string) => {
427
+ const numbers = d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [];
428
+ const out: { x: number; y: number }[] = [];
429
+ for (let i = 0; i + 1 < numbers.length; i += 2) out.push({x: numbers[i], y: numbers[i + 1]});
430
+ return out;
431
+ };
432
+
433
+ const wirePaths = async (state: FlowState) => {
434
+ const el = await fixture<ZnFlowBuilder>(html`
435
+ <zn-flow-builder></zn-flow-builder>`);
436
+ el.registerNodeTypes([TRIPLE]);
437
+ el.setState(state);
438
+ await el.updateComplete;
439
+ const canvas = el.shadowRoot?.querySelector('zn-flow-canvas');
440
+ await canvas?.updateComplete;
441
+ return Array.from(canvas?.shadowRoot?.querySelectorAll('path.wire--link') ?? [])
442
+ .map(p => points(p.getAttribute('d') ?? ''))
443
+ .filter(pts => pts.length > 1);
444
+ };
445
+
446
+ // Segments are orthogonal, so a crossing is a plain rect overlap. The card's
447
+ // own edges are fair game: wires legitimately end on the top one.
448
+ const crossesCard = (pts: { x: number; y: number }[], card: { x: number; y: number }) => {
449
+ const E = 1;
450
+ for (let i = 0; i < pts.length - 1; i++) {
451
+ const minX = Math.min(pts[i].x, pts[i + 1].x);
452
+ const maxX = Math.max(pts[i].x, pts[i + 1].x);
453
+ const minY = Math.min(pts[i].y, pts[i + 1].y);
454
+ const maxY = Math.max(pts[i].y, pts[i + 1].y);
455
+ if (minX < card.x + NODE_WIDTH - E && maxX > card.x + E
456
+ && minY < card.y + NODE_HEIGHT - E && maxY > card.y + E) {
457
+ return true;
458
+ }
459
+ }
460
+ return false;
461
+ };
462
+
463
+ it('should route a row-skipping wire around the card it flies over', async () => {
464
+ const paths = await wirePaths(SKIP_A_ROW);
465
+ const middle = {x: 200, y: 920};
466
+
467
+ expect(paths.length).to.be.greaterThan(0);
468
+ paths.forEach(pts => {
469
+ expect(crossesCard(pts, middle), `wire ${JSON.stringify(pts)} crosses the middle card`)
470
+ .to.equal(false);
471
+ });
472
+ });
473
+
474
+ it('should take the row-skipping wire out to the side, not through the column', async () => {
475
+ const paths = await wirePaths(SKIP_A_ROW);
476
+ // A's failure pill sits on A's bus, so its wire is the one leaving highest.
477
+ const skipping = paths.reduce((lowestY, pts) => (pts[0].y < lowestY[0].y ? pts : lowestY));
478
+
479
+ expect(skipping[skipping.length - 1].y).to.equal(1140); // ends on C's input
480
+ expect(skipping.length).to.be.greaterThan(2); // not the straight drop
481
+ // It leaves the corridor the cards occupy (200..440) on its way past.
482
+ const outside = skipping.some(p => p.x > 440 || p.x < 200);
483
+ expect(outside, `expected a detour outside the column, got ${JSON.stringify(skipping)}`).to.equal(true);
484
+ });
485
+
486
+ it('should still draw a straight wire when the corridor is clear', async () => {
487
+ const paths = await wirePaths({
488
+ nodes: [
489
+ {id: 'a', type: 'step', x: 200, y: 700, data: {}},
490
+ {id: 'b', type: 'step', x: 200, y: 920, data: {}},
491
+ ],
492
+ connections: [{id: 'c1', source: {node: 'a', port: 'success'}, target: {node: 'b', port: 'in'}}],
493
+ notes: [],
494
+ });
495
+
496
+ expect(paths).to.have.length(1);
497
+ expect(paths[0]).to.have.length(2);
498
+ expect(paths[0][0].x).to.equal(paths[0][1].x);
499
+ });
500
+ });
296
501
  });
@@ -0,0 +1,102 @@
1
+ import {
2
+ BRANCH_SPREAD,
3
+ branchDropXs,
4
+ branchPillTop,
5
+ BUS_OFFSET,
6
+ type FlowConnection,
7
+ type FlowNodeInstance,
8
+ type FlowNodeType,
9
+ NODE_HEIGHT,
10
+ NODE_WIDTH,
11
+ } from './flow.types';
12
+ import {expect} from '@open-wc/testing';
13
+
14
+ const TRIPLE: FlowNodeType = {
15
+ type: 'step',
16
+ label: 'Step',
17
+ group: 'action',
18
+ outputs: [
19
+ {id: 'success', label: 'Success'},
20
+ {id: 'failure', label: 'Failure'},
21
+ {id: 'skip', label: 'Skip'},
22
+ ],
23
+ };
24
+
25
+ const typeOf = () => TRIPLE;
26
+
27
+ const node = (id: string, x: number, y = 0): FlowNodeInstance => ({id, type: 'step', x, y, data: {}});
28
+
29
+ const wire = (from: string, port: string, to = 'child'): FlowConnection =>
30
+ ({id: `c-${from}-${port}`, source: {node: from, port}, target: {node: to, port: 'in'}});
31
+
32
+ const centreOf = (n: FlowNodeInstance) => n.x + NODE_WIDTH / 2;
33
+
34
+ describe('flow branch geometry', () => {
35
+ it('should keep a fully wired fan on the classic spread', () => {
36
+ const source = node('n1', 400);
37
+ const xs = branchDropXs(source, typeOf, [
38
+ wire('n1', 'success', 'a'), wire('n1', 'failure', 'b'), wire('n1', 'skip', 'c'),
39
+ ]);
40
+
41
+ expect(xs).to.deep.equal([
42
+ centreOf(source) - BRANCH_SPREAD,
43
+ centreOf(source),
44
+ centreOf(source) + BRANCH_SPREAD,
45
+ ]);
46
+ });
47
+
48
+ it('should run a lone wired branch straight down, whatever else the step declares', () => {
49
+ const source = node('n1', 400);
50
+ const xs = branchDropXs(source, typeOf, [wire('n1', 'success')]);
51
+
52
+ // Success is wired, so it keeps the node's centre line and its child sits
53
+ // directly below; the two spare branches tuck in beside it.
54
+ expect(xs[0]).to.equal(centreOf(source));
55
+ expect(xs[1]).to.be.greaterThan(xs[0]);
56
+ expect(xs[2]).to.be.greaterThan(xs[1]);
57
+ });
58
+
59
+ it('should give a spare branch its pill\'s width, not a whole child lane', () => {
60
+ const source = node('n1', 400);
61
+ const wired = branchDropXs(source, typeOf, [
62
+ wire('n1', 'success', 'a'), wire('n1', 'failure', 'b'), wire('n1', 'skip', 'c'),
63
+ ]);
64
+ const spare = branchDropXs(source, typeOf, [wire('n1', 'success')]);
65
+
66
+ // Same three branches, two of them unused: the fan collapses to well under
67
+ // half of what three child lanes claim.
68
+ expect(spare[2] - spare[0]).to.be.lessThan((wired[2] - wired[0]) / 2 + 1);
69
+ expect(spare[1] - spare[0]).to.be.lessThan(BRANCH_SPREAD);
70
+ expect(spare[2] - spare[1]).to.be.lessThan(BRANCH_SPREAD);
71
+ });
72
+
73
+ it('should centre two wired branches on the node with a spare branch declared', () => {
74
+ const source = node('n1', 400);
75
+ const xs = branchDropXs(source, typeOf, [wire('n1', 'success', 'a'), wire('n1', 'failure', 'b')]);
76
+
77
+ expect((xs[0] + xs[1]) / 2).to.equal(centreOf(source));
78
+ expect(xs[1] - xs[0]).to.equal(BRANCH_SPREAD);
79
+ });
80
+
81
+ it('should centre a pill along a short wire to the next row', () => {
82
+ const source = node('n1', 0, 0);
83
+ const child = node('n2', 0, NODE_HEIGHT + 160);
84
+
85
+ const top = branchPillTop(source, 40, child);
86
+ expect(top).to.be.greaterThan(source.y + NODE_HEIGHT);
87
+ expect(top).to.be.lessThan(child.y);
88
+ });
89
+
90
+ it('should keep a pill under its own node when the wire skips past a row', () => {
91
+ const source = node('n1', 0, 0);
92
+ const near = node('n2', 0, NODE_HEIGHT + 160);
93
+ const far = node('n3', 0, NODE_HEIGHT + 800);
94
+
95
+ // A long branch must not park its pill halfway down, in among the rows it
96
+ // passes — that is what stacked two nodes' pills on top of each other. It
97
+ // sits on the bus, leaving the gap below clear for the wire to route.
98
+ const top = branchPillTop(source, 40, far);
99
+ expect(top).to.equal(source.y + NODE_HEIGHT + BUS_OFFSET);
100
+ expect(top).to.be.lessThan(near.y);
101
+ });
102
+ });